phaedo-mcp 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +182 -19
  3. package/action-binding.js +82 -0
  4. package/agreement.js +86 -2
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +126 -8
  12. package/consult-policy.js +427 -33
  13. package/consult-runtime.js +60 -0
  14. package/consult.js +29 -1
  15. package/delegation-sync.js +95 -0
  16. package/escalation-policy.sample.json +13 -0
  17. package/escalation.js +383 -0
  18. package/fingerprint-source.js +112 -13
  19. package/health.js +54 -0
  20. package/hooks/pre-ship.mjs +409 -0
  21. package/hooks/session-end.mjs +244 -0
  22. package/hooks/stop-capture.mjs +140 -0
  23. package/install.mjs +226 -36
  24. package/lib/capture-home.js +46 -0
  25. package/lib/domain.js +101 -0
  26. package/lib/is-main.js +40 -0
  27. package/lib/phaedo-crypto.js +17 -1
  28. package/package.json +29 -5
  29. package/phone-source.js +85 -12
  30. package/projection.js +4 -1
  31. package/pull.js +19 -3
  32. package/receipts-sync.js +197 -0
  33. package/receipts.js +34 -3
  34. package/server.js +323 -54
  35. package/server.json +3 -3
  36. package/ship-actions.js +164 -0
  37. package/ship-readout.js +106 -0
  38. package/signing-audit.js +111 -0
  39. package/signing-request.js +217 -0
  40. package/signing.js +176 -0
  41. package/vendor/authored-source.js +250 -0
  42. package/vendor/capabilities.js +203 -0
  43. package/vendor/capture/capture-core.js +67 -0
  44. package/vendor/capture/detectors.js +287 -0
  45. package/vendor/capture/exchanges.js +77 -0
  46. package/vendor/capture/model-detect.js +195 -0
  47. package/vendor/capture/owner.js +35 -0
  48. package/vendor/capture/package.json +4 -0
  49. package/vendor/capture/source-id.js +56 -0
  50. package/vendor/capture/state.js +148 -0
  51. package/vendor/capture/transcript.js +184 -0
  52. package/vendor/commitments.js +169 -0
  53. package/vendor/context-block.js +251 -18
  54. package/vendor/envelope.js +50 -4
  55. package/vendor/extraction/capture-combine.js +160 -0
  56. package/vendor/extraction/catalog.js +232 -0
  57. package/vendor/extraction/delta-lift.js +109 -0
  58. package/vendor/extraction/fingerprint-update.js +227 -0
  59. package/vendor/extraction/package.json +4 -0
  60. package/vendor/extraction/persist.js +45 -0
  61. package/vendor/extraction/reconcile.js +310 -0
  62. package/vendor/extraction/resolve.js +178 -0
  63. package/vendor/extraction/rule-promotion.js +103 -0
  64. package/vendor/kdf.js +20 -1
  65. package/vendor/protocol.js +7 -0
  66. package/vendor/signal-ledger.js +402 -0
  67. package/vendor/trait-phrases.js +656 -0
  68. package/vendored-files.mjs +86 -0
package/signing.js ADDED
@@ -0,0 +1,176 @@
1
+ // §8.3 asymmetric signing — the verification half, server-side.
2
+ //
3
+ // The MCP server CANNOT sign: the subject's signing key is non-extractable inside the
4
+ // phone's Secure Enclave (spec §8.3, verified on hardware 2026-07-23). What the server
5
+ // does is (a) compute the exact bytes a signature covers, so the phone can be asked to
6
+ // sign them, and (b) VERIFY a signature once the phone returns one. Signing itself is a
7
+ // ceremony that belongs to the device holding the key, and deliberately stays there.
8
+ //
9
+ // PURE: no I/O, no globals. Crypto is node:crypto only — no third-party dependency is
10
+ // introduced into the verification path.
11
+
12
+ import { createPublicKey, verify as nodeVerify } from 'node:crypto';
13
+
14
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
15
+
16
+ // ── RFC 8785 canonical JSON (spec §6) ────────────────────────────────────────
17
+ //
18
+ // ⚠️ A FAITHFUL PORT. This is the FOURTH copy in the repo — envelope.js (extension),
19
+ // mobile/src/lib/envelope.ts, the mirror in scripts/test_cross_compat.mjs, and now this
20
+ // one. It MUST stay byte-identical to those three: a signature is only verifiable if every
21
+ // implementation agrees on the bytes, so a divergence here silently produces signatures
22
+ // nobody else can check. Locked by the published TC-CANON vectors in
23
+ // mcp/test-signing.mjs — if this drifts, that suite fails.
24
+ //
25
+ // JSON.stringify already emits RFC 8785-conformant scalars in ECMAScript (shortest
26
+ // round-trip numbers, minimal string escaping). What it does NOT do is order keys
27
+ // recursively, and it silently turns non-finite numbers into null. Full RFC 8785 here =
28
+ // UTF-16 code-unit key sort + explicit REJECTION of values JSON cannot express.
29
+ const notJson = (v) => v === undefined || typeof v === 'function' || typeof v === 'symbol';
30
+
31
+ export function canonicalStringify(value) {
32
+ if (typeof value === 'number' && !Number.isFinite(value)) {
33
+ throw new Error('canonicalStringify: non-finite numbers are not expressible in RFC 8785 JSON');
34
+ }
35
+ if (value === null || typeof value !== 'object') {
36
+ const s = JSON.stringify(typeof value === 'string' ? value.normalize('NFC') : value);
37
+ if (s === undefined) throw new Error('canonicalStringify: a ' + typeof value + ' is not expressible in JSON');
38
+ return s;
39
+ }
40
+ if (Array.isArray(value)) {
41
+ return '[' + value.map(v => (notJson(v) ? 'null' : canonicalStringify(v))).join(',') + ']';
42
+ }
43
+ // §6.2 NFC — "Implementations MUST normalize text to NFC before canonicalization to avoid
44
+ // signature breakage from equivalent but byte different strings." It was implemented nowhere.
45
+ // This is the VERIFIER: if the phone ever normalizes and this does not, every signature it
46
+ // produces fails here, over text that looks identical in any log. KEYS too, and a post-NFC
47
+ // collision is REJECTED rather than silently dropping one. Byte-identical to envelope.js and
48
+ // mobile/src/lib/envelope.ts.
49
+ const seen = new Map();
50
+ for (const k of Object.keys(value)) {
51
+ if (notJson(value[k])) continue;
52
+ const nk = k.normalize('NFC');
53
+ if (seen.has(nk)) throw new Error('canonicalStringify: keys collide under NFC: ' + JSON.stringify(k));
54
+ seen.set(nk, value[k]);
55
+ }
56
+ const keys = [...seen.keys()].sort(); // UTF-16 code-unit order (RFC 8785 §3.2.3), post-NFC
57
+ const parts = [];
58
+ for (const k of keys) parts.push(JSON.stringify(k) + ':' + canonicalStringify(seen.get(k)));
59
+ return '{' + parts.join(',') + '}';
60
+ }
61
+
62
+ // ── What a signature covers ──────────────────────────────────────────────────
63
+
64
+ /**
65
+ * The exact bytes a §8.3 signature is computed over: the §6 canonical serialization of
66
+ * the object with the signature field itself OMITTED. Omitting it is what makes the input
67
+ * well defined no matter how many signatures are later appended — the same construction
68
+ * §8.2 bootstrap mode uses for `digest`.
69
+ *
70
+ * @param {object} obj
71
+ * @param {{ field?: string }} [opts] field to omit (default 'signatures'; AP2 uses 'signature')
72
+ * @returns {Buffer} UTF-8 canonical bytes
73
+ */
74
+ export function signingInput(obj, { field = 'signatures' } = {}) {
75
+ if (!isObj(obj)) throw new Error('signingInput: expected an object');
76
+ const rest = {};
77
+ for (const k of Object.keys(obj)) {
78
+ if (k !== field) rest[k] = obj[k];
79
+ }
80
+ return Buffer.from(canonicalStringify(rest), 'utf8');
81
+ }
82
+
83
+ // ── Key + signature encodings ────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Convert an X9.63 uncompressed EC point (0x04 || X || Y, 65 bytes) — what the Secure
87
+ * Enclave exports — into a node:crypto public key.
88
+ */
89
+ export function publicKeyFromRawPoint(raw) {
90
+ const bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
91
+ if (bytes.length !== 65 || bytes[0] !== 0x04) {
92
+ throw new Error('publicKeyFromRawPoint: expected a 65-byte uncompressed P-256 point (0x04 || X || Y)');
93
+ }
94
+ return createPublicKey({
95
+ key: {
96
+ kty: 'EC',
97
+ crv: 'P-256',
98
+ x: bytes.subarray(1, 33).toString('base64url'),
99
+ y: bytes.subarray(33, 65).toString('base64url'),
100
+ },
101
+ format: 'jwk',
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Convert a DER (X9.62) ECDSA signature — what SecKeyCreateSignature emits — into the raw
107
+ * r||s form (64 bytes) that JOSE ES256 and the W3C VC suites use.
108
+ *
109
+ * This conversion is the boundary the spec deferred: the enclave speaks DER, interoperable
110
+ * verifiers expect raw, and something has to translate. Doing it here means the wire form
111
+ * is the interoperable one while the device keeps using its native output.
112
+ */
113
+ export function derToRawSignature(der) {
114
+ const b = Buffer.isBuffer(der) ? der : Buffer.from(der);
115
+ let i = 0;
116
+ if (b[i++] !== 0x30) throw new Error('derToRawSignature: not a DER SEQUENCE');
117
+ let seqLen = b[i++];
118
+ if (seqLen & 0x80) { // long form length
119
+ const n = seqLen & 0x7f;
120
+ if (n < 1 || n > 2) throw new Error('derToRawSignature: unsupported DER length');
121
+ seqLen = 0;
122
+ for (let k = 0; k < n; k++) seqLen = (seqLen << 8) | b[i++];
123
+ }
124
+ if (i + seqLen !== b.length) throw new Error('derToRawSignature: DER length mismatch');
125
+
126
+ const readInt = () => {
127
+ if (b[i++] !== 0x02) throw new Error('derToRawSignature: expected DER INTEGER');
128
+ const len = b[i++];
129
+ if (len & 0x80) throw new Error('derToRawSignature: unsupported INTEGER length');
130
+ let v = b.subarray(i, i + len);
131
+ i += len;
132
+ // DER integers are signed and minimally encoded: strip leading zero padding, then
133
+ // left-pad to the 32-byte field width the raw form requires.
134
+ while (v.length > 1 && v[0] === 0x00) v = v.subarray(1);
135
+ if (v.length > 32) throw new Error('derToRawSignature: integer wider than P-256 field');
136
+ return Buffer.concat([Buffer.alloc(32 - v.length), v]);
137
+ };
138
+
139
+ const r = readInt();
140
+ const s = readInt();
141
+ if (i !== b.length) throw new Error('derToRawSignature: trailing bytes after signature');
142
+ return Buffer.concat([r, s]);
143
+ }
144
+
145
+ // ── Verification ─────────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Verify a §8.3 ES256 signature over an object.
149
+ *
150
+ * Never throws on a bad signature — returns false. A malformed key or an unusable
151
+ * encoding still throws, because that is a caller bug rather than a failed verification,
152
+ * and the two must not be conflated (silently returning false for a broken key would
153
+ * report "not the subject's signature" when the truth is "we could not check").
154
+ *
155
+ * @param {object} args.payload the signed object (signature field may be present; it is omitted)
156
+ * @param {Buffer|Uint8Array} args.signature
157
+ * @param {Buffer|Uint8Array} args.publicKey X9.63 uncompressed point (65 bytes)
158
+ * @param {'der'|'raw'} [args.encoding='raw']
159
+ * @param {string} [args.field='signatures'] which field to omit from the signing input
160
+ * @returns {boolean}
161
+ */
162
+ export function verifyEs256({ payload, signature, publicKey, encoding = 'raw', field = 'signatures' }) {
163
+ if (encoding !== 'der' && encoding !== 'raw') {
164
+ throw new Error(`verifyEs256: unknown signature encoding '${encoding}'`);
165
+ }
166
+ const key = publicKeyFromRawPoint(publicKey); // throws on a malformed key — deliberate
167
+ const data = signingInput(payload, { field });
168
+ const sig = Buffer.isBuffer(signature) ? signature : Buffer.from(signature);
169
+ try {
170
+ return nodeVerify('sha256', data, { key, dsaEncoding: encoding === 'der' ? 'der' : 'ieee-p1363' }, sig);
171
+ } catch {
172
+ // node throws on structurally invalid signature bytes; that is a failed verification,
173
+ // not a caller error.
174
+ return false;
175
+ }
176
+ }
@@ -0,0 +1,250 @@
1
+ // Where authored content comes FROM — the step-3 read path.
2
+ //
3
+ // Authored standing rules live in two places during the migration: the fingerprint A-home
4
+ // (`fp.standing_rules`, Proposal 0007) and the §5 User Configuration (`standing_instructions`,
5
+ // derived from the A-home on every write). The sequencing decision is that the §5 copy is
6
+ // AUTHORITATIVE ON READ where both exist, so that step 3 can later delete the A-home and change
7
+ // nothing about what readers see. Every reader routing through here is what makes that true.
8
+ //
9
+ // WHY A RESOLVER AND NOT `cfg.standing_instructions || fp.standing_rules`:
10
+ //
11
+ // 1. §5.4. A configuration that does not name the fingerprint it arrived with is not this
12
+ // subject's — stale, mis-routed, or hostile. Letting it govern would let one identity's
13
+ // authored rules apply to another. It must fall back, not be trusted.
14
+ // 2. ABSENT and EMPTY are different. No §5 document at all means "this surface has not been
15
+ // migrated, use the A-home". A §5 document with an empty list means "the subject has no
16
+ // rules", and falling back there would RESURRECT rules the subject deleted — the same
17
+ // failure as the union-vs-tombstone bug in §10.5 revocation (#247).
18
+ // 3. The caller needs to know WHICH home answered. A reader that cannot report its provenance
19
+ // cannot be verified on device, and "it works" would be indistinguishable from "it silently
20
+ // read the old home the whole time".
21
+ //
22
+ // Mirrored by mobile/src/lib/authoredSource.ts; kept honest by
23
+ // scripts/test_mobile_authored_source_parity.mjs, which RUNS both over the same inputs.
24
+
25
+ (function () {
26
+ 'use strict';
27
+
28
+ /**
29
+ * WHICH HOME IS AUTHORITATIVE for authored rules — the step-3 cutover switch.
30
+ *
31
+ * It lives HERE, with the resolver, because "where does authored content live" is one question
32
+ * and both the reader and the writer must answer it identically. authored-write.js re-exports
33
+ * it; user-config.js reads it to decide whether a re-derive may overwrite standing_instructions.
34
+ * This module imports nothing, so every consumer can read it without an import cycle.
35
+ *
36
+ * 'fingerprint' — today. Rules live on the A-home; §5 is DERIVED from it on every write.
37
+ * 'config' — after step 3. §5 is the source; the A-home is a mirror, and the derive must
38
+ * stop overwriting §5 or a routine fingerprint write erases authored rules.
39
+ *
40
+ * Flipping this IS the cutover. It ships only once the upgrade gate reads green against real
41
+ * device reports (owner decision 2026-07-27: release check, not runtime interlock).
42
+ *
43
+ * FLIPPED to 'config' on 2026-07-28 and REVERTED the same day, before any device carried it.
44
+ * The revert is the mechanism working: one constant, so a bad answer costs one line.
45
+ *
46
+ * A post-flip review found SIX confirmed paths that lose authored content, every one of them
47
+ * benign while the A-home is authoritative and live the moment it is not:
48
+ * 1. the phone's chokepoint replaces §5 wholesale from the mirror, so any authored save
49
+ * destroys a §5-only rule (mobile/src/lib/authoredWrite.ts)
50
+ * 2. background.js never loads THIS module, so the vault write finalizer throws on every
51
+ * service-worker write and §5 is never maintained there
52
+ * 3. the live /v1/profile sync path carries no §5 at all (popup.js silentSync)
53
+ * 4. popup.js _actOnOverride authors a rule outside the write chokepoint
54
+ * 5. the SW capture re-deposit strips the §5 envelope off the wire, so authored rules and
55
+ * the irreplaceable example_corpus never reach the phone
56
+ * 6. a non-governing §5 document is never cleared, and absorbs authored content into a
57
+ * document that can never govern — and is excluded from §8.2 backup at the same moment
58
+ *
59
+ * WHAT MUST BE TRUE BEFORE FLIPPING AGAIN — all six closed, plus:
60
+ * - every authoring site routes through the chokepoint, enumerated BY BEHAVIOUR
61
+ * - every reader routes through resolveStandingInstructions, likewise
62
+ * - the upgrade gate is actually CONSULTED (it currently has zero production call sites)
63
+ * - the phone advertises config_home_v0.2 only once it genuinely satisfies it
64
+ * - a device pass on a build that carries the phone half
65
+ * See docs/state/current-state.md and scripts/test_cutover_readiness.mjs.
66
+ */
67
+ const AUTHORED_HOME = 'config';
68
+
69
+ // ── The upgrade gate, wired ────────────────────────────────────────────────────────────────
70
+ //
71
+ // `AUTHORED_HOME` ARMS the cutover. It does not perform it. The effective home is `'config'`
72
+ // only when the constant says so AND capabilities.js reports every reader current — so flipping
73
+ // the constant on a fleet where the phone or the agent is behind changes NOTHING until they
74
+ // catch up, instead of retiring the A-home out from under them.
75
+ //
76
+ // This is the wiring the gate was missing. `canRetireAHome` and `retirementBlockers` were
77
+ // correct and well tested and had ZERO production call sites: evidence was collected into
78
+ // `phaedo_peer_capabilities` and never asked. The 2026-07-28 flip shipped without the mechanism
79
+ // designed to authorise it ever being consulted, and six loss paths went with it.
80
+ //
81
+ // WHY A LATCH RATHER THAN A READ. The consumers (user-config.js, authored-write.js) are pure
82
+ // functions over a vault object, and the capability record lives outside the vault on purpose —
83
+ // it is fleet metadata, and the gate must be answerable without unlocking anything. Making
84
+ // those functions async, or handing them a storage handle, would put I/O on the authoring path.
85
+ // So the surface that CAN read storage pushes the record in, and the pure modules read it
86
+ // synchronously.
87
+ //
88
+ // IT FAILS CLOSED IN EVERY DIRECTION. Unset (nobody pushed a record), capabilities.js not
89
+ // loaded, an empty record, a reader that never reported, a reader on an older build, a report
90
+ // too old — all of them resolve to `'fingerprint'`. Silence is not consent; the only path to
91
+ // `'config'` is positive evidence from every reader.
92
+ let _peerCapabilities = null;
93
+
94
+ /**
95
+ * Publish what the fleet last reported. Called by whichever surface can read storage
96
+ * (background.js on startup, popup.js after each mailbox drain).
97
+ */
98
+ function setPeerCapabilities(known) {
99
+ _peerCapabilities = isObj(known) ? known : null;
100
+ }
101
+
102
+ /** What was last published. Exposed so a caller can show the subject the same evidence. */
103
+ function peerCapabilities() { return _peerCapabilities; }
104
+
105
+ /**
106
+ * The home that is actually in force, and why. Returns a REASON so a withheld cutover can be
107
+ * explained rather than merely observed — the same discipline `retirementBlockers` follows.
108
+ *
109
+ * @returns {{ home: string, armed: boolean, blockers: Array, reason: string }}
110
+ */
111
+ function effectiveAuthoredHomeDetail(now) {
112
+ // Read the PUBLISHED value, not the closure constant. `api.AUTHORED_HOME` is what every
113
+ // consumer sees, so reading anything else would let the module disagree with its own exports
114
+ // — and it is the value the cutover suites set to stage post-cutover conditions, which means
115
+ // they exercise this path rather than a parallel one.
116
+ const armed = (api && api.AUTHORED_HOME) || AUTHORED_HOME;
117
+ if (armed !== 'config') {
118
+ return { home: 'fingerprint', armed: false, blockers: [], reason: 'not_armed' };
119
+ }
120
+ const CAP = (typeof globalThis !== 'undefined' && globalThis.PhaedoCapabilities) || null;
121
+ if (!CAP || typeof CAP.retirementBlockers !== 'function') {
122
+ // The gate module is not present, so the fleet's state is unknowable here. Unknowable is
123
+ // not the same as fine — this is the `[].every(...)` trap the gate itself was built around.
124
+ return { home: 'fingerprint', armed: true, blockers: [], reason: 'gate_unavailable' };
125
+ }
126
+ if (!isObj(_peerCapabilities)) {
127
+ return { home: 'fingerprint', armed: true, blockers: [], reason: 'no_capability_record' };
128
+ }
129
+ // NAME THE ASKER. The extension's peers are the phone and the agent; passing nothing would
130
+ // fall back to checking all three including itself, which never reports and would block
131
+ // forever. See readersFor in capabilities.js.
132
+ const blockers = CAP.retirementBlockers(_peerCapabilities, now, { self: 'extension' });
133
+ if (blockers.length) {
134
+ return { home: 'fingerprint', armed: true, blockers, reason: 'readers_behind' };
135
+ }
136
+ return { home: 'config', armed: true, blockers: [], reason: 'fleet_current' };
137
+ }
138
+
139
+ /** The home in force. Use `effectiveAuthoredHomeDetail` when you need to explain a fallback. */
140
+ function effectiveAuthoredHome(now) {
141
+ return effectiveAuthoredHomeDetail(now).home;
142
+ }
143
+
144
+ /** Where an answer came from. Returned, never inferred by the caller. */
145
+ const SOURCE = {
146
+ CONFIG: 'config', // §5 — the post-cutover home
147
+ FINGERPRINT: 'fingerprint', // the A-home — the fallback step 3 removes
148
+ NONE: 'none', // neither home had anything to say
149
+ };
150
+
151
+ function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
152
+
153
+ /**
154
+ * The configuration's live instructions — those not buried by a §5.2 tombstone.
155
+ *
156
+ * Keyed by the FROZEN identity in sync-merge.js. When that module is absent (a reader that
157
+ * loads only the resolver) the tombstones cannot be evaluated, and the honest answer is to
158
+ * return the rules unchanged rather than guess: dropping rules we cannot key would lose
159
+ * authored content, and that is the worse direction here.
160
+ */
161
+ function withoutRevoked(cfg) {
162
+ const rules = cfg.standing_instructions;
163
+ const tombs = Array.isArray(cfg.revoked_instructions) ? cfg.revoked_instructions : [];
164
+ if (!tombs.length) return rules;
165
+ const SM = (typeof globalThis !== 'undefined' && globalThis.PhaedoSyncMerge) || null;
166
+ if (!SM || typeof SM.instructionKey !== 'function') return rules;
167
+ const dead = new Set(tombs.map((t) => (isObj(t) ? t.key : null)).filter(Boolean));
168
+ if (!dead.size) return rules;
169
+ return rules.filter((r) => !dead.has(SM.instructionKey(r)));
170
+ }
171
+
172
+ /**
173
+ * Does this configuration belong to this fingerprint? Mirrors config-sync.js `pairingHolds`
174
+ * deliberately rather than importing it: this module is loaded by readers that have no reason
175
+ * to pull in the transport, and the rule is three lines. Parity between the two is asserted in
176
+ * scripts/test_authored_source.mjs so the duplication cannot drift.
177
+ */
178
+ function configGoverns(cfg, fp) {
179
+ if (!isObj(cfg) || !isObj(fp)) return false;
180
+ if (typeof fp.fingerprint_id !== 'string' || !fp.fingerprint_id) return false;
181
+ if (cfg.fingerprint_id !== fp.fingerprint_id) return false;
182
+ if (typeof fp.subject_id === 'string' && fp.subject_id &&
183
+ typeof cfg.subject_id === 'string' && cfg.subject_id &&
184
+ cfg.subject_id !== fp.subject_id) return false;
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * The authoritative standing instructions, and where they came from.
190
+ *
191
+ * §5 wins whenever it is present AND governs — including when it is EMPTY, because an empty
192
+ * authored set is a statement ("I have no rules"), not an absence. Only a missing or
193
+ * non-governing configuration falls through to the A-home.
194
+ *
195
+ * @returns {{ rules: Array, source: string, reason?: string }}
196
+ */
197
+ function resolveStandingInstructions(fp, cfg) {
198
+ const aHome = isObj(fp) && Array.isArray(fp.standing_rules) ? fp.standing_rules : null;
199
+
200
+ if (isObj(cfg) && Array.isArray(cfg.standing_instructions)) {
201
+ if (configGoverns(cfg, fp)) {
202
+ // §5.2 tombstones are enforced HERE, not only at the merge. A device that has not merged
203
+ // since the revocation still holds the rule in `standing_instructions`; honouring the
204
+ // tombstone at read time is what makes the revocation take effect immediately rather
205
+ // than on the next sync. §10.5 learned this the hard way — ap2.js honoured its
206
+ // tombstones while the consult resolver did not, so one artifact gave two answers.
207
+ return { rules: withoutRevoked(cfg), source: SOURCE.CONFIG };
208
+ }
209
+ // Present but not this subject's. Fall back rather than trust it, and SAY so — a §5.4
210
+ // rejection that looks identical to "no configuration here" is how a mis-paired document
211
+ // would go unnoticed for months.
212
+ //
213
+ // BUT ITS DELETIONS STILL COUNT. The A-home has no revocation concept, and its copy is
214
+ // union-merged, so a rule the subject deleted comes back from any peer that still holds it
215
+ // — and falling back to that copy put the deleted rule into force again (measured
216
+ // 2026-08-12). Honouring the tombstones on a document we otherwise refuse is safe in a way
217
+ // that honouring its rules would not be: a tombstone can only ever REMOVE an instruction,
218
+ // never add one or change what it says. The worst it can do is withhold a rule the subject
219
+ // wanted; the alternative is obeying one they revoked, which is the direction that cannot
220
+ // be noticed from the outside.
221
+ if (aHome) {
222
+ return { rules: withoutRevoked({ standing_instructions: aHome, revoked_instructions: cfg.revoked_instructions }),
223
+ source: SOURCE.FINGERPRINT, reason: 'config_pairing_failed' };
224
+ }
225
+ return { rules: [], source: SOURCE.NONE, reason: 'config_pairing_failed' };
226
+ }
227
+
228
+ if (aHome) return { rules: aHome, source: SOURCE.FINGERPRINT, reason: 'no_config' };
229
+ return { rules: [], source: SOURCE.NONE, reason: 'no_config' };
230
+ }
231
+
232
+ /**
233
+ * True once this surface would behave identically with the A-home deleted — i.e. §5 answered.
234
+ * This is the evidence a surface needs before it may advertise `config_home_v0.2` to the
235
+ * upgrade gate. Claiming the capability on the strength of "the code is deployed" rather than
236
+ * "the read actually resolved to §5" is exactly how a gate opens on the wrong evidence.
237
+ */
238
+ function readsFromConfigHome(fp, cfg) {
239
+ return resolveStandingInstructions(fp, cfg).source === SOURCE.CONFIG;
240
+ }
241
+
242
+ // Declared before the functions above can run (they only read it at call time), so
243
+ // `effectiveAuthoredHomeDetail` can consult the published value.
244
+ const api = {
245
+ AUTHORED_HOME, SOURCE, configGoverns, resolveStandingInstructions, readsFromConfigHome,
246
+ setPeerCapabilities, peerCapabilities, effectiveAuthoredHome, effectiveAuthoredHomeDetail,
247
+ };
248
+ if (typeof globalThis !== 'undefined') globalThis.PhaedoAuthoredSource = api;
249
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
250
+ })();
@@ -0,0 +1,203 @@
1
+ // The §5 step-3 upgrade gate — who is current, and may the A-home be retired yet.
2
+ //
3
+ // WHY THIS EXISTS. Step 3 deletes the fingerprint A-home, which is where authored content lives
4
+ // today. A surface that has not upgraded then reads NOTHING where the subject's rules used to be,
5
+ // and the failure is asymmetric: a missing GRANT fails safe (no `autoproceed` match, so the agent
6
+ // asks) but a missing PROHIBITION does not — with no `escalate`/`decline` rule present the
7
+ // inferred signal governs, and that can be "proceed". Retiring the A-home while any reader is
8
+ // behind therefore removes caution specifically, never permission.
9
+ //
10
+ // Sequencing decision 3 (config-home-v02-sequencing-2026-07-26) defines the gate: with one user
11
+ // it is "Randy's phone and extension are both on the new build." This module makes that a
12
+ // runtime fact instead of a release-day memory.
13
+ //
14
+ // THE SHAPE THAT MATTERS: this fails CLOSED, and the way it fails closed is not the obvious one.
15
+ // `[].every(...)` is TRUE in JavaScript, so a naive "do all peers support it?" answers YES when
16
+ // NO PEER HAS EVER REPORTED. That is this codebase's recurring failure exactly — silence read as
17
+ // success. Silence here means "you do not know", and not knowing means do not retire anything.
18
+ //
19
+ // Mirrored by mobile/src/lib/capabilities.ts; kept honest by
20
+ // scripts/test_mobile_capabilities_parity.mjs, which RUNS both over the same inputs.
21
+
22
+ (function () {
23
+ 'use strict';
24
+
25
+ /** This surface can send and receive the §5 artifact. True on both surfaces since #266/#267. */
26
+ const CONFIG_TRANSPORT = 'config_transport_v0.1';
27
+
28
+ /**
29
+ * The capability step 3 requires: this surface SOURCES authored content from §5 and stays
30
+ * correct when the A-home is absent. Deliberately distinct from CONFIG_TRANSPORT — carrying
31
+ * the artifact and reading from it are different claims, and conflating them is how a gate
32
+ * ends up opening on the wrong evidence. Nothing advertises this yet; the step-3 read path
33
+ * is what earns it.
34
+ */
35
+ //
36
+ // ⚠️ NECESSARY BUT NOT SUFFICIENT FOR STEP 3 — found on device 2026-07-27. Every surface now
37
+ // genuinely READS from §5, and this gate goes green on that basis. But §5 cannot yet CARRY an
38
+ // authored rule between devices: mergeConfig folds only `example_corpus`, so a rule authored on
39
+ // the extension is discarded by the phone's merge. Correct today (standing_instructions is
40
+ // re-derived from the A-home, which is what actually syncs rules) and broken the moment step 3
41
+ // makes §5 the authored home. See scripts/test_config_sync_rules_gap.mjs — the tripwire that
42
+ // must be deleted, not silenced, when the merge learns to carry rules.
43
+ const CONFIG_HOME = 'config_home_v0.2';
44
+
45
+ /**
46
+ * What each surface implements — PER SURFACE, not one shared list.
47
+ *
48
+ * This file is loaded by the extension AND vendored into the published MCP, and the two do not
49
+ * become §5-native at the same moment. A single shared list would have made the agent claim
50
+ * CONFIG_HOME the instant the extension earned it, which is precisely the "gate opens on the
51
+ * wrong evidence" failure the gate exists to prevent — and it would have been invisible, since
52
+ * the agent would simply have started reading a home it does not understand.
53
+ *
54
+ * A surface earns CONFIG_HOME only when EVERY one of its authored-content reads resolves
55
+ * through §5 and it stays correct with the A-home absent.
56
+ */
57
+ const CAPABILITIES_BY_SURFACE = {
58
+ // Both read sites go through §5: the injection path (context-block.js renderContextBlock)
59
+ // and the popup's rule display (_readStandingRules → authored-source.js).
60
+ extension: [CONFIG_TRANSPORT, CONFIG_HOME],
61
+ // The agent's authored-content readers (ap2.js, autonomy-promotion.js, consult-core.js) all
62
+ // take a FINGERPRINT rather than a vault, so §5 is folded in ONCE at the vd boundary
63
+ // (fingerprint-source.js foldConfigHome) and every one of them is §5-canonical by
64
+ // construction. Same boundary carries the §5.4 check.
65
+ agent: [CONFIG_TRANSPORT, CONFIG_HOME],
66
+ };
67
+
68
+ /** Back-compat for callers that ask without naming a surface: the conservative intersection. */
69
+ const CAPABILITIES = [CONFIG_TRANSPORT];
70
+
71
+
72
+ // WHO MUST BE CURRENT depends on WHO IS ASKING. Retiring the A-home harms any surface that READS
73
+ // authored content, and there are three; the asker is not one of its own peers, because it knows
74
+ // its own capability from CAPABILITIES_BY_SURFACE — it is the code that is running.
75
+ //
76
+ // This used to be a single list, ['phone', 'agent'], which is the EXTENSION's view written as if
77
+ // it were universal. The moment the phone gained a gate of its own that list had it checking
78
+ // ITSELF and the agent while never hearing about the extension — the one peer whose readiness
79
+ // actually matters to it.
80
+ //
81
+ // `self` is REQUIRED in effect: an unrecognised value falls back to checking all three, which
82
+ // can only add blockers. The opposite default — assuming a caller meant the extension — could
83
+ // REMOVE one, and a gate that quietly stops checking a reader is the failure this whole module
84
+ // exists to prevent.
85
+ const ALL_READERS = ['extension', 'phone', 'agent'];
86
+ function readersFor(self) {
87
+ return ALL_READERS.includes(self) ? ALL_READERS.filter((s) => s !== self) : ALL_READERS.slice();
88
+ }
89
+
90
+ /** The extension's peer set. Kept as a named export because callers and tests reference it. */
91
+ const READERS = readersFor('extension');
92
+
93
+ // A report older than this is not evidence about the fleet as it stands now — a device can be
94
+ // reinstalled, downgraded, or simply gone. 30 days is long enough that a phone left in a drawer
95
+ // over a holiday does not trip the gate, short enough that a stale record cannot authorise a
96
+ // one-way door months later.
97
+ const MAX_REPORT_AGE_MS = 30 * 24 * 60 * 60 * 1000;
98
+
99
+ function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
100
+
101
+ /**
102
+ * The payload a surface stamps into what it deposits. Deliberately a CAPABILITY LIST rather
103
+ * than a version number: "what can you do" survives version drift and forks, "what version are
104
+ * you" needs a comparison table that nobody maintains.
105
+ */
106
+ function advertise(surface, now) {
107
+ if (typeof surface !== 'string' || !surface) return null;
108
+ // An UNKNOWN surface gets the conservative set, never the most capable one. Guessing upward
109
+ // here would let a surface nobody has audited close the gate's last blocker.
110
+ const caps = CAPABILITIES_BY_SURFACE[surface] || CAPABILITIES;
111
+ return {
112
+ surface,
113
+ capabilities: caps.slice(),
114
+ stamped_at: now || new Date().toISOString(),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Fold an incoming advertisement into the record of what each peer last reported. Keyed by
120
+ * surface, newest-wins — a surface only ever describes itself, so there is no merge conflict
121
+ * to resolve and no union to get wrong.
122
+ */
123
+ function recordPeer(known, incoming) {
124
+ const base = isObj(known) ? known : {};
125
+ if (!isObj(incoming) || typeof incoming.surface !== 'string' || !incoming.surface) return base;
126
+ const prior = base[incoming.surface];
127
+ const pt = prior ? Date.parse(prior.stamped_at) || 0 : 0;
128
+ const it = Date.parse(incoming.stamped_at) || 0;
129
+ if (it <= pt) return base;
130
+ return {
131
+ ...base,
132
+ [incoming.surface]: {
133
+ capabilities: Array.isArray(incoming.capabilities) ? incoming.capabilities.slice() : [],
134
+ stamped_at: incoming.stamped_at,
135
+ },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Fold a LIST of advertisements. The phone deposits its own plus any it is couriering for a
141
+ * paired agent, because the agent has no direct channel to the extension — the same courier
142
+ * role the phone already plays for capture snapshots and delegation suggestions.
143
+ */
144
+ function recordPeers(known, list) {
145
+ if (!Array.isArray(list)) return isObj(known) ? known : {};
146
+ return list.reduce((acc, ad) => recordPeer(acc, ad), isObj(known) ? known : {});
147
+ }
148
+
149
+ /**
150
+ * May the A-home be retired? Returns a REASON, not just a boolean — the caller has to be able
151
+ * to tell the subject why their cutover is being withheld, and "false" alone cannot.
152
+ *
153
+ * Fails closed on: no record at all, a reader that has never reported, a reader that reported
154
+ * without the capability, and a report too old to be evidence.
155
+ */
156
+ function retirementBlockers(known, now, opts) {
157
+ const at = now ? Date.parse(now) : Date.now();
158
+ const rec = isObj(known) ? known : {};
159
+ const blockers = [];
160
+ for (const surface of readersFor(opts && opts.self)) {
161
+ const entry = rec[surface];
162
+ if (!isObj(entry)) {
163
+ // The empty-record case. This is the branch `[].every(...)` would have answered "fine".
164
+ blockers.push({ surface, reason: 'never_reported' });
165
+ continue;
166
+ }
167
+ const caps = Array.isArray(entry.capabilities) ? entry.capabilities : [];
168
+ if (!caps.includes(CONFIG_HOME)) {
169
+ blockers.push({ surface, reason: 'lacks_capability' });
170
+ continue;
171
+ }
172
+ const stamped = Date.parse(entry.stamped_at) || 0;
173
+ if (!stamped || at - stamped > MAX_REPORT_AGE_MS) {
174
+ blockers.push({ surface, reason: 'report_too_old' });
175
+ }
176
+ }
177
+ return blockers;
178
+ }
179
+
180
+ /** Convenience over retirementBlockers. Never call this without surfacing the blockers. */
181
+ function canRetireAHome(known, now, opts) {
182
+ return retirementBlockers(known, now, opts).length === 0;
183
+ }
184
+
185
+ /** One line a human can act on. */
186
+ function describeBlockers(blockers) {
187
+ if (!Array.isArray(blockers) || !blockers.length) return 'every reader is current';
188
+ const words = {
189
+ never_reported: 'has never reported in',
190
+ lacks_capability: 'is on an older build',
191
+ report_too_old: 'has not been seen recently enough to count',
192
+ };
193
+ return blockers.map((b) => `${b.surface} ${words[b.reason] || b.reason}`).join('; ');
194
+ }
195
+
196
+ const api = {
197
+ CONFIG_TRANSPORT, CONFIG_HOME, CAPABILITIES, CAPABILITIES_BY_SURFACE, READERS, ALL_READERS,
198
+ readersFor, MAX_REPORT_AGE_MS,
199
+ advertise, recordPeer, recordPeers, retirementBlockers, canRetireAHome, describeBlockers,
200
+ };
201
+ if (typeof globalThis !== 'undefined') globalThis.PhaedoCapabilities = api;
202
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
203
+ })();