feat(007): generic credential rail — make defineCredential complete on the phone (#19)#42
feat(007): generic credential rail — make defineCredential complete on the phone (#19)#42TheBlackBit wants to merge 6 commits into
Conversation
Spec-only design deliverable for issue #19 — generalize the credential-gate rail so `defineCredential`'s "no new code path" promise (ARCHITECTURE.md) becomes literally true for an arbitrary mdoc credential, and enforce any applicable `gate()` credential at completion (not only age). Design (Approach 1 — register-on-resolve): an in-process credential registry populated synchronously in `requirements()` (no public API change, so Principle V's "no registration step" holds), injected at `mount()` into the rail + `completeOrder`. Completion re-derives applicable `gate()` credentials from the RE-PRICED order (invariant 2) and refuses any not in the per-order `verifiedGates` map (invariant 1). Scope: runtime generalization + one worked pack (professional-license gate()). Out of scope (separate follow-ups): SD-JWT VC, broader pack catalog, real-wallet e2e / dev test-issuer, issuer-verified trust (#14). Artifacts under specs/007-generic-credential-rail/: spec, plan, research, data-model, contract, quickstart, tasks, requirements checklist. A /speckit-analyze pass was applied (F1 spec self-consistency on gate()-always- blocks; F2 mount-level wiring test; F3 amount-binding; F4 trust_level asserts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ever Morales <ever.morales@koombea.com>
…s on the phone (#19) Make ARCHITECTURE.md's "A new credential — no new code path" literally true for an arbitrary mdoc credential, and enforce any applicable gate() credential at completion (not only age). Implements specs/007-generic-credential-rail (T001–T031). Registry (register-on-resolve): CredentAgent holds an in-process Map<string,Credential> populated synchronously in requirements() — no public API change, Principle V's "no registration step" preserved — and mount() injects it into the ceremony context + publishes it on app.locals so a host's completion seam reaches it. Rail: the credential-gate rail retires the closed "age" | "membership" dispatch. For a custom id it builds the wallet request from the credential's OWN request DCQL (buildSignedRequestForDcql / mdocDocSpecFromDcql) and runs its OWN verify (evaluateCustom) across the instant-demo and OpenID4VP/mdoc paths; page.ts renders from ui + a derived demo claim. Built-in age/membership stay on their existing order-parameterized path (threshold/percent preserved — no regression). Completion (security core, invariant 1): completeOrder sweeps the injected registry for applicable gate()-effect credentials, re-derives applicability from the RE-PRICED order (invariant 2), and refuses any not in the per-order verifiedGates map (invariant 4) with reason "gate". gate() is the hard-block effect — enforced whenever it applies, independent of required/optional (F1). Reserved built-ins excluded. Absent registry ⇒ additive no-op. Storefront: the completion closure passes the published registry to completeOrder (read lazily from app.locals) so the mounted flow enforces custom gates end-to-end. Pack + docs: examples/professional-license.mjs (a gate() proven + enforced end-to-end); custom-credential.mjs footer + examples/README + ARCHITECTURE.md + the gate README corrected to the now-true behavior. Tests (257 green: 188 gate + 69 storefront). New bypass suites, each verified RED when its control is removed: - generic-credential.test.ts (US1): custom request/verify served; positive records, negative doesn't; unknown/reserved id → 404 (FR-013); presence-only-demo (F4). - completion.test.ts (US2): unverified applicable gate refused (reason "gate"); verify → complete; per-order scoping; non-applicable no-block; amount-binding with a custom gate (F3); additive no-op without a registry. - mount.test.ts (US2 F2): mount() publishes the registry so a host completion seam enforces — catches a registry-injection mis-wire the unit test (which seeds the registry directly) cannot. Incidental: jose 6 dropped `KeyLike`; fixed the ES256 SignJWT cast in credential-gate/ and dc-payment/request.ts so `npm run build` (tsc) is green again. Honesty: every custom surface stays trust_level "presence-only-demo" (no issuer/device trust anchor yet — #14). Multi-instance: register-on-resolve needs each instance to resolve the policy once (documented); single-server/demo always does. Out of scope (separate follow-ups): SD-JWT VC, broader pack catalog, real-wallet e2e / dev test-issuer, issuer-verified trust. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ever Morales <ever.morales@koombea.com>
28f96a4 to
d37fdce
Compare
🔴 Blocker — the checkout hub renders a custom gate as an age gate@TheBlackBit — running the shipped
Why it happens — the backend was generalized, the page layer wasn't. The manifest is correct: the entry carries // gate effect (age):
const age = entry.minAge ?? 21;
...
🔒 This order contains age-restricted items. Verify you're ${age} or older to continue.
<a ...>Verify age (${age}+)</a>It never reads It's not just cosmetic — it deadlocks. Even after the buyer proves the license, this page can never unlock: Fix is contained to the presentation layer — no backend/enforcement change needed:
This is the second of two blockers from a fuller review of this PR; the companion (the first) is the completion-time (edited to fix |
🔴 Follow-up to the checkout-hub blocker — running the example end-to-end, the license flow can't complete (and "Verify your license (instant demo)" appears to do nothing)@TheBlackBit — I built and ran the shipped The verify call succeeds server-side. Replaying the exact body the instant-demo button POSTs, against a real order: It also records Then So the flow loops: prove license → redirect to checkout → checkout can't see Second, separate wrinkle found while reproducing: hitting Repro (self-contained): git worktree add ../credentagent-pr42 origin/007-generic-credential-rail
cd ../credentagent-pr42 && npm install
npm run build -w @openmobilehub/credentagent-gate -w @openmobilehub/credentagent-storefront
PORT=3011 GATE_SECRET=demo node examples/professional-license.mjs
# checkout the contractor-drill → open the credential link → click "Verify your license (instant demo)"
# → you land back on the locked "Verify age (21+)" checkout.Ask: this is the flagship example, and it doesn't complete through its own UI. The backend seam is correct and well-tested, but nothing drives the browser hub — I couldn't find an end-to-end test that checkouts a custom-gate order, proves the credential, and asserts a pay button appears. One such test would have caught both this and the age-mislabel, and would keep the "257 tests green" from masking a broken demo. Fix is presentation-layer only (teach |
🔴 Blocker (security) — completion enforcement fails open for any
|
Remaining review findings (correctness + quality)@TheBlackBit — rounding out the review so the record is complete. The two checkout-hub comments and the fail-open comment above are the blockers; these are the rest, ranked. All verified against this branch. 3 · Registry is instance-lifetime and unscoped → cross-policy stuck orders. 4 · 5 · Cold-registry fail-open on multi-instance (register-on-resolve). Custom-gate enforcement is guarded by 6 · Multi-credential DCQL silently truncates on iOS. 7 · Two parallel per-credential-id stores on 8 · Reuse / test-hygiene. (a) The DCQL claim-leaf extraction ( ("finding N" are review items, not issue references.) |
…en (PR #42 review) Addresses the blockers from Diego's review of #42. Blocker — checkout hub deadlocked (rendered a custom gate as an age gate and could never unlock). The backend was generalized but the shared checkout page (checkout-page.ts) and the storefront were still age-only: - renderGate now renders a custom gate from its OWN `entry.label` (not the age copy); railSteps emits a step per gate() effect; the lock copy is generic. - isSatisfied + RenderVerification now consult `verifiedGates`, and the storefront passes it into the render — so a proven custom gate clears the lock and offers the pay rail instead of looping back to a locked page. Blocker (security, fail-OPEN) — the completion sweep evaluated `appliesTo` against a lossy 5-field projection of the re-priced order, so a gate keyed on any other field (e.g. the README's `requiresRx` prescription gate) was surfaced as applicable at manifest time yet SKIPPED at completion → the order completed unproven (invariant 1). Fix: spread the full re-priced line (no allow-list) so completion and the manifest resolver see identical inputs; add `requiresRx` to CeremonyOrderLine as a catalog-derived attribute. New bypass test drives a requiresRx gate through completeOrder and asserts refusal (red without the fix). Missing end-to-end coverage — added a storefront test that checks out a custom-gate order, proves the credential, and asserts the pay button appears (and completes through dc-payment), plus a checkout-page render test for a custom gate. Verified at runtime against examples/professional-license.mjs: prove license → payment unlocks → completes. Cleanups from the review: - #7: dropped the dead/misleading `[credentialId: string]: unknown` index signature on VerificationRecord; `verifiedGates` is the single source. - #8a: extracted `claimLeaf(path)` next to `dcql()`; used by doc-spec.ts and routes.ts (was duplicated). - #8b: created the shared `credential-gate/__fixtures__/customCredential.ts` (T002) and consumed it from the completion / generic-credential / mount suites (was copy-pasted three times). Deferred to follow-ups (need a design call): #4 optional(gate())/required(non-gate) policy validation, #5 multi-instance cold-registry asymmetry, #6 multi-credential DCQL truncation on iOS. 266 tests green (193 gate + 73 storefront); build green. Bypass controls verified red when removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ever Morales <ever.morales@koombea.com>
|
@dzuluaga — addressed in Blockers — fixed
Findings 7 & 8 — fixed
Findings 3–6 — deferred (need a design decision)Scoped this round to the blockers + safe cleanups; these are tracked as follow-ups:
Every new bypass test was verified red with its control removed. Separate note from testing: the credential rail has no cross-device/QR transport (same-device DC API + instant-demo only) — pre-existing on age/membership, unchanged here; worth its own enhancement. Tested on browser
|
Two currently-RED regression tests pinning the confirmed blockers from the review. finding 1 (fail-OPEN): a defineCredential whose id collides with a reserved built-in (age/membership/payment) is silently shadowed — resolveCred routes it to the built-in and the completion sweep skips it (RESERVED_CREDENTIAL_IDS), so a declared hard gate() completes unproven with no error. Test asserts defineCredential rejects a reserved id (credentials.test.ts). Red until the fail-fast guard lands. finding 2: the flagship prescription example keys appliesTo on requiresRx, but priceCart forwards a fixed field set and drops it, so the gate can never apply on the reference storefront (an Rx product checks out unproven while the README says "enforced end-to-end"). Test asserts priceCart forwards requiresRx onto the priced line (index.test.ts). Red until Product/priceCart thread the field. Both verified RED on this branch; the rest of each suite stays green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QxYCu2K49F4o1CZguC2NF Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…ts green) finding 1 (fail-OPEN): defineCredential now throws on a reserved built-in id (age / membership / payment). A custom credential with a reserved id was silently routed to the built-in (resolveCred) and skipped by the completion sweep (RESERVED_CREDENTIAL_IDS), so a declared hard gate() completed unproven with no error. Fail-fast at construction beats accepting a policy the seam cannot honor. finding 2: thread requiresRx through the storefront so the flagship prescription gate is enforceable end-to-end. Product and PricedCartLine carry requiresRx, and priceCart forwards it onto the priced line — so a custom appliesTo sees the SAME field at manifest time and at the completion sweep (never fail-open). The gate README's `appliesTo: (o) => o.lines.some(l => l.requiresRx)` example now fires on createStorefront instead of silently never applying. Makes the two regression tests from c0c4e65 green. Gate suite 196 green; the storefront priceCart/index suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QxYCu2K49F4o1CZguC2NF Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Pushed fixes for review findings 1 & 2 (with regression tests)@TheBlackBit — I turned the two blockers from the review into regression tests and fixed them. Two commits on the branch:
Finding 1 — a custom credential with a reserved id silently fails open. Finding 2 — the prescription example never fires on the storefront. The gate README's flagship gate keys One thing left open (deeper option on finding 2): Still open from the review and untouched here: the register-on-resolve cold-instance fail-open (your deferred item 5), |
dzuluaga
left a comment
There was a problem hiding this comment.
Please check the comments.

What this does
Lets you gate a purchase (or any action) with any credential you define via
defineCredential— and actually prove it on the phone. Until now only the built-in age and membership credentials worked end-to-end; a custom credential showed up to the agent but couldn't be completed at checkout. Now it can. Closes #19.What changed
Try it
Status
presence-only-demo): it proves the credential was presented, not that a real issuer signed it — not a safety control yet (Issuer-verified trust anchor — real KB-JWT / mdoc issuer+device signatures (v0.3) #14).Design notes live in
specs/007-generic-credential-rail/.🤖 Generated with Claude Code