phaedo-mcp 0.1.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.
package/cache.js ADDED
@@ -0,0 +1,76 @@
1
+ // MCP Phase 1b-iii — encrypted at-rest fingerprint cache.
2
+ //
3
+ // So the server can serve injections when the phone is ABSENT (the frictionless
4
+ // default — desktop/CLI use where the phone often isn't reachable). Mirrors the
5
+ // extension's Option C: the fingerprint is cached as CIPHERTEXT under a
6
+ // server-held key. Defense-in-depth for a DERIVED cache only — NOT phone-gated,
7
+ // NOT forensic-grade: the device can decrypt itself, and the cache is always
8
+ // recoverable from the phone (re-pull) on loss.
9
+ //
10
+ // Key storage (v1): a 32-byte AES key in a 0600 file. Weaker than the extension's
11
+ // non-extractable WebCrypto key (the raw bytes are on disk), because Node has no
12
+ // IndexedDB-style non-extractable-key store that survives restarts. OS-keychain
13
+ // storage is the hardening follow-up. The cache file holds only ciphertext.
14
+
15
+ import { mkdir, readFile, writeFile, chmod, rm } from 'fs/promises';
16
+ import { existsSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { homedir } from 'os';
19
+ import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
20
+
21
+ const { Envelope } = Phaedo;
22
+
23
+ export function defaultStateDir() {
24
+ return process.env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
25
+ }
26
+
27
+ async function ensureDir(dir) { await mkdir(dir, { recursive: true, mode: 0o700 }); }
28
+
29
+ export async function getOrCreateCacheKey(stateDir) {
30
+ await ensureDir(stateDir);
31
+ const keyPath = join(stateDir, 'cache.key');
32
+ if (existsSync(keyPath)) return b64uDec((await readFile(keyPath, 'utf8')).trim());
33
+ const key = crypto.getRandomValues(new Uint8Array(32));
34
+ await writeFile(keyPath, b64uEnc(key), { mode: 0o600 });
35
+ await chmod(keyPath, 0o600);
36
+ return key;
37
+ }
38
+
39
+ // Encrypt + persist the vault payload. `now` injectable for tests.
40
+ export async function writeCache(stateDir, vaultPayload, now = Date.now()) {
41
+ const key = await getOrCreateCacheKey(stateDir);
42
+ const plaintext = new TextEncoder().encode(JSON.stringify(vaultPayload));
43
+ const envelope = await Envelope.wrap(plaintext, key, {
44
+ artifact_kind: 'fingerprint',
45
+ fingerprint_id: vaultPayload?.phaedo_fingerprint?.fingerprint_id || null,
46
+ created_at: new Date(now).toISOString(),
47
+ });
48
+ plaintext.fill(0);
49
+ const cachePath = join(stateDir, 'cache.enc');
50
+ await writeFile(cachePath, JSON.stringify({ envelope, cached_at: now }), { mode: 0o600 });
51
+ await chmod(cachePath, 0o600);
52
+ }
53
+
54
+ // Remove the at-rest cache. Used on revocation: a cached profile must not
55
+ // outlive authorization, so once the phone rejects this client the ciphertext
56
+ // copy is deleted (the cache is always re-pullable after a fresh pairing).
57
+ export async function clearCache(stateDir) {
58
+ try { await rm(join(stateDir, 'cache.enc'), { force: true }); } catch { /* nothing to clear */ }
59
+ }
60
+
61
+ // Decrypt the cache → { vault, cachedAt } or null (absent/corrupt/undecryptable
62
+ // → treated as no cache, since it's recoverable from the phone).
63
+ export async function readCache(stateDir) {
64
+ const cachePath = join(stateDir, 'cache.enc');
65
+ if (!existsSync(cachePath)) return null;
66
+ try {
67
+ const key = await getOrCreateCacheKey(stateDir);
68
+ const { envelope, cached_at } = JSON.parse(await readFile(cachePath, 'utf8'));
69
+ const plaintext = await Envelope.unwrap(envelope, key);
70
+ const vault = JSON.parse(new TextDecoder().decode(plaintext));
71
+ plaintext.fill(0);
72
+ return { vault, cachedAt: cached_at };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,439 @@
1
+ // Phaedo §10 Agent Consultation — the PURE resolver core.
2
+ //
3
+ // resolveConsultationCore(vd, request[, opts]) -> §10.3 response. PURE: no browser
4
+ // globals, no chrome, no fs, no network, no model, no process, no side effects —
5
+ // the same discipline that makes context-block.js reusable across runtimes. The
6
+ // OPTIONAL local model judge and all transport/config live in consult.js (the MCP
7
+ // wrapper). Keeping the deterministic shapes here and the model there makes the
8
+ // open-core line a file boundary (brief Invariant 7): this file is the publishable
9
+ // reference resolver; the judge is the proprietary-side upgrade that wraps it.
10
+ //
11
+ // Resolution order (deterministic → authored): (A) inferred Decision/Risk signal,
12
+ // confidence-gated; (B) abstain floor — if NO relevant cue was found, return
13
+ // `insufficient_signal` rather than a confidently-wrong guess (§10.3); (C) authored
14
+ // standing policies (§10.5) override either, so a hard guardrail wins even over an
15
+ // abstain. Signals out, never structure out (§10.4).
16
+
17
+ import { PhaedoError } from './errors.js';
18
+ import { loadPolicies, applyPolicies, matchAuthorization } from './consult-policy.js';
19
+
20
+ export const CONSULTATION_TYPES = [
21
+ 'action_approval',
22
+ 'voice_draft',
23
+ 'domain_risk_check',
24
+ 'escalation_default',
25
+ ];
26
+
27
+ // §10.3 signal enum. `insufficient_signal` (abstain) is the producer declining to
28
+ // answer for lack of coverage — distinct from `clarify` (gather more) and from an
29
+ // error. Folded into the spec at v0.1.9 (proposal 0001).
30
+ export const SIGNALS = ['proceed', 'proceed_with_note', 'clarify', 'escalate', 'decline', 'insufficient_signal'];
31
+ const MAGNITUDES = ['low', 'medium', 'high'];
32
+
33
+ // NOTE: for UNTAGGED signals (all fingerprints today), coverage is fingerprint-WIDE —
34
+ // a rich fingerprint never abstains on an unfamiliar domain. As domain-TAGGED
35
+ // Decision&Risk signals accrue (extraction/domain-inference.js prototype), coverage
36
+ // becomes per-domain naturally: signals tagged for a different domain are skipped,
37
+ // so a fingerprint whose relevant signals are all wrong-domain abstains here.
38
+ const ABSTAIN_HINT =
39
+ 'The fingerprint has no decision/risk coverage to ground a signal, so the oracle ' +
40
+ 'abstains — fall back to your own default or escalate to the subject.';
41
+
42
+ // ── vd contract guard (brief M0.4) ────────────────────────────────────────────
43
+ // The consultation boundary's mirror of context-block.js validateVaultData (which
44
+ // cannot be imported here — it is a classic browser script; the two are kept in
45
+ // agreement by scripts/test_context_block_snapshots.mjs's cross-check). A breach
46
+ // throws a structured PhaedoError naming the violating field — a malformed vault
47
+ // payload is a producer bug and must never be resolved silently with partial data.
48
+ // A MISSING fingerprint stays `decryption_failed` (the source had nothing), and
49
+ // weird-but-conforming content (odd signal values, empty layers) still resolves
50
+ // defensively — this guard rejects only type breaches of the documented vd shape
51
+ // (spec/schemas/vault-data.schema.json).
52
+ const isPlainObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
53
+ export function validateVaultShape(vd) {
54
+ const errors = [];
55
+ if (!isPlainObject(vd)) return ['vault data must be a plain object'];
56
+ const fp = vd.phaedo_fingerprint;
57
+ if (fp !== undefined && fp !== null) {
58
+ if (!isPlainObject(fp)) errors.push('phaedo_fingerprint must be an object');
59
+ else {
60
+ if (!isPlainObject(fp.layers)) errors.push('phaedo_fingerprint.layers must be an object');
61
+ else for (const [id, L] of Object.entries(fp.layers)) if (!isPlainObject(L)) errors.push(`layer "${id}" must be an object`);
62
+ if (fp.persona_strength !== undefined && (typeof fp.persona_strength !== 'number' || fp.persona_strength < 0 || fp.persona_strength > 1))
63
+ errors.push('persona_strength must be a number in [0,1]');
64
+ if (fp.fingerprint_id !== undefined && fp.fingerprint_id !== null && typeof fp.fingerprint_id !== 'string')
65
+ errors.push('fingerprint_id must be a string');
66
+ }
67
+ }
68
+ for (const k of ['phaedo_behavioral_signals', 'phaedo_linguistic_profile', 'phaedo_session_metrics'])
69
+ if (vd[k] !== undefined && vd[k] !== null && !isPlainObject(vd[k])) errors.push(`${k} must be an object`);
70
+ return errors;
71
+ }
72
+
73
+ function assertVaultUsable(vd) {
74
+ if (!vd || !vd.phaedo_fingerprint) {
75
+ throw new PhaedoError('decryption_failed', 'No fingerprint available from the configured source.');
76
+ }
77
+ const errors = validateVaultShape(vd);
78
+ if (errors.length) {
79
+ throw new PhaedoError('internal_error', `vault data malformed: ${errors.join('; ')}`);
80
+ }
81
+ }
82
+
83
+ // ── Low-level fingerprint reads (defensive; never throw) ──────────────────────
84
+ function layer(vd, id) { return vd?.phaedo_fingerprint?.layers?.[id]; }
85
+
86
+ // Evidence basis of a signal, derived from its `source` (proposal 0002 vocabulary).
87
+ // Drives how much a cue's per-signal confidence is trusted (below): observed/
88
+ // corroborated learned signals > self-reported questionnaire answers > a bare
89
+ // keyword hit in free text.
90
+ function evidenceBasis(source) {
91
+ const s = String(source || '').toLowerCase();
92
+ if (s.includes('corrobor') || s.includes('reconcil')) return 'corroborated';
93
+ if (s.includes('question') || s.includes('self') || s.includes('interview')) return 'self_reported';
94
+ return 'observed'; // extraction/delta-sourced structured signals
95
+ }
96
+ const BASIS_WEIGHT = { observed: 1.0, corroborated: 1.0, self_reported: 0.9, text_scan: 0.75 };
97
+
98
+ // Does a signal's §4.2.7 domain tag pertain to the action's domain? Same
99
+ // case-insensitive substring convention as authored policies (either direction:
100
+ // signal "financial" matches action "financial services" and vice versa).
101
+ function domainMatches(sigDomain, actionDomain) {
102
+ const a = String(sigDomain).toLowerCase(), b = String(actionDomain).toLowerCase();
103
+ return a.includes(b) || b.includes(a);
104
+ }
105
+
106
+ // Resolve a relevant cue to { text, confidence, basis }, preferring a structured
107
+ // §4.2 signal (which carries a real per-signal confidence + provenance) over a
108
+ // dimensioned questionnaire response. Returns undefined when neither is present
109
+ // (caller falls back to a free-text keyword scan). Positive/neutral polarity only.
110
+ //
111
+ // Domain preference (PROTOTYPE, §4.2.7 optional `domain`): when the action carries
112
+ // a domain, a signal TAGGED with a matching domain wins; an UNTAGGED signal is the
113
+ // fallback (today's fingerprint-wide behavior); a signal tagged with a DIFFERENT
114
+ // domain is skipped — financial caution must not drive a writing decision. When
115
+ // the action has no domain, tags are ignored (any field-matching signal serves).
116
+ function signalCue(vd, layerId, fields, actionDomain) {
117
+ const L = layer(vd, layerId);
118
+ if (!L) return undefined;
119
+ const want = new Set(fields);
120
+ const asCue = (s) => {
121
+ const confidence = typeof s.confidence === 'number' ? Math.max(0, Math.min(1, s.confidence)) : 0.6;
122
+ // Prefer the producer-emitted evidence_basis (proposal 0002); else derive from source.
123
+ const basis = typeof s.evidence_basis === 'string' ? s.evidence_basis : evidenceBasis(s.source);
124
+ return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis };
125
+ };
126
+ let untagged;
127
+ for (const s of (L.signals || [])) {
128
+ if (!(s && want.has(s.field) && (s.polarity ?? 'positive') !== 'negative' && s.value != null)) continue;
129
+ if (typeof s.domain === 'string' && actionDomain) {
130
+ if (domainMatches(s.domain, actionDomain)) return asCue(s); // domain match wins outright
131
+ continue; // wrong domain — skip
132
+ }
133
+ if (typeof s.domain === 'string' && !actionDomain) return asCue(s); // no action domain → tags ignored
134
+ if (!untagged) untagged = asCue(s); // untagged fallback
135
+ }
136
+ if (untagged) return untagged;
137
+ for (const r of (L.responses || L.answers || [])) {
138
+ const dim = r?.dimension || r?.field;
139
+ const v = r?.value ?? r?.answer;
140
+ if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported' };
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ // All free-text the layer carries (interview answers + learned summary), lowercased
146
+ // — the fallback corpus for the keyword scan when a clean signal isn't present.
147
+ function layerText(vd, layerId) {
148
+ const L = layer(vd, layerId);
149
+ if (!L) return '';
150
+ const parts = [];
151
+ for (const r of (L.responses || [])) if (r?.answer) parts.push(String(r.answer));
152
+ for (const r of (L.answers || [])) { const v = r?.answer ?? r?.value; if (v != null) parts.push(String(v)); }
153
+ if (typeof L.summary === 'string') parts.push(L.summary);
154
+ return parts.join(' \n ').toLowerCase();
155
+ }
156
+
157
+ const hit = (text, needles) => needles.some(n => text.includes(n));
158
+
159
+ // ── Cue catalog ───────────────────────────────────────────────────────────────
160
+ // Each cue resolves a lean from a signal field if present, else from a keyword
161
+ // scan of its layers' text. lean: +1 = cautious/high-bar, -1 = bold/fast, 0 = unknown.
162
+ // Keywords are DIRECTIONAL phrases, not bare words — "quality over speed" must
163
+ // read as cautious even though it contains "speed".
164
+ const CUES = {
165
+ irreversibleCaution: {
166
+ layers: ['decision_and_risk'],
167
+ fields: ['reversible_vs_irreversible', 'irreversible_style'],
168
+ cautious: ['more_cautious', 'more cautious', 'seek more input', 'careful_still', 'think longer', 'longer_think'],
169
+ bold: ['consistent_style', 'consistent regardless', 'style is consistent', 'fast_adjust', 'decide fast'],
170
+ },
171
+ decisionThreshold: {
172
+ layers: ['behavioral', 'decision_and_risk'],
173
+ fields: ['decision_threshold'],
174
+ cautious: ['cover_risks', 'covered the main risks', 'cover the main risks', 'main risks before', 'depends_reversibility'],
175
+ bold: ['act_adjust', 'act on the best', 'adjust as i learn', 'best signal available'],
176
+ },
177
+ evidenceBar: {
178
+ layers: ['decision_and_risk'],
179
+ fields: ['evidence_bar', 'value_tradeoffs', 'evidence_threshold'],
180
+ cautious: ['accuracy over', 'thoroughness over', 'thoroughness', 'high evidence', 'evidence bar: high', 'consistency over'],
181
+ bold: ['speed over', 'simplicity over', 'adaptability over', 'good enough'],
182
+ },
183
+ speedQuality: {
184
+ layers: ['decision_and_risk'],
185
+ fields: ['speed_vs_quality'],
186
+ cautious: ['quality over', 'accuracy over'],
187
+ bold: ['speed over', 'fast over'],
188
+ },
189
+ };
190
+
191
+ // Ambiguity has a distinct axis (ask-first vs proceed), resolved separately.
192
+ const AMBIGUITY = {
193
+ layers: ['behavioral', 'decision_and_risk'],
194
+ fields: ['handling_ambiguity', 'ambiguity_response', 'ambiguity_posture'],
195
+ askFirst: ['ask_first', 'clarifying question', 'ask a clarifying', 'ask before doing'],
196
+ proceed: ['assume_silent', 'assume_state', 'reasonable assumption', 'offer_two', 'offer two interpretations'],
197
+ };
198
+
199
+ function readCue(vd, spec, actionDomain) {
200
+ let cue;
201
+ for (const lid of spec.layers) { cue = signalCue(vd, lid, spec.fields, actionDomain); if (cue) break; } // a structured §4.2 signal wins
202
+ const text = cue ? cue.text : spec.layers.map(lid => layerText(vd, lid)).join(' '); // else scan answers/summary
203
+ if (!text) return { found: false, lean: 0, confidence: 0, basis: null };
204
+ const c = hit(text, spec.cautious), b = hit(text, spec.bold);
205
+ const found = c || b;
206
+ const lean = (c && !b) ? 1 : (b && !c) ? -1 : 0; // both/neither → present but neutral
207
+ // per-cue confidence + basis: a structured signal carries its own; a keyword hit
208
+ // in free text is the weakest evidence (text_scan).
209
+ return { found, lean, confidence: found ? (cue ? cue.confidence : 0.6) : 0, basis: found ? (cue ? cue.basis : 'text_scan') : null };
210
+ }
211
+
212
+ function readAmbiguity(vd, actionDomain) {
213
+ let cue;
214
+ for (const lid of AMBIGUITY.layers) { cue = signalCue(vd, lid, AMBIGUITY.fields, actionDomain); if (cue) break; }
215
+ const text = cue ? cue.text : AMBIGUITY.layers.map(lid => layerText(vd, lid)).join(' ');
216
+ const confidence = cue ? cue.confidence : 0.6;
217
+ const basis = cue ? cue.basis : 'text_scan';
218
+ if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis };
219
+ if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis };
220
+ return { found: false, value: null, confidence: 0, basis: null };
221
+ }
222
+
223
+ function personaStrength(vd) {
224
+ const v = vd?.phaedo_fingerprint?.persona_strength;
225
+ return typeof v === 'number' ? Math.max(0, Math.min(1, v)) : 0.4;
226
+ }
227
+
228
+ // Build a scoped caution profile from ONLY the cues relevant to this consultation
229
+ // type (§10 relevance scoping). Returns the caution score (0..1), the coverage
230
+ // (found / relevant), the raw `found` count (0 = no relevant cue → abstain), and
231
+ // the ambiguity verdict.
232
+ function profile(vd, relevantCues, includeAmbiguity, actionDomain) {
233
+ let score = 0.5, found = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0);
234
+ const effConfs = []; // per-cue confidence × evidence-basis weight, for the cues actually used
235
+ for (const key of relevantCues) {
236
+ const r = readCue(vd, CUES[key], actionDomain);
237
+ if (r.found) { found++; score += r.lean * 0.13; effConfs.push(r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75)); }
238
+ }
239
+ let ambiguity = { found: false, value: null };
240
+ if (includeAmbiguity) {
241
+ ambiguity = readAmbiguity(vd, actionDomain);
242
+ if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(ambiguity.confidence * (BASIS_WEIGHT[ambiguity.basis] ?? 0.75)); }
243
+ }
244
+ return {
245
+ score: Math.max(0, Math.min(1, score)),
246
+ coverage: total ? found / total : 0,
247
+ found,
248
+ signalConfidence: effConfs.length ? effConfs.reduce((a, b) => a + b, 0) / effConfs.length : 0,
249
+ ambiguity,
250
+ };
251
+ }
252
+
253
+ // Confidence + deference for a resolved consultation.
254
+ // deference_level — breadth: RELEVANT cue coverage × persona strength (unchanged).
255
+ // confidence — now blends that breadth with `signalConfidence`: the mean per-signal
256
+ // confidence (evidence-basis weighted) of the cues actually used. So confidence
257
+ // VARIES with how strong/observed the underlying Decision&Risk signals are, rather
258
+ // than sitting flat across every action on one fingerprint (the fix for the pinned-
259
+ // confidence critique). A fingerprint of strong observed signals reads confident; a
260
+ // thin/keyword-only one reads low. `signalConfidence` 0 (no usable cues — e.g. the
261
+ // abstain floor's calibrate(vd,0)) → the breadth model alone, so abstain is unchanged.
262
+ function calibrate(vd, coverage, signalConfidence = 0) {
263
+ const ps = personaStrength(vd);
264
+ const breadth = 0.3 + 0.4 * coverage * ps + 0.2 * ps;
265
+ const confidence = signalConfidence > 0
266
+ ? 0.45 * breadth + 0.55 * signalConfidence * (0.6 + 0.4 * ps)
267
+ : breadth;
268
+ const deference_level = (coverage >= 0.66 && ps >= 0.5) ? 'high'
269
+ : (coverage >= 0.33 && ps >= 0.3) ? 'medium'
270
+ : 'low';
271
+ return { confidence: Math.round(Math.max(0, Math.min(1, confidence)) * 100) / 100, deference_level };
272
+ }
273
+
274
+ const magnitudeRank = (m) => ({ low: 0, medium: 1, high: 2 }[m] ?? 1);
275
+
276
+ // ── Request validation/normalization (§10.2) ──────────────────────────────────
277
+ export function normalizeRequest(request) {
278
+ const type = request.consultation_type;
279
+ if (!CONSULTATION_TYPES.includes(type)) {
280
+ throw new PhaedoError('request_malformed', `Unknown consultation_type: ${type}. One of: ${CONSULTATION_TYPES.join(', ')}.`);
281
+ }
282
+ const ad = (request.action_descriptor && typeof request.action_descriptor === 'object') ? request.action_descriptor : {};
283
+ let magnitude = typeof ad.magnitude === 'string' ? ad.magnitude.toLowerCase() : undefined;
284
+ if (magnitude && !MAGNITUDES.includes(magnitude)) {
285
+ throw new PhaedoError('request_malformed', `magnitude must be one of: ${MAGNITUDES.join(', ')}.`);
286
+ }
287
+ const ctx = (request.context && typeof request.context === 'object') ? request.context : {};
288
+ const ev = ctx.evidence_provided;
289
+ return {
290
+ type,
291
+ domain: typeof ad.domain === 'string' ? ad.domain : undefined,
292
+ reversible: typeof ad.reversible === 'boolean' ? ad.reversible : undefined,
293
+ magnitude: magnitude || 'medium',
294
+ amount: typeof ad.amount === 'number' && isFinite(ad.amount) ? ad.amount : undefined,
295
+ summary: typeof ad.summary === 'string' ? ad.summary : undefined,
296
+ evidenceProvided: Array.isArray(ev) ? ev : (ev != null ? [ev] : []),
297
+ };
298
+ }
299
+
300
+ // ── Per-type resolvers → { signal, hint, coverage, found } ────────────────────
301
+ function resolveActionApproval(vd, n) {
302
+ const p = profile(vd, ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'], true, n.domain);
303
+ const irreversible = n.reversible === false;
304
+ const mag = magnitudeRank(n.magnitude);
305
+ const thinEvidence = n.evidenceProvided.length === 0;
306
+
307
+ let signal;
308
+ if (irreversible && mag >= 2) {
309
+ signal = p.score >= 0.55 ? (thinEvidence ? 'clarify' : 'escalate')
310
+ : p.score >= 0.4 ? 'escalate'
311
+ : 'proceed_with_note';
312
+ } else if (irreversible || mag >= 2) {
313
+ signal = p.score >= 0.6 ? 'escalate'
314
+ : (thinEvidence && p.score >= 0.45) ? 'clarify'
315
+ : 'proceed_with_note';
316
+ } else {
317
+ signal = p.score >= 0.7 ? 'proceed_with_note' : 'proceed';
318
+ }
319
+ const domain = n.domain ? ` in the ${n.domain} domain` : '';
320
+ const posture = p.score >= 0.55 ? 'favors caution and human review'
321
+ : p.score <= 0.4 ? 'favors acting and adjusting'
322
+ : 'weighs the main risks before committing';
323
+ const hint = irreversible
324
+ ? `On irreversible${mag >= 2 ? ', high-magnitude' : ''} actions${domain}, the subject ${posture}.`
325
+ : `On reversible actions${domain}, the subject ${posture}.`;
326
+ return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
327
+ }
328
+
329
+ function resolveDomainRiskCheck(vd, n) {
330
+ const p = profile(vd, ['evidenceBar', 'speedQuality', 'decisionThreshold'], false, n.domain);
331
+ const high = p.score >= 0.55;
332
+ const signal = high ? 'clarify' : p.score <= 0.4 ? 'proceed' : 'proceed_with_note';
333
+ const where = n.domain ? ` in ${n.domain}` : '';
334
+ const posture = high ? 'holds a high evidence bar and prefers thoroughness'
335
+ : p.score <= 0.4 ? 'is comfortable moving on the best available signal'
336
+ : 'balances evidence against speed';
337
+ return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
338
+ }
339
+
340
+ function resolveEscalationDefault(vd, n) {
341
+ const p = profile(vd, ['decisionThreshold', 'irreversibleCaution'], true, n.domain);
342
+ let signal;
343
+ if (p.ambiguity.value === 'ask_first') signal = 'clarify';
344
+ else if (p.ambiguity.value === 'proceed') signal = 'proceed_with_note';
345
+ else signal = p.score >= 0.55 ? 'escalate' : 'clarify';
346
+ const hint = signal === 'clarify'
347
+ ? 'When uncertain, the subject defaults to asking a clarifying question before acting.'
348
+ : signal === 'escalate'
349
+ ? 'When uncertain on consequential calls, the subject defaults to surfacing for a live decision.'
350
+ : 'When uncertain on low-stakes calls, the subject defaults to a reasonable assumption, stated openly.';
351
+ return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
352
+ }
353
+
354
+ // voice_draft: v0 does not generate text. Voice == style == the injection
355
+ // projection, so point the agent there (§10.4: content goes through injection).
356
+ function resolveVoiceDraft() {
357
+ return {
358
+ signal: 'clarify',
359
+ hint: 'Drafting in the subject’s voice is served by the injection projection — call phaedo_request_injection and write in that style. (Generation is out of scope for consultation v0.)',
360
+ coverage: null,
361
+ found: null,
362
+ };
363
+ }
364
+
365
+ // §10.3 response shape — the ONLY fields that cross the boundary (§10.4).
366
+ export function shape(type, signal, confidence, rationale_hint, deference_level) {
367
+ return {
368
+ phaedo_response_version: '0.1',
369
+ request_type: 'consultation',
370
+ consultation_type: type,
371
+ signal: SIGNALS.includes(signal) ? signal : 'clarify',
372
+ confidence: Math.max(0, Math.min(1, Number(confidence) || 0)),
373
+ rationale_hint: String(rationale_hint || '').replace(/\s+/g, ' ').trim().slice(0, 280),
374
+ deference_level: ['high', 'medium', 'low'].includes(deference_level) ? deference_level : 'medium',
375
+ };
376
+ }
377
+
378
+ // ── Rule resolver (deterministic, local, always available — the floor) ─────────
379
+ export function ruleResponse(vd, n) {
380
+ let resolved;
381
+ switch (n.type) {
382
+ case 'action_approval': resolved = resolveActionApproval(vd, n); break;
383
+ case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n); break;
384
+ case 'escalation_default': resolved = resolveEscalationDefault(vd, n); break;
385
+ case 'voice_draft': resolved = resolveVoiceDraft(); break;
386
+ }
387
+ // Abstain floor (§10.3 `insufficient_signal`): a real decision type with ZERO
388
+ // relevant cues has no pattern basis. Say so rather than emit a signal derived
389
+ // only from the action's structure — a confidently-wrong answer is the worst
390
+ // output (brief principle 2). Structural safety belongs in authored policy (step
391
+ // C below), which still overrides this abstain. voice_draft never abstains (it's
392
+ // a redirect). An authored policy can still force a decision on top of this.
393
+ if (n.type !== 'voice_draft' && resolved.found === 0) {
394
+ return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence, ABSTAIN_HINT, 'low');
395
+ }
396
+ const cal = n.type === 'voice_draft'
397
+ ? { confidence: 0.2, deference_level: 'low' }
398
+ : calibrate(vd, resolved.coverage, resolved.signalConfidence);
399
+ return shape(n.type, resolved.signal, cal.confidence, resolved.hint, cal.deference_level);
400
+ }
401
+
402
+ // ── Public pure entry ─────────────────────────────────────────────────────────
403
+ // (vd, request[, opts]) -> §10.3 response. Deterministic; applies authored §10.5
404
+ // policies last so a standing guardrail wins (even over an abstain). `opts.policies`
405
+ // (a loaded local-config policy doc, passed in by the server) is data, not I/O —
406
+ // the file read happens in the transport layer, so this stays pure.
407
+ export function resolveConsultationCore(vd, request = {}, opts = {}) {
408
+ assertVaultUsable(vd);
409
+ const n = normalizeRequest(request);
410
+ return applyPolicies(ruleResponse(vd, n), n, loadPolicies(vd, opts));
411
+ }
412
+
413
+ // Back-compat alias: the prior public name of the sync rule entry. Kept so existing
414
+ // importers (tests, spec/test-schemas.mjs) are unchanged.
415
+ export const buildConsultationResponse = resolveConsultationCore;
416
+
417
+ // ── Deterministic-only authorization check (brief M3) ─────────────────────────
418
+ // (vd, request[, opts]) -> a small, fixed-shape result. Checks ONLY standing
419
+ // authorizations (§10.5 force rules) — it does NOT run inferred evaluation, so it is
420
+ // a fast pre-flight an agent can call before a full consultation: "is this already
421
+ // decided by one of my standing rules?". A matched authorization is the subject's
422
+ // deterministic pre-decision (confidence 1.0, deference high, by definition). Honors
423
+ // §10.4 (no layer content crosses — only the rule's decision + provenance note).
424
+ export function checkAuthorization(vd, request = {}, opts = {}) {
425
+ assertVaultUsable(vd);
426
+ const n = normalizeRequest(request);
427
+ const auth = matchAuthorization(n, loadPolicies(vd, opts));
428
+ return {
429
+ phaedo_response_version: '0.1',
430
+ request_type: 'authorization_check',
431
+ consultation_type: n.type,
432
+ authorization_matched: !!auth,
433
+ signal: auth ? auth.signal : null,
434
+ rule_id: auth ? (auth.rule.id || null) : null,
435
+ rationale_hint: auth
436
+ ? `Per your standing rule: ${auth.note}.`.replace(/\s+/g, ' ').trim().slice(0, 280)
437
+ : 'No standing authorization matches this action; run phaedo_consult for a full signal, or apply your own default.',
438
+ };
439
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "version": "0.1",
3
+ "_comment": "Standing AUTHORIZATIONS for phaedo_consult (§10.5): subject-authored rules that OVERRIDE the inferred signal. Force effects (escalate | require_human | decline | autoproceed) resolve the decision deterministically at confidence:1.0, deference:high; bias_cautious | bias_bold nudge one step. match: domains[] (substring, case-insensitive) · magnitude_min/max (low|medium|high) · reversible (true/false) · amount_gt (number; needs action_descriptor.amount). Portable: copy into the vault as phaedo_fingerprint.consult_policies, or point PHAEDO_CONSULT_POLICIES at this file to try them.",
4
+ "rules": [
5
+ {
6
+ "id": "irreversible-spend-over-5k-escalate",
7
+ "match": { "domains": ["financial", "payment", "spend", "purchase"], "reversible": false, "amount_gt": 5000 },
8
+ "effect": "escalate",
9
+ "note": "Any irreversible spend over $5,000 goes to me for a live decision"
10
+ },
11
+ {
12
+ "id": "irreversible-legal-escalate",
13
+ "match": { "domains": ["legal", "contract"], "reversible": false },
14
+ "effect": "escalate",
15
+ "note": "Irreversible legal or contract actions always go to a human"
16
+ },
17
+ {
18
+ "id": "autonomous-external-email-decline",
19
+ "match": { "domains": ["external email", "outbound email", "cold outreach"] },
20
+ "effect": "decline",
21
+ "note": "Do not send autonomous external email on my behalf"
22
+ },
23
+ {
24
+ "id": "low-stakes-writing-autoproceed",
25
+ "match": { "domains": ["writing", "draft", "internal note", "content"], "magnitude_max": "medium", "reversible": true },
26
+ "effect": "autoproceed",
27
+ "note": "Low-stakes, reversible writing is pre-approved"
28
+ },
29
+ {
30
+ "id": "people-matters-bias-cautious",
31
+ "match": { "domains": ["immigration", "hr", "personnel", "visa"] },
32
+ "effect": "bias_cautious",
33
+ "note": "Lean cautious on people / immigration matters"
34
+ }
35
+ ]
36
+ }