feat(005): HNP shared gate seams — Intent Mandate bounds + checkDraw + completeOrder draw branch#41
feat(005): HNP shared gate seams — Intent Mandate bounds + checkDraw + completeOrder draw branch#41dzuluaga wants to merge 25 commits into
Conversation
…07-08) Plan authored 2026-07-07 (parallel session), committed now that the maintainer ratified the sequencing fork (Option B: wallet-custody, seams-first) and the Group-A decisions D1-D3. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…ng Option B (2026-07-08) Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…d 2026-07-08) MINOR bump per the ratified draft: Principle II (Context 1 may verify a pre-existing delegation grant — redeem is verification, not a ceremony), Principle III (HNP consolidation = one delegate ceremony + full server-side gate chain on every redemption), Principle VII (orthogonal presence axis; trust_level gains server-issued-demo, weaker than presence-only-demo; enforcedAt gains "intent"; delegated-demo surfaces carry a disclaimer, no authorizedByUser field, never settle). Adds the envelope-vs-grant terminology ruling and retains Mode-B gated() unchanged. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…+ completeOrder draw branch Option B, seams-first (plan.md T001-T009). Ships the four signer-agnostic gate seams the GDC demo and the eventual wallet server both depend on — no merchant-side minting rail, no HTTP surface. - refusals.ts: shared typed-refusal vocabulary (code/enforcer/retryable) — §9 - mandate.ts: IntentBounds + Draw types, canonical()/contentAddressId()/sealIntent, ES256 delegate signing, checkDraw() (pure, total, accumulating) — ported from spike/intent-mandate (13 tests → mandate.intent.test.ts, 15 green) - revocation.ts: RevocationStore seam (per-intent + subject kill-switch, atomic commitDraw single-use), MemoryRevocationStore default; fail-closed - completion.ts: additive fail-closed draw branch — re-runs checkDraw + revocation + atomic consume server-side, age ALWAYS steps up (non-delegable), draw.amount must equal the re-priced total, writes delegationId, suppresses ctx.settle. HP paths byte-unchanged. Constitution v1.1.0 compliant. 272 tests green (+25). Bypass tests spot-verified RED when their control is removed (settlement suppression; revocation check). Finding: single-use is defended in two layers — checkDraw's replay guard (sequential) and commitDraw's atomic consume (concurrent race); test accepts either code. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9fbfce196d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 1. binds to THIS intent | ||
| if (draw.intentId !== intent.intentId) refusals.push(refusal("intent-mismatch")); |
There was a problem hiding this comment.
Reject mutated intent bounds before accepting draws
When checkDraw receives the caller-supplied intent, this equality only proves the draw names the same string; it never recomputes contentAddressId(intent) to prove the bounds still match that id. A malicious caller can keep a victim intentId, replace fields such as delegate, maxAmount, or merchants, sign the draw with the replacement delegate key, and pass the signature/cap/scope checks against bounds the user never approved.
Useful? React with 👍 / 👎.
| // both pass without this). Exactly one of N racing draws with one pspTransactionId wins. | ||
| let committed: boolean; | ||
| try { | ||
| committed = await store.commitDraw(intent.intentId, { amount: draw.amount, pspTransactionId: draw.pspTransactionId }); |
There was a problem hiding this comment.
Make the cumulative cap check atomic with commit
For concurrent draws against the same intent with different pspTransactionIds, both requests can read the same priorDraws, pass checkDraw's over-total test, and then both succeed here because commitDraw only rejects duplicate PSP ids. That allows the committed sum to exceed intent.totalAmount; the store-side CAS/commit needs to include the cumulative-cap decision, not just single-use transaction ids.
Useful? React with 👍 / 👎.
… no DOM) + drop unused Refusal import CI's gate tsconfig uses lib ES2022 without DOM, so the global CryptoKey type is unavailable (local build resolved it via leaked DOM types). Alias it from node:crypto's webcrypto namespace. Also remove the unused Refusal type import. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…ced (T008) Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…mples/hnp-draws/
- examples/README.md gains a grouped table of contents (storefront+gate /
gating patterns / cart-mandate / human-not-present) linking each demo.
- The HNP draws demo moves to examples/hnp-draws/{demo.mjs,README.md},
matching the run-storefront/ + stateless-orders/ subdir convention for
demos that warrant their own walkthrough. Verified: runs from the new path.
- Existing flat single-file demos stay put (referenced by the published
package READMEs — moving them would break documented commands).
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…ic cumulative cap
P1a (checkDraw trusted the intentId string): checkDraw now recomputes
contentAddressId(intent) and refuses `bounds-tampered` on mismatch. Without
it, a caller could keep a victim's intentId while swapping delegate/cap/scope
and sign with the substituted key — every downstream check ran against
unapproved bounds. The content-address IS the trust root; now it's verified.
P1b (cap check non-atomic with commit): RevocationStore.commitDraw now makes
BOTH the single-use AND the cumulative-cap decision atomically (takes
totalAmount, returns { ok } | { ok:false, reason:'consumed'|'over-total' }).
checkDraw's over-total stays a fast pre-check; commitDraw is the atomic
backstop, so two concurrent distinct-txid draws can't both pass the pre-check
and breach the cap.
Tests: +6 (bounds-tampered sole-refusal; store-level atomic cap incl. the
two-draws race + exact-cap boundary; completion over-total wiring). 278 green.
Both new controls spot-verified RED when removed.
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
|
Addressed both Codex P1s in P1 — Reject mutated intent bounds (mandate.ts): P1 — Atomic cumulative cap (revocation.ts + completion.ts): +6 tests (278 green); both new controls spot-verified RED when their control is removed. |
IntentBounds (an interface) isn't assignable to Record<string, unknown> under CI's strict tsconfig (no implicit index signature). Widen the param to `object` and cast internally for the destructure — the function already works on any object (sealIntent passes Omit<IntentBounds,'intentId'>). Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…nical merchant ids Reader confusion (2 real questions): (1) it looked like each tx_N might be a separate approval — now the demo states up front that all draws spend against ONE pre-approval, labels steps 1/2/3, and tags each line with its [tx_N]; (2) merchant 'starbucks' read like a display name — now blue-bottle.example / starbucks.example with a comment that scope ids are canonical machine ids matched exactly, never brand names. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…already use) The examples READMEs, getting-started, PUBLISHING, and the 001-003 specs all say 'npm run build:packages' (carried from the demo repo), but this library's root package.json only had 'build' — so the documented command errored with 'Missing script'. Add build:packages as an alias (identical to build; this repo is packages-only, no app) so every doc's command works as written. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…checks each draw
Reader confusion: the mandate seemed to vanish after sealIntent(). Now the demo
models the three parties as explicit objects — you (mint) → agent { holds:
mandate } → gate { ledger, no mandate } — and each purchase builds
presented = { intent: agent.holds, draw } and calls gate.receiveDraw(), so where
the mandate 'goes' is visible: the agent carries it and re-presents it every
draw; the gate stores only the revocation/consume ledger and re-verifies from
scratch. Revocation flips the gate's ledger while the agent still holds the
mandate — the stateless-grant model, made visible.
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…council-reviewed) A 5-persona council scored the prior demo 4/10 (unanimous) and diagnosed three complexity leaks: tx-id + quantity + amount bookkeeping the reader must track; hand-restated prices; and limits tuned so a bad case tripped two rules at once (jargon-soup output). Fix (judge synthesis): story-first layout — the whole point reads top-to-bottom in ~18 lines via preApprove() + agent.buy(), with the real API wiring pushed below a PLUMBING banner and refusal codes humanized so each line prints ONE plain-English reason. Still runs against the real exports. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Reader feedback: agent.buy("tx1", "1 coffee") forced decoding positional args
plus a split string. Now buy({ charge, item, quantity=1, store=approved }) —
every field self-labels: quantity:3 / store:STARBUCKS / item:'wine' read at a
glance, the double-spend is visible because charge:'c1' appears twice, and the
normal line stays short via defaults.
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…ommit
Trimmed the plumbing to the minimum the real API reads (minimal catalog {id,
lines,total}; gates:[]; inline verificationStore stub) — verified by running,
identical 6-scenario output. Independent code-review pass (traced against the
gate source) returned no must-fix; applied its nice-to-haves: reworded the
re-price comment so it doesn't imply the demo exercises amount-tamper (one
shared catalog can't diverge); restored the required paymentMandateId field for
TS-copy fidelity; guarded refusals?.[0]?.code against non-draw refusals; noted
why the verification-store stub exists; grammar.
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Reader could not see where '3 × $18' came from — the unit price lived only in the PRICE map down in the plumbing. Now the pre-approval banner prints the menu (coffee $18 · wine $20 (21+)) from the real PRICE/MIN_AGE data, so it appears above every purchase and can't drift from what the gate charges. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…rices) Reader noted the story's '3 × $18 = $54' comment reached across the file to a PRICE map defined ~10 lines below. Root fix: the story shouldn't state prices at all — the agent deals in quantities, the gate in prices. Comment is now '3 coffees — over your $30 cap (the gate prices it)'; the price appears only in the PRICE map and at runtime (menu + the $54 column), never cross-referenced up top. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…the story)
Guiding insight (Diego): a developer reads the code first to decide if they
understand it, and only then runs it — so the CODE, not the runtime output,
must carry understanding. Prior version leaned on the runtime menu/$54 column
for prices, invisible to a code-first reader. Fix: a 'THE SHOP' block at the top
(coffee $18 · wine $20 21+) before the story, so each story line is
self-contained on a read ('3 × $18 = $54 — over your $30 cap', 'wine is 21+ —
age is never delegable') with the price defined a few lines above, not across
the file.
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Constitution Principle I: the example exposed raw seams (hand-wired catalog,
stores, completeOrder). This adds a configure-once facade so the flow reads
`new DelegatedGate({catalog})` -> `gate.preApprove({...})` -> `grant.spend({...})`
-> `grant.revoke()`; the ceremony (keys, signing, stores, completeOrder) is
bundled. The example drops 104 -> 36 lines with the ease now IN the package.
Reviewed before commit (superpowers:code-reviewer traced the seams) — fixed two
must-fix bugs it found, both order-id/idempotency-shaped:
- SECURITY (invariant 4): per-grant seq + per-gate records let a 2nd grant on one
gate read the 1st's completion via a colliding order id, bypassing
revocation/scope/cap. Fixed: order id namespaced by the grant's intentId.
Regression test added (revoked 2nd grant must refuse, no delegationId).
- spend() threw on an unknown item (TypeError), breaking its no-throw contract.
Now throws a helpful error at the boundary (programming error, fail fast);
contract reworded; regression test added.
Also from review: perMonth -> total (it's a lifetime cap, no monthly reset);
honesty axis exposed via grant.presence/grant.trustLevel + asserted in a test
that would fail on a real value (constitution VII); revoke() is async for the
wallet-custody swap; SpendResult carries retryable (the unattended-loop signal);
charge -> paymentId (Stripe naming). 287 tests green (+9 facade).
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…t prints) 'show' named the trivial half (the console.log) and hid the real half (grant.spend). 'attempt' reads true at the call site — the agent attempts a purchase and the gate decides (half are refused). Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…ch spend A delegated grant authorizes MANY purchases (one per paymentId) drawing down a cumulative cap; the demo only ever showed one approved spend, so the draw-down was invisible. spend() now returns `remaining` (total − committed draws, after this spend), so a running balance is API-reported, not demo-tallied — the Stripe-grade 'balance' shape. Demo shows two real purchases (100 → 82 → 64) then the refusals. Tested incl. the over-total boundary (22 → 4 → refused, headroom unchanged on refusal). 288 tests green. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
… on main) Codex review on PR #43 (accurate): the binding rubric cited DelegatedGate as the golden AFTER API, but a repo-wide search on this branch finds it only in the docs — the facade lands via the concurrent PR #41 (005-hnp-seams). Note it as a companion change cited as the worked pattern, not a claim the class is already in the merged tree, so contributors aren't directed to a nonexistent surface. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…eams # ------------------------ >8 ------------------------ # Do not modify or remove the line above. # Everything below it will be ignored. # # Conflicts: # .specify/memory/constitution.md
…seams+facade (#41), next increment = intent rail → wallet server Resolved decisions moved to Done (Group-A/Option-B/Decision-13 ratified; rename shipped; 0.2.0 published). Open decisions: approve+merge #41, CLAUDE_CODE_OAUTH_TOKEN, counsel brief. Added the DX-is-blocking standing constraint and the next-increment pointer (HTTP intent rail → wallet server). Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Header + decisions + in-flight + Done log now reflect: the 005 intent-rail core is built and in draft PR #44 (stacked on #41); the DX crispness punch-list + vision doc landed; the counsel brief is banner-flagged stale (reconcile before sending). The three deferred-for-review items (routes/page key custody, #1 idempotency, IntentStore seam) are called out as decisions, not lost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ca4efh5VrJdtZdN4nkbiqh Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
In plain terms
This teaches the checkout gate to accept a pre-approval so an agent can buy something for you while you're away — safely.
Think of a gift card you set up in advance:
over-cap,expired,revoked, …)Three safety rules it always enforces:
And honestly: no real money moves yet (demo-fenced). This is the plumbing every future piece needs — the actual phone-wallet ceremony and the "while you sleep" trigger come in later PRs.
See it run
For reviewers — what's in the code
First 005 increment, per the ratified Option B (wallet-custody, seams-first) plan: the four signer-agnostic gate seams that both the GDC demo and the eventual wallet server depend on. No merchant-side minting rail, no HTTP surface (later increments). Spec + plan + tasks in
specs/005-human-not-present/; governance is constitution v1.1.0 (Decision-13 amendment applied).mandate.ts(extended)IntentBounds/Drawtypes, content-addressedintentId, ES256 delegate signing, pure/totalcheckDraw— ported fromspike/intent-mandate/refusals.ts(NEW)code/enforcer/retryable— refusals are data, not proserevocation.ts(NEW)RevocationStore(per-intent + subject kill-switch, atomiccommitDraw), in-memory default, fail-closedcompletion.ts(extended)draw.amount == re-priced total, writesdelegationId, suppresses settlementVerification
input.drawis present)Security invariants: 1 (re-check at the seam on every path), 2 (re-price authoritative; grant carries no price), 4 (per-intent state), 5 (age non-delegable) all upheld. Invariant 6 partial by design (no DeviceKey proof-of-possession yet — the wallet-server increment), honesty-fenced.
One finding: single-use is defended in two layers —
checkDraw's replay guard (sequential) andcommitDraw's atomic consume (concurrent race). The single-use test asserts exactly-one-completes and accepts either refusal code.Out of scope (next increments): HTTP intent rail · wallet server (
credentagent-wallet, Kotlin) · settlement verifier ·credentagent-agent.🤖 Generated with Claude Code