phaedo-mcp 0.2.2 → 0.3.1

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 (68) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +182 -19
  3. package/action-binding.js +82 -0
  4. package/agreement.js +86 -2
  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 +126 -8
  12. package/consult-policy.js +427 -33
  13. package/consult-runtime.js +60 -0
  14. package/consult.js +29 -1
  15. package/delegation-sync.js +95 -0
  16. package/escalation-policy.sample.json +13 -0
  17. package/escalation.js +383 -0
  18. package/fingerprint-source.js +112 -13
  19. package/health.js +54 -0
  20. package/hooks/pre-ship.mjs +409 -0
  21. package/hooks/session-end.mjs +244 -0
  22. package/hooks/stop-capture.mjs +140 -0
  23. package/install.mjs +226 -36
  24. package/lib/capture-home.js +46 -0
  25. package/lib/domain.js +101 -0
  26. package/lib/is-main.js +40 -0
  27. package/lib/phaedo-crypto.js +17 -1
  28. package/package.json +29 -5
  29. package/phone-source.js +85 -12
  30. package/projection.js +4 -1
  31. package/pull.js +19 -3
  32. package/receipts-sync.js +197 -0
  33. package/receipts.js +34 -3
  34. package/server.js +323 -54
  35. package/server.json +3 -3
  36. package/ship-actions.js +164 -0
  37. package/ship-readout.js +106 -0
  38. package/signing-audit.js +111 -0
  39. package/signing-request.js +217 -0
  40. package/signing.js +176 -0
  41. package/vendor/authored-source.js +250 -0
  42. package/vendor/capabilities.js +203 -0
  43. package/vendor/capture/capture-core.js +67 -0
  44. package/vendor/capture/detectors.js +287 -0
  45. package/vendor/capture/exchanges.js +77 -0
  46. package/vendor/capture/model-detect.js +195 -0
  47. package/vendor/capture/owner.js +35 -0
  48. package/vendor/capture/package.json +4 -0
  49. package/vendor/capture/source-id.js +56 -0
  50. package/vendor/capture/state.js +148 -0
  51. package/vendor/capture/transcript.js +184 -0
  52. package/vendor/commitments.js +169 -0
  53. package/vendor/context-block.js +251 -18
  54. package/vendor/envelope.js +50 -4
  55. package/vendor/extraction/capture-combine.js +160 -0
  56. package/vendor/extraction/catalog.js +232 -0
  57. package/vendor/extraction/delta-lift.js +109 -0
  58. package/vendor/extraction/fingerprint-update.js +227 -0
  59. package/vendor/extraction/package.json +4 -0
  60. package/vendor/extraction/persist.js +45 -0
  61. package/vendor/extraction/reconcile.js +310 -0
  62. package/vendor/extraction/resolve.js +178 -0
  63. package/vendor/extraction/rule-promotion.js +103 -0
  64. package/vendor/kdf.js +20 -1
  65. package/vendor/protocol.js +7 -0
  66. package/vendor/signal-ledger.js +402 -0
  67. package/vendor/trait-phrases.js +656 -0
  68. package/vendored-files.mjs +86 -0
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "phaedo-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
- "description": "Phaedo MCP server — inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
7
+ "description": "Phaedo MCP server \u2014 inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
8
8
  "mcpName": "io.github.phaedo-labs/phaedo-mcp",
9
9
  "homepage": "https://phaedo.so",
10
10
  "repository": {
@@ -32,16 +32,33 @@
32
32
  "consult.js",
33
33
  "consult-core.js",
34
34
  "consult-policy.js",
35
+ "consult-runtime.js",
36
+ "ship-actions.js",
37
+ "ship-readout.js",
38
+ "action-binding.js",
39
+ "autonomy-promotion.js",
40
+ "ap2.js",
35
41
  "receipts.js",
42
+ "receipts-sync.js",
36
43
  "agreement.js",
37
44
  "calibration.js",
45
+ "health.js",
38
46
  "phone-source.js",
47
+ "delegation-sync.js",
48
+ "escalation.js",
49
+ "signing.js",
50
+ "signing-request.js",
51
+ "signing-audit.js",
52
+ "capture-sync.js",
53
+ "capture-local.js",
54
+ "capture-a11y.js",
39
55
  "cache.js",
40
56
  "pairing.js",
41
57
  "pair.js",
42
58
  "pull.js",
43
59
  "install.mjs",
44
60
  "build-package.mjs",
61
+ "vendored-files.mjs",
45
62
  "vendor/",
46
63
  "lib/",
47
64
  "hooks/",
@@ -50,7 +67,9 @@
50
67
  "sample-fingerprint.json",
51
68
  "pairing.sample.json",
52
69
  "consult-policies.sample.json",
53
- "README.md"
70
+ "escalation-policy.sample.json",
71
+ "README.md",
72
+ "NOTICE"
54
73
  ],
55
74
  "scripts": {
56
75
  "start": "node server.js",
@@ -60,12 +79,14 @@
60
79
  "setup": "node install.mjs",
61
80
  "setup:pair": "node install.mjs --pair",
62
81
  "setup:code-hook": "node install.mjs --code-hook",
82
+ "setup:capture-model": "node install.mjs --capture-model",
63
83
  "unsetup": "node install.mjs --uninstall",
64
84
  "pair": "node pair.js",
65
85
  "pull": "node pull.js",
66
- "test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-escalation.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs",
86
+ "test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs",
67
87
  "receipts": "node receipts.js list",
68
88
  "agreement": "node agreement.js",
89
+ "ship-readout": "node ship-readout.js",
69
90
  "calibration": "node calibration.js",
70
91
  "health": "node health.js",
71
92
  "validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json",
@@ -80,7 +101,10 @@
80
101
  "smoke:escalation": "node test-escalation.mjs",
81
102
  "smoke:receipts": "node test-receipts.mjs",
82
103
  "smoke:agreement": "node test-agreement.mjs",
83
- "smoke:calibration": "node test-calibration.mjs"
104
+ "smoke:calibration": "node test-calibration.mjs",
105
+ "smoke:binding": "node test-action-binding.mjs",
106
+ "smoke:autonomy": "node test-autonomy-promotion.mjs",
107
+ "smoke:ap2": "node test-ap2.mjs"
84
108
  },
85
109
  "dependencies": {
86
110
  "@modelcontextprotocol/sdk": "^1.0.0",
package/phone-source.js CHANGED
@@ -1,7 +1,14 @@
1
- // MCP Phase 1b — real key boundary (Option B: share the extension's pairing).
1
+ // MCP Phase 1b — real key boundary.
2
2
  //
3
- // Given a pairing record that already holds `pair_key` (exported from the paired
4
- // extension), this runs the spec-faithful authorize → pull → decrypt flow — a
3
+ // The pairing record may come from EITHER provenance: `pair.js`, which pairs this
4
+ // server as its own SAS-approved, revocable client (mcp-revocable-approval-plan.md,
5
+ // DONE + device-verified), or the older "Option B" export of the extension's
6
+ // `phaedo_local_pairing`. This path works under both — the phone's drains try each
7
+ // verified client's key — but the extension-facing `agent_to_ext` lane does NOT;
8
+ // see delegation-sync.js and docs/roadmap/paired-client-identity-2026-07-22.md.
9
+ //
10
+ // Given a pairing record holding `pair_key`, this runs the spec-faithful
11
+ // authorize → pull → decrypt flow — a
5
12
  // direct port of popup.js reAuthorize (:745) + silentSync (:883):
6
13
  //
7
14
  // authorize: fresh ephemeral X25519, HMAC-bound to pair_key, POST /v1/authorize,
@@ -16,15 +23,34 @@
16
23
  // after the one-time pairing.
17
24
 
18
25
  import { readFile } from 'fs/promises';
19
- import { existsSync } from 'fs';
26
+ import { existsSync, readFileSync } from 'fs';
20
27
  import { resolve } from 'path';
21
- import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
28
+ import { Phaedo, b64uEnc, b64uDec, Capabilities } from './lib/phaedo-crypto.js';
22
29
 
23
30
  const { Identity, Kdf, Envelope } = Phaedo;
24
31
  const EXPIRY_SKEW_MS = 30 * 1000; // re-auth this early to avoid edge-of-expiry races
25
- const TIMEOUT_MS = 5000; // fail fast when the phone is unreachable → cache fallback
26
32
 
27
- // ── Pairing record (Option B: exported from the extension's phaedo_local_pairing)
33
+ // TWO timeouts, because the two requests wait on completely different things.
34
+ //
35
+ // `/v1/profile` waits on a MACHINE: the phone either answers or it doesn't, so failing fast
36
+ // is right — an unreachable phone should fall back to the cache quickly.
37
+ //
38
+ // `/v1/authorize` waits on a PERSON: they must notice their phone, read the card, and pass
39
+ // a biometric gate. A single 5s budget covered both, which made every authorize a race the
40
+ // subject usually lost — the client aborted, the card vanished mid-tap, and an approval
41
+ // completed seconds later landed on a request nobody was listening for. Worse, the abort is
42
+ // a TRANSIENT failure, so it took the silent cache fallback: the agent then answered
43
+ // authorization questions from a stale fingerprint while sounding certain. Observed on
44
+ // device 2026-07-25 — the cache had not been refreshed for ~22 hours and a REVOKED payment
45
+ // grant was still being honoured, because no live pull had ever completed.
46
+ //
47
+ // Face ID on the first try can fit in 5s. A passcode fallback cannot, and neither can a
48
+ // phone that is face-down on a table. Two minutes is the human budget; the phone's own card
49
+ // expiry remains the real bound, this just stops the client giving up first.
50
+ const TIMEOUT_MS = 5000; // reachability: /v1/profile
51
+ const APPROVE_TIMEOUT_MS = 120000; // human-in-the-loop: /v1/authorize
52
+
53
+ // ── Pairing record (from `pair.js`, or the legacy extension export — see header)
28
54
  // { endpoint, client_id, client_secret, pair_key (b64url 32B), vault_pub, ... }
29
55
  export async function loadPairingRecord(baseDir) {
30
56
  const path = process.env.PHAEDO_PAIRING
@@ -55,6 +81,30 @@ export function hasPairingRecord(baseDir) {
55
81
  return existsSync(resolve(baseDir, 'pairing.json'));
56
82
  }
57
83
 
84
+ // This server's OWN paired-client identifier — the identity §10.5 `agents` scoping
85
+ // matches on (`docs/roadmap/paired-client-identity-2026-07-22.md`).
86
+ //
87
+ // Read SYNCHRONOUSLY at startup, beside the policy file, because it is process-wide
88
+ // configuration rather than per-call state: one MCP server process serves exactly one
89
+ // paired client, so this cannot change between calls without a restart. That is also
90
+ // what makes the whole design work — the server never has to ask a caller who it is.
91
+ //
92
+ // Fail-safe by construction: any failure (no pairing, unreadable, malformed, missing
93
+ // or non-string `client_id`) yields null, and null makes every `agents`-scoped
94
+ // RELAXING rule inert. A broken read can therefore only ever withhold authority, never
95
+ // grant it. Never throws.
96
+ export function readServingClientId(baseDir) {
97
+ const path = process.env.PHAEDO_PAIRING
98
+ ? resolve(process.cwd(), process.env.PHAEDO_PAIRING)
99
+ : resolve(baseDir, 'pairing.json');
100
+ try {
101
+ const id = JSON.parse(readFileSync(path, 'utf8'))?.client_id;
102
+ return typeof id === 'string' && id.trim() ? id.trim() : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
58
108
  // In-memory session cache, keyed by endpoint. Holds session_key (never persisted).
59
109
  const _sessions = new Map();
60
110
 
@@ -74,8 +124,14 @@ async function authorize(rec, deps) {
74
124
  scopes: ['profile:full'],
75
125
  eph_ext_pub: b64uEnc(eph.publicKey),
76
126
  eph_mac: b64uEnc(extMac),
127
+ // What this agent can do, for the §5 step-3 upgrade gate. The agent reads authored
128
+ // content but has no channel of its own to the extension, so the phone records this and
129
+ // couriers it on. Rides the authorize body because that is the one request the agent
130
+ // makes that the phone authenticates AND that recurs — sessions expire, so the record
131
+ // refreshes while the agent is in use and goes stale when it is not, which is the intent.
132
+ capabilities: Capabilities.advertise('agent'),
77
133
  }),
78
- signal: AbortSignal.timeout(TIMEOUT_MS),
134
+ signal: AbortSignal.timeout(APPROVE_TIMEOUT_MS), // waits on a human, not a machine
79
135
  });
80
136
  if (!res.ok) throw new PhoneError(`authorize failed (${res.status})`, res.status);
81
137
  const body = await res.json();
@@ -100,12 +156,29 @@ async function getSession(rec, deps) {
100
156
  }
101
157
 
102
158
  // Map the §9-profile inner plaintext to the vault payload shape the projection
103
- // expects. Profile inner is { data: <fingerprint>, metrics: {signals, ling,
104
- // sessions}, scopes } (mobile localServer handleProfile + popup restoreMetrics).
105
- function toVaultPayload(inner) {
106
- const m = inner.metrics || {};
159
+ // expects. Profile inner is { data: <fingerprint>, user_configuration?, metrics:
160
+ // {signals, ling, sessions}, scopes } (mobile localServer handleProfile + popup
161
+ // restoreMetrics). The optional §5 sibling (config home migration, part 3) maps
162
+ // to phaedo_user_configuration, activating the canonical authored read
163
+ // (consult-policy loadPolicies / context-block renderContextBlock); absent —
164
+ // pre-part-3 phone — consumers fall back to the fingerprint A-home unchanged.
165
+ export function toVaultPayload(inner) {
166
+ // Prefer every paired browser's counters combined (`metrics_combined`, 2026-10-01): the subject
167
+ // is one person across browsers, and `metrics` is only the browser that posted last. A phone
168
+ // that predates the sibling sends only `metrics`, which still serves.
169
+ const m = (inner.metrics_combined && typeof inner.metrics_combined === 'object' && !Array.isArray(inner.metrics_combined))
170
+ ? inner.metrics_combined
171
+ : (inner.metrics || {});
107
172
  return {
108
173
  phaedo_fingerprint: inner.data,
174
+ ...(inner.user_configuration && typeof inner.user_configuration === 'object'
175
+ ? { phaedo_user_configuration: inner.user_configuration } : {}),
176
+ // §8.3 signing public key — a sibling of the fingerprint, carried through so a
177
+ // consumer can VERIFY a signature the phone produced (e.g. an AP2 mandate) against a
178
+ // key learned over the trusted pairing channel, not one asserted in the signed object
179
+ // itself. A plain non-empty string or nothing; never trusted from inside `data`.
180
+ ...(typeof inner.signing_public_key === 'string' && inner.signing_public_key
181
+ ? { phaedo_signing_public_key: inner.signing_public_key } : {}),
109
182
  phaedo_behavioral_signals: m.signals || {},
110
183
  phaedo_linguistic_profile: m.ling || {},
111
184
  phaedo_session_metrics: m.sessions || {},
package/projection.js CHANGED
@@ -87,7 +87,10 @@ export function buildInjectionResponse(vd, request = {}, now = Date.now()) {
87
87
  }
88
88
 
89
89
  const { vd: selectedVd, included } = selectLayers(vd, request.requested_layers);
90
- const projection = PhaedoContext.renderContextBlock(selectedVd);
90
+ // §5.2 v0.2: the §9.2 session_context (domain / task_hint) drives deterministic
91
+ // activation of domain/task-scoped standing instructions; absent, scoped rules
92
+ // render with a conditional prefix instead.
93
+ const projection = PhaedoContext.renderContextBlock(selectedVd, { sessionContext: request.session_context || null });
91
94
  if (!projection) {
92
95
  throw new PhaedoError('decryption_failed', 'Fingerprint produced no projection (empty or missing layers).');
93
96
  }
package/pull.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { fileURLToPath } from 'url';
9
9
  import { dirname } from 'path';
10
- import { loadFingerprint } from './fingerprint-source.js';
10
+ import { loadFingerprint, sourceOf } from './fingerprint-source.js';
11
11
 
12
12
  const BASE_DIR = dirname(fileURLToPath(import.meta.url));
13
13
 
@@ -15,10 +15,26 @@ loadFingerprint(BASE_DIR)
15
15
  .then((vd) => {
16
16
  const fp = (vd && vd.phaedo_fingerprint) || {};
17
17
  const layers = fp.layers ? Object.keys(fp.layers).length : 0;
18
+ const src = sourceOf(vd);
19
+ // This line used to say "from the phone" unconditionally — including when the pull had
20
+ // failed and the at-rest cache was served instead. That is precisely how a 22-hour-old
21
+ // profile went unnoticed while the agent answered authorization questions from it. The
22
+ // whole point of this command is to verify the pairing works, so it has to distinguish
23
+ // "your phone answered" from "your phone didn't, here's an old copy".
24
+ if (src.live) {
25
+ process.stdout.write(
26
+ `✓ Read your profile from the phone — fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s).\n`
27
+ );
28
+ process.exit(0);
29
+ }
30
+ const hours = src.ageMs != null ? Math.round(src.ageMs / 3600000) : null;
18
31
  process.stdout.write(
19
- `✓ Read your profile from the phone — fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s).\n`
32
+ `⚠ Could NOT reach your phone — served a CACHED copy${hours != null ? ` about ${hours}h old` : ''} ` +
33
+ `(fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s)).\n` +
34
+ ` Anything you changed since then — including a revoked authorization — is NOT reflected.\n` +
35
+ ` Open Phaedo on your phone and approve the card, then run this again.\n`
20
36
  );
21
- process.exit(0);
37
+ process.exit(2); // distinct from 1 (hard failure) — stale, not broken
22
38
  })
23
39
  .catch((err) => {
24
40
  process.stderr.write(`✗ Couldn't read your profile: ${err.message}\n`);
@@ -0,0 +1,197 @@
1
+ // §10.6 receipts audit channel — MCP ↔ phone transport (G1+G2).
2
+ //
3
+ // Closes the act-as-me learning loop: today receipts written by `phaedo_consult` /
4
+ // `phaedo_escalate` sit at `user_action: null` until the subject runs `node receipts.js
5
+ // mark` — a dev CLI only. This module puts the audit on the subject's PHONE so they can
6
+ // mark outcomes in the flow, which then feeds delegation learning (consult-core.js
7
+ // `deriveDelegationSignals`).
8
+ //
9
+ // Two directions over the existing fp-mailbox transport (relay/mailbox.js):
10
+ //
11
+ // agent_to_phone — MCP deposits a digest of the latest UNMARKED receipts (a single
12
+ // payload with at most N entries, last-write-wins idempotent: the
13
+ // phone always sees the latest snapshot). §10.4-safe: only the
14
+ // fields the phone needs to render — receipt_id, created_at, via,
15
+ // agent_id, action_descriptor, response.signal — never `drivers`
16
+ // (local provenance) and never layer content.
17
+ // phone_to_agent — phone deposits a BATCH of `{ receipt_id, outcome }`; this module
18
+ // drains and calls `markOutcome` per mark. Idempotent on the MCP
19
+ // side (re-marking the same outcome is a no-op), so re-draining is
20
+ // safe and the phone need not track per-mark ACKs — the next
21
+ // receipts digest reflects applied marks (`user_action` set).
22
+ //
23
+ // Crypto: fp_sync_key derived from this server's pair_key. Drained by the PHONE, whose
24
+ // multi-client loop tries each verified client's key — so independent pairing is fine
25
+ // here (unlike agent_to_ext, see delegation-sync.js). The relay only ever sees
26
+ // ciphertext. Best-effort and isolated:
27
+ // a deposit/drain failure must NEVER break the tool that triggered it.
28
+
29
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
30
+ import { readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
31
+ import { agreementDigest } from './agreement.js';
32
+
33
+ const { Kdf, Envelope } = Phaedo;
34
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
35
+ const DIGEST_CAP = 50; // phone shows recent N — enough for the latest sittings, bounded payload
36
+
37
+ export function relayBaseFromEnv(env = process.env) {
38
+ return env.PHAEDO_RELAY || DEFAULT_RELAY;
39
+ }
40
+
41
+ // Strip a receipt down to the §10.4-safe fields the phone renders. NEVER includes
42
+ // `drivers` (local provenance cues that would expose Decision&Risk reasoning to the
43
+ // subject device — fine on this device, but the audit UI doesn't need them and not
44
+ // shipping them keeps the digest small + the boundary tight).
45
+ function digestFields(r) {
46
+ if (!r || typeof r !== 'object') return null;
47
+ return {
48
+ receipt_id: r.receipt_id,
49
+ created_at: r.created_at,
50
+ via: r.via,
51
+ agent_id: r.agent_id || null,
52
+ // The paired client that actually SERVED the call, beside the vendor string it
53
+ // claimed. §10.4-safe (an opaque id the subject's own phone issued, not persona
54
+ // content) and the phone is the one surface that can render it usefully: it holds
55
+ // the client registry, so it can show the friendly NAME where the receipt holds
56
+ // only the id. Without it the Activity screen can only report what the agent said
57
+ // it was — which is precisely the claim §10.5 stopped trusting.
58
+ client_id: r.client_id || null,
59
+ request: {
60
+ consultation_type: r?.request?.consultation_type || null,
61
+ action_descriptor: {
62
+ domain: r?.request?.action_descriptor?.domain ?? null,
63
+ reversible: r?.request?.action_descriptor?.reversible ?? null,
64
+ magnitude: r?.request?.action_descriptor?.magnitude ?? null,
65
+ amount: r?.request?.action_descriptor?.amount ?? null,
66
+ summary: r?.request?.action_descriptor?.summary ?? null,
67
+ },
68
+ },
69
+ response: {
70
+ signal: r?.response?.signal ?? null,
71
+ deference_level: r?.response?.deference_level ?? null,
72
+ },
73
+ user_action: r.user_action ?? null, // already-marked entries stay visible (phone shows status)
74
+ };
75
+ }
76
+
77
+ // Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
78
+ // recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
79
+ // same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
80
+ export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
81
+ const list = Array.isArray(receipts) ? receipts : [];
82
+ // Most-recent N (receipts are append-only, ordered by creation in the store).
83
+ const head = list.slice(-cap).map(digestFields).filter(Boolean);
84
+ // Agreement rides the SAME digest, but is computed over the WHOLE store, not the
85
+ // capped head — the rate is a 30-day property of the subject's history, and slicing
86
+ // it to the last 50 entries would silently redefine the metric.
87
+ const generatedAt = now();
88
+ const agreementAt = Date.parse(generatedAt);
89
+ const agreement = agreementDigest(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
90
+ const pairKey = b64uDec(pairKeyB64u);
91
+ let fpSyncKey;
92
+ try {
93
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
94
+ } finally {
95
+ pairKey.fill(0);
96
+ }
97
+ try {
98
+ const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, generated_at: generatedAt } };
99
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
100
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
101
+ plaintext.fill(0);
102
+ return { envelope };
103
+ } finally {
104
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
105
+ }
106
+ }
107
+
108
+ // Decrypt a phone-deposited marks payload. Returns `{ marks: [{receipt_id, outcome}, ...] }`
109
+ // or null on a malformed/wrong-key envelope. Pure (no I/O) so it's testable.
110
+ export async function parseMarksDeposit(pairKeyB64u, envelope) {
111
+ if (!envelope) return null;
112
+ const pairKey = b64uDec(pairKeyB64u);
113
+ let fpSyncKey;
114
+ try { fpSyncKey = await Kdf.deriveFpSyncKey(pairKey); }
115
+ finally { pairKey.fill(0); }
116
+ try {
117
+ const plaintext = await Envelope.unwrap(envelope, fpSyncKey);
118
+ let inner;
119
+ try { inner = JSON.parse(new TextDecoder().decode(plaintext)); }
120
+ finally { if (plaintext.fill) plaintext.fill(0); }
121
+ if (!inner || inner.kind !== 'receipt_marks' || !inner.data) return null;
122
+ const arr = Array.isArray(inner.data.marks) ? inner.data.marks : [];
123
+ return {
124
+ marks: arr
125
+ .map((m) => ({
126
+ receipt_id: typeof m?.receipt_id === 'string' ? m.receipt_id : null,
127
+ outcome: typeof m?.outcome === 'string' ? m.outcome : null,
128
+ }))
129
+ .filter((m) => m.receipt_id && USER_ACTIONS.includes(m.outcome)),
130
+ };
131
+ } catch {
132
+ return null;
133
+ } finally {
134
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
135
+ }
136
+ }
137
+
138
+ // Deposit a receipts digest to the relay's agent_to_phone slot. Best-effort; mirrors
139
+ // `depositDelegationSuggestions` exactly (same auth, same timeout, same isolation —
140
+ // a failure NEVER throws out into the consult/escalate path that called us).
141
+ export async function depositReceiptsDigest(pairing, receipts, deps = {}) {
142
+ const fetchFn = deps.fetch || globalThis.fetch;
143
+ const list = Array.isArray(receipts) ? receipts : [];
144
+ if (!list.length) return { deposited: false, reason: 'no-receipts' };
145
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
146
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
147
+ try {
148
+ const body = await buildReceiptsDeposit(pairing.pair_key, list, deps);
149
+ const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_phone`;
150
+ const authHeader = pairing.relay_device_secret
151
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
152
+ : {};
153
+ const res = await fetchFn(url, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json', ...authHeader },
156
+ body: JSON.stringify(body),
157
+ signal: AbortSignal.timeout(deps.timeoutMs || 8000),
158
+ });
159
+ return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
160
+ } catch (e) {
161
+ return { deposited: false, reason: String(e && e.message || e) };
162
+ }
163
+ }
164
+
165
+ // Drain phone-deposited outcome marks from the phone_to_agent slot and apply each via
166
+ // markOutcome (idempotent). Returns `{ applied, skipped }`. Best-effort; a transport
167
+ // failure returns `{ applied: 0, skipped: 0, reason }` instead of throwing.
168
+ export async function drainAndApplyMarks(pairing, stateDir, deps = {}) {
169
+ const fetchFn = deps.fetch || globalThis.fetch;
170
+ if (!pairing || !pairing.pair_key) return { applied: 0, skipped: 0, reason: 'unpaired' };
171
+ if (!pairing.relay_device_id) return { applied: 0, skipped: 0, reason: 'no-relay-device-id' };
172
+ try {
173
+ const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/phone_to_agent`;
174
+ const authHeader = pairing.relay_device_secret
175
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
176
+ : {};
177
+ const res = await fetchFn(url, {
178
+ method: 'GET',
179
+ headers: authHeader,
180
+ signal: AbortSignal.timeout(deps.timeoutMs || 8000),
181
+ });
182
+ if (!res || !res.ok) return { applied: 0, skipped: 0, status: res && res.status };
183
+ const body = await res.json().catch(() => null);
184
+ const parsed = await parseMarksDeposit(pairing.pair_key, body && body.envelope);
185
+ if (!parsed) return { applied: 0, skipped: 0, reason: 'no-marks' };
186
+ let applied = 0, skipped = 0;
187
+ for (const m of parsed.marks) {
188
+ try {
189
+ const r = await markOutcome(stateDir, m.receipt_id, m.outcome);
190
+ if (r.matched) applied++; else skipped++;
191
+ } catch { skipped++; }
192
+ }
193
+ return { applied, skipped, total: parsed.marks.length };
194
+ } catch (e) {
195
+ return { applied: 0, skipped: 0, reason: String(e && e.message || e) };
196
+ }
197
+ }
package/receipts.js CHANGED
@@ -25,7 +25,9 @@ import { mkdir, readFile, writeFile, chmod, rename } from 'fs/promises';
25
25
  import { existsSync } from 'fs';
26
26
  import { join } from 'path';
27
27
  import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
28
+ import { canonicalDomain } from './lib/domain.js';
28
29
  import { getOrCreateCacheKey, defaultStateDir } from './cache.js';
30
+ import { normalizeActionBinding } from './action-binding.js';
29
31
 
30
32
  const { Envelope } = Phaedo;
31
33
 
@@ -105,11 +107,17 @@ function withStoreLock(stateDir, fn) {
105
107
  // never the projection (§10.4 holds inside the vault too). `via` records which
106
108
  // tool resolved it (phaedo_consult | phaedo_check_authorization) so M5 can weigh
107
109
  // full consultations and pre-flight matches separately.
108
- export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) {
110
+ export function buildReceipt({ via, agentId, clientId, request, response, drivers, now = Date.now() }) {
109
111
  const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
110
112
  // Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that
111
113
  // drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer
112
114
  // text; coerced to the known shape; absent for deterministic pre-flights.
115
+ // Defensive: a malformed binding must not fail the receipt write (a receipt-write
116
+ // failure must never break the consultation — §10.6). The resolver has already
117
+ // rejected structurally-bad bindings with `request_malformed`, so anything that
118
+ // throws here is a caller bypassing it; record nothing rather than half of it.
119
+ let bnd = null;
120
+ try { bnd = normalizeActionBinding(request?.action_binding); } catch { bnd = null; }
113
121
  const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({
114
122
  cue: typeof d?.cue === 'string' ? d.cue : null,
115
123
  ...(typeof d?.lean === 'number' ? { lean: d.lean } : {}),
@@ -121,16 +129,33 @@ export function buildReceipt({ via, agentId, request, response, drivers, now = D
121
129
  receipt_id: crypto.randomUUID(),
122
130
  created_at: new Date(now).toISOString(),
123
131
  via: via || 'phaedo_consult',
132
+ // WHO CLAIMED to be calling. Self-asserted, unverified, and never a gate — the
133
+ // same standing `action_descriptor` was given in v0.1.48. Audit label only.
124
134
  agent_id: typeof agentId === 'string' ? agentId : null,
135
+ // WHO ACTUALLY SERVED the call: this server's own paired-client id, established
136
+ // out-of-band with the phone under a user-verified SAS. This is the identity
137
+ // §10.5 `agents` scoping matches on, so the audit can distinguish which paired
138
+ // client acted from whichever vendor string it chose to claim. Null = unpaired.
139
+ client_id: typeof clientId === 'string' && clientId ? clientId : null,
125
140
  request: {
126
141
  consultation_type: request?.consultation_type || 'action_approval',
127
142
  action_descriptor: {
128
- domain: typeof ad.domain === 'string' ? ad.domain : null,
143
+ // Canonical form, matching what the resolver reasoned about (lib/domain.js).
144
+ // The receipt is the evidence store the promotion tallies key on, so a raw
145
+ // `"Finance"` here would be a class of its own and split the subject's
146
+ // approvals away from `"finance"`.
147
+ domain: typeof ad.domain === 'string' ? (canonicalDomain(ad.domain) || null) : null,
129
148
  reversible: typeof ad.reversible === 'boolean' ? ad.reversible : null,
130
149
  magnitude: typeof ad.magnitude === 'string' ? ad.magnitude : null,
131
150
  amount: typeof ad.amount === 'number' ? ad.amount : null,
132
151
  summary: typeof ad.summary === 'string' ? ad.summary.slice(0, 500) : null,
133
152
  },
153
+ // §10.2 binding: the concrete call the descriptor above CLAIMS to describe.
154
+ // This is the field that makes a descriptor/action mismatch provable later —
155
+ // the descriptor is the agent's story, this is what it said it would run.
156
+ // Never the arguments themselves: a digest, the tool name, and at most a
157
+ // short human preview.
158
+ ...(bnd ? { action_binding: bnd } : {}),
134
159
  },
135
160
  response: {
136
161
  signal: response?.signal ?? null,
@@ -164,6 +189,12 @@ export async function updateUserAction(stateDir, receiptId, action, { now = Date
164
189
  const r = store.receipts.find((x) => x.receipt_id === receiptId);
165
190
  if (!r) return false;
166
191
  r.user_action = action;
192
+ // WHEN the outcome was recorded, alongside WHAT it was. Not decision latency —
193
+ // the subject may mark in a later sitting — but it does reveal a screenful of
194
+ // receipts stamped in one pass, which §10.7 autonomy promotion treats as
195
+ // bookkeeping rather than deliberate consent. Mutable with `user_action`, and
196
+ // only with it (§10.6: user_action is the sole mutable field; this is part of it).
197
+ r.user_action_at = new Date(now).toISOString();
167
198
  await writeStore(stateDir, store, now);
168
199
  return true;
169
200
  });
@@ -230,7 +261,7 @@ if (process.argv[1] && process.argv[1].endsWith('receipts.js')) {
230
261
  for (const r of rs.slice(-50)) {
231
262
  const ad = r.request.action_descriptor;
232
263
  console.log(
233
- `${r.receipt_id.slice(0, 8)} ${r.created_at} ${r.via === 'phaedo_check_authorization' ? '[pre-flight]' : '[consult] '}` +
264
+ `${r.receipt_id.slice(0, 8)} ${r.created_at} ${r.via === 'phaedo_check_authorization' ? '[pre-flight]' : r.agent_id === 'claude-code/ship-hook' ? '[ship-hook] ' : '[consult] '}` +
234
265
  ` ${r.request.consultation_type} ${ad.domain || '-'}` +
235
266
  ` → ${r.response.signal} (conf ${r.response.confidence}, def ${r.response.deference_level})` +
236
267
  ` user_action: ${r.user_action ?? '—'}`