phaedo-mcp 0.1.1 → 0.2.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/consult-policy.js CHANGED
@@ -49,6 +49,14 @@ export function normalizePolicies(raw) {
49
49
  magMax: m.magnitude_max != null ? magRank(m.magnitude_max) : null,
50
50
  reversible: typeof m.reversible === 'boolean' ? m.reversible : null,
51
51
  amountGt: typeof m.amount_gt === 'number' && isFinite(m.amount_gt) ? m.amount_gt : null,
52
+ // Conditional + revocable authority (§10.5). `expiresAt` is ENFORCED: a lapsed
53
+ // authorization stops firing (time-bound = revocable, no identity needed; the
54
+ // direction is always authority-NARROWING, so it's fail-safe). `agents` is
55
+ // RESERVED (parsed, not yet enforced): scoping authority to specific agents needs
56
+ // an AUTHENTICATED agent identity — a self-asserted agent_id would be false
57
+ // security — so enforcement waits for that (build-later).
58
+ expiresAt: typeof r.expires_at === 'string' && Number.isFinite(Date.parse(r.expires_at)) ? Date.parse(r.expires_at) : null,
59
+ agents: Array.isArray(r.agents) ? r.agents.map((a) => String(a).toLowerCase()).filter(Boolean) : [],
52
60
  effect,
53
61
  note: typeof r.note === 'string' ? r.note : null,
54
62
  });
@@ -56,16 +64,50 @@ export function normalizePolicies(raw) {
56
64
  return out;
57
65
  }
58
66
 
59
- // Merge policy sources, most-portable first:
60
- // 1. inside the fingerprint (`phaedo_fingerprint.consult_policies`) — rides
61
- // `inner.data` on the phone pull, so it travels everywhere the fingerprint
62
- // does (extension ↔ phone ↔ MCP) with NO profile-schema change. Preferred
63
- // home for authored, synced policies.
64
- // 2. a top-level vault sibling (`phaedo_consult_policies`) — local/extension use.
65
- // 3. opts.policies — a loaded local file (server: mcp/consult-policies.json).
67
+ // Project the AUTHORIZATION-kind entries of `standing_rules` (Proposal 0007 — the
68
+ // single authored home) into the raw consult_policies rule shape normalizePolicies
69
+ // consumes. `kind:"instruction"` entries are verbatim injection rules, not gates —
70
+ // the resolver ignores them. `note` falls back to the injectable `text` so the
71
+ // rationale_hint still names the rule.
72
+ function authorizationsFromStandingRules(fp) {
73
+ const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
74
+ return rules
75
+ .filter((r) => r && r.kind === 'authorization' && r.effect)
76
+ .map((r) => ({ id: r.instruction_id || null, match: r.match || {}, effect: r.effect, note: r.note || r.text || null, expires_at: r.expires_at, agents: r.agents }));
77
+ }
78
+
79
+ // Project a bound `kind:"instruction"` (Proposal 0007 §D) into a consultation BIAS.
80
+ // An instruction carries prose + a `decision` dimension + polarity, but not a
81
+ // structured enum value, so it cannot resolve a dimension to a value the way an
82
+ // inferred signal does. v0.1 semantics, deliberately conservative: a NEGATIVE
83
+ // (anti-preference / "I won't…") bound rule nudges the matching-domain consultation
84
+ // one step cautious (bias_cautious) — authored rules may make the agent MORE
85
+ // deferential, never less. POSITIVE-polarity rules expand autonomy, the riskier
86
+ // direction, and are NOT projected in v0.1 (they still inject verbatim); that is
87
+ // gated behind structured per-dimension values. An unscoped (no decision.domain)
88
+ // rule biases all actions cautious — global by construction.
89
+ function biasesFromStandingRules(fp) {
90
+ const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
91
+ return rules
92
+ .filter((r) => r && r.kind === 'instruction' && r.decision && r.decision.polarity === 'negative')
93
+ .map((r) => ({ id: r.instruction_id || null, match: { domains: r.decision.domain ? [r.decision.domain] : [] }, effect: 'bias_cautious', note: r.text || null }));
94
+ }
95
+
96
+ // Merge policy sources, the authored home first:
97
+ // 1. `phaedo_fingerprint.standing_rules` — the Proposal 0007 authored home:
98
+ // kind:authorization → a deterministic gate; kind:instruction bound negative →
99
+ // a cautious bias (§D). Rides `inner.data` on the phone pull, travels everywhere
100
+ // the fingerprint does (extension ↔ phone ↔ MCP) with NO profile-schema change.
101
+ // 2. `phaedo_fingerprint.consult_policies` — DEPRECATED (0007) back-compat read path.
102
+ // 3. a top-level vault sibling (`phaedo_consult_policies`) — DEPRECATED, local/extension use.
103
+ // 4. opts.policies — a loaded local file (server: mcp/consult-policies.json).
104
+ // Force rules (authorizations) always take precedence over bias rules in applyPolicies,
105
+ // so ordering within the list only affects which same-class rule wins (first match).
66
106
  // Defensive — never throws.
67
107
  export function loadPolicies(vd, opts = {}) {
68
108
  let list = [];
109
+ try { list = list.concat(normalizePolicies(authorizationsFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
110
+ try { list = list.concat(normalizePolicies(biasesFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
69
111
  try { list = list.concat(normalizePolicies(vd?.phaedo_fingerprint?.consult_policies)); } catch { /* ignore */ }
70
112
  try { list = list.concat(normalizePolicies(vd?.phaedo_consult_policies)); } catch { /* ignore */ }
71
113
  try { if (opts.policies) list = list.concat(normalizePolicies(opts.policies)); } catch { /* ignore */ }
@@ -94,9 +136,16 @@ function ruleMatches(rule, n) {
94
136
  // This is the engine behind the lightweight `phaedo_check_authorization` pre-flight
95
137
  // (brief M3) — an agent can ask "is this already decided by a standing rule?" without
96
138
  // paying for a full consultation. voice_draft is exempt (a redirect, not a decision).
97
- export function matchAuthorization(n, policies) {
139
+ // A rule is ACTIVE only if it has not lapsed (§10.5 revocable authority). `expiresAt`
140
+ // is enforced; `agents` scoping is reserved (see normalizePolicies). Authority-narrowing
141
+ // only — an expired rule simply stops applying.
142
+ function ruleActive(rule, now) {
143
+ return !(rule.expiresAt != null && now >= rule.expiresAt);
144
+ }
145
+
146
+ export function matchAuthorization(n, policies, { now = Date.now() } = {}) {
98
147
  if (!policies || !policies.length || n.type === 'voice_draft') return null;
99
- const matched = policies.filter((r) => ruleMatches(r, n));
148
+ const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
100
149
  const force = matched.find((r) => r.effect in FORCE);
101
150
  if (!force) return null;
102
151
  return { rule: force, signal: FORCE[force.effect], note: force.note || FORCE_NOTES[force.effect] };
@@ -107,10 +156,10 @@ export function matchAuthorization(n, policies) {
107
156
  // Else apply the first matching BIAS rule (nudge one step on the ladder). Returns a
108
157
  // NEW response (§10.3 shape preserved; provenance in rationale_hint). voice_draft is
109
158
  // never policy-overridden (it's a redirect, not a decision).
110
- export function applyPolicies(response, n, policies) {
159
+ export function applyPolicies(response, n, policies, { now = Date.now() } = {}) {
111
160
  if (!policies || !policies.length || n.type === 'voice_draft') return response;
112
161
 
113
- const auth = matchAuthorization(n, policies);
162
+ const auth = matchAuthorization(n, policies, { now });
114
163
  if (auth) {
115
164
  return {
116
165
  ...response,
@@ -121,7 +170,7 @@ export function applyPolicies(response, n, policies) {
121
170
  };
122
171
  }
123
172
 
124
- const matched = policies.filter((r) => ruleMatches(r, n));
173
+ const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
125
174
  const bias = matched[0];
126
175
  if (!bias) return response;
127
176
  const i = LADDER.indexOf(response.signal);
package/consult.js CHANGED
@@ -14,12 +14,14 @@ import { loadPolicies, applyPolicies } from './consult-policy.js';
14
14
  import { PhaedoError } from './errors.js';
15
15
  import {
16
16
  resolveConsultationCore, buildConsultationResponse, checkAuthorization, normalizeRequest,
17
- shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape,
17
+ shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers,
18
+ deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR,
19
+ authoredDelegationSignals,
18
20
  } from './consult-core.js';
19
21
 
20
22
  // Re-export the core surface so existing importers (server.js, tests,
21
23
  // spec/test-schemas.mjs) keep their `from './consult.js'` paths.
22
- export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape };
24
+ export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape, consultDrivers, deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR, authoredDelegationSignals };
23
25
 
24
26
  // ── Model resolver (richest fingerprint, LOCAL compute — opt-in) ──────────────
25
27
  // When a local model is configured (PHAEDO_CONSULT_MODEL + an Ollama/OpenAI-
@@ -13,6 +13,31 @@ import { resolve } from 'path';
13
13
  import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
14
14
  import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
15
15
 
16
+ // §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
17
+ // A stored fingerprint from a producer that predates identity stamping (or whose
18
+ // phone copy was never re-persisted) can arrive missing the REQUIRED §4.1 fields
19
+ // — `npm run health` surfaced exactly this on a live fingerprint. The
20
+ // deterministic ones (phaedo_protocol_version, schema_revision) are safe for any
21
+ // consumer to ensure; subject_id falls back to the existing, stable, non-PII
22
+ // `fingerprint_id` when absent, rather than minting a value that would churn on
23
+ // every load. This makes the reference server serve §4.1-conformant fingerprints
24
+ // regardless of producer staleness. Source cleanup (re-persisting these on the
25
+ // phone) remains the producer's job. SEMANTIC hygiene (episodic/opaque content)
26
+ // is deliberately NOT masked here: health keeps surfacing it as an honest
27
+ // producer signal, and context-block's render guard protects the AI projection.
28
+ const IDENTITY_PROTOCOL_VERSION = '0.1';
29
+ const IDENTITY_SCHEMA_REVISION = 1;
30
+ function normalizeIdentity(vd) {
31
+ const fp = vd && vd.phaedo_fingerprint;
32
+ if (!fp || typeof fp !== 'object') return vd;
33
+ const patch = {};
34
+ if (fp.phaedo_protocol_version === undefined) patch.phaedo_protocol_version = IDENTITY_PROTOCOL_VERSION;
35
+ if (fp.schema_revision === undefined) patch.schema_revision = IDENTITY_SCHEMA_REVISION;
36
+ if (fp.subject_id === undefined && typeof fp.fingerprint_id === 'string' && fp.fingerprint_id) patch.subject_id = fp.fingerprint_id;
37
+ if (!Object.keys(patch).length) return vd;
38
+ return { ...vd, phaedo_fingerprint: { ...fp, ...patch } };
39
+ }
40
+
16
41
  // Resolution order for the local fixture (1a):
17
42
  // 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
18
43
  // 2. ./fingerprint.json next to the server
@@ -44,7 +69,7 @@ export async function loadFingerprint(baseDir) {
44
69
  try {
45
70
  const vd = await loadFingerprintFromPhone(rec);
46
71
  await writeCache(stateDir, vd);
47
- return vd;
72
+ return normalizeIdentity(vd);
48
73
  } catch (err) {
49
74
  // A 4xx means the phone REACHED us and rejected this client (revoked /
50
75
  // invalid credentials / failed handshake). Revocation must be effective:
@@ -56,7 +81,7 @@ export async function loadFingerprint(baseDir) {
56
81
  throw err;
57
82
  }
58
83
  const cached = await readCache(stateDir);
59
- if (cached) return cached.vault; // phone unreachable → encrypted cache (may be stale)
84
+ if (cached) return normalizeIdentity(cached.vault); // phone unreachable → encrypted cache (may be stale)
60
85
  throw err; // no phone + no cache → cannot serve
61
86
  }
62
87
  }
@@ -73,5 +98,38 @@ export async function loadFingerprint(baseDir) {
73
98
  if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {
74
99
  throw new Error(`Fingerprint source at ${path} is missing phaedo_fingerprint.`);
75
100
  }
76
- return vd;
101
+ return normalizeIdentity(vd);
102
+ }
103
+
104
+ // Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
105
+ // Used by the escalation path so reading the fingerprint (to compute the consult signal)
106
+ // doesn't trigger a SECOND prompt — the fingerprint-read AUTHORIZE — on top of the
107
+ // escalation decision card the subject already answers. One prompt per escalation, not two.
108
+ //
109
+ // The cache is seeded + refreshed by the normal live-pull consults, so it stays current; a
110
+ // consult SIGNAL tolerates a slightly-stale fingerprint (it doesn't change minute to minute).
111
+ // Falls back to a live pull only when no cache exists yet (first run on this machine), which
112
+ // seeds it. Revocation safety is unaffected: a revoked MCP's escalation can't be decrypted by
113
+ // the phone anyway (its client entry is gone), so it never reaches the subject regardless of
114
+ // what the MCP read locally.
115
+ // Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
116
+ // Used by the escalation path so reading the fingerprint (to compute the consult signal)
117
+ // NEVER triggers an interactive fingerprint-read AUTHORIZE on top of the escalation
118
+ // decision card. One prompt per escalation — the decision — not two, and critically no
119
+ // authorize modal racing the decision modal on the phone (that double-present crashed iOS).
120
+ //
121
+ // Uses ANY decryptable cache regardless of age. Revocation is enforced PHONE-SIDE for the
122
+ // escalation path: a revoked agent's escalation can't be decrypted by the phone (its client
123
+ // registry entry is gone), so reading a stale cache here yields nothing the agent can
124
+ // actually deliver to the subject. (The normal consult path — loadFingerprint — still does
125
+ // the live pull with its 4xx-purge revocation, so a revoked agent stops getting fresh reads
126
+ // there.) Falls through to a live pull ONLY when no cache exists at all (first run on this
127
+ // machine), which seeds it; run one phaedo_consult after pairing to seed without an
128
+ // escalation in flight.
129
+ export async function loadFingerprintPreferCache(baseDir) {
130
+ if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
131
+ const cached = await readCache(defaultStateDir());
132
+ if (cached) return normalizeIdentity(cached.vault);
133
+ }
134
+ return loadFingerprint(baseDir);
77
135
  }
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { fileURLToPath } from 'url';
22
22
  import { dirname, resolve } from 'path';
23
- import { loadFingerprint } from '../fingerprint-source.js';
23
+ import { loadFingerprintPreferCache } from '../fingerprint-source.js';
24
24
  import { buildInjectionResponse } from '../projection.js';
25
25
 
26
26
  // hooks/ lives one level under mcp/ — the sources resolve against mcp/.
@@ -37,7 +37,14 @@ function emitContext(text) {
37
37
  }
38
38
 
39
39
  async function run() {
40
- const vd = await loadFingerprint(MCP_DIR);
40
+ // PREFER the at-rest encrypted cache over a live phone pull. Claude Code (and Claude
41
+ // Desktop's per-tab MCP spawn) runs SessionStart often — including on every tab bounce
42
+ // to a session that uses this hook — so a LIVE pull here surfaces a stray "authorize"
43
+ // sheet on the phone with no live caller behind it, since the user didn't initiate
44
+ // anything from the subject side. The cache is exactly what session-start should rely
45
+ // on: fast, non-interrupting, and refreshed by any real consult/escalate that follows.
46
+ // Same pattern that #76 applied to escalation (cache-preferred fingerprint read).
47
+ const vd = await loadFingerprintPreferCache(MCP_DIR);
41
48
  const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' });
42
49
  if (response && response.projection) emitContext(response.projection);
43
50
  }
package/install.mjs CHANGED
@@ -356,6 +356,12 @@ async function main(argv) {
356
356
  } else {
357
357
  console.log(' • Claude Code: `--code-hook` makes it automatic at every session start.');
358
358
  }
359
+ console.log(' 4. Let Phaedo learn how you delegate — set `phaedo_record_outcome`');
360
+ console.log(' to "Always allow" in your client\'s tool-permission settings.');
361
+ console.log(' It records when you override an agent (the act-as-me signal); on');
362
+ console.log(' "Ask" it prompts every time, so the learning stays cold if skipped.');
363
+ console.log(' It only writes a local, encrypted audit receipt — nothing leaves the');
364
+ console.log(' device, and an authored rule still needs your explicit endorsement.');
359
365
  }
360
366
  if (opts.pair && !opts.dryRun && touched.length) return runPairing();
361
367
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "phaedo-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
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).",
8
8
  "mcpName": "io.github.galavoxx/phaedo-mcp",
9
- "homepage": "https://getphaedo.com",
9
+ "homepage": "https://phaedo.so",
10
10
  "repository": {
11
11
  "type": "git",
12
12
  "url": "git+https://github.com/galavoxx/phaedo-mcp.git"
@@ -33,6 +33,7 @@
33
33
  "consult-policy.js",
34
34
  "receipts.js",
35
35
  "agreement.js",
36
+ "calibration.js",
36
37
  "phone-source.js",
37
38
  "cache.js",
38
39
  "pairing.js",
@@ -54,15 +55,19 @@
54
55
  "start": "node server.js",
55
56
  "build": "node build-package.mjs",
56
57
  "prepack": "node build-package.mjs",
58
+ "pretest": "node build-package.mjs",
57
59
  "setup": "node install.mjs",
58
60
  "setup:pair": "node install.mjs --pair",
59
61
  "setup:code-hook": "node install.mjs --code-hook",
60
62
  "unsetup": "node install.mjs --uninstall",
61
63
  "pair": "node pair.js",
62
64
  "pull": "node pull.js",
63
- "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-receipts.mjs && node test-agreement.mjs",
65
+ "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",
64
66
  "receipts": "node receipts.js list",
65
67
  "agreement": "node agreement.js",
68
+ "calibration": "node calibration.js",
69
+ "health": "node health.js",
70
+ "validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json",
66
71
  "smoke": "node test-smoke.mjs",
67
72
  "smoke:phone": "node test-phone-boundary.mjs",
68
73
  "smoke:cache": "node test-cache.mjs",
@@ -71,8 +76,10 @@
71
76
  "smoke:install": "node test-install.mjs",
72
77
  "smoke:consult": "node test-consult.mjs",
73
78
  "smoke:policy": "node test-consult-policy.mjs",
79
+ "smoke:escalation": "node test-escalation.mjs",
74
80
  "smoke:receipts": "node test-receipts.mjs",
75
- "smoke:agreement": "node test-agreement.mjs"
81
+ "smoke:agreement": "node test-agreement.mjs",
82
+ "smoke:calibration": "node test-calibration.mjs"
76
83
  },
77
84
  "dependencies": {
78
85
  "@modelcontextprotocol/sdk": "^1.0.0",
package/pairing.js CHANGED
@@ -137,6 +137,17 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
137
137
  if (outcome === 'denied') throw new Error('pairing was denied on the phone');
138
138
  if (outcome === 'timeout') throw new Error('timed out waiting for phone confirmation');
139
139
 
140
+ // The relay lane id: prefer the one resolved during a relay rendezvous, but a LAN
141
+ // pairing ALSO needs it (the escalation channel + fp-mailbox deposit to the relay by
142
+ // this id). The phone returns its current relay_device_id in the /v1/pair response
143
+ // (localServer.ts), so capture it whichever rendezvous we used — without it, a LAN
144
+ // pairing has no relay lane and phaedo_escalate fails with "no-relay-device-id".
145
+ const relayId = relayDeviceId || body.relay_device_id || null;
146
+ // Per-device bearer (relay/auth.js): the phone shares the secret it minted with paired
147
+ // clients so they can authenticate to the relay AS this device. Without it,
148
+ // escalation/fp-mailbox deposits get 401.
149
+ const relaySecret = body.relay_device_secret || null;
150
+
140
151
  return {
141
152
  endpoint,
142
153
  client_id: body.client_id,
@@ -144,7 +155,8 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
144
155
  pair_key: b64uEnc(pairKey),
145
156
  vault_pub: body.vault_pub,
146
157
  sas,
147
- ...(relayDeviceId ? { relay_device_id: relayDeviceId } : {}),
158
+ ...(relayId ? { relay_device_id: relayId } : {}),
159
+ ...(relaySecret ? { relay_device_secret: relaySecret } : {}),
148
160
  };
149
161
  }
150
162
 
package/receipts.js CHANGED
@@ -87,8 +87,18 @@ async function writeStore(stateDir, store, now) {
87
87
  // never the projection (§10.4 holds inside the vault too). `via` records which
88
88
  // tool resolved it (phaedo_consult | phaedo_check_authorization) so M5 can weigh
89
89
  // full consultations and pre-flight matches separately.
90
- export function buildReceipt({ via, agentId, request, response, now = Date.now() }) {
90
+ export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) {
91
91
  const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
92
+ // Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that
93
+ // drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer
94
+ // text; coerced to the known shape; absent for deterministic pre-flights.
95
+ const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({
96
+ cue: typeof d?.cue === 'string' ? d.cue : null,
97
+ ...(typeof d?.lean === 'number' ? { lean: d.lean } : {}),
98
+ ...(d?.value != null ? { value: String(d.value) } : {}),
99
+ confidence: typeof d?.confidence === 'number' ? d.confidence : null,
100
+ basis: typeof d?.basis === 'string' ? d.basis : null,
101
+ })) : [];
92
102
  return {
93
103
  receipt_id: crypto.randomUUID(),
94
104
  created_at: new Date(now).toISOString(),
@@ -109,6 +119,7 @@ export function buildReceipt({ via, agentId, request, response, now = Date.now()
109
119
  confidence: typeof response?.confidence === 'number' ? response.confidence : null,
110
120
  deference_level: response?.deference_level ?? null,
111
121
  },
122
+ ...(drv.length ? { drivers: drv } : {}),
112
123
  user_action: null,
113
124
  };
114
125
  }
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "phaedo_fingerprint": {
3
+ "phaedo_protocol_version": "0.1",
3
4
  "fingerprint_id": "sample-0000-0000-0000-000000000000",
5
+ "subject_id": "sample-subj-0000-0000-000000000000",
6
+ "schema_revision": 1,
4
7
  "updated_at": "2026-06-01T12:00:00.000Z",
5
8
  "persona_strength": 0.58,
6
9
  "layers": {