phaedo-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
package/NOTICE ADDED
@@ -0,0 +1,17 @@
1
+ Phaedo
2
+ Copyright 2026 Phaedo Labs, Inc.
3
+
4
+ This product includes software developed by Phaedo Labs, Inc. (https://phaedo.so),
5
+ licensed under the Apache License, Version 2.0.
6
+
7
+ "Phaedo" and the Phaedo logo are trademarks of Phaedo Labs, Inc. The Apache License,
8
+ Version 2.0 grants no trademark rights (Apache-2.0 §6); see TRADEMARKS for the brand
9
+ policy and permitted nominative use.
10
+
11
+ ----------------------------------------------------------------------
12
+ Third-party components (not redistributed in this package)
13
+ ----------------------------------------------------------------------
14
+ The reference browser extension vendors @mlc-ai/web-llm (Apache-2.0). That bundle
15
+ is NOT included in this npm package or the public repository (it is a gitignored
16
+ build artifact). If/when the extension is distributed, the @mlc-ai/web-llm
17
+ LICENSE and NOTICE travel with it.
package/README.md CHANGED
@@ -1,9 +1,12 @@
1
- # Phaedo MCP server — Phase 1 (push injection)
1
+ # Phaedo MCP server (`phaedo-mcp`)
2
2
 
3
- A local [MCP](https://modelcontextprotocol.io) server that exposes your Phaedo
4
- cognitive-fingerprint **projection** to any MCP client (Claude Desktop, Claude
5
- Code, Cursor, agent frameworks) — so the assistant responds in your style
6
- without the Chrome extension. One integration, every MCP client.
3
+ A local [MCP](https://modelcontextprotocol.io) server that brings your Phaedo
4
+ cognitive fingerprint to any MCP client (Claude Desktop, Claude Code, Cursor,
5
+ agent frameworks) — no Chrome extension needed. It injects your fingerprint
6
+ **projection** so the assistant responds in your style, and adds **consultation**
7
+ (decision/risk signals), **real-time escalation** to your paired phone for a live
8
+ approve / deny / modify on a blocking decision, and **act-as-me** learning from
9
+ your outcome marks. One integration, every MCP client.
7
10
 
8
11
  Implements `docs/protocol/mcp-binding.md` §3.1:
9
12
 
@@ -17,12 +20,24 @@ Implements `docs/protocol/mcp-binding.md` §3.1:
17
20
  | tool | `phaedo_escalate` | run the consult and, on a blocking signal (`escalate`, or a consequential `clarify`), wake the subject's paired device for a live **approve / deny / modify**; returns a `request_id` (non-blocking). Safe default on no answer: **hold**. |
18
21
  | tool | `phaedo_escalation_status` | poll an escalation by `request_id`; on resolution records the subject's live decision as the §10.6 outcome automatically (a real-time approve/deny is the strongest act-as-me signal). |
19
22
  | tool | `phaedo_record_outcome` | §10.6 — record what the SUBJECT decided about a consulted action (`approved`/`rejected`/`modified`/`unknown`). A `rejected`/`modified` outcome teaches the **act-as-me** channel (how the user wants delegated work handled). |
23
+ | tool | `phaedo_sign_mandate` | §8.3 — ask the subject to **sign** an AP2 Intent Mandate built from their standing authorizations (a verifiable payment credential). Deposits the request and returns a `request_id` + the unsigned mandate (non-blocking). The subject signs on their phone in the Secure Enclave, or declines. |
24
+ | tool | `phaedo_sign_status` | poll a `phaedo_sign_mandate` request by `request_id` (+ the returned mandate); returns the mandate carrying a **verified** signature once signed, `refused` if declined, or pending/expired. The signature is checked against the subject's published signing key before it is returned. |
20
25
 
21
26
  The projection is rendered by the **same** `context-block.js` the extension
22
27
  injects, so MCP injection is byte-identical to web injection.
23
28
 
24
29
  ## Status
25
30
 
31
+ The phases below track the **fingerprint source** (how the server obtains the
32
+ fingerprint). The consultation, real-time escalation, act-as-me / delegation, and
33
+ receipts-audit surfaces (the tools table above) shipped on top of these across the
34
+ **0.2.x** line, and the §8.3 signing pair in **0.3.0** — see `CHANGELOG.md`.
35
+
36
+ > **Install 0.3.0 or later.** 0.2.0, 0.2.1 and 0.2.2 were published with three
37
+ > modules missing from the package, so they exit with `ERR_MODULE_NOT_FOUND`
38
+ > instead of starting. 0.3.0 is the first working release since 0.1.1, and
39
+ > `test-package-closure.mjs` now boots the packed tarball in CI.
40
+
26
41
  - **Phase 1a (this cut):** reads the fingerprint from a local JSON file (see
27
42
  *Fingerprint source*). Lets injection work in a real client today, no crypto.
28
43
  - **Phase 1b-i (done):** the spec-faithful key boundary — authorize → pull
@@ -50,8 +65,10 @@ injects, so MCP injection is byte-identical to web injection.
50
65
  `../docs/roadmap/mcp-revocable-approval-plan.md`. Phone-gated unlock stays a
51
66
  deferred enterprise mode behind a config flag.
52
67
 
53
- **Injection-only.** MCP never returns the conversation, so there is **no
54
- capture** here (binding §5) — your fingerprint does not learn from MCP sessions.
68
+ **No conversation capture.** MCP never returns the conversation, so your
69
+ fingerprint does not learn from MCP sessions (binding §5). (Consultation,
70
+ escalation, and `phaedo_record_outcome` shape and record *decisions*, but none
71
+ capture conversation text into the fingerprint.)
55
72
  Local **stdio** only; **standard** mode only (enhanced-privacy/Stained-Glass and
56
73
  remote HTTP/SSE are out of this cut).
57
74
 
@@ -181,6 +198,31 @@ fully automatic depending on the client:
181
198
 
182
199
  Every chat in that project then injects automatically.
183
200
 
201
+ ### Capture from Claude Code / Cowork (optional)
202
+
203
+ `npm run setup:code-hook` also installs a **SessionEnd** hook that reads the just-ended
204
+ transcript locally, derives behavioral signals (deterministic pattern detectors), and
205
+ deposits an encrypted **signal delta** to the owner — so your Claude Code / Cowork usage
206
+ feeds the fingerprint, not just the browser. The transcript is counted and **discarded**
207
+ (nothing but signal names + integers leaves the machine); a paired phone/extension drains
208
+ and merges it.
209
+
210
+ Optionally enrich the deterministic detectors with a **local model** (opt-in) — it
211
+ classifies the messages the regexes miss, against the same taxonomy, and feeds the same
212
+ counters (so re-fires stay idempotent). Any **Ollama / OpenAI-compatible** endpoint works:
213
+
214
+ ```
215
+ ollama pull llama3.1:8b # or any local model
216
+ npm run setup:capture-model -- llama3.1:8b # writes ~/.phaedo-mcp/capture-model.json
217
+ npm run setup:capture-model -- off # turn it back off
218
+ ```
219
+
220
+ Point it elsewhere with `--capture-url <url>` (default `http://localhost:11434`). It's
221
+ fail-safe: endpoint down/slow/garbage → the hook silently falls back to the deterministic
222
+ detectors, and a model call can never break or stall a session (30s budget, hook always
223
+ exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
224
+ Dock-launched Claude Desktop, a shell `export` does not.
225
+
184
226
  ## Consult at decision points (`phaedo_consult`, §10)
185
227
 
186
228
  Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
@@ -303,7 +345,7 @@ The installer writes exactly this. The minimal **paired** entry needs no `env`
303
345
  "mcpServers": {
304
346
  "phaedo": {
305
347
  "command": "/absolute/path/to/node",
306
- "args": ["/Users/galavoxx/phaedo/mcp/server.js"]
348
+ "args": ["/absolute/path/to/phaedo/mcp/server.js"]
307
349
  }
308
350
  }
309
351
  }
@@ -315,5 +357,5 @@ chat and prepend the returned `projection`.
315
357
 
316
358
  **Claude Code** — `.mcp.json` in a project, or:
317
359
  ```bash
318
- claude mcp add phaedo -- /absolute/path/to/node /Users/galavoxx/phaedo/mcp/server.js
360
+ claude mcp add phaedo -- /absolute/path/to/node /absolute/path/to/phaedo/mcp/server.js
319
361
  ```
@@ -0,0 +1,82 @@
1
+ // §10.2 `action_binding` — tying a consultation to the call it claims to describe.
2
+ //
3
+ // THE PROBLEM. Every field the resolver decides on lives in `action_descriptor`,
4
+ // and every one of them is authored by the agent being gated: domain, magnitude,
5
+ // reversible, amount, summary. A standing authorization like
6
+ //
7
+ // { match: { domains: ["writing"], magnitude_max: "medium", reversible: true },
8
+ // effect: "autoproceed" }
9
+ //
10
+ // therefore fires on whatever the agent SAYS it is doing, not on what it is doing.
11
+ // A buggy, prompt-injected, or hostile agent auto-proceeds on anything by
12
+ // describing it as low-magnitude reversible writing. The escalation rail inherits
13
+ // the same assumption: the subject approves the agent's prose summary.
14
+ //
15
+ // This is a known 2026 vulnerability class, not a hypothetical — agents requesting
16
+ // approval on a shown path while operating on the resolved target (CWE-61 symlink
17
+ // following + CWE-451 UI misrepresentation) produced CVE-2026-12958 (Amazon) and
18
+ // CVE-2026-50549 (Cursor), among others.
19
+ //
20
+ // WHAT THIS DOES, AND WHAT IT DOES NOT. A binding does NOT make the descriptor
21
+ // truthful — nothing at this layer can, short of the resolver executing the call
22
+ // itself. What it does:
23
+ //
24
+ // 1. makes a mismatch PROVABLE after the fact (the receipt records the exact
25
+ // tool and a hash of its arguments, so the agent's own logs can be checked
26
+ // against what the oracle was told),
27
+ // 2. gives the subject the REAL call on the escalation card, next to the
28
+ // agent's description of it, and
29
+ // 3. lets an authored authorization demand a binding before it will RELAX
30
+ // anything (§10.5 `require_binding`), so the blast radius of a fabricated
31
+ // descriptor is bounded by what the subject explicitly opted into.
32
+ //
33
+ // The hash is over the RFC 8785 canonical form of the arguments, so an agent and
34
+ // an auditor computing it independently agree byte-for-byte. Arguments themselves
35
+ // are NEVER carried — only a digest, plus an optional short human preview that the
36
+ // subject sees on the escalation sheet.
37
+ //
38
+ // PURE: no I/O, no globals, no crypto beyond the hash the caller injects. Shared by
39
+ // the resolver (mcp/consult-core.js), the receipt writer (mcp/receipts.js) and the
40
+ // escalation payload builder (mcp/escalation.js) so the three cannot drift.
41
+
42
+ import { PhaedoError } from './errors.js';
43
+
44
+ // A preview is for a human glance on a phone sheet, not a serialization format.
45
+ export const PREVIEW_MAX = 200;
46
+ export const TOOL_NAME_MAX = 120;
47
+ // A hex or base64url digest. Bounded so a malformed field can't bloat the receipt
48
+ // store or the (encrypted) escalation payload.
49
+ const HASH_RE = /^[A-Za-z0-9_-]{16,128}$/;
50
+
51
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
52
+ const clip = (s, n) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
53
+
54
+ // Parse + validate §10.2 `action_binding`. Returns the normalized binding, or null
55
+ // when absent. THROWS `request_malformed` when present but structurally wrong — a
56
+ // half-formed binding is worse than none, because a caller could believe it is
57
+ // bound when the resolver treats it as unbound.
58
+ export function normalizeActionBinding(raw) {
59
+ if (raw == null) return null;
60
+ if (!isObj(raw)) throw new PhaedoError('request_malformed', 'action_binding must be an object.');
61
+
62
+ const toolName = typeof raw.tool_name === 'string' ? raw.tool_name.trim() : '';
63
+ if (!toolName) throw new PhaedoError('request_malformed', 'action_binding.tool_name is required and must be a non-empty string.');
64
+ if (toolName.length > TOOL_NAME_MAX) throw new PhaedoError('request_malformed', `action_binding.tool_name exceeds ${TOOL_NAME_MAX} characters.`);
65
+
66
+ const hash = typeof raw.arguments_hash === 'string' ? raw.arguments_hash.trim() : '';
67
+ if (!hash) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash is required and must be a non-empty string.');
68
+ if (!HASH_RE.test(hash)) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash must be a hex or base64url digest (16–128 chars).');
69
+
70
+ const out = { tool_name: clip(toolName, TOOL_NAME_MAX), arguments_hash: hash };
71
+ if (typeof raw.arguments_preview === 'string' && raw.arguments_preview.trim()) {
72
+ out.arguments_preview = clip(raw.arguments_preview, PREVIEW_MAX);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Is this request bound to a concrete call? The single predicate every consumer
78
+ // asks, so "bound" means exactly one thing across the resolver, the policy engine,
79
+ // the receipt and the escalation card.
80
+ export function isBound(binding) {
81
+ return !!(binding && binding.tool_name && binding.arguments_hash);
82
+ }
package/agreement.js CHANGED
@@ -77,6 +77,44 @@ export function computeAgreement(receipts, { windowDays = DEFAULT_WINDOW_DAYS, n
77
77
  };
78
78
  }
79
79
 
80
+ // ── The transportable summary (receipts digest → phone) ───────────────────────
81
+ // `computeAgreement` is the full local report; this is the bounded slice that rides
82
+ // the `agent_to_phone` receipts digest so the subject can see how well their oracle
83
+ // is tracking them, per domain, without opening a terminal.
84
+ //
85
+ // It is derived ENTIRELY from the subject's own receipts and only ever travels
86
+ // between the subject's own devices (ciphertext over the relay), so it raises no
87
+ // §10.4 question — but it still carries no layer content, no rationale, and no
88
+ // `drivers`: counts and a rate, nothing else.
89
+ //
90
+ // Bounded on purpose (the digest is a last-write-wins payload, so it must not grow
91
+ // without limit): unscored domains are dropped (a domain with nothing scored says
92
+ // nothing), the rest are ranked by `scored` and capped. Ties break by name so the
93
+ // payload is DETERMINISTIC — an unstable key order would make every deposit look
94
+ // like a change.
95
+ export const DIGEST_DOMAIN_CAP = 8;
96
+
97
+ const slimBucket = (b) => ({
98
+ rate: b.rate,
99
+ scored: b.scored,
100
+ agreements: b.agreements,
101
+ disagreements: b.disagreements,
102
+ non_directional: b.non_directional,
103
+ awaiting_user_action: b.awaiting_user_action,
104
+ });
105
+
106
+ // (receipts, { windowDays, now, domainCap }) -> { window_days, overall, domains }
107
+ export function agreementDigest(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now(), domainCap = DIGEST_DOMAIN_CAP } = {}) {
108
+ const report = computeAgreement(receipts, { windowDays, now });
109
+ const domains = {};
110
+ Object.entries(report.domains)
111
+ .filter(([, b]) => b.scored > 0)
112
+ .sort((a, b) => (b[1].scored - a[1].scored) || a[0].localeCompare(b[0]))
113
+ .slice(0, Math.max(0, domainCap))
114
+ .forEach(([name, b]) => { domains[name] = slimBucket(b); });
115
+ return { window_days: windowDays, overall: slimBucket(report.overall), domains };
116
+ }
117
+
80
118
  // ── Dev CLI: `node agreement.js` / `npm run agreement` — local readout only ───
81
119
  // Decrypts the local receipt store with the local key; nothing leaves the device.
82
120
  // NOT product UX and NOT fed back into deference (see header).
package/ap2.js ADDED
@@ -0,0 +1,214 @@
1
+ // Anamnesis ↔ AP2 profile — standing authorizations as Intent Mandates.
2
+ //
3
+ // WHY THIS EXISTS. The standing risk to Anamnesis is not that a rival builds a
4
+ // better decision oracle; it is the standards cold-start. One implementer is a
5
+ // format, not a standard, and evangelism has not moved that number. Binding to a
6
+ // standard that ALREADY has implementers does: every AP2 participant becomes a
7
+ // partial Anamnesis consumer without adopting Anamnesis.
8
+ //
9
+ // AP2 (Google → FIDO Alliance, May 2026; Mastercard's Verifiable Intent
10
+ // co-contributed; the FIDO Payments TWG is chaired by Mastercard and Visa) defines
11
+ // an INTENT MANDATE: a verifiable credential, signed by the user with a
12
+ // hardware-backed device key, TTL'd, carrying structured constraints and the
13
+ // agent's stated understanding of the request ("prompt playback"), plus an
14
+ // intentionally open-ended risk payload.
15
+ //
16
+ // Read that against §10.5. An Intent Mandate is a standing authorization
17
+ // (`match` + `amount_gt` + `expires_at`) rendered as a credential the card networks
18
+ // already accept — and Phaedo's escalate-to-phone flow is architecturally the signing
19
+ // ceremony, once §8.3 gives the phone a signing key. The two designs arrived
20
+ // at the same primitive from opposite ends. "Prompt playback" is the payments
21
+ // industry independently discovering §10.2 `action_binding`.
22
+ //
23
+ // WHAT THIS MODULE DOES, AND DOES NOT. It is a PURE SERIALIZER: it renders the
24
+ // subject's authorizations into the Intent Mandate payload shape, and renders a
25
+ // consultation signal into a risk-payload entry. It does NOT sign — and today
26
+ // NOTHING does: §8.3 asymmetric signing is unbuilt, so no subject signing key
27
+ // exists anywhere in the product yet. That is the honest current state; the
28
+ // intended design is that the key is generated non-extractably on the phone and
29
+ // the ceremony that turns this payload into a credential is the phone's, not the
30
+ // server's. Deliberately unreachable from here either way. Scope + the P-256 vs
31
+ // Ed25519 decision: docs/roadmap/asymmetric-signing-scope-2026-07-23.md.
32
+ //
33
+ // The mapping is lossy in ONE direction on purpose. Anamnesis carries dispositions
34
+ // and inferred signals that have no place in a payment credential, so only the
35
+ // deterministic, subject-authored half crosses: authorizations, their bounds, and
36
+ // their expiry. Nothing inferred, no layer content, no persona. That is the §10.4
37
+ // boundary applied to a new transport, and it is also why this is safe to hand to a
38
+ // merchant's stack.
39
+ //
40
+ // PURE: no I/O, no crypto, no network.
41
+
42
+ import { signingInput } from './signing.js';
43
+
44
+ const MAG_RANK = { low: 0, medium: 1, high: 2 };
45
+
46
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
47
+
48
+ // AP2 expresses shopping constraints as structured fields, not prose. §10.5 `match`
49
+ // is already structured, so the translation is mechanical — the only real decision is
50
+ // which constraints have a faithful counterpart and which must be dropped rather than
51
+ // approximated. `magnitude` has none (it is a behavioral notion, not a commerce one),
52
+ // so it is carried as an advisory annotation, never as a spend limit.
53
+ function matchToConstraints(match) {
54
+ const m = isObj(match) ? match : {};
55
+ const out = {};
56
+ const domains = Array.isArray(m.domains) ? m.domains.map((d) => String(d)).filter(Boolean) : [];
57
+ if (domains.length) out.categories = domains;
58
+ // AP2's `max_amount` is a spend cap, so only a genuine CEILING may fill it.
59
+ // `amount_lt` is that ceiling. `amount_gt` is a FLOOR — "fires above N" — and on an
60
+ // autoproceed rule it means "auto-proceed only the LARGE ones", the opposite of a
61
+ // cap. Mapping it to `max_amount` (as this did before `amount_lt` existed) would
62
+ // have told a merchant the subject capped spending at exactly the figure above
63
+ // which they wanted extra scrutiny. It is carried as an annotation instead.
64
+ // Both ceiling forms map to `max_amount`, and when both are present the LOWER wins — AP2 has
65
+ // one field and no way to say "exclusive", so the credential must not assert more headroom than
66
+ // the rule grants. `amount_lte: 20` becomes `max_amount: 20`; `amount_lt: 20` also becomes 20,
67
+ // which is the pre-existing (slightly generous by one cent) mapping, unchanged.
68
+ const ceilings = [m.amount_lt, m.amount_lte].filter((v) => typeof v === 'number' && Number.isFinite(v));
69
+ if (ceilings.length) {
70
+ out.max_amount = Math.min(...ceilings);
71
+ }
72
+ if (typeof m.amount_gt === 'number' && Number.isFinite(m.amount_gt)) {
73
+ out.annotations = { ...(out.annotations || {}), anamnesis_amount_floor: m.amount_gt };
74
+ }
75
+ if (typeof m.reversible === 'boolean') out.refundable_only = m.reversible;
76
+ if (m.magnitude_max && MAG_RANK[m.magnitude_max] !== undefined) {
77
+ out.annotations = { ...(out.annotations || {}), anamnesis_magnitude_max: m.magnitude_max };
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // One §10.5 authorization → the Intent Mandate constraint block it corresponds to,
83
+ // or null when it has no faithful translation.
84
+ //
85
+ // Only AUTOPROCEED authorizations become mandates. That is the whole semantic match:
86
+ // an Intent Mandate is a grant of authority to act without the human present, and
87
+ // `autoproceed` is the only Anamnesis effect that grants anything. An `escalate` or
88
+ // `decline` rule is the ABSENCE of a mandate — the correct AP2 rendering of "ask me
89
+ // first" is not a restrictive credential, it is no credential at all, and emitting
90
+ // one would invert its meaning to a merchant.
91
+ export function authorizationToIntentConstraints(rule, { now = Date.now() } = {}) {
92
+ if (!isObj(rule)) return null;
93
+ if (rule.kind !== undefined && rule.kind !== 'authorization') return null;
94
+ if (rule.effect !== 'autoproceed') return null;
95
+ // A lapsed authorization must not be exported as live authority.
96
+ const exp = Date.parse(rule.expires_at);
97
+ if (Number.isFinite(exp) && exp <= now) return null;
98
+ // Nor must a CLIENT-SCOPED one (§10.5 `agents`). AP2 has no vocabulary for "this
99
+ // specific paired client", so a scoped grant cannot be expressed in a mandate — and
100
+ // emitting it unscoped would tell a merchant the subject granted unconditionally
101
+ // what they in fact granted to exactly one client. That is the same inversion this
102
+ // function already refuses for restrictive rules, and it crosses to a third party,
103
+ // where it is least correctable. A scope that cannot be carried must instead
104
+ // withhold the credential: the correct AP2 rendering of a grant this format cannot
105
+ // express is no grant at all. (An ABSENT `agents` is unscoped and exports normally;
106
+ // an empty array is a scope naming nobody, so it withholds too.)
107
+ if (Array.isArray(rule.agents)) return null;
108
+ return {
109
+ source_rule_id: rule.instruction_id || rule.id || null,
110
+ constraints: matchToConstraints(rule.match),
111
+ // §10.5 require_binding maps onto AP2's own posture that a mandate authorizes a
112
+ // BOUNDED transaction, not open-ended spending. Carried through so a verifier can
113
+ // see the subject demanded the agent commit to a concrete action.
114
+ requires_action_binding: rule.require_binding === true,
115
+ expires_at: Number.isFinite(exp) ? new Date(exp).toISOString() : null,
116
+ };
117
+ }
118
+
119
+ // The unsigned Intent Mandate payload for a subject's live authorizations.
120
+ //
121
+ // Returns null when the subject has granted nothing — which is the common case and
122
+ // the right answer: no authority, no mandate. Callers MUST NOT synthesize an empty
123
+ // mandate, because an empty credential reads to a merchant as "authorized with no
124
+ // constraints", the exact inversion this module exists to avoid.
125
+ export function buildIntentMandatePayload(fp, opts = {}) {
126
+ const {
127
+ now = Date.now(),
128
+ agentId = null,
129
+ promptPlayback = null, // the agent's stated understanding (AP2 "prompt playback")
130
+ consultSignal = null, // §10.3 signal, if a consultation preceded this
131
+ } = opts;
132
+
133
+ // A standing authorization lives in EITHER home (spec §10.5: "a `consult_policies` rule
134
+ // or `standing_rules` authorization"), so an Intent Mandate — the subject's live
135
+ // authorizations — must read both. Reading only `standing_rules` silently dropped every
136
+ // autoproceed guardrail authored through the extension's Decision-guardrails form (which
137
+ // writes `consult_policies`), so a subject with real grants got an empty mandate. The
138
+ // per-rule guards are unchanged and live in authorizationToIntentConstraints: autoproceed
139
+ // only, not lapsed, and never a client-scoped grant (AP2 can't express `agents`).
140
+ const standingRules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
141
+ const policyRules = Array.isArray(fp?.consult_policies?.rules) ? fp.consult_policies.rules : [];
142
+ // §10.5 tombstones — a REVOKED authorization must never become a mandate grant. This is
143
+ // the highest-stakes read of the two homes: a grant here is what a payment credential is
144
+ // minted against, so a rule the subject revoked that survived in a stale or restored copy
145
+ // would sign away authority they took back. Same consumer MUST as normalizePolicies.
146
+ const revoked = new Set(
147
+ (Array.isArray(fp?.consult_policies?.revoked) ? fp.consult_policies.revoked : [])
148
+ .map((t) => (t && typeof t === 'object' && t.id != null ? String(t.id) : null))
149
+ .filter(Boolean)
150
+ );
151
+ const grants = [];
152
+ const seen = new Set(); // dedupe by source rule id — the same authorization must not
153
+ for (const r of [...standingRules, ...policyRules]) { // become two mandate grants
154
+ if (r && typeof r === 'object') {
155
+ const rid = r.id != null ? String(r.id) : (r.instruction_id != null ? String(r.instruction_id) : null);
156
+ if (rid && revoked.has(rid)) continue;
157
+ }
158
+ const g = authorizationToIntentConstraints(r, { now });
159
+ if (!g) continue;
160
+ if (g.source_rule_id && seen.has(g.source_rule_id)) continue;
161
+ if (g.source_rule_id) seen.add(g.source_rule_id);
162
+ grants.push(g);
163
+ }
164
+ if (!grants.length) return null;
165
+
166
+ // The mandate expires with the FIRST grant to lapse — an aggregate credential must
167
+ // not outlive the narrowest authority inside it.
168
+ const expiries = grants.map((g) => Date.parse(g.expires_at)).filter(Number.isFinite);
169
+ const expiresAt = expiries.length ? new Date(Math.min(...expiries)).toISOString() : null;
170
+
171
+ return {
172
+ mandate_type: 'intent',
173
+ // Version the profile, not the payment protocol: AP2 moves on its own cadence and
174
+ // a verifier needs to know which mapping produced this.
175
+ anamnesis_profile: '0.1',
176
+ issued_at: new Date(now).toISOString(),
177
+ expires_at: expiresAt,
178
+ ...(agentId ? { agent_id: String(agentId) } : {}),
179
+ ...(promptPlayback ? { prompt_playback: String(promptPlayback).slice(0, 500) } : {}),
180
+ grants,
181
+ // AP2 leaves the risk payload open-ended for exactly this kind of signal. It is
182
+ // the ONE place a behavioral system has something to say to a payments network,
183
+ // and it says it in the §10.4 currency: a constrained signal, never persona.
184
+ risk_payload: buildRiskPayload({ consultSignal }),
185
+ // Unsigned by construction. The server CANNOT sign: per §8.3 the subject's key is
186
+ // non-extractable in the phone's Secure Enclave, so signing is a ceremony that belongs
187
+ // to the device holding the key. `intentMandateSigningInput()` below computes the exact
188
+ // bytes the phone would be asked to sign. Until a signature comes back, this object is
189
+ // a proposal, never a credential.
190
+ signature: null,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * The exact bytes a §8.3 signature over an Intent Mandate covers: the §6 RFC 8785 canonical
196
+ * form of the payload with `signature` omitted. This is the seam between the server (which
197
+ * builds the mandate) and the phone (which holds the only key that can sign it) — both sides
198
+ * must agree on these bytes or the resulting signature verifies nowhere.
199
+ *
200
+ * Omitting `signature` is what keeps the input stable once the signature is attached, so a
201
+ * verifier recomputes the same bytes from the finished credential.
202
+ */
203
+ export function intentMandateSigningInput(payload) {
204
+ return signingInput(payload, { field: 'signature' });
205
+ }
206
+
207
+ // §10.3 signal → AP2 risk-payload entry. Deliberately minimal: a merchant learns
208
+ // that the subject's own oracle was consulted and what it said — which is exactly the
209
+ // claim that makes a mandate more trustworthy — and learns nothing about the subject.
210
+ export function buildRiskPayload({ consultSignal = null } = {}) {
211
+ const payload = { anamnesis_consulted: !!consultSignal };
212
+ if (consultSignal) payload.anamnesis_signal = String(consultSignal);
213
+ return payload;
214
+ }