phaedo-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
@@ -0,0 +1,40 @@
1
+ // Phaedo Loop 2 extraction — fingerprint persist merge (the CAS-safe contract).
2
+ //
3
+ // The offscreen pass produces a raw per-layer `update` delta. Merging that delta
4
+ // INTO the persistent fingerprint is a read-modify-write, and it MUST run against
5
+ // the freshest fingerprint under a lock — otherwise a pass that read the
6
+ // fingerprint before it ran will blind-overwrite anything authored in between:
7
+ // a subject-endorsed standing_rule, another tab's accumulated signals, or a
8
+ // just-dismissed conflict (the extension-CAS lost-update finding).
9
+ //
10
+ // This module is the PURE core of that merge: reconcile the delta, stage any
11
+ // newly-promotable suggested rules, stamp updated_at. The service worker
12
+ // (background.js `mergeExtraction`) wraps it in `withVaultLock` against a fresh
13
+ // `readVault`, so the whole read → merge → write is atomic. Keeping the merge
14
+ // pure makes the race-safety contract node-testable without chrome.
15
+
16
+ import { reconcileFingerprint } from './reconcile.js';
17
+ import { promoteRules } from './rule-promotion.js';
18
+ import { applyCaptureSources } from './capture-combine.js';
19
+
20
+ // Reconcile `update` into `fingerprint` and return the next fingerprint. Never
21
+ // mutates the input. reconcile merges layer signals accumulatively and upserts
22
+ // conflict_records (dismissals stay sticky); promoteRules stages suggested_rules
23
+ // for established, not-yet-authored D&R patterns (skipping dimensions the subject
24
+ // already authored). Because both are pure and merge — rather than replace — the
25
+ // current fingerprint, running them on a FRESH read is the whole lost-update fix.
26
+ //
27
+ // `captureSources` (optional): the vault's per-surface capture snapshots
28
+ // (phaedo_capture_sources values). When present, the update's delta signals are
29
+ // replaced by the CROSS-SURFACE combined set before reconcile (capture-combine.js)
30
+ // — otherwise a delta write from one surface would clobber the accumulated counts
31
+ // of the others (delta reconcile is replace-by-key). Callers that hold a vault
32
+ // with capture sources MUST pass them on every delta-writing merge.
33
+ export function mergeExtractionInto(fingerprint, update, now = new Date().toISOString(), captureSources = null) {
34
+ const effective = (Array.isArray(captureSources) && captureSources.length)
35
+ ? applyCaptureSources(update, captureSources, now)
36
+ : update;
37
+ const merged = promoteRules(reconcileFingerprint(fingerprint || {}, effective, { now }), { now });
38
+ merged.updated_at = now; // bump for sync last-write-wins
39
+ return merged;
40
+ }
@@ -0,0 +1,269 @@
1
+ // Phaedo Loop 2 extraction — reconciliation (update → fingerprint).
2
+ //
3
+ // Merges a partial fingerprint update (projection.buildFingerprintUpdate) into
4
+ // the stored fingerprint, per extraction-task-spec §6/§7 and §4.3:
5
+ //
6
+ // - Signals are keyed by (field, polarity). A new pass either reinforces an
7
+ // existing signal (same key → merge) or adds a new one.
8
+ // - Reinforcement raises confidence (more consistent observations = more
9
+ // likely a stable trait, §7), sums observation_count, unions session_refs,
10
+ // advances last_observed.
11
+ // - Anti-preference precedence (§4.3, §6): when a layer holds both a positive
12
+ // and a negative signal for the same field, the negative (anti-preference)
13
+ // is the effective one. Both are retained for provenance; effectiveSignals()
14
+ // applies the precedence for downstream consumers.
15
+ // - Non-destructive: questionnaire data on a layer (e.g. `responses[]`, the
16
+ // cold-start shape in §2) is preserved. Extraction only writes the §4.2
17
+ // signal-bearing fields. Authored User Configuration (§5) is a sibling
18
+ // artifact extraction never touches.
19
+ //
20
+ // NUMERIC CALIBRATION IS DEFERRED (§7): the confidence-combine curve and
21
+ // contradiction handling below are honest placeholders to be tuned by the eval
22
+ // harness once a real model exists. They are monotone and bounded, not claimed
23
+ // to be calibrated.
24
+
25
+ import { renderLayerSummary } from './fingerprint-update.js';
26
+ import { resolveDimension, valKey, authoredGovernedKeys } from './resolve.js';
27
+
28
+ const CONFIDENCE_CAP = 0.95;
29
+
30
+ // Noisy-OR: independent consistent observations push confidence up, never past
31
+ // the cap. Placeholder pending calibration; contradiction-driven *lowering*
32
+ // (§7, persona-algorithm "adversarial robustness") is a deferred refinement.
33
+ function combineConfidence(a, b) {
34
+ return Math.min(CONFIDENCE_CAP, 1 - (1 - (a ?? 0)) * (1 - (b ?? 0)));
35
+ }
36
+
37
+ function unionArrays(a = [], b = []) {
38
+ return Array.from(new Set([...a, ...b]));
39
+ }
40
+
41
+ // Merge two signal objects with the same (field, polarity) key.
42
+ function mergeSignal(existing, incoming, now) {
43
+ // Delta signals carry Path A's CUMULATIVE count — a re-lift is a fresh
44
+ // snapshot of the same counters, not new evidence. Replace wholesale so
45
+ // re-running the lift every pass is idempotent (no count/confidence
46
+ // inflation). Keep the stable signal_id.
47
+ if (incoming.source === 'delta') {
48
+ return { ...incoming, signal_id: existing.signal_id || incoming.signal_id };
49
+ }
50
+ const observation_count = (existing.observation_count || 1) + (incoming.observation_count || 1);
51
+ // Value merge: arrays union; scalar/enum/string keep the higher-confidence
52
+ // value (a contradiction across passes resolves to the better-supported one).
53
+ let value = existing.value;
54
+ if (Array.isArray(existing.value) && Array.isArray(incoming.value)) {
55
+ value = unionArrays(existing.value, incoming.value);
56
+ } else if ((incoming.confidence ?? 0) > (existing.confidence ?? 0)) {
57
+ value = incoming.value;
58
+ }
59
+ return {
60
+ ...existing,
61
+ // A questionnaire (self-reported) signal that observation now corroborates is
62
+ // no longer "stated only" — adopt the observing source so it counts as
63
+ // learned (and surfaces in the §5b summary). Pure self-report keeps
64
+ // source 'questionnaire' and stays out of the summary.
65
+ source: incoming.source || existing.source,
66
+ value,
67
+ confidence: Number(combineConfidence(existing.confidence, incoming.confidence).toFixed(3)),
68
+ observation_count,
69
+ session_refs: unionArrays(existing.session_refs, incoming.session_refs),
70
+ last_observed: maxIso(existing.last_observed, incoming.last_observed) || now,
71
+ };
72
+ }
73
+
74
+ function maxIso(a, b) {
75
+ if (!a) return b;
76
+ if (!b) return a;
77
+ return a > b ? a : b;
78
+ }
79
+
80
+ // Dedupe key. Domain-scoped Decision & Risk signals (§4.2.7 optional `domain`)
81
+ // reconcile per (field, polarity, domain) so "financial caution" and "writing
82
+ // boldness" coexist and reinforce independently; an untagged signal keeps the
83
+ // legacy key — fully backward-compatible.
84
+ const sigKey = s => `${s.field}::${s.polarity}${typeof s.domain === 'string' ? `::${s.domain}` : ''}`;
85
+
86
+ // Reconcile one incoming layer doc into an existing layer doc (or undefined).
87
+ // Returns a new layer object; does not mutate inputs. `suppress` (optional Set of
88
+ // "field"/"field::domain") withholds those dimensions from the rendered summary —
89
+ // the §4.7 injection-suppression of an open-conflict dimension.
90
+ export function reconcileLayer(existingLayer, incomingLayer, now, suppress) {
91
+ const base = existingLayer ? { ...existingLayer } : {};
92
+ const existingSignals = Array.isArray(base.signals) ? base.signals : [];
93
+ const byKey = new Map(existingSignals.map(s => [sigKey(s), s]));
94
+
95
+ for (const inc of incomingLayer.signals || []) {
96
+ const k = sigKey(inc);
97
+ byKey.set(k, byKey.has(k) ? mergeSignal(byKey.get(k), inc, now) : inc);
98
+ }
99
+
100
+ const mergedSignals = Array.from(byKey.values());
101
+ return {
102
+ ...base, // preserve questionnaire `responses`, `label`, etc.
103
+ layer_version: incomingLayer.layer_version || base.layer_version || '0.1',
104
+ last_updated: now,
105
+ confidence: Number((mergedSignals.reduce((a, s) => a + (s.confidence || 0), 0) /
106
+ (mergedSignals.length || 1)).toFixed(3)),
107
+ signals: mergedSignals,
108
+ // §5b "Learned from your conversations" should reflect observation, not
109
+ // restate the interview (§5 already renders questionnaire answers). Render
110
+ // only observation-backed signals; pure self-report (source 'questionnaire',
111
+ // never corroborated) stays in signals[] for provenance but is excluded here.
112
+ summary: renderLayerSummary(effectiveSignals(mergedSignals).filter(s => s.source !== 'questionnaire'), suppress),
113
+ };
114
+ }
115
+
116
+ // Apply §4.3/§6 anti-preference precedence: if a field has a negative signal,
117
+ // the positive one for that field is suppressed from the effective set (kept in
118
+ // storage for provenance, dropped from the rendered/consumed view).
119
+ export function effectiveSignals(signals) {
120
+ const negFields = new Set(signals.filter(s => s.polarity === 'negative').map(s => s.field));
121
+ return signals.filter(s => !(s.polarity === 'positive' && negFields.has(s.field)));
122
+ }
123
+
124
+ // ── Conflict records (the update gate's "contradict" outcome) ────────────────
125
+ //
126
+ // mergeSignal() above collapses same-(field,polarity,domain) signals by confidence,
127
+ // so a genuine contradiction — a new OBSERVED value that disagrees with the subject's
128
+ // SELF-REPORT while the observed evidence is still thin (< §4.7 floor) — would vanish
129
+ // silently. Detect it at the merge moment (both values still visible), classify it
130
+ // with the SHARED §4.7 resolver, and stage a durable, reviewable record. This is the
131
+ // producer-written `suggested_rules` pattern (Proposal 0007): never injected, never
132
+ // consulted — a queue the subject reviews. We do NOT change what is stored or injected
133
+ // here (that injection-suppression fix is a separate, behaviour-changing follow-up);
134
+ // this only makes the contradiction visible and keeps both sides' provenance.
135
+
136
+ const uuid = () =>
137
+ (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
138
+ : 'cfl-' + Math.random().toString(36).slice(2, 10);
139
+
140
+ const conflictKey = (layer, field, domain) =>
141
+ `${layer}::${field}${typeof domain === 'string' ? `::${domain}` : ''}`;
142
+
143
+ // Compare an incoming layer's signals against the existing ones BEFORE the merge
144
+ // collapses them. For each incoming signal that shares a (field,polarity,domain) key
145
+ // with an existing one but carries a DIFFERENT scalar/enum value, ask the §4.7 resolver:
146
+ // - status "unresolved" (thin inferred value vs a self-report) → an OPEN conflict.
147
+ // - status "resolved" (the inferred side crossed the floor, or observed-vs-observed) →
148
+ // the dimension now settles; if a record was open for it, CLOSE it.
149
+ // Array-valued fields union (never conflict) and are skipped. Pure; no mutation.
150
+ export function detectConflicts(existingSignals, incomingSignals) {
151
+ const byKey = new Map((Array.isArray(existingSignals) ? existingSignals : []).map((s) => [sigKey(s), s]));
152
+ const open = [], settled = [], overrides = [];
153
+ for (const inc of (Array.isArray(incomingSignals) ? incomingSignals : [])) {
154
+ const ex = byKey.get(sigKey(inc));
155
+ if (!ex) continue; // novel → not a conflict
156
+ if (Array.isArray(ex.value) || Array.isArray(inc.value)) continue; // list fields union
157
+ if (valKey(ex.value) === valKey(inc.value)) continue; // agree → corroboration
158
+ const r = resolveDimension([ex, inc]);
159
+ const dim = { field: inc.field, domain: typeof inc.domain === 'string' ? inc.domain : undefined, candidates: r.provenance };
160
+ if (r.status === 'unresolved') { open.push(dim); continue; }
161
+ settled.push(dim);
162
+ // OVERRIDE detection: inferred behavior crossed the §4.7 floor AND there was a
163
+ // contradicting self_reported candidate → behavior just silently overrode what the
164
+ // subject said in the interview. Stage it so the subject SEES it (the existing
165
+ // "settled" path only closes already-open records; without this an override produces
166
+ // zero visibility — the subject's stated preference vanishes silently). Read by
167
+ // upsertConflictRecords to create a resolved record with resolution.by:'system'.
168
+ const selfReport = (r.provenance || []).find(c => c && c.evidence_basis === 'self_reported');
169
+ const winnerNotSelfReport = r.evidence_basis !== 'self_reported';
170
+ if (selfReport && winnerNotSelfReport && valKey(r.value) !== valKey(selfReport.value)) {
171
+ overrides.push({ ...dim, chosen_value: r.value, prior_value: selfReport.value });
172
+ }
173
+ }
174
+ return { open, settled, overrides };
175
+ }
176
+
177
+ // Fold a layer's detected conflicts into the fingerprint-level records list. Open a
178
+ // new record (or refresh an existing open one); close an open record whose dimension
179
+ // now settles. A subject's `dismissed` is sticky — never auto-reopened or closed.
180
+ function upsertConflictRecords(records, layerId, detected, now) {
181
+ const list = (Array.isArray(records) ? records : []).map((r) => ({ ...r }));
182
+ const byKey = new Map(list.map((r) => [conflictKey(r.layer, r.field, r.domain), r]));
183
+ for (const d of detected.open) {
184
+ const k = conflictKey(layerId, d.field, d.domain);
185
+ const ex = byKey.get(k);
186
+ if (ex && ex.status === 'dismissed') continue;
187
+ if (ex && ex.status === 'open') { ex.last_seen = now; ex.candidates = d.candidates; continue; }
188
+ const rec = { conflict_id: uuid(), layer: layerId, field: d.field, status: 'open', detected_at: now, last_seen: now, candidates: d.candidates };
189
+ if (d.domain) rec.domain = d.domain;
190
+ byKey.set(k, rec);
191
+ }
192
+ for (const d of detected.settled) {
193
+ const ex = byKey.get(conflictKey(layerId, d.field, d.domain));
194
+ if (ex && ex.status === 'open') { ex.status = 'resolved'; ex.resolved_at = now; }
195
+ }
196
+ // Overrides: stage a `resolved` record marked as a system-auto-resolution against a
197
+ // self-report, so the subject can see + acknowledge or revert. Dismissed records
198
+ // stay sticky; an existing record gets refreshed last_seen + candidates instead of
199
+ // duplicating. by:'system' is the cue conflict-review uses to surface for review.
200
+ for (const d of (detected.overrides || [])) {
201
+ const k = conflictKey(layerId, d.field, d.domain);
202
+ const ex = byKey.get(k);
203
+ if (ex && ex.status === 'dismissed') continue;
204
+ if (ex) {
205
+ ex.last_seen = now; ex.candidates = d.candidates;
206
+ // Don't re-stamp an override the subject already acknowledged (by:'user').
207
+ if (!ex.resolution || ex.resolution.by !== 'user') {
208
+ ex.status = 'resolved'; ex.resolved_at = now;
209
+ ex.resolution = { chosen_value: d.chosen_value, prior_value: d.prior_value, by: 'system' };
210
+ }
211
+ continue;
212
+ }
213
+ const rec = {
214
+ conflict_id: uuid(), layer: layerId, field: d.field,
215
+ status: 'resolved', detected_at: now, last_seen: now, resolved_at: now,
216
+ candidates: d.candidates,
217
+ resolution: { chosen_value: d.chosen_value, prior_value: d.prior_value, by: 'system' },
218
+ };
219
+ if (d.domain) rec.domain = d.domain;
220
+ byKey.set(k, rec);
221
+ }
222
+ return Array.from(byKey.values());
223
+ }
224
+
225
+ // Reconcile a full update ({ layerId: layerDoc }) into a fingerprint.
226
+ // Fingerprint shape: { layers: { layerId: layerDoc }, ... }. Returns a new
227
+ // fingerprint object (shallow-cloned down to touched layers). Also stages
228
+ // `conflict_records[]` for genuine self-report↔observed contradictions (above).
229
+ export function reconcileFingerprint(fingerprint, update, opts = {}) {
230
+ const now = opts.now || new Date().toISOString();
231
+ const fp = { ...(fingerprint || {}) };
232
+ fp.layers = { ...(fp.layers || {}) };
233
+ let conflicts = Array.isArray(fp.conflict_records) ? fp.conflict_records : null;
234
+ for (const [layerId, incomingLayer] of Object.entries(update || {})) {
235
+ const prev = fp.layers[layerId];
236
+ const detected = detectConflicts(prev && prev.signals, incomingLayer.signals);
237
+ if (detected.open.length || detected.settled.length || (detected.overrides && detected.overrides.length) || (conflicts && conflicts.length)) {
238
+ conflicts = upsertConflictRecords(conflicts || [], layerId, detected, now);
239
+ }
240
+ // Build the summary AFTER the conflict set is finalized, withholding from the
241
+ // projection (§4.7): (a) any dimension with an OPEN conflict, and (b) any dimension an
242
+ // AUTHORED bound rule governs — the authored value is canonical for the projection, so
243
+ // the inferred summary must not restate/contradict it (resolveEffective decides; the
244
+ // rule is injected separately via renderOperatingRules). authoredGovernedKeys runs over
245
+ // both the existing and incoming signals so every field that could render is covered.
246
+ const rules = Array.isArray(fp.standing_rules) ? fp.standing_rules : [];
247
+ const suppress = new Set([
248
+ ...openConflictFieldKeys(conflicts, layerId),
249
+ ...authoredGovernedKeys([...((prev && prev.signals) || []), ...(incomingLayer.signals || [])], rules, opts.domain || null),
250
+ ]);
251
+ fp.layers[layerId] = reconcileLayer(prev, incomingLayer, now, suppress);
252
+ }
253
+ if (conflicts && conflicts.length) fp.conflict_records = conflicts;
254
+ return fp;
255
+ }
256
+
257
+ // The (field / field::domain) keys of the OPEN conflicts on a layer — the dimensions
258
+ // the projection withholds (renderLayerSummary skips them).
259
+ function openConflictFieldKeys(records, layerId) {
260
+ const out = new Set();
261
+ for (const r of (Array.isArray(records) ? records : [])) {
262
+ if (r && r.layer === layerId && r.status === 'open') {
263
+ out.add(r.field + (typeof r.domain === 'string' ? `::${r.domain}` : ''));
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ export { combineConfidence, sigKey };
@@ -0,0 +1,178 @@
1
+ // §4.7 Conflict Resolution (Proposal 0006, Route A) — pure, deterministic, no I/O.
2
+ //
3
+ // Single source of truth for resolving multiple signals on ONE dimension (field)
4
+ // into a single value + provenance. Evidence-tier precedence (corroborated >
5
+ // observed > self_reported), observation-count then recency tiebreak, with a
6
+ // minimum-evidence FLOOR: thin observed evidence does NOT confidently override a
7
+ // self-reported (interview) value — below the floor a genuine conflict resolves to
8
+ // status:"unresolved", which consumers treat conservatively (assert nothing / an
9
+ // agent escalates rather than proceeds).
10
+ //
11
+ // Lives here (browser-safe ESM, packaged with the extension's extraction engine so
12
+ // the producer can render FROM it) and is re-exported by spec/validate-fingerprint.mjs
13
+ // so the spec test-vectors and mcp/health use the exact same resolver. Pure: any
14
+ // conformant implementation computes the same result from the same evidence log, so
15
+ // the fingerprint stays the raw signal log and provenance is derived (no wire change).
16
+
17
+ const isObj = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
18
+
19
+ export const RESOLUTION_FLOOR = 3; // observed/corroborated needs ≥ this to override self_reported
20
+ const EV_TIER = { corroborated: 3, observed: 2, self_reported: 1 };
21
+ const tierOf = (s) => EV_TIER[s && s.evidence_basis] || 1; // unknown basis → weakest
22
+ const obsOf = (s) => (typeof s.observation_count === 'number' && s.observation_count > 0) ? s.observation_count : 1;
23
+ const isoOf = (s) => typeof s.last_observed === 'string' ? s.last_observed : '';
24
+ export const valKey = (v) => JSON.stringify(v ?? null);
25
+
26
+ // Strict total order: tier, then observation_count, then recency, then value
27
+ // string — fully deterministic, no ties.
28
+ function moreAuthoritative(a, b) {
29
+ if (tierOf(a) !== tierOf(b)) return tierOf(a) - tierOf(b);
30
+ if (obsOf(a) !== obsOf(b)) return obsOf(a) - obsOf(b);
31
+ if (isoOf(a) !== isoOf(b)) return isoOf(a) < isoOf(b) ? -1 : 1;
32
+ const va = valKey(a.value), vb = valKey(b.value);
33
+ return va < vb ? -1 : va > vb ? 1 : 0;
34
+ }
35
+
36
+ export function resolveDimension(signals) {
37
+ const cand = (signals || []).filter(isObj);
38
+ if (!cand.length) return null;
39
+ const winner = cand.reduce((best, s) => moreAuthoritative(s, best) > 0 ? s : best);
40
+ const distinct = new Set(cand.map((s) => valKey(s.value)));
41
+ let status = 'resolved';
42
+ if (distinct.size > 1) { // a genuine value conflict
43
+ const inferredWinner = tierOf(winner) >= EV_TIER.observed;
44
+ const contradictingSelfReport = cand.some(
45
+ (s) => tierOf(s) === EV_TIER.self_reported && valKey(s.value) !== valKey(winner.value));
46
+ if (inferredWinner && contradictingSelfReport && obsOf(winner) < RESOLUTION_FLOOR) status = 'unresolved';
47
+ }
48
+ return {
49
+ field: winner.field,
50
+ value: winner.value,
51
+ evidence_basis: winner.evidence_basis,
52
+ confidence: winner.confidence,
53
+ status, // "resolved" | "unresolved"
54
+ provenance: cand.map((s) => ({
55
+ value: s.value, source: s.source, evidence_basis: s.evidence_basis,
56
+ confidence: s.confidence, observation_count: s.observation_count, last_observed: s.last_observed,
57
+ })),
58
+ };
59
+ }
60
+
61
+ // Resolve a whole layer's signals → { field: resolution }. Groups by field, then
62
+ // resolveDimension per field. (Polarity precedence per §4.3 is applied upstream by
63
+ // the producer's effectiveSignals(); this resolves VALUE conflicts.)
64
+ export function resolveLayer(signals) {
65
+ const byField = new Map();
66
+ for (const s of (signals || [])) {
67
+ if (!isObj(s) || s.field == null) continue;
68
+ if (!byField.has(s.field)) byField.set(s.field, []);
69
+ byField.get(s.field).push(s);
70
+ }
71
+ const out = {};
72
+ for (const [field, sigs] of byField) out[field] = resolveDimension(sigs);
73
+ return out;
74
+ }
75
+
76
+ // ── §4.7 effective resolution in context (v0.1.43, resolves OQ7) ──────────────
77
+ //
78
+ // Composes AUTHORED configuration (§5.2 decision-bound standing instructions)
79
+ // with the INFERRED evidence log above, per dimension, in an optional domain
80
+ // context — the exact rules for "a standing instruction that contradicts a
81
+ // high-confidence fingerprint signal in only one domain". The specificity
82
+ // ladder (a lower tier is consulted only when every higher tier is empty):
83
+ //
84
+ // 1. authored_domain — a decision-bound instruction whose decision.domain
85
+ // matches the context domain (resolver substring namespace: the context
86
+ // domain string CONTAINS the rule's domain, case-insensitive). A
87
+ // domain-bound rule NEVER governs outside its domain: with a
88
+ // non-matching or unknown context it is skipped entirely, so the
89
+ // inferred signal keeps governing everywhere else — that is the OQ7
90
+ // one-domain-override semantics.
91
+ // 2. authored_global — a decision-bound instruction with no domain binding.
92
+ // Authored beats inferred regardless of the signal's confidence or
93
+ // evidence tier (§4.7 authored-canonical, unchanged — now stated at
94
+ // dimension granularity).
95
+ // 3. inferred_domain — §4.2.7 domain-tagged signals matching the context
96
+ // domain, resolved among themselves by resolveDimension. An
97
+ // "unresolved" here does NOT fall through to tier 4: a contested
98
+ // domain-specific dimension stays contested (assert nothing).
99
+ // 4. inferred_global — untagged signals, resolved by resolveDimension.
100
+ // Signals tagged with a DIFFERENT domain are excluded when a context
101
+ // domain is known (they describe another context); with no context
102
+ // domain, all signals resolve together (pre-v0.1.43 behavior).
103
+ //
104
+ // Within an authored tier, ties break §5.2-style: lowest priority number,
105
+ // then created_at, then instruction_id — fully deterministic.
106
+ //
107
+ // Pure; same conformance surface as resolveDimension (TC-RESOLVE vectors).
108
+ const prioOf = (r) => (typeof r.priority === 'number' && Number.isFinite(r.priority)) ? r.priority : Infinity;
109
+ const cmpStr = (a, b) => { const x = String(a ?? ''), y = String(b ?? ''); return x < y ? -1 : x > y ? 1 : 0; };
110
+
111
+ export function resolveEffective({ field, signals = [], rules = [], domain = null } = {}) {
112
+ if (field == null) return null;
113
+ const d = (typeof domain === 'string' && domain) ? domain.toLowerCase() : null;
114
+
115
+ const bound = (rules || []).filter((r) => isObj(r) && r.kind === 'instruction'
116
+ && isObj(r.decision) && r.decision.dimension === field);
117
+ const ruleDomain = (r) => (typeof r.decision.domain === 'string' && r.decision.domain)
118
+ ? r.decision.domain.toLowerCase() : null;
119
+ const tier1 = d ? bound.filter((r) => { const rd = ruleDomain(r); return rd && d.includes(rd); }) : [];
120
+ const tier2 = bound.filter((r) => !ruleDomain(r));
121
+ const pickRule = (arr) => arr.slice().sort((a, b) =>
122
+ (prioOf(a) - prioOf(b)) || cmpStr(a.created_at, b.created_at) || cmpStr(a.instruction_id, b.instruction_id))[0];
123
+
124
+ const authoredTier = tier1.length ? 'authored_domain' : tier2.length ? 'authored_global' : null;
125
+ if (authoredTier) {
126
+ const rule = pickRule(tier1.length ? tier1 : tier2);
127
+ return {
128
+ field,
129
+ status: 'resolved',
130
+ basis: authoredTier,
131
+ authored: true,
132
+ // A bound rule may carry a precise catalog value (template-sourced) or
133
+ // none — a valueless negative binding is a cautious bias, which §10
134
+ // consumers already map (consult-policy.js). Surface both faithfully.
135
+ value: rule.decision.value ?? null,
136
+ polarity: rule.decision.polarity || 'positive',
137
+ rule_id: rule.instruction_id || null,
138
+ };
139
+ }
140
+
141
+ const sigs = (signals || []).filter(isObj);
142
+ const sigDomain = (s) => (typeof s.domain === 'string' && s.domain) ? s.domain.toLowerCase() : null;
143
+ const tagged = d ? sigs.filter((s) => { const sd = sigDomain(s); return sd && d.includes(sd); }) : [];
144
+ const pool = tagged.length ? tagged
145
+ : d ? sigs.filter((s) => !sigDomain(s)) // other-domain-tagged excluded in a known context
146
+ : sigs; // no context → all signals (back-compat)
147
+ const resolved = resolveDimension(pool);
148
+ if (!resolved) return null;
149
+ return { ...resolved, basis: tagged.length ? 'inferred_domain' : 'inferred_global', authored: false };
150
+ }
151
+
152
+ // The (field / field::domain) suppress keys for a layer's INFERRED summary that an
153
+ // AUTHORED bound rule already governs. §4.7 makes a bound rule authored-canonical for
154
+ // its dimension in the projection, so the summary must not ALSO assert a (possibly
155
+ // contradictory) inferred line for that dimension — the authored rule is already injected
156
+ // verbatim (context-block.js renderOperatingRules, higher priority than inferred style).
157
+ // This routes the decision through resolveEffective: a field whose resolution is authored
158
+ // (`authored:true`) has all its rendered-signal keys suppressed. `domain` is optional —
159
+ // null at extraction time, where only authored_GLOBAL rules govern; domain-scoped rules
160
+ // defer to injection-time rendering, which carries the session domain. Pure.
161
+ export function authoredGovernedKeys(signals, rules, domain = null) {
162
+ const out = new Set();
163
+ const list = (Array.isArray(signals) ? signals : []).filter(isObj);
164
+ if (!list.length || !Array.isArray(rules) || !rules.length) return out;
165
+ const byField = new Map();
166
+ for (const s of list) {
167
+ if (typeof s.field !== 'string') continue;
168
+ if (!byField.has(s.field)) byField.set(s.field, []);
169
+ byField.get(s.field).push(s);
170
+ }
171
+ for (const [field, sigs] of byField) {
172
+ const r = resolveEffective({ field, signals: sigs, rules, domain });
173
+ if (r && r.authored) {
174
+ for (const s of sigs) out.add(s.field + (typeof s.domain === 'string' ? `::${s.domain}` : ''));
175
+ }
176
+ }
177
+ return out;
178
+ }
@@ -0,0 +1,137 @@
1
+ // Proposal 0007 §E — delta promotion (the steady-state path).
2
+ //
3
+ // When the extraction engine's INFERRED Decision & Risk signals show a pattern that
4
+ // has recurred enough to be established (evidence_basis observed/corroborated,
5
+ // observation_count ≥ floor) and the subject has NOT already turned it into an
6
+ // authored rule, propose a `suggested` standing rule into a STAGING queue
7
+ // (`phaedo_fingerprint.suggested_rules`). This is the one rule-shaped object the
8
+ // producer writes — and it is explicitly a *proposal*: never injected, never
9
+ // consulted. Only the subject's endorsement (rule-review.js) turns a suggestion into
10
+ // an authored `standing_rules` entry. So the authored-only invariant on standing_rules
11
+ // holds: the producer stages, the user authors.
12
+ //
13
+ // Pure + deterministic, zero-dep — the house style. The review UI lets the subject
14
+ // edit the proposed text before endorsing, so `proposed_text` only needs to be a
15
+ // reasonable starting sentence.
16
+
17
+ export const PROMOTION_FLOOR = 3; // §4.7 RESOLUTION_FLOOR — same "established" bar
18
+ const PROMOTABLE_TIERS = new Set(['observed', 'corroborated']); // never promote a self_reported guess
19
+ const DR_LAYER = 'decision_and_risk';
20
+
21
+ const uuid = () =>
22
+ (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
23
+ : 'sug-' + Math.random().toString(36).slice(2, 10);
24
+
25
+ const keyOf = (dim, val) => `${dim}::${JSON.stringify(val ?? null)}`;
26
+
27
+ // (dimension, value) → a first-person candidate. Unknown pairs fall back to a
28
+ // generic sentence; the subject edits before endorsing either way.
29
+ const TEMPLATES = {
30
+ evidence_threshold: { high: 'I want the main risks covered and the key facts nailed down before I commit.' },
31
+ ambiguity_posture: { ask: 'When a request is ambiguous, I ask one clarifying question before acting.',
32
+ default: 'When things are ambiguous I stay decisive and commit rather than hedge.',
33
+ delay: 'When things are ambiguous I would rather wait than commit prematurely.' },
34
+ speed_vs_quality: { quality: 'I trade speed for accuracy on work that matters.',
35
+ accuracy: 'I trade speed for accuracy on work that matters.',
36
+ speed: 'I favor moving fast and accept rough edges when speed matters.' },
37
+ reversible_vs_irreversible: { more_cautious: "On decisions I can't easily undo, I slow down and seek more input.",
38
+ consistent_style: 'I bring the same care to a decision whether or not it can be undone.' },
39
+ options_vs_recommendation: { single_recommendation: 'Give me a single clear recommendation, not a menu of options.',
40
+ option_set: 'I prefer to see a set of options and make the call myself.' },
41
+ // Surface (response/style) — the dimensions auto-overrides surface against.
42
+ response_length_pref: { short: 'Default to concise — keep responses tight.',
43
+ thorough: 'Default to thorough responses; go deep and cover the edge cases.',
44
+ medium: 'Default to medium-length responses — neither terse nor exhaustive.' },
45
+ tone_register: { casual: 'Default to a casual, conversational tone.',
46
+ formal: 'Default to a formal, professional tone.' },
47
+ };
48
+ const DIM_LABEL = {
49
+ evidence_threshold: 'how much evidence I need before committing',
50
+ ambiguity_posture: 'how I handle ambiguity',
51
+ speed_vs_quality: 'how I weigh speed against quality',
52
+ reversible_vs_irreversible: 'how I treat irreversible decisions',
53
+ options_vs_recommendation: 'whether I want options or a single call',
54
+ response_length_pref: 'response length',
55
+ tone_register: 'tone',
56
+ directness: 'directness',
57
+ jargon_tolerance: 'jargon level',
58
+ formatting_prefs: 'formatting',
59
+ autonomy_pref: 'how much autonomy I give the assistant',
60
+ reasoning_visibility: 'whether I want the reasoning shown',
61
+ };
62
+ export function proposeText(dimension, value) {
63
+ const v = Array.isArray(value) ? value[0] : value;
64
+ const byDim = TEMPLATES[dimension];
65
+ if (byDim && v != null && byDim[v]) return byDim[v];
66
+ const label = DIM_LABEL[dimension] || dimension;
67
+ return v != null ? `I have a consistent way I handle ${label} (${v}).` : `I have a consistent way I handle ${label}.`;
68
+ }
69
+
70
+ function endorsedDimensions(fp) {
71
+ const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
72
+ return new Set(rules.map((r) => r?.decision?.dimension).filter(Boolean));
73
+ }
74
+ function stagedKeys(fp) {
75
+ const sug = Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [];
76
+ return new Set(sug.map((s) => keyOf(s?.decision?.dimension, s?.evidence?.value)));
77
+ }
78
+
79
+ // Inspect a fingerprint's Decision & Risk signals and return suggested-rule
80
+ // candidates (does NOT mutate the fingerprint). A candidate is produced for an
81
+ // established, not-yet-authored, not-yet-staged/dismissed (dimension,value).
82
+ export function detectPromotions(fp, { floor = PROMOTION_FLOOR, now = new Date().toISOString() } = {}) {
83
+ const dr = fp?.layers?.[DR_LAYER];
84
+ const signals = Array.isArray(dr?.signals) ? dr.signals : [];
85
+ const covered = endorsedDimensions(fp);
86
+ const seen = stagedKeys(fp); // already suggested OR dismissed → skip
87
+ const local = new Set();
88
+ const out = [];
89
+ for (const s of signals) {
90
+ if (!s || typeof s !== 'object') continue;
91
+ const dim = s.dimension || s.field;
92
+ if (!dim) continue;
93
+ if (!PROMOTABLE_TIERS.has(s.evidence_basis)) continue;
94
+ if ((s.observation_count || 0) < floor) continue;
95
+ if (covered.has(dim)) continue; // already an authored rule on this dimension
96
+ const k = keyOf(dim, s.value);
97
+ if (seen.has(k) || local.has(k)) continue;
98
+ local.add(k);
99
+ out.push({
100
+ suggestion_id: uuid(),
101
+ status: 'suggested',
102
+ source: 'delta_promotion',
103
+ proposed_text: proposeText(dim, s.value),
104
+ decision: { dimension: dim, polarity: s.polarity === 'negative' ? 'negative' : 'positive', ...(s.domain ? { domain: s.domain } : {}) },
105
+ evidence: {
106
+ observation_count: s.observation_count || 0,
107
+ last_observed: s.last_observed || null,
108
+ evidence_basis: s.evidence_basis,
109
+ value: s.value,
110
+ ...(s.signal_id ? { signal_id: s.signal_id } : {}),
111
+ },
112
+ created: now,
113
+ });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // Merge new suggestions into the staging queue (dedup by dimension+value). Writes
119
+ // ONLY suggested_rules — never standing_rules. Returns a new fingerprint.
120
+ export function stageSuggestions(fp, suggestions) {
121
+ if (!Array.isArray(suggestions) || !suggestions.length) return fp;
122
+ const existing = Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [];
123
+ const seen = stagedKeys(fp);
124
+ const add = suggestions.filter((s) => {
125
+ const k = keyOf(s?.decision?.dimension, s?.evidence?.value);
126
+ if (seen.has(k)) return false;
127
+ seen.add(k);
128
+ return true;
129
+ });
130
+ if (!add.length) return fp;
131
+ return { ...fp, suggested_rules: [...existing, ...add] };
132
+ }
133
+
134
+ // Producer convenience: detect + stage in one pass.
135
+ export function promoteRules(fp, opts) {
136
+ return stageSuggestions(fp, detectPromotions(fp, opts));
137
+ }
package/vendor/kdf.js CHANGED
@@ -59,6 +59,25 @@
59
59
  )
60
60
  }
61
61
 
62
+ /**
63
+ * §5 User Configuration sync key. Same role as deriveFpSyncKey, for the sibling
64
+ * artifact: a distinct sub-key of pair_key so the config envelope is decryptable by
65
+ * either device with no live session, and never reuses another role's key.
66
+ *
67
+ * Deliberately NOT the §5.5 `phaedo:config:enc:v0.1` context. That derives from MASTER
68
+ * key material, and the master key never leaves the vault device (§7.4) — so it can only
69
+ * seal a device-LOCAL at-rest copy, never an artifact the paired extension must open.
70
+ * Both keys exist and do different jobs: §5.5 at rest, this one in transit.
71
+ */
72
+ async function deriveConfigSyncKey(pairKey) {
73
+ return hkdfBytes(
74
+ pairKey,
75
+ new Uint8Array(0),
76
+ utf8(P.HKDF_INFO.CONFIG_SYNC_KEY),
77
+ P.SIZES.AES_KEY,
78
+ )
79
+ }
80
+
62
81
  async function deriveSessionKey(ephemeralShared, sessionToken) {
63
82
  return hkdfBytes(
64
83
  ephemeralShared,
@@ -123,7 +142,7 @@
123
142
 
124
143
  globalThis.Phaedo = globalThis.Phaedo || {}
125
144
  globalThis.Phaedo.Kdf = {
126
- derivePairKey, deriveSeedKey, deriveFpSyncKey, deriveSessionKey, deriveSAS, formatSAS,
145
+ derivePairKey, deriveSeedKey, deriveFpSyncKey, deriveConfigSyncKey, deriveSessionKey, deriveSAS, formatSAS,
127
146
  authEphemeral, verifyAuthEphemeral,
128
147
  }
129
148
  })()