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.
- package/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +81 -19
- package/consult-policy.js +427 -33
- package/consult.js +25 -3
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +26 -6
- package/pairing.js +5 -1
- package/phone-source.js +92 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +69 -16
- package/server.js +305 -15
- package/server.json +4 -4
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +153 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendored-files.mjs +82 -0
package/receipts-sync.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// §10.6 receipts audit channel — MCP ↔ phone transport (G1+G2).
|
|
2
|
+
//
|
|
3
|
+
// Closes the act-as-me learning loop: today receipts written by `phaedo_consult` /
|
|
4
|
+
// `phaedo_escalate` sit at `user_action: null` until the subject runs `node receipts.js
|
|
5
|
+
// mark` — a dev CLI only. This module puts the audit on the subject's PHONE so they can
|
|
6
|
+
// mark outcomes in the flow, which then feeds delegation learning (consult-core.js
|
|
7
|
+
// `deriveDelegationSignals`).
|
|
8
|
+
//
|
|
9
|
+
// Two directions over the existing fp-mailbox transport (relay/mailbox.js):
|
|
10
|
+
//
|
|
11
|
+
// agent_to_phone — MCP deposits a digest of the latest UNMARKED receipts (a single
|
|
12
|
+
// payload with at most N entries, last-write-wins idempotent: the
|
|
13
|
+
// phone always sees the latest snapshot). §10.4-safe: only the
|
|
14
|
+
// fields the phone needs to render — receipt_id, created_at, via,
|
|
15
|
+
// agent_id, action_descriptor, response.signal — never `drivers`
|
|
16
|
+
// (local provenance) and never layer content.
|
|
17
|
+
// phone_to_agent — phone deposits a BATCH of `{ receipt_id, outcome }`; this module
|
|
18
|
+
// drains and calls `markOutcome` per mark. Idempotent on the MCP
|
|
19
|
+
// side (re-marking the same outcome is a no-op), so re-draining is
|
|
20
|
+
// safe and the phone need not track per-mark ACKs — the next
|
|
21
|
+
// receipts digest reflects applied marks (`user_action` set).
|
|
22
|
+
//
|
|
23
|
+
// Crypto: fp_sync_key derived from this server's pair_key. Drained by the PHONE, whose
|
|
24
|
+
// multi-client loop tries each verified client's key — so independent pairing is fine
|
|
25
|
+
// here (unlike agent_to_ext, see delegation-sync.js). The relay only ever sees
|
|
26
|
+
// ciphertext. Best-effort and isolated:
|
|
27
|
+
// a deposit/drain failure must NEVER break the tool that triggered it.
|
|
28
|
+
|
|
29
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
30
|
+
import { readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
31
|
+
import { agreementDigest } from './agreement.js';
|
|
32
|
+
|
|
33
|
+
const { Kdf, Envelope } = Phaedo;
|
|
34
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
35
|
+
const DIGEST_CAP = 50; // phone shows recent N — enough for the latest sittings, bounded payload
|
|
36
|
+
|
|
37
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
38
|
+
return env.PHAEDO_RELAY || DEFAULT_RELAY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Strip a receipt down to the §10.4-safe fields the phone renders. NEVER includes
|
|
42
|
+
// `drivers` (local provenance cues that would expose Decision&Risk reasoning to the
|
|
43
|
+
// subject device — fine on this device, but the audit UI doesn't need them and not
|
|
44
|
+
// shipping them keeps the digest small + the boundary tight).
|
|
45
|
+
function digestFields(r) {
|
|
46
|
+
if (!r || typeof r !== 'object') return null;
|
|
47
|
+
return {
|
|
48
|
+
receipt_id: r.receipt_id,
|
|
49
|
+
created_at: r.created_at,
|
|
50
|
+
via: r.via,
|
|
51
|
+
agent_id: r.agent_id || null,
|
|
52
|
+
// The paired client that actually SERVED the call, beside the vendor string it
|
|
53
|
+
// claimed. §10.4-safe (an opaque id the subject's own phone issued, not persona
|
|
54
|
+
// content) and the phone is the one surface that can render it usefully: it holds
|
|
55
|
+
// the client registry, so it can show the friendly NAME where the receipt holds
|
|
56
|
+
// only the id. Without it the Activity screen can only report what the agent said
|
|
57
|
+
// it was — which is precisely the claim §10.5 stopped trusting.
|
|
58
|
+
client_id: r.client_id || null,
|
|
59
|
+
request: {
|
|
60
|
+
consultation_type: r?.request?.consultation_type || null,
|
|
61
|
+
action_descriptor: {
|
|
62
|
+
domain: r?.request?.action_descriptor?.domain ?? null,
|
|
63
|
+
reversible: r?.request?.action_descriptor?.reversible ?? null,
|
|
64
|
+
magnitude: r?.request?.action_descriptor?.magnitude ?? null,
|
|
65
|
+
amount: r?.request?.action_descriptor?.amount ?? null,
|
|
66
|
+
summary: r?.request?.action_descriptor?.summary ?? null,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
response: {
|
|
70
|
+
signal: r?.response?.signal ?? null,
|
|
71
|
+
deference_level: r?.response?.deference_level ?? null,
|
|
72
|
+
},
|
|
73
|
+
user_action: r.user_action ?? null, // already-marked entries stay visible (phone shows status)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
|
|
78
|
+
// recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
|
|
79
|
+
// same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
|
|
80
|
+
export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
|
|
81
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
82
|
+
// Most-recent N (receipts are append-only, ordered by creation in the store).
|
|
83
|
+
const head = list.slice(-cap).map(digestFields).filter(Boolean);
|
|
84
|
+
// Agreement rides the SAME digest, but is computed over the WHOLE store, not the
|
|
85
|
+
// capped head — the rate is a 30-day property of the subject's history, and slicing
|
|
86
|
+
// it to the last 50 entries would silently redefine the metric.
|
|
87
|
+
const generatedAt = now();
|
|
88
|
+
const agreementAt = Date.parse(generatedAt);
|
|
89
|
+
const agreement = agreementDigest(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
|
|
90
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
91
|
+
let fpSyncKey;
|
|
92
|
+
try {
|
|
93
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
94
|
+
} finally {
|
|
95
|
+
pairKey.fill(0);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, generated_at: generatedAt } };
|
|
99
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
100
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
|
|
101
|
+
plaintext.fill(0);
|
|
102
|
+
return { envelope };
|
|
103
|
+
} finally {
|
|
104
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Decrypt a phone-deposited marks payload. Returns `{ marks: [{receipt_id, outcome}, ...] }`
|
|
109
|
+
// or null on a malformed/wrong-key envelope. Pure (no I/O) so it's testable.
|
|
110
|
+
export async function parseMarksDeposit(pairKeyB64u, envelope) {
|
|
111
|
+
if (!envelope) return null;
|
|
112
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
113
|
+
let fpSyncKey;
|
|
114
|
+
try { fpSyncKey = await Kdf.deriveFpSyncKey(pairKey); }
|
|
115
|
+
finally { pairKey.fill(0); }
|
|
116
|
+
try {
|
|
117
|
+
const plaintext = await Envelope.unwrap(envelope, fpSyncKey);
|
|
118
|
+
let inner;
|
|
119
|
+
try { inner = JSON.parse(new TextDecoder().decode(plaintext)); }
|
|
120
|
+
finally { if (plaintext.fill) plaintext.fill(0); }
|
|
121
|
+
if (!inner || inner.kind !== 'receipt_marks' || !inner.data) return null;
|
|
122
|
+
const arr = Array.isArray(inner.data.marks) ? inner.data.marks : [];
|
|
123
|
+
return {
|
|
124
|
+
marks: arr
|
|
125
|
+
.map((m) => ({
|
|
126
|
+
receipt_id: typeof m?.receipt_id === 'string' ? m.receipt_id : null,
|
|
127
|
+
outcome: typeof m?.outcome === 'string' ? m.outcome : null,
|
|
128
|
+
}))
|
|
129
|
+
.filter((m) => m.receipt_id && USER_ACTIONS.includes(m.outcome)),
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
} finally {
|
|
134
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Deposit a receipts digest to the relay's agent_to_phone slot. Best-effort; mirrors
|
|
139
|
+
// `depositDelegationSuggestions` exactly (same auth, same timeout, same isolation —
|
|
140
|
+
// a failure NEVER throws out into the consult/escalate path that called us).
|
|
141
|
+
export async function depositReceiptsDigest(pairing, receipts, deps = {}) {
|
|
142
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
143
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
144
|
+
if (!list.length) return { deposited: false, reason: 'no-receipts' };
|
|
145
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
|
|
146
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
|
|
147
|
+
try {
|
|
148
|
+
const body = await buildReceiptsDeposit(pairing.pair_key, list, deps);
|
|
149
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/agent_to_phone`;
|
|
150
|
+
const authHeader = pairing.relay_device_secret
|
|
151
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
152
|
+
: {};
|
|
153
|
+
const res = await fetchFn(url, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'Content-Type': 'application/json', ...authHeader },
|
|
156
|
+
body: JSON.stringify(body),
|
|
157
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
158
|
+
});
|
|
159
|
+
return { deposited: !!(res && res.ok), status: res && res.status, count: list.length };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { deposited: false, reason: String(e && e.message || e) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Drain phone-deposited outcome marks from the phone_to_agent slot and apply each via
|
|
166
|
+
// markOutcome (idempotent). Returns `{ applied, skipped }`. Best-effort; a transport
|
|
167
|
+
// failure returns `{ applied: 0, skipped: 0, reason }` instead of throwing.
|
|
168
|
+
export async function drainAndApplyMarks(pairing, stateDir, deps = {}) {
|
|
169
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
170
|
+
if (!pairing || !pairing.pair_key) return { applied: 0, skipped: 0, reason: 'unpaired' };
|
|
171
|
+
if (!pairing.relay_device_id) return { applied: 0, skipped: 0, reason: 'no-relay-device-id' };
|
|
172
|
+
try {
|
|
173
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/phone_to_agent`;
|
|
174
|
+
const authHeader = pairing.relay_device_secret
|
|
175
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
176
|
+
: {};
|
|
177
|
+
const res = await fetchFn(url, {
|
|
178
|
+
method: 'GET',
|
|
179
|
+
headers: authHeader,
|
|
180
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
181
|
+
});
|
|
182
|
+
if (!res || !res.ok) return { applied: 0, skipped: 0, status: res && res.status };
|
|
183
|
+
const body = await res.json().catch(() => null);
|
|
184
|
+
const parsed = await parseMarksDeposit(pairing.pair_key, body && body.envelope);
|
|
185
|
+
if (!parsed) return { applied: 0, skipped: 0, reason: 'no-marks' };
|
|
186
|
+
let applied = 0, skipped = 0;
|
|
187
|
+
for (const m of parsed.marks) {
|
|
188
|
+
try {
|
|
189
|
+
const r = await markOutcome(stateDir, m.receipt_id, m.outcome);
|
|
190
|
+
if (r.matched) applied++; else skipped++;
|
|
191
|
+
} catch { skipped++; }
|
|
192
|
+
}
|
|
193
|
+
return { applied, skipped, total: parsed.marks.length };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { applied: 0, skipped: 0, reason: String(e && e.message || e) };
|
|
196
|
+
}
|
|
197
|
+
}
|
package/receipts.js
CHANGED
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
// resolver core stays pure (no I/O). Receipt-write failure must never break the
|
|
22
22
|
// consultation itself (the server warns on stderr and still answers).
|
|
23
23
|
|
|
24
|
-
import { mkdir, readFile, writeFile, chmod } from 'fs/promises';
|
|
24
|
+
import { mkdir, readFile, writeFile, chmod, rename } from 'fs/promises';
|
|
25
25
|
import { existsSync } from 'fs';
|
|
26
26
|
import { join } from 'path';
|
|
27
27
|
import { Phaedo, b64uEnc, b64uDec } from './lib/phaedo-crypto.js';
|
|
28
|
+
import { canonicalDomain } from './lib/domain.js';
|
|
28
29
|
import { getOrCreateCacheKey, defaultStateDir } from './cache.js';
|
|
30
|
+
import { normalizeActionBinding } from './action-binding.js';
|
|
29
31
|
|
|
30
32
|
const { Envelope } = Phaedo;
|
|
31
33
|
|
|
@@ -78,8 +80,26 @@ async function writeStore(stateDir, store, now) {
|
|
|
78
80
|
});
|
|
79
81
|
plaintext.fill(0);
|
|
80
82
|
const p = storePath(stateDir);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
// Atomic replace: write a temp file then rename over the live store. Receipts are
|
|
84
|
+
// the ONLY copy (readStore throws on a corrupt store) — a crash mid-write must not
|
|
85
|
+
// truncate/corrupt receipts.enc and brick the audit trail + every markOutcome.
|
|
86
|
+
const tmp = `${p}.tmp`;
|
|
87
|
+
await writeFile(tmp, JSON.stringify({ envelope }), { mode: 0o600 });
|
|
88
|
+
await chmod(tmp, 0o600);
|
|
89
|
+
await rename(tmp, p); // atomic on POSIX
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Serialize store mutations per state dir. MCP tool handlers are async and NOT
|
|
93
|
+
// serialized by the SDK, so a concurrent appendReceipt (phaedo_consult) and
|
|
94
|
+
// markOutcome (phaedo_record_outcome/escalation_status) both read the same snapshot
|
|
95
|
+
// and the second write clobbers the first — a lost receipt or lost outcome mark.
|
|
96
|
+
// A per-stateDir promise chain forces read-modify-write to run one at a time.
|
|
97
|
+
const _storeLocks = new Map();
|
|
98
|
+
function withStoreLock(stateDir, fn) {
|
|
99
|
+
const prev = _storeLocks.get(stateDir) || Promise.resolve();
|
|
100
|
+
const next = prev.then(fn, fn); // run regardless of the prior result
|
|
101
|
+
_storeLocks.set(stateDir, next.then(() => {}, () => {})); // a rejection must not break the chain
|
|
102
|
+
return next;
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
// Build the §10.6 receipt object from a resolved consultation. Pure. Captures the
|
|
@@ -87,11 +107,17 @@ async function writeStore(stateDir, store, now) {
|
|
|
87
107
|
// never the projection (§10.4 holds inside the vault too). `via` records which
|
|
88
108
|
// tool resolved it (phaedo_consult | phaedo_check_authorization) so M5 can weigh
|
|
89
109
|
// full consultations and pre-flight matches separately.
|
|
90
|
-
export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) {
|
|
110
|
+
export function buildReceipt({ via, agentId, clientId, request, response, drivers, now = Date.now() }) {
|
|
91
111
|
const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
|
|
92
112
|
// Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that
|
|
93
113
|
// drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer
|
|
94
114
|
// text; coerced to the known shape; absent for deterministic pre-flights.
|
|
115
|
+
// Defensive: a malformed binding must not fail the receipt write (a receipt-write
|
|
116
|
+
// failure must never break the consultation — §10.6). The resolver has already
|
|
117
|
+
// rejected structurally-bad bindings with `request_malformed`, so anything that
|
|
118
|
+
// throws here is a caller bypassing it; record nothing rather than half of it.
|
|
119
|
+
let bnd = null;
|
|
120
|
+
try { bnd = normalizeActionBinding(request?.action_binding); } catch { bnd = null; }
|
|
95
121
|
const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({
|
|
96
122
|
cue: typeof d?.cue === 'string' ? d.cue : null,
|
|
97
123
|
...(typeof d?.lean === 'number' ? { lean: d.lean } : {}),
|
|
@@ -103,16 +129,33 @@ export function buildReceipt({ via, agentId, request, response, drivers, now = D
|
|
|
103
129
|
receipt_id: crypto.randomUUID(),
|
|
104
130
|
created_at: new Date(now).toISOString(),
|
|
105
131
|
via: via || 'phaedo_consult',
|
|
132
|
+
// WHO CLAIMED to be calling. Self-asserted, unverified, and never a gate — the
|
|
133
|
+
// same standing `action_descriptor` was given in v0.1.48. Audit label only.
|
|
106
134
|
agent_id: typeof agentId === 'string' ? agentId : null,
|
|
135
|
+
// WHO ACTUALLY SERVED the call: this server's own paired-client id, established
|
|
136
|
+
// out-of-band with the phone under a user-verified SAS. This is the identity
|
|
137
|
+
// §10.5 `agents` scoping matches on, so the audit can distinguish which paired
|
|
138
|
+
// client acted from whichever vendor string it chose to claim. Null = unpaired.
|
|
139
|
+
client_id: typeof clientId === 'string' && clientId ? clientId : null,
|
|
107
140
|
request: {
|
|
108
141
|
consultation_type: request?.consultation_type || 'action_approval',
|
|
109
142
|
action_descriptor: {
|
|
110
|
-
|
|
143
|
+
// Canonical form, matching what the resolver reasoned about (lib/domain.js).
|
|
144
|
+
// The receipt is the evidence store the promotion tallies key on, so a raw
|
|
145
|
+
// `"Finance"` here would be a class of its own and split the subject's
|
|
146
|
+
// approvals away from `"finance"`.
|
|
147
|
+
domain: typeof ad.domain === 'string' ? (canonicalDomain(ad.domain) || null) : null,
|
|
111
148
|
reversible: typeof ad.reversible === 'boolean' ? ad.reversible : null,
|
|
112
149
|
magnitude: typeof ad.magnitude === 'string' ? ad.magnitude : null,
|
|
113
150
|
amount: typeof ad.amount === 'number' ? ad.amount : null,
|
|
114
151
|
summary: typeof ad.summary === 'string' ? ad.summary.slice(0, 500) : null,
|
|
115
152
|
},
|
|
153
|
+
// §10.2 binding: the concrete call the descriptor above CLAIMS to describe.
|
|
154
|
+
// This is the field that makes a descriptor/action mismatch provable later —
|
|
155
|
+
// the descriptor is the agent's story, this is what it said it would run.
|
|
156
|
+
// Never the arguments themselves: a digest, the tool name, and at most a
|
|
157
|
+
// short human preview.
|
|
158
|
+
...(bnd ? { action_binding: bnd } : {}),
|
|
116
159
|
},
|
|
117
160
|
response: {
|
|
118
161
|
signal: response?.signal ?? null,
|
|
@@ -126,11 +169,13 @@ export function buildReceipt({ via, agentId, request, response, drivers, now = D
|
|
|
126
169
|
|
|
127
170
|
// Append one receipt; evict oldest beyond the cap. Returns the stored receipt.
|
|
128
171
|
export async function appendReceipt(stateDir, receipt, { cap = DEFAULT_CAP, now = Date.now() } = {}) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
return withStoreLock(stateDir, async () => {
|
|
173
|
+
const store = await readStore(stateDir);
|
|
174
|
+
store.receipts.push(receipt);
|
|
175
|
+
if (store.receipts.length > cap) store.receipts.splice(0, store.receipts.length - cap);
|
|
176
|
+
await writeStore(stateDir, store, now);
|
|
177
|
+
return receipt;
|
|
178
|
+
});
|
|
134
179
|
}
|
|
135
180
|
|
|
136
181
|
// The ONE permitted mutation (§10.6): set user_action on an existing receipt.
|
|
@@ -139,12 +184,20 @@ export async function updateUserAction(stateDir, receiptId, action, { now = Date
|
|
|
139
184
|
if (!USER_ACTIONS.includes(action)) {
|
|
140
185
|
throw new Error(`user_action must be one of: ${USER_ACTIONS.join(', ')}`);
|
|
141
186
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
187
|
+
return withStoreLock(stateDir, async () => {
|
|
188
|
+
const store = await readStore(stateDir);
|
|
189
|
+
const r = store.receipts.find((x) => x.receipt_id === receiptId);
|
|
190
|
+
if (!r) return false;
|
|
191
|
+
r.user_action = action;
|
|
192
|
+
// WHEN the outcome was recorded, alongside WHAT it was. Not decision latency —
|
|
193
|
+
// the subject may mark in a later sitting — but it does reveal a screenful of
|
|
194
|
+
// receipts stamped in one pass, which §10.7 autonomy promotion treats as
|
|
195
|
+
// bookkeeping rather than deliberate consent. Mutable with `user_action`, and
|
|
196
|
+
// only with it (§10.6: user_action is the sole mutable field; this is part of it).
|
|
197
|
+
r.user_action_at = new Date(now).toISOString();
|
|
198
|
+
await writeStore(stateDir, store, now);
|
|
199
|
+
return true;
|
|
200
|
+
});
|
|
148
201
|
}
|
|
149
202
|
|
|
150
203
|
// Decrypt and return all receipts (oldest first). The dev/harness read path — and
|