Skip to content

feat(005): HNP shared gate seams — Intent Mandate bounds + checkDraw + completeOrder draw branch#41

Open
dzuluaga wants to merge 25 commits into
mainfrom
005-hnp-seams
Open

feat(005): HNP shared gate seams — Intent Mandate bounds + checkDraw + completeOrder draw branch#41
dzuluaga wants to merge 25 commits into
mainfrom
005-hnp-seams

Conversation

@dzuluaga

@dzuluaga dzuluaga commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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:

  • You pre-approve once → an Intent Mandate ("up to $40, Blue Bottle, this month").
  • Later the agent makes a specific purchase → a draw.
  • This PR is the bouncer at the door that checks each draw against your rules before letting it through:
    • right store? under the cap? not expired? not already used? signature real? → complete it
    • anything off → refuse, with a clear reason (over-cap, expired, revoked, …)

Three safety rules it always enforces:

  1. You can cancel anytime — the very next attempt is blocked (and if the check can't run, it refuses, never allows).
  2. One pre-approval = one purchase — can't be reused or double-spent.
  3. Age-restricted items never go through this — buying wine always requires a fresh, in-person tap.

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

node examples/hnp-draws/demo.mjs
🎫  Pre-approval: "Reorder Blue Bottle coffee, up to $40, this month"
✅  1 bag of coffee ($18)          → COMPLETED (no real money moved)
⛔  reuse same transaction         → REFUSED: replay
⛔  $54 cart, over the cap         → REFUSED: over-cap, over-total
⛔  different store (Starbucks)    → REFUSED: out-of-scope
⛔  wine on a coffee approval      → REFUSED: step-up   (age is never delegable)
🔴  you revoke from your phone…
⛔  another reorder                → REFUSED: revoked

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).

Seam File What it is
The pre-approval + the rule-check mandate.ts (extended) IntentBounds/Draw types, content-addressed intentId, ES256 delegate signing, pure/total checkDraw — ported from spike/intent-mandate/
The refusal vocabulary refusals.ts (NEW) typed code / enforcer / retryable — refusals are data, not prose
Cancel + single-use ledger revocation.ts (NEW) RevocationStore (per-intent + subject kill-switch, atomic commitDraw), in-memory default, fail-closed
The bouncer at the door completion.ts (extended) additive + fail-closed draw branch: re-runs every check server-side, age always steps up, draw.amount == re-priced total, writes delegationId, suppresses settlement

Verification

  • 272 tests green (+25: 15 bounds + 10 draw-branch bypass)
  • Bypass tests spot-verified RED when their control is removed (settlement suppression → 1 fail; revocation check → 2 fails), green when restored
  • Human-present paths byte-unchanged (the branch only activates when input.draw is 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 layerscheckDraw's replay guard (sequential) and commitDraw'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

dzuluaga added 5 commits July 8, 2026 17:28
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +344 to +345
// 1. binds to THIS intent
if (draw.intentId !== intent.intentId) refusals.push(refusal("intent-mismatch"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

dzuluaga added 4 commits July 8, 2026 17:48
… 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>
@dzuluaga

Copy link
Copy Markdown
Contributor Author

Addressed both Codex P1s in ff956fa:

P1 — Reject mutated intent bounds (mandate.ts): checkDraw now recomputes contentAddressId(intent) and refuses bounds-tampered on mismatch. The content-address is the trust root; previously only the intentId string was compared, so a caller could keep a victim's id, swap delegate/maxAmount/merchants, and sign with the substituted key. New test asserts a fully-inflated tampered-bounds forgery is refused with bounds-tampered as the sole refusal (proves it's the only thing stopping it).

P1 — Atomic cumulative cap (revocation.ts + completion.ts): commitDraw now makes 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; the atomic commit is the backstop, so two concurrent distinct-pspTransactionId draws can't both pass the (non-atomic) pre-check and breach the cap. New store-level test commits two $30 draws against a $40 cap and asserts the second is refused over-total.

+6 tests (278 green); both new controls spot-verified RED when their control is removed.

dzuluaga added 14 commits July 9, 2026 17:11
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>
dzuluaga added a commit that referenced this pull request Jul 10, 2026
… 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
@dzuluaga dzuluaga requested a review from TheBlackBit July 10, 2026 04:41
…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>
dzuluaga added a commit that referenced this pull request Jul 10, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant