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
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — Track B a11y capture core (the Node side of the OS-accessibility helper).
3
+ //
4
+ // The native macOS helper (capture/spike-a11y-macos → the productized reader) observes a
5
+ // chat app's accessibility tree, reconstructs the visible transcript into {role, text}
6
+ // turns, and — on a response-complete SETTLE — pipes a JSON payload to THIS process on
7
+ // stdin. We convert the turns to the shared exchange_batch (capture/exchanges.js), fold
8
+ // them through the SAME spine the Claude Code hook uses (capture/capture-core.js →
9
+ // detectors → per-session state → delta lift), and deposit the signal delta to the
10
+ // relay's SUB-KEYED capture lane (mcp/capture-sync.js), encrypted under fp_sync_key.
11
+ //
12
+ // The native front-end does OS work; this owns the pairing + deposit, so the two tiers
13
+ // (a11y, later OCR) share one Node pipeline and differ only in how turns are obtained.
14
+ //
15
+ // DISCARD-RAW: the turns' text is counted and dropped — nothing but signal names +
16
+ // integers is persisted or transmitted (architecture/persona-vs-conversation-memory.md).
17
+ // FAIL-SAFE: any error exits 0 with no output; unpaired machines still accumulate local
18
+ // state (the mcp-local owner tier reads it), the deposit just no-ops.
19
+ //
20
+ // Payload (one settle = one invocation), stdin JSON:
21
+ // { conversation_id: string, // STABLE per chat → per-session REPLACE (idempotent)
22
+ // source_surface?: string, // default 'a11y-macos'
23
+ // captured_at?: string, // ISO; default now
24
+ // turns: [ { role:'user'|'assistant', text:string, id?, timestamp? }, ... ] }
25
+
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+ import { dirname, resolve } from 'node:path';
28
+ import { loadCaptureModule } from './lib/capture-home.js';
29
+ import { isMainModule } from './lib/is-main.js';
30
+ import { loadPairingRecord } from './phone-source.js';
31
+ import { loadCapturePairing, CapturePairingError } from './hooks/session-end.mjs';
32
+ import { buildCaptureDelta, depositCaptureDelta } from './capture-sync.js';
33
+
34
+ const [
35
+ { turnsToBatch },
36
+ { foldBatchToSignals },
37
+ { deriveSourceDeviceId },
38
+ ] = await Promise.all([
39
+ 'capture/exchanges.js',
40
+ 'capture/capture-core.js',
41
+ 'capture/source-id.js',
42
+ ].map(loadCaptureModule));
43
+
44
+ const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
45
+ const DEFAULT_SURFACE = 'a11y-macos';
46
+ const BUDGET_MS = 10000;
47
+
48
+ // Run one a11y capture payload → deposit. Dependency-injectable for tests.
49
+ // payload — the native helper's stdin JSON (see header).
50
+ // deps — { pairing, statePath, fetch, relayBase, now, mcpDir, env, captureModel }.
51
+ // Returns a result record; never throws.
52
+ export async function runA11yCapture(payload, deps = {}) {
53
+ const nowIso = deps.now || (payload && payload.captured_at) || new Date().toISOString();
54
+ const surface = (payload && payload.source_surface) || DEFAULT_SURFACE;
55
+
56
+ const turns = payload && Array.isArray(payload.turns) ? payload.turns : null;
57
+ if (!turns) return { deposited: false, reason: 'no-turns' };
58
+ const conversationId = (payload && (payload.conversation_id || payload.session_id)) || null;
59
+
60
+ const batch = turnsToBatch(turns, { sessionId: conversationId, platform: surface });
61
+ if (!batch.user_prompts.length) return { deposited: false, reason: 'no-exchanges' };
62
+
63
+ // Resolve the pairing whose key the draining owner can decrypt (same order as the hook:
64
+ // explicit override / legacy capture-pairing file / the MCP's own pairing.json). A
65
+ // PRESENT-but-broken capture-pairing file must not silently fall back to a different key
66
+ // — accumulate state locally and skip the deposit instead.
67
+ let pairing = deps.pairing;
68
+ if (pairing === undefined) {
69
+ try {
70
+ pairing = loadCapturePairing(deps.mcpDir || MCP_DIR, deps.env);
71
+ } catch (e) {
72
+ if (!(e instanceof CapturePairingError)) throw e;
73
+ process.stderr.write(`[phaedo capture:a11y] ${e.message}\n`);
74
+ const sid0 = deriveSourceDeviceId(null, surface);
75
+ await foldBatchToSignals(batch, { sourceId: sid0, surface, statePath: deps.statePath, sessionLocalId: conversationId, now: nowIso, captureModel: deps.captureModel, env: deps.env });
76
+ return { deposited: false, reason: 'capture-pairing-invalid', counted: batch.user_prompts.length };
77
+ }
78
+ if (!pairing) {
79
+ try { pairing = await loadPairingRecord(deps.mcpDir || MCP_DIR); } catch { pairing = null; }
80
+ }
81
+ }
82
+ const sourceId = deriveSourceDeviceId(pairing, surface);
83
+
84
+ const { signals } = await foldBatchToSignals(batch, {
85
+ sourceId, surface, statePath: deps.statePath, sessionLocalId: conversationId,
86
+ now: nowIso, captureModel: deps.captureModel, env: deps.env,
87
+ });
88
+ if (!signals.length) return { deposited: false, reason: 'no-signal', counted: batch.user_prompts.length };
89
+
90
+ const delta = buildCaptureDelta(signals, { sourceSurface: surface, sourceDeviceId: sourceId, capturedAt: nowIso });
91
+ const res = await depositCaptureDelta(pairing, delta, { ...deps, now: () => nowIso });
92
+ if (res && res.deposited === false && (res.status === 401 || (res.reason && /^http-/.test(String(res.reason))))) {
93
+ process.stderr.write(`[phaedo capture:a11y] deposit rejected (${res.status || res.reason}) — re-pair so capture can sync.\n`);
94
+ }
95
+ return { ...res, counted: batch.user_prompts.length, signals: signals.length };
96
+ }
97
+
98
+ // ── script entry (the native helper spawns `node capture-a11y.js` and pipes JSON) ──────
99
+ const isMain = isMainModule(import.meta.url); // symlink-safe — see lib/is-main.js
100
+ if (isMain) {
101
+ const readStdin = () => new Promise((res) => {
102
+ let data = '';
103
+ process.stdin.on('data', (c) => { data += c; });
104
+ process.stdin.on('end', () => res(data));
105
+ process.stdin.on('error', () => res(''));
106
+ });
107
+ const run = async () => {
108
+ let payload = {};
109
+ try { payload = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
110
+ await runA11yCapture(payload);
111
+ };
112
+ const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
113
+ Promise.race([run().catch(() => {}), timeout])
114
+ .then(() => process.exit(0))
115
+ .catch(() => process.exit(0)); // capture must never break the helper
116
+ }
@@ -0,0 +1,85 @@
1
+ // The mcp-local merge-owner tier (desktop-capture-deposit.md §4 tier 3).
2
+ //
3
+ // The phone-less, browser-less desktop/CLI user — the Claude Code developer, the
4
+ // audience desktop capture was built FOR — has nobody to drain what their capture
5
+ // hook accumulates: no phone to own the capture_to_owner lane, no extension to
6
+ // materialize it. But the hook and this server share a machine AND a state dir:
7
+ // the hook accumulates cumulative Path-A counters in capture/state.js
8
+ // (~/.phaedo-mcp/capture-state.json) even when unpaired. This module folds that
9
+ // local state into the fingerprint the server SERVES, at read time:
10
+ //
11
+ // - READ-TIME OVERLAY, no persistence. The lift is a cumulative snapshot
12
+ // (delta-lift's idempotency contract), so re-folding on every load converges
13
+ // to the same result — there is nothing to store and no race to manage.
14
+ // The local fingerprint.json is never rewritten; discard-raw holds.
15
+ // - OWNER-GATED. Only when this server is the merge owner per the presence
16
+ // precedence (capture/owner.js): a configured pairing record means a phone
17
+ // owns the lane (its merged fingerprint arrives through the normal pull),
18
+ // so folding again here would double-count the same signal. The caller
19
+ // (fingerprint-source.js) applies this tier only in local, unpaired mode.
20
+ // - WORKS IN THE PUBLISHED PACKAGE. The capture/extraction modules load via
21
+ // lib/capture-home.js (repo root in dev, mcp/vendor/ in the tarball —
22
+ // build-package.mjs vendors the closure). Loading stays dynamic and any
23
+ // import/IO failure still returns the payload unchanged — the fold is an
24
+ // enrichment, never a gate on serving.
25
+ //
26
+ // Re-broadcast (§5): none needed here. If a phone is paired later, the hook's
27
+ // normal capture_to_owner deposits carry the CUMULATIVE counts, so the phone
28
+ // inherits this machine's full history on its first drain — and this tier stops
29
+ // applying (owner-gated), so nothing forks.
30
+
31
+ import { loadCaptureModule } from './lib/capture-home.js';
32
+
33
+ // Build per-source snapshots from the hook's local capture state. Loading stays
34
+ // try-wrapped so a broken layout (neither repo root nor vendor/) degrades to
35
+ // "no snapshots" instead of failing the serve.
36
+ async function localSnapshots(now) {
37
+ let stateMod, liftMod;
38
+ try {
39
+ stateMod = await loadCaptureModule('capture/state.js');
40
+ liftMod = await loadCaptureModule('extraction/delta-lift.js');
41
+ } catch {
42
+ return []; // no capture home in this layout
43
+ }
44
+ const state = stateMod.loadState(stateMod.defaultStatePath());
45
+ const out = [];
46
+ for (const sourceId of Object.keys(state.surfaces || {})) {
47
+ const counts = stateMod.cumulativeCounts(state, sourceId);
48
+ const signals = liftMod.liftDeltaSignals(counts.behavioral_signals, now);
49
+ if (!signals.length) continue;
50
+ const updatedAt = state.surfaces[sourceId] && state.surfaces[sourceId].updated_at;
51
+ out.push({ surface: sourceId, captured_at: updatedAt || now, signals });
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // Fold the local capture state into a vault payload's fingerprint and return the
57
+ // folded payload (or the input unchanged when there is nothing to fold / the
58
+ // modules are unavailable). Pure with respect to the input object.
59
+ export async function foldLocalCapture(vaultPayload, { now = new Date().toISOString() } = {}) {
60
+ if (!vaultPayload || typeof vaultPayload !== 'object' || !vaultPayload.phaedo_fingerprint) return vaultPayload;
61
+ try {
62
+ const sources = await localSnapshots(now);
63
+ if (!sources.length) return vaultPayload;
64
+ const { mergeExtractionInto } = await loadCaptureModule('extraction/persist.js');
65
+ // Empty own-update: the server has no counters of its own; the combine builds
66
+ // the delta update entirely from the source snapshots, then the standard
67
+ // reconcile folds it into the fingerprint (creating layers as needed).
68
+ const fp = mergeExtractionInto(vaultPayload.phaedo_fingerprint, {}, now, sources);
69
+ return { ...vaultPayload, phaedo_fingerprint: fp };
70
+ } catch {
71
+ return vaultPayload; // enrichment only — never break serving
72
+ }
73
+ }
74
+
75
+ // Is this server the capture merge owner? Thin wrapper over capture/owner.js so
76
+ // the precedence lives in exactly one place; an unloadable module (broken
77
+ // layout) → not owner (the fold simply doesn't apply).
78
+ export async function isLocalCaptureOwner(phonePaired) {
79
+ try {
80
+ const { isOwner } = await loadCaptureModule('capture/owner.js');
81
+ return isOwner('mcp-local', { phonePaired: !!phonePaired, extensionPresent: false });
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
@@ -0,0 +1,99 @@
1
+ // Capture-delta transport (capture surface → relay → merge owner).
2
+ //
3
+ // Track A of the desktop-capture plan. The Claude Code / Cowork capture hook reads the
4
+ // local session transcript, runs the surface-agnostic extraction engine, and deposits
5
+ // the resulting SIGNAL DELTA (never a whole fingerprint) into the relay's
6
+ // `capture_to_owner` mailbox lane, encrypted under fp_sync_key. Exactly one merge owner
7
+ // (phone → extension → mcp-local, capture/owner.js) drains and reconciles it per-source
8
+ // (capture/source-id.js), so concurrent capture on multiple surfaces accumulates instead
9
+ // of clobbering — docs/roadmap/desktop-capture-deposit.md.
10
+ //
11
+ // Mirrors delegation-sync.js in trust posture, but NOT in key sensitivity: this lane is
12
+ // drained by the PHONE, which tries each verified client's key until one decrypts, so an
13
+ // independently-paired MCP works fine here (unlike agent_to_ext — see delegation-sync.js).
14
+ // The relay only ever holds ciphertext; and the deposit is best-effort and
15
+ // isolated — a transport failure never breaks the session that produced the capture.
16
+ //
17
+ // The lane carries a DELTA, so a deposit can never overwrite signal the owner captured
18
+ // on its own; and per-source idempotency (source_device_id) means a re-deposit or a
19
+ // transient two-owner overlap is a no-op, not a double-count.
20
+
21
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
22
+
23
+ const { Kdf, Envelope } = Phaedo;
24
+ const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
25
+
26
+ export function relayBaseFromEnv(env = process.env) {
27
+ return env.PHAEDO_RELAY || DEFAULT_RELAY;
28
+ }
29
+
30
+ // Build the canonical capture-delta payload from lifted signals (Q1: lift on the
31
+ // surface — the hook runs extraction and ships §5.4 signals, not raw counters).
32
+ // signals — the extraction engine's signal list (source-tagged; 'delta' snapshots
33
+ // are idempotent on re-lift).
34
+ export function buildCaptureDelta(signals, { sourceSurface, sourceDeviceId, capturedAt } = {}) {
35
+ return {
36
+ capture_delta_version: 'v0.1',
37
+ source_surface: sourceSurface || 'unknown',
38
+ source_device_id: sourceDeviceId || null,
39
+ captured_at: capturedAt || null,
40
+ delta: { signals: Array.isArray(signals) ? signals : [] },
41
+ };
42
+ }
43
+
44
+ // Wrap a capture-delta into the same envelope shape the owner's fp-mailbox drain
45
+ // expects: ciphertext over `{ kind:'capture_delta', data: <captureDelta> }`. Returns the
46
+ // deposit body `{ envelope }`. Pure crypto (no network) so the round-trip is testable.
47
+ export async function buildCaptureDeposit(pairKeyB64u, captureDelta, { now = () => new Date().toISOString() } = {}) {
48
+ const pairKey = b64uDec(pairKeyB64u);
49
+ let fpSyncKey;
50
+ try {
51
+ fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
52
+ } finally {
53
+ pairKey.fill(0);
54
+ }
55
+ try {
56
+ const inner = { kind: 'capture_delta', data: captureDelta };
57
+ const plaintext = new TextEncoder().encode(JSON.stringify(inner));
58
+ const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'capture_delta', created_at: now() });
59
+ plaintext.fill(0);
60
+ return { envelope };
61
+ } finally {
62
+ if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
63
+ }
64
+ }
65
+
66
+ // Deposit a capture-delta to the relay's capture_to_owner slot. Best-effort: returns
67
+ // { deposited, ... } and never throws. `deps.fetch` is injectable for tests.
68
+ export async function depositCaptureDelta(pairing, captureDelta, deps = {}) {
69
+ const fetchFn = deps.fetch || globalThis.fetch;
70
+ const signals = captureDelta && captureDelta.delta && Array.isArray(captureDelta.delta.signals)
71
+ ? captureDelta.delta.signals : [];
72
+ // Precise reasons so the caller can log WHY a deposit didn't happen.
73
+ if (!signals.length) return { deposited: false, reason: 'no-signal' };
74
+ if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
75
+ if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
76
+ try {
77
+ const body = await buildCaptureDeposit(pairing.pair_key, captureDelta, deps);
78
+ // Sub-key the lane per source so a second surface on this device doesn't clobber the
79
+ // hook's deposit before the owner drains (desktop-capture-deposit.md). Falls back to
80
+ // the flat lane only if the delta somehow lacks a source id (pre-source-id builds).
81
+ const src = captureDelta && captureDelta.source_device_id;
82
+ const lane = src ? `capture_to_owner/${encodeURIComponent(src)}` : 'capture_to_owner';
83
+ const base = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/${lane}`;
84
+ // Per-device auth header (relay/auth.js). Without it the relay 401s → deposit no-ops
85
+ // (correct failure mode: the user re-pairs to capture the relay_device_secret).
86
+ const authHeader = pairing.relay_device_secret
87
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
88
+ : {};
89
+ const res = await fetchFn(base, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json', ...authHeader },
92
+ body: JSON.stringify(body),
93
+ signal: AbortSignal.timeout(deps.timeoutMs || 8000),
94
+ });
95
+ return { deposited: !!(res && res.ok), status: res && res.status, count: signals.length };
96
+ } catch (e) {
97
+ return { deposited: false, reason: String(e && e.message || e) };
98
+ }
99
+ }
package/consult-core.js CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  import { PhaedoError } from './errors.js';
18
18
  import { loadPolicies, applyPolicies, matchAuthorization } from './consult-policy.js';
19
+ import { normalizeActionBinding, isBound } from './action-binding.js';
20
+ import { normalizeDomain, canonicalDomain } from './lib/domain.js';
19
21
 
20
22
  export const CONSULTATION_TYPES = [
21
23
  'action_approval',
@@ -354,6 +356,68 @@ function calibrate(vd, coverage, signalConfidence = 0) {
354
356
 
355
357
  const magnitudeRank = (m) => ({ low: 0, medium: 1, high: 2 }[m] ?? 1);
356
358
 
359
+ // ── Amount bands (precedent track G8 B1) ──────────────────────────────────────
360
+ // Until this, the inferred path never read `amount`: $12 and $12,000 answered alike
361
+ // unless the agent ALSO labelled the magnitude, and only authored §10.5 rules saw money.
362
+ // Now an amount sets a FLOOR under the magnitude the inferred path reasons about.
363
+ //
364
+ // Band edges are the subject's own numbers wherever they exist: every amount bound on an
365
+ // authored rule (amount_gt / amount_lt / amount_lte — a grant's ceiling says what is small
366
+ // to them as surely as a prohibition's floor says what is large). Rules scoped to the
367
+ // action's domain (or unscoped) win; failing those, any authored amount anywhere; failing
368
+ // that, DEFAULT_AMOUNT_EDGES — a stated default, recorded as such on the receipt, replaced
369
+ // by the subject's first money rule.
370
+ //
371
+ // CAUTION-ONLY, by construction: the floor is max()'d with the declared magnitude, so it
372
+ // can raise the stakes and never lower them. And it feeds the INFERRED path only — §10.5
373
+ // matching keeps reading the declared magnitude, so an authored grant's `magnitude_max`
374
+ // means exactly what the subject wrote.
375
+ export const DEFAULT_AMOUNT_EDGES = [100, 1000];
376
+
377
+ // ── Resolver revision (precedent track Phase 1b) ──────────────────────────────
378
+ // Which version of the inferred path answered. Stamped on every inferred receipt (as a
379
+ // `resolverRevision` driver — local provenance, never in the response) so agreement can be
380
+ // split before/after a behaviour change instead of blurring into one number.
381
+ //
382
+ // BUMP THIS WHENEVER THE INFERRED PATH'S ANSWERS CHANGE. You will not be able to forget:
383
+ // test-resolver-revision.mjs fingerprints the answers over a fixed grid and fails, naming
384
+ // this constant, when they move under an unchanged revision. Format: <date>.<what changed>.
385
+ // (receipts before 2026-10-20 carry none — the pre-B1 rules and the first days of B1)
386
+ export const RESOLVER_REVISION = '2026-10-19.amount-bands';
387
+
388
+ function ruleAmounts(r) {
389
+ return [r?.amountGt, r?.amountLt, r?.amountLte].filter((x) => typeof x === 'number' && Number.isFinite(x) && x > 0);
390
+ }
391
+ function ruleCoversDomain(r, domain) {
392
+ const ds = Array.isArray(r?.domains) ? r.domains : [];
393
+ if (!ds.length) return true; // unscoped → speaks for every domain
394
+ if (!domain) return false;
395
+ const canon = canonicalDomain(domain);
396
+ return ds.some((d) => domain.includes(d) || (canon && canonicalDomain(d) === canon));
397
+ }
398
+
399
+ /** The band edges for this action: { edges: [low, high] (ascending, low may equal high), basis }. */
400
+ export function amountEdges(policies, domain) {
401
+ const list = Array.isArray(policies) ? policies : [];
402
+ const scoped = list.filter((r) => ruleCoversDomain(r, domain)).flatMap(ruleAmounts);
403
+ const any = scoped.length ? scoped : list.flatMap(ruleAmounts);
404
+ if (!any.length) return { edges: DEFAULT_AMOUNT_EDGES.slice(), basis: null };
405
+ return { edges: [Math.min(...any), Math.max(...any)], basis: 'self_reported' };
406
+ }
407
+
408
+ /**
409
+ * The magnitude floor an amount implies, or null when it implies none.
410
+ * Above the high edge → `high`; above the low edge → `medium`. With a single authored
411
+ * number ("escalate above $1,000") both edges are that number: above it is `high`.
412
+ */
413
+ export function amountFloor(n, policies) {
414
+ const a = n?.amount;
415
+ if (typeof a !== 'number' || !Number.isFinite(a) || a <= 0) return null;
416
+ const { edges: [lo, hi], basis } = amountEdges(policies, n.domain);
417
+ const band = a > hi ? 'high' : a > lo ? 'medium' : null;
418
+ return band ? { band, rank: magnitudeRank(band), basis } : null;
419
+ }
420
+
357
421
  // ── Request validation/normalization (§10.2) ──────────────────────────────────
358
422
  export function normalizeRequest(request) {
359
423
  const type = request.consultation_type;
@@ -367,14 +431,31 @@ export function normalizeRequest(request) {
367
431
  }
368
432
  const ctx = (request.context && typeof request.context === 'object') ? request.context : {};
369
433
  const ev = ctx.evidence_provided;
434
+ // §10.2 action_binding: the concrete call this descriptor claims to describe.
435
+ // Optional — an unbound request resolves exactly as before — but an authored
436
+ // authorization may refuse to RELAX anything without it (§10.5 require_binding).
437
+ const binding = normalizeActionBinding(request.action_binding);
370
438
  return {
371
439
  type,
372
- domain: typeof ad.domain === 'string' ? ad.domain : undefined,
440
+ // NORMALIZED only (case/whitespace) — deliberately NOT vocabulary-mapped. The
441
+ // request domain feeds §10.5 matching, and a relaxing rule must keep matching on
442
+ // the literal the agent sent; `domainCovers` applies the vocabulary itself, and
443
+ // only for restrictive rules. Mapping here instead would let an alias widen a
444
+ // grant. The RECEIPT stores the canonical form (that is the evidence axis).
445
+ domain: typeof ad.domain === 'string' ? (normalizeDomain(ad.domain) || undefined) : undefined,
373
446
  reversible: typeof ad.reversible === 'boolean' ? ad.reversible : undefined,
374
- magnitude: magnitude || 'medium',
447
+ // UNSTATED STAYS UNSTATED (v0.1.68). This defaulted to 'medium', which meant the resolver could
448
+ // never tell "the agent said medium" from "the agent said nothing" — and §10.5 then let a
449
+ // medium-capped grant fire on the nothing. The receipt now records what was actually sent
450
+ // (null), which is also what situational precedent (OQ24) needs to band on. The inferred
451
+ // oracle path still reads an unknown as the middle of the scale (`magnitudeRank`); that is a
452
+ // disposition heuristic, not a grant, and it is unchanged.
453
+ magnitude,
375
454
  amount: typeof ad.amount === 'number' && isFinite(ad.amount) ? ad.amount : undefined,
376
455
  summary: typeof ad.summary === 'string' ? ad.summary : undefined,
377
456
  evidenceProvided: Array.isArray(ev) ? ev : (ev != null ? [ev] : []),
457
+ binding,
458
+ bound: isBound(binding),
378
459
  };
379
460
  }
380
461
 
@@ -392,7 +473,8 @@ const USES_AMBIGUITY = new Set(['action_approval', 'escalation_default']);
392
473
  function resolveActionApproval(vd, n, suppressed) {
393
474
  const p = profile(vd, RELEVANT_CUES.action_approval, true, n.domain, suppressed);
394
475
  const irreversible = n.reversible === false;
395
- const mag = magnitudeRank(n.magnitude);
476
+ // Declared stakes, raised (never lowered) by what the amount means to this subject.
477
+ const mag = Math.max(magnitudeRank(n.magnitude), n.amountBand ? n.amountBand.rank : 0);
396
478
  const thinEvidence = n.evidenceProvided.length === 0;
397
479
 
398
480
  let signal;
@@ -462,7 +544,7 @@ function resolveVoiceDraft() {
462
544
  // sees (§10.4 holds: provenance is for the subject's audit/calibration, not the agent).
463
545
  // Pure + defensive: bad/redirect/abstain inputs → []. Takes the raw request; normalizes
464
546
  // internally so callers pass exactly what they passed to the consult.
465
- export function consultDrivers(vd, request) {
547
+ export function consultDrivers(vd, request, opts = {}) {
466
548
  let n;
467
549
  try { n = normalizeRequest(request || {}); } catch { return []; }
468
550
  const keys = RELEVANT_CUES[n.type];
@@ -480,6 +562,13 @@ export function consultDrivers(vd, request) {
480
562
  const a = readAmbiguity(vd, n.domain, suppressed);
481
563
  if (a.found) drivers.push(withOrigin({ cue: 'ambiguityPosture', value: a.value, confidence: a.confidence, basis: a.basis }, a.origin));
482
564
  }
565
+ // The amount band, when it raised the stakes. basis `self_reported` = the subject's own
566
+ // thresholds; null = the stated default (no subject evidence behind it).
567
+ if (n.type === 'action_approval') {
568
+ let band = null;
569
+ try { band = amountFloor(n, loadPolicies(vd, opts)); } catch { /* provenance is non-critical */ }
570
+ if (band) drivers.push({ cue: 'amountBand', value: band.band, confidence: 1, basis: band.basis });
571
+ }
483
572
  return drivers;
484
573
  }
485
574
 
@@ -530,7 +619,15 @@ export function deriveDelegationSignals(receipts, { now = new Date().toISOString
530
619
  const sig = r?.response?.signal;
531
620
  const contribution = delegationCorrection(sig, r && r.user_action); // signed; 0 = agreement/no lesson
532
621
  if (!contribution) continue;
533
- const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
622
+ // Canonicalize the domain BEFORE bucketing, or one concept spelled several ways
623
+ // (finance / financial / Finance) fragments into several near-identical buckets — the
624
+ // drift §10.2's published vocabulary exists to end. Both the live overlay
625
+ // (deriveDelegationSignals) and the promotion cards (buildDelegationPromotions) key on
626
+ // this, so both must canonicalize or a card floods / an overlay splits. Free text still
627
+ // survives — an unrecognized domain canonicalizes to itself — so only known synonyms
628
+ // merge, never distinct classes.
629
+ const domain = canonicalDomain(
630
+ (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '');
534
631
  for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
535
632
  if (!CUES[d && d.cue]) continue; // scalar caution cues only (ambiguity later)
536
633
  const k = `${d.cue}|${domain}`;
@@ -627,7 +724,15 @@ export function buildDelegationPromotions(receipts, fp, { now = new Date().toISO
627
724
  const sig = r?.response?.signal;
628
725
  const contribution = delegationCorrection(sig, r && r.user_action);
629
726
  if (!contribution) continue;
630
- const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
727
+ // Canonicalize the domain BEFORE bucketing, or one concept spelled several ways
728
+ // (finance / financial / Finance) fragments into several near-identical buckets — the
729
+ // drift §10.2's published vocabulary exists to end. Both the live overlay
730
+ // (deriveDelegationSignals) and the promotion cards (buildDelegationPromotions) key on
731
+ // this, so both must canonicalize or a card floods / an overlay splits. Free text still
732
+ // survives — an unrecognized domain canonicalizes to itself — so only known synonyms
733
+ // merge, never distinct classes.
734
+ const domain = canonicalDomain(
735
+ (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '');
631
736
  for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
632
737
  const dim = CUE_DIMENSION[d && d.cue];
633
738
  if (!dim) continue;
@@ -744,6 +849,15 @@ export function ruleResponse(vd, n) {
744
849
  return shape(n.type, resolved.signal, cal.confidence, resolved.hint, cal.deference_level);
745
850
  }
746
851
 
852
+ // The §10.5 evaluation context, lifted out of `opts` once so both public entries
853
+ // gate identically. `clientId` is the SERVING paired client's own `client_id` (from
854
+ // the server's `pairing.json`, supplied by the transport layer) — the identity
855
+ // `agents` scoping matches on; never anything the caller asserted. Absent here means
856
+ // unpaired, which makes every `agents`-scoped relaxing rule inert. Passing `now`
857
+ // through as well keeps rule expiry on the same clock `loadPolicies` already gives
858
+ // §5.7 commitments; both fall back to `Date.now()` when a caller omits them.
859
+ const policyCtx = (opts = {}) => ({ now: opts.now, clientId: opts.clientId ?? null });
860
+
747
861
  // ── Public pure entry ─────────────────────────────────────────────────────────
748
862
  // (vd, request[, opts]) -> §10.3 response. Deterministic; applies authored §10.5
749
863
  // policies last so a standing guardrail wins (even over an abstain). `opts.policies`
@@ -752,7 +866,11 @@ export function ruleResponse(vd, n) {
752
866
  export function resolveConsultationCore(vd, request = {}, opts = {}) {
753
867
  assertVaultUsable(vd);
754
868
  const n = normalizeRequest(request);
755
- return applyPolicies(ruleResponse(vd, n), n, loadPolicies(vd, opts));
869
+ const policies = loadPolicies(vd, opts);
870
+ // G8 B1: the amount's band, read against the subject's own thresholds. On `n` so the
871
+ // resolver sees it; applyPolicies does not read it (§10.5 matches the declared magnitude).
872
+ n.amountBand = amountFloor(n, policies);
873
+ return applyPolicies(ruleResponse(vd, n), n, policies, policyCtx(opts));
756
874
  }
757
875
 
758
876
  // Back-compat alias: the prior public name of the sync rule entry. Kept so existing
@@ -769,7 +887,7 @@ export const buildConsultationResponse = resolveConsultationCore;
769
887
  export function checkAuthorization(vd, request = {}, opts = {}) {
770
888
  assertVaultUsable(vd);
771
889
  const n = normalizeRequest(request);
772
- const auth = matchAuthorization(n, loadPolicies(vd, opts));
890
+ const auth = matchAuthorization(n, loadPolicies(vd, opts), policyCtx(opts));
773
891
  return {
774
892
  phaedo_response_version: '0.1',
775
893
  request_type: 'authorization_check',