@mnemom/mnemom 0.16.1 → 0.17.0-next.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/README.md +1 -0
- package/dist/commands/agents.d.ts +14 -0
- package/dist/commands/agents.js +100 -2
- package/dist/commands/card.d.ts +43 -0
- package/dist/commands/card.js +153 -102
- package/dist/commands/code-config.d.ts +17 -0
- package/dist/commands/code-config.js +147 -0
- package/dist/commands/code-doctor.d.ts +18 -0
- package/dist/commands/code-doctor.js +138 -0
- package/dist/commands/code-setup.d.ts +97 -0
- package/dist/commands/code-setup.js +330 -0
- package/dist/commands/code.d.ts +133 -0
- package/dist/commands/code.js +661 -0
- package/dist/commands/logs.js +11 -1
- package/dist/commands/onboard.d.ts +59 -0
- package/dist/commands/onboard.js +395 -0
- package/dist/commands/org.d.ts +13 -0
- package/dist/commands/org.js +63 -2
- package/dist/commands/protection.d.ts +10 -0
- package/dist/commands/protection.js +109 -0
- package/dist/commands/status.js +5 -0
- package/dist/commands/try-me.js +16 -1
- package/dist/commands/usage.d.ts +35 -0
- package/dist/commands/usage.js +265 -0
- package/dist/commands/wrap.d.ts +28 -0
- package/dist/commands/wrap.js +331 -0
- package/dist/index.js +315 -7
- package/dist/lib/agent-config.d.ts +27 -0
- package/dist/lib/agent-config.js +86 -0
- package/dist/lib/api.d.ts +139 -1
- package/dist/lib/api.js +132 -183
- package/dist/lib/cli-config.d.ts +33 -0
- package/dist/lib/cli-config.js +70 -0
- package/dist/lib/code-config.d.ts +78 -0
- package/dist/lib/code-config.js +281 -0
- package/dist/lib/code.d.ts +154 -0
- package/dist/lib/code.js +252 -0
- package/dist/lib/config.d.ts +12 -0
- package/dist/lib/config.js +55 -3
- package/dist/lib/keyed-identity.d.ts +35 -0
- package/dist/lib/keyed-identity.js +363 -0
- package/dist/lib/protection-drift.d.ts +117 -0
- package/dist/lib/protection-drift.js +180 -0
- package/dist/lib/skills.js +25 -12
- package/dist/lib/version-gate.d.ts +37 -0
- package/dist/lib/version-gate.js +84 -0
- package/dist/rc-proxy.mjs +341 -0
- package/package.json +9 -7
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Keyed-mode identity manifest validator (issue #1429 / MNE-5522)
|
|
3
|
+
//
|
|
4
|
+
// Pure, I/O-free validator for the three-mode keyed-identity record: the
|
|
5
|
+
// `cards/keyed-modes.manifest.yaml` binding plus the three protection-card
|
|
6
|
+
// snapshots it references. It asserts the CROSS-ENTRY invariant — exactly the
|
|
7
|
+
// three modes {observe, nudge, enforce}, one each, distinct identities, each
|
|
8
|
+
// snapshot valid and matching the manifest — and REUSES `validateProtectionCard`
|
|
9
|
+
// (cli/src/commands/protection.ts) for per-card ADR-037 validation rather than
|
|
10
|
+
// re-implementing card rules (MNE-437: no logic-bearing duplication).
|
|
11
|
+
//
|
|
12
|
+
// No I/O, no process.exit: callers read the returned ValidationCheck[] and
|
|
13
|
+
// render/aggregate. This is the deterministic guard exercised by the manifest
|
|
14
|
+
// `test` verb (cli vitest): against inline fixtures AND against the real
|
|
15
|
+
// committed cards/ files (the on-disk integration test in
|
|
16
|
+
// cli/src/__tests__/keyed-identity.test.ts).
|
|
17
|
+
// ============================================================================
|
|
18
|
+
import { validateProtectionCard } from "../commands/protection.js";
|
|
19
|
+
// The three enforcement modes a keyed identity may be pinned to. `off` is a
|
|
20
|
+
// valid protection-card mode but NOT a keyed-mode target — a keyed identity
|
|
21
|
+
// exists precisely to exercise one of the three ACTIVE modes side-by-side.
|
|
22
|
+
export const KEYED_MODES = ["observe", "nudge", "enforce"];
|
|
23
|
+
// The evaluation-battery model, pinned as ONE config fact (issue #1460 / D13 /
|
|
24
|
+
// MNE-5707). This is the compile-time mirror of the AUTHORITATIVE `model:` field
|
|
25
|
+
// in cards/keyed-modes.manifest.yaml — the manifest is the single source of
|
|
26
|
+
// truth; this constant lets the validator (and its drift-guard test) assert the
|
|
27
|
+
// manifest has not silently diverged from the value the codebase expects.
|
|
28
|
+
// Holding the model fixed across all four lanes is what keeps the downstream
|
|
29
|
+
// cost probe valid; a changed model invalidates it. Re-opening the model choice
|
|
30
|
+
// is OUT of scope (MNE-5707).
|
|
31
|
+
export const EVAL_BATTERY_MODEL = "claude-opus-5";
|
|
32
|
+
function isObject(v) {
|
|
33
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate the keyed-mode manifest against the three parsed snapshots.
|
|
37
|
+
*
|
|
38
|
+
* @param manifest Parsed `cards/keyed-modes.manifest.yaml` (`{ entries: [...] }`).
|
|
39
|
+
* @param snapshots Map of snapshot filename → parsed protection card, keyed by
|
|
40
|
+
* the `snapshot:` value each manifest entry declares.
|
|
41
|
+
* @returns Flat list of `{ name, passed, message }` checks (mirrors
|
|
42
|
+
* ValidationCheck) so callers can render/aggregate. A returned list
|
|
43
|
+
* with every `passed === true` means the record is internally
|
|
44
|
+
* consistent; any `passed === false` is a failing invariant.
|
|
45
|
+
*/
|
|
46
|
+
export function validateKeyedModeManifest(manifest, snapshots) {
|
|
47
|
+
const checks = [];
|
|
48
|
+
const rawEntries = manifest.entries;
|
|
49
|
+
if (!Array.isArray(rawEntries)) {
|
|
50
|
+
checks.push({
|
|
51
|
+
name: "manifest.entries",
|
|
52
|
+
passed: false,
|
|
53
|
+
message: "Required: manifest must have an `entries` array (one object per keyed identity).",
|
|
54
|
+
});
|
|
55
|
+
return checks;
|
|
56
|
+
}
|
|
57
|
+
const entries = rawEntries;
|
|
58
|
+
// ── Pinned battery model: one authoritative config fact, no lane divergence ──
|
|
59
|
+
// `manifest.model` must be present, a string, and equal to the codebase's
|
|
60
|
+
// EVAL_BATTERY_MODEL mirror; any entry declaring its OWN `model` must match it
|
|
61
|
+
// (a divergent per-lane model would invalidate the cost probe — MNE-440).
|
|
62
|
+
checks.push(...modelConsistencyChecks(manifest, entries));
|
|
63
|
+
// ── Per-snapshot ADR-037 validation (reuse validateProtectionCard) ──
|
|
64
|
+
// Each entry references a snapshot by filename; validate the referenced card.
|
|
65
|
+
for (let i = 0; i < entries.length; i++) {
|
|
66
|
+
const entry = entries[i];
|
|
67
|
+
const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
|
|
68
|
+
const snapshotName = entry.snapshot;
|
|
69
|
+
if (typeof snapshotName !== "string" || snapshotName.length === 0) {
|
|
70
|
+
checks.push({
|
|
71
|
+
name: `${label}.snapshot`,
|
|
72
|
+
passed: false,
|
|
73
|
+
message: "Required: entry must reference a snapshot filename (string).",
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const card = snapshots[snapshotName];
|
|
78
|
+
if (!isObject(card)) {
|
|
79
|
+
checks.push({
|
|
80
|
+
name: `${label}.snapshot`,
|
|
81
|
+
passed: false,
|
|
82
|
+
message: `Snapshot "${snapshotName}" was not provided (or did not parse to an object).`,
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const cardChecks = validateProtectionCard(card);
|
|
87
|
+
const failed = cardChecks.filter((c) => !c.passed);
|
|
88
|
+
if (failed.length > 0) {
|
|
89
|
+
checks.push({
|
|
90
|
+
name: `${label}.snapshot`,
|
|
91
|
+
passed: false,
|
|
92
|
+
message: `Snapshot "${snapshotName}" is not a valid protection card: ${failed
|
|
93
|
+
.map((f) => `${f.name}: ${f.message}`)
|
|
94
|
+
.join("; ")}`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
checks.push({
|
|
99
|
+
name: `${label}.snapshot`,
|
|
100
|
+
passed: true,
|
|
101
|
+
message: `${snapshotName} is a valid protection card`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ── Mode coverage: exactly {observe, nudge, enforce}, one each ──
|
|
106
|
+
const modeCounts = new Map();
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (typeof entry.mode === "string") {
|
|
109
|
+
modeCounts.set(entry.mode, (modeCounts.get(entry.mode) ?? 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const missing = KEYED_MODES.filter((m) => !modeCounts.has(m));
|
|
113
|
+
const duplicated = KEYED_MODES.filter((m) => (modeCounts.get(m) ?? 0) > 1);
|
|
114
|
+
const unexpected = [...modeCounts.keys()].filter((m) => !KEYED_MODES.includes(m));
|
|
115
|
+
if (missing.length === 0 && duplicated.length === 0 && unexpected.length === 0) {
|
|
116
|
+
checks.push({
|
|
117
|
+
name: "modes.coverage",
|
|
118
|
+
passed: true,
|
|
119
|
+
message: `covers exactly ${KEYED_MODES.join(", ")}, one each`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const problems = [];
|
|
124
|
+
if (missing.length > 0)
|
|
125
|
+
problems.push(`missing: ${missing.join(", ")}`);
|
|
126
|
+
if (duplicated.length > 0)
|
|
127
|
+
problems.push(`duplicated: ${duplicated.join(", ")}`);
|
|
128
|
+
if (unexpected.length > 0)
|
|
129
|
+
problems.push(`unexpected: ${unexpected.join(", ")}`);
|
|
130
|
+
checks.push({
|
|
131
|
+
name: "modes.coverage",
|
|
132
|
+
passed: false,
|
|
133
|
+
message: `Must cover exactly ${KEYED_MODES.join(", ")}, one each. ${problems.join("; ")}.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// ── Distinct agent_id across entries ──
|
|
137
|
+
checks.push(distinctnessCheck(entries, "agent_id"));
|
|
138
|
+
// ── Distinct agent_hash across entries ──
|
|
139
|
+
checks.push(distinctnessCheck(entries, "agent_hash"));
|
|
140
|
+
// ── Each entry's mode equals its referenced snapshot's mode ──
|
|
141
|
+
for (let i = 0; i < entries.length; i++) {
|
|
142
|
+
const entry = entries[i];
|
|
143
|
+
const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
|
|
144
|
+
const snapshotName = entry.snapshot;
|
|
145
|
+
if (typeof snapshotName !== "string")
|
|
146
|
+
continue; // already reported above
|
|
147
|
+
const card = snapshots[snapshotName];
|
|
148
|
+
if (!isObject(card))
|
|
149
|
+
continue; // already reported above
|
|
150
|
+
if (entry.mode !== card.mode) {
|
|
151
|
+
checks.push({
|
|
152
|
+
name: `${label}.mode`,
|
|
153
|
+
passed: false,
|
|
154
|
+
message: `Manifest mode "${String(entry.mode)}" does not match snapshot "${snapshotName}" mode "${String(card.mode)}".`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
checks.push({
|
|
159
|
+
name: `${label}.mode`,
|
|
160
|
+
passed: true,
|
|
161
|
+
message: `mode "${String(entry.mode)}" matches snapshot`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// ── Each entry's agent_id matches its referenced snapshot's agent_id ──
|
|
166
|
+
for (let i = 0; i < entries.length; i++) {
|
|
167
|
+
const entry = entries[i];
|
|
168
|
+
const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
|
|
169
|
+
const snapshotName = entry.snapshot;
|
|
170
|
+
if (typeof snapshotName !== "string")
|
|
171
|
+
continue; // already reported above
|
|
172
|
+
const card = snapshots[snapshotName];
|
|
173
|
+
if (!isObject(card))
|
|
174
|
+
continue; // already reported above
|
|
175
|
+
if (entry.agent_id !== card.agent_id) {
|
|
176
|
+
checks.push({
|
|
177
|
+
name: `${label}.agent_id`,
|
|
178
|
+
passed: false,
|
|
179
|
+
message: `Manifest agent_id "${String(entry.agent_id)}" does not match snapshot "${snapshotName}" agent_id "${String(card.agent_id)}".`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
checks.push({
|
|
184
|
+
name: `${label}.agent_id`,
|
|
185
|
+
passed: true,
|
|
186
|
+
message: `agent_id "${String(entry.agent_id)}" matches snapshot`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ── Direct (true-off) calibration lane shape ──
|
|
191
|
+
checks.push(...validateDirectLane(manifest, entries));
|
|
192
|
+
return checks;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Model-consistency checks: the manifest pins one authoritative `model`, and no
|
|
196
|
+
* entry may declare a divergent per-lane `model`. Returns one `model.pinned`
|
|
197
|
+
* check plus, for any entry that declares its own `model`, one per-entry check.
|
|
198
|
+
*/
|
|
199
|
+
function modelConsistencyChecks(manifest, entries) {
|
|
200
|
+
const checks = [];
|
|
201
|
+
const model = manifest.model;
|
|
202
|
+
if (typeof model !== "string" || model.length === 0) {
|
|
203
|
+
checks.push({
|
|
204
|
+
name: "model.pinned",
|
|
205
|
+
passed: false,
|
|
206
|
+
message: "Required: manifest must pin `model` (a non-empty string) — the single evaluation-battery model held fixed across all lanes.",
|
|
207
|
+
});
|
|
208
|
+
return checks;
|
|
209
|
+
}
|
|
210
|
+
if (model !== EVAL_BATTERY_MODEL) {
|
|
211
|
+
checks.push({
|
|
212
|
+
name: "model.pinned",
|
|
213
|
+
passed: false,
|
|
214
|
+
message: `Manifest model "${model}" does not equal EVAL_BATTERY_MODEL "${EVAL_BATTERY_MODEL}". The pinned battery model must not silently diverge (MNE-5707).`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
checks.push({
|
|
219
|
+
name: "model.pinned",
|
|
220
|
+
passed: true,
|
|
221
|
+
message: `model pinned to "${model}" (matches EVAL_BATTERY_MODEL)`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Any entry that declares its own `model` must match the authoritative value.
|
|
225
|
+
for (let i = 0; i < entries.length; i++) {
|
|
226
|
+
const entry = entries[i];
|
|
227
|
+
if (entry.model === undefined)
|
|
228
|
+
continue; // no per-lane override (the norm)
|
|
229
|
+
const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
|
|
230
|
+
if (entry.model !== model) {
|
|
231
|
+
checks.push({
|
|
232
|
+
name: `${label}.model`,
|
|
233
|
+
passed: false,
|
|
234
|
+
message: `Entry model "${String(entry.model)}" diverges from the pinned manifest model "${model}". All lanes must use the identical model (MNE-440).`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
checks.push({
|
|
239
|
+
name: `${label}.model`,
|
|
240
|
+
passed: true,
|
|
241
|
+
message: `entry model matches the pinned "${model}"`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return checks;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Validate the fourth, DIRECT (true-off) calibration lane. It is a distinct
|
|
249
|
+
* top-level section, kept OUT of the three-mode `entries` coverage count. Asserts
|
|
250
|
+
* it declares exactly the direct-lane shape (`mode: off`, `path: direct`, a
|
|
251
|
+
* non-empty `secret_ref` distinct from every entry's) and declares NONE of the
|
|
252
|
+
* gateway-only fields (`snapshot`/`agent_id`/`agent_hash`) nor a per-lane `model`
|
|
253
|
+
* — the direct lane never touches the gateway, so any of those would be
|
|
254
|
+
* misleading dead config (MNE-440).
|
|
255
|
+
*/
|
|
256
|
+
function validateDirectLane(manifest, entries) {
|
|
257
|
+
const checks = [];
|
|
258
|
+
const raw = manifest.direct_lane;
|
|
259
|
+
if (!isObject(raw)) {
|
|
260
|
+
checks.push({
|
|
261
|
+
name: "direct_lane",
|
|
262
|
+
passed: false,
|
|
263
|
+
message: "Required: manifest must declare a `direct_lane` object (the true-off calibration path that bypasses the gateway).",
|
|
264
|
+
});
|
|
265
|
+
return checks;
|
|
266
|
+
}
|
|
267
|
+
const lane = raw;
|
|
268
|
+
// mode must be exactly "off" (true-off calibration).
|
|
269
|
+
if (lane.mode !== "off") {
|
|
270
|
+
checks.push({
|
|
271
|
+
name: "direct_lane.mode",
|
|
272
|
+
passed: false,
|
|
273
|
+
message: `direct_lane.mode must be "off" (true-off calibration); got "${String(lane.mode)}".`,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
checks.push({ name: "direct_lane.mode", passed: true, message: 'mode "off" (true-off)' });
|
|
278
|
+
}
|
|
279
|
+
// path must be exactly "direct".
|
|
280
|
+
if (lane.path !== "direct") {
|
|
281
|
+
checks.push({
|
|
282
|
+
name: "direct_lane.path",
|
|
283
|
+
passed: false,
|
|
284
|
+
message: `direct_lane.path must be "direct" (it bypasses the gateway); got "${String(lane.path)}".`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
checks.push({ name: "direct_lane.path", passed: true, message: 'path "direct"' });
|
|
289
|
+
}
|
|
290
|
+
// secret_ref must be a non-empty string, distinct from every entry's.
|
|
291
|
+
if (typeof lane.secret_ref !== "string" || lane.secret_ref.length === 0) {
|
|
292
|
+
checks.push({
|
|
293
|
+
name: "direct_lane.secret_ref",
|
|
294
|
+
passed: false,
|
|
295
|
+
message: "direct_lane.secret_ref must be a non-empty string (secret-store reference name).",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const entryRefs = new Set(entries.map((e) => e.secret_ref).filter((r) => typeof r === "string"));
|
|
300
|
+
if (entryRefs.has(lane.secret_ref)) {
|
|
301
|
+
checks.push({
|
|
302
|
+
name: "direct_lane.secret_ref",
|
|
303
|
+
passed: false,
|
|
304
|
+
message: `direct_lane.secret_ref "${lane.secret_ref}" must be distinct from every entry's secret_ref (the direct lane uses its own provider key).`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
checks.push({
|
|
309
|
+
name: "direct_lane.secret_ref",
|
|
310
|
+
passed: true,
|
|
311
|
+
message: `secret_ref "${lane.secret_ref}" is distinct from all entry secret_refs`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Forbidden gateway-only / per-lane fields: declaring any is misleading dead
|
|
316
|
+
// config, because the direct lane never traverses the gateway (MNE-440).
|
|
317
|
+
const forbidden = ["snapshot", "agent_id", "agent_hash", "model"].filter((f) => lane[f] !== undefined);
|
|
318
|
+
if (forbidden.length > 0) {
|
|
319
|
+
checks.push({
|
|
320
|
+
name: "direct_lane.forbidden_fields",
|
|
321
|
+
passed: false,
|
|
322
|
+
message: `direct_lane must NOT declare ${forbidden.join(", ")}: it never traverses the gateway (no identity/card) and uses the authoritative top-level manifest.model. Remove the field(s) to avoid misleading dead config (MNE-440).`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
checks.push({
|
|
327
|
+
name: "direct_lane.forbidden_fields",
|
|
328
|
+
passed: true,
|
|
329
|
+
message: "declares no gateway-only identity/card/model fields",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return checks;
|
|
333
|
+
}
|
|
334
|
+
/** Assert a field is present and mutually distinct across all entries. */
|
|
335
|
+
function distinctnessCheck(entries, field) {
|
|
336
|
+
const values = entries.map((e) => e[field]);
|
|
337
|
+
if (values.some((v) => typeof v !== "string" || v.length === 0)) {
|
|
338
|
+
return {
|
|
339
|
+
name: `${field}.distinct`,
|
|
340
|
+
passed: false,
|
|
341
|
+
message: `Every entry must declare a non-empty ${field}.`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
const dupes = new Set();
|
|
346
|
+
for (const v of values) {
|
|
347
|
+
if (seen.has(v))
|
|
348
|
+
dupes.add(v);
|
|
349
|
+
seen.add(v);
|
|
350
|
+
}
|
|
351
|
+
if (dupes.size > 0) {
|
|
352
|
+
return {
|
|
353
|
+
name: `${field}.distinct`,
|
|
354
|
+
passed: false,
|
|
355
|
+
message: `${field} must be distinct across entries; repeated: ${[...dupes].join(", ")}.`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
name: `${field}.distinct`,
|
|
360
|
+
passed: true,
|
|
361
|
+
message: `${values.length} distinct ${field} value(s)`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protection-drift.ts — pure comparison between a COMMITTED protection-card
|
|
3
|
+
* snapshot (a file under `cards/`) and the LIVE canonical protection card
|
|
4
|
+
* returned by the platform.
|
|
5
|
+
*
|
|
6
|
+
* Split follows the convention `mnemom-api/src/composition/reconcile-flat-columns.ts`
|
|
7
|
+
* established: the decision logic is a pure, network-free module so it can be
|
|
8
|
+
* unit-tested exhaustively; the thin driver that actually fetches the live card
|
|
9
|
+
* lives in the command layer (`commands/protection.ts` → `mnemom protection drift`).
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS BEING COMPARED (read this before extending):
|
|
12
|
+
* - `committed` is a repo SNAPSHOT of the agent-scope card — human-authored,
|
|
13
|
+
* declared intent. See `cards/README.md`.
|
|
14
|
+
* - `live` is the CANONICAL COMPOSED card (`GET /v1/protection/agent/:id`,
|
|
15
|
+
* via `getProtectionCard()`), i.e. the OUTPUT of
|
|
16
|
+
* `composeProtectionCard(platform, org, teams…, agent)` with strictest-wins
|
|
17
|
+
* mode resolution.
|
|
18
|
+
* These are NOT the same object. A snapshot field matching the composed card
|
|
19
|
+
* means "the effective posture is what the repo says it should be" — it does
|
|
20
|
+
* NOT mean "publishing this snapshot would reproduce the live card". A
|
|
21
|
+
* stricter scope upstream (org / team / platform floor) can raise the composed
|
|
22
|
+
* mode above whatever the agent-scope card asks for.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Fields on the canonical card that are re-derived on every compose and
|
|
26
|
+
* therefore carry no posture meaning. Comparing them would make any drift check
|
|
27
|
+
* permanently red.
|
|
28
|
+
*
|
|
29
|
+
* - `card_version`: `compose.ts` sets it to
|
|
30
|
+
* `protection/${new Date().toISOString().slice(0, 10)}` — a wall-clock stamp
|
|
31
|
+
* of the LAST COMPOSE, not a semantic version. It changes whenever anything
|
|
32
|
+
* recomposes, and never changes while the card sits untouched.
|
|
33
|
+
* - `content_hash` / `composed_at` / `agent_protection_card_id` /
|
|
34
|
+
* `updated_at` / `created_at` / `needs_recompose`: storage + provenance
|
|
35
|
+
* bookkeeping written by the composer and the canonical-write RPC.
|
|
36
|
+
*
|
|
37
|
+
* The first version of this list was written from the composer's own field
|
|
38
|
+
* names. That was the wrong source: the list has to match what the SERIALIZER
|
|
39
|
+
* emits. A live read of `GET /v1/protection/agent/:id` on us-1 (2026-07-28)
|
|
40
|
+
* returns exactly:
|
|
41
|
+
*
|
|
42
|
+
* mode, card_id, agent_id, issued_at, thresholds, card_version,
|
|
43
|
+
* screen_surfaces, trusted_sources, protected_surface, _composition,
|
|
44
|
+
* content_hash, version
|
|
45
|
+
*
|
|
46
|
+
* so `composed_at`, `created_at`, `updated_at`, `needs_recompose` and
|
|
47
|
+
* `agent_protection_card_id` never appear at all (kept below anyway — harmless,
|
|
48
|
+
* and they do appear on the raw DB row some callers pass in), while four
|
|
49
|
+
* derived fields the serializer DOES emit were missing: `card_id` (the
|
|
50
|
+
* server-stamped canonical id, provenance `kind: server_stamped`), `issued_at`
|
|
51
|
+
* (a compose-time stamp), `_composition` (the whole provenance envelope,
|
|
52
|
+
* `layer: derived` throughout, and itself containing `_composition.composed_at`)
|
|
53
|
+
* and `version` (the serializer's envelope version, not a posture field).
|
|
54
|
+
* Without them `--strict` reports four permanent `unrecorded` holes that no
|
|
55
|
+
* snapshot can ever close, which is precisely the state that would make an
|
|
56
|
+
* operator stop trusting the check.
|
|
57
|
+
*
|
|
58
|
+
* KNOWN COST of ignoring `version`: it is the serializer's envelope version, so
|
|
59
|
+
* a schema bump (2 → 3) passes silently here. That is the intended trade — a
|
|
60
|
+
* serializer version is not a posture claim, and an envelope change that alters
|
|
61
|
+
* a posture field will still surface as a mismatch or an unrecorded field. If
|
|
62
|
+
* the envelope version ever starts carrying posture semantics, drop it from
|
|
63
|
+
* this list.
|
|
64
|
+
*/
|
|
65
|
+
export declare const VOLATILE_FIELDS: readonly string[];
|
|
66
|
+
export interface FieldMismatch {
|
|
67
|
+
/** Dotted path, e.g. `mode` or `screen_surfaces.tool_calls`. */
|
|
68
|
+
path: string;
|
|
69
|
+
committed: unknown;
|
|
70
|
+
live: unknown;
|
|
71
|
+
}
|
|
72
|
+
export interface DriftResult {
|
|
73
|
+
/**
|
|
74
|
+
* A field is present in BOTH the snapshot and the live card but the values
|
|
75
|
+
* differ. This is unambiguous drift: the repo's declared posture is not the
|
|
76
|
+
* posture actually in force. Always a failure.
|
|
77
|
+
*/
|
|
78
|
+
mismatches: FieldMismatch[];
|
|
79
|
+
/**
|
|
80
|
+
* A top-level field the LIVE card carries that the snapshot does not record
|
|
81
|
+
* at all. Not a value conflict — a COVERAGE hole: that part of the live
|
|
82
|
+
* posture has no committed representation, so it could change with no diff.
|
|
83
|
+
* Reported always; fatal only under `strict`.
|
|
84
|
+
*/
|
|
85
|
+
unrecorded: string[];
|
|
86
|
+
/**
|
|
87
|
+
* A top-level field the SNAPSHOT declares that the live card does not carry.
|
|
88
|
+
* The declared intent is simply not in force. Always a failure — treated as a
|
|
89
|
+
* mismatch against `undefined` would lose the distinction, so it is its own
|
|
90
|
+
* bucket.
|
|
91
|
+
*/
|
|
92
|
+
missingLive: string[];
|
|
93
|
+
/** Volatile fields skipped, for transparency in the report. */
|
|
94
|
+
ignored: string[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Compare a committed snapshot against the live canonical card.
|
|
98
|
+
*
|
|
99
|
+
* Pure: no I/O, no clock, no env. Both arguments are already-parsed objects.
|
|
100
|
+
*/
|
|
101
|
+
export declare function compareProtectionCards(committed: Record<string, unknown>, live: Record<string, unknown>): DriftResult;
|
|
102
|
+
/**
|
|
103
|
+
* Policy: does this result fail the check?
|
|
104
|
+
*
|
|
105
|
+
* `mismatches` and `missingLive` always fail — the repo says one thing and the
|
|
106
|
+
* live posture is another. `unrecorded` is a coverage gap: reported by default,
|
|
107
|
+
* fatal only under `--strict`, so the check can go green today on a partial
|
|
108
|
+
* snapshot while still surfacing exactly which parts of the live posture remain
|
|
109
|
+
* unversioned. See `cards/README.md`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function isDrift(result: DriftResult, opts?: {
|
|
112
|
+
strict?: boolean;
|
|
113
|
+
}): boolean;
|
|
114
|
+
/** Human-readable multi-line report. Returned, not printed, so it is testable. */
|
|
115
|
+
export declare function formatDriftReport(result: DriftResult, opts?: {
|
|
116
|
+
strict?: boolean;
|
|
117
|
+
}): string;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protection-drift.ts — pure comparison between a COMMITTED protection-card
|
|
3
|
+
* snapshot (a file under `cards/`) and the LIVE canonical protection card
|
|
4
|
+
* returned by the platform.
|
|
5
|
+
*
|
|
6
|
+
* Split follows the convention `mnemom-api/src/composition/reconcile-flat-columns.ts`
|
|
7
|
+
* established: the decision logic is a pure, network-free module so it can be
|
|
8
|
+
* unit-tested exhaustively; the thin driver that actually fetches the live card
|
|
9
|
+
* lives in the command layer (`commands/protection.ts` → `mnemom protection drift`).
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS BEING COMPARED (read this before extending):
|
|
12
|
+
* - `committed` is a repo SNAPSHOT of the agent-scope card — human-authored,
|
|
13
|
+
* declared intent. See `cards/README.md`.
|
|
14
|
+
* - `live` is the CANONICAL COMPOSED card (`GET /v1/protection/agent/:id`,
|
|
15
|
+
* via `getProtectionCard()`), i.e. the OUTPUT of
|
|
16
|
+
* `composeProtectionCard(platform, org, teams…, agent)` with strictest-wins
|
|
17
|
+
* mode resolution.
|
|
18
|
+
* These are NOT the same object. A snapshot field matching the composed card
|
|
19
|
+
* means "the effective posture is what the repo says it should be" — it does
|
|
20
|
+
* NOT mean "publishing this snapshot would reproduce the live card". A
|
|
21
|
+
* stricter scope upstream (org / team / platform floor) can raise the composed
|
|
22
|
+
* mode above whatever the agent-scope card asks for.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Fields on the canonical card that are re-derived on every compose and
|
|
26
|
+
* therefore carry no posture meaning. Comparing them would make any drift check
|
|
27
|
+
* permanently red.
|
|
28
|
+
*
|
|
29
|
+
* - `card_version`: `compose.ts` sets it to
|
|
30
|
+
* `protection/${new Date().toISOString().slice(0, 10)}` — a wall-clock stamp
|
|
31
|
+
* of the LAST COMPOSE, not a semantic version. It changes whenever anything
|
|
32
|
+
* recomposes, and never changes while the card sits untouched.
|
|
33
|
+
* - `content_hash` / `composed_at` / `agent_protection_card_id` /
|
|
34
|
+
* `updated_at` / `created_at` / `needs_recompose`: storage + provenance
|
|
35
|
+
* bookkeeping written by the composer and the canonical-write RPC.
|
|
36
|
+
*
|
|
37
|
+
* The first version of this list was written from the composer's own field
|
|
38
|
+
* names. That was the wrong source: the list has to match what the SERIALIZER
|
|
39
|
+
* emits. A live read of `GET /v1/protection/agent/:id` on us-1 (2026-07-28)
|
|
40
|
+
* returns exactly:
|
|
41
|
+
*
|
|
42
|
+
* mode, card_id, agent_id, issued_at, thresholds, card_version,
|
|
43
|
+
* screen_surfaces, trusted_sources, protected_surface, _composition,
|
|
44
|
+
* content_hash, version
|
|
45
|
+
*
|
|
46
|
+
* so `composed_at`, `created_at`, `updated_at`, `needs_recompose` and
|
|
47
|
+
* `agent_protection_card_id` never appear at all (kept below anyway — harmless,
|
|
48
|
+
* and they do appear on the raw DB row some callers pass in), while four
|
|
49
|
+
* derived fields the serializer DOES emit were missing: `card_id` (the
|
|
50
|
+
* server-stamped canonical id, provenance `kind: server_stamped`), `issued_at`
|
|
51
|
+
* (a compose-time stamp), `_composition` (the whole provenance envelope,
|
|
52
|
+
* `layer: derived` throughout, and itself containing `_composition.composed_at`)
|
|
53
|
+
* and `version` (the serializer's envelope version, not a posture field).
|
|
54
|
+
* Without them `--strict` reports four permanent `unrecorded` holes that no
|
|
55
|
+
* snapshot can ever close, which is precisely the state that would make an
|
|
56
|
+
* operator stop trusting the check.
|
|
57
|
+
*
|
|
58
|
+
* KNOWN COST of ignoring `version`: it is the serializer's envelope version, so
|
|
59
|
+
* a schema bump (2 → 3) passes silently here. That is the intended trade — a
|
|
60
|
+
* serializer version is not a posture claim, and an envelope change that alters
|
|
61
|
+
* a posture field will still surface as a mismatch or an unrecorded field. If
|
|
62
|
+
* the envelope version ever starts carrying posture semantics, drop it from
|
|
63
|
+
* this list.
|
|
64
|
+
*/
|
|
65
|
+
export const VOLATILE_FIELDS = [
|
|
66
|
+
"_composition",
|
|
67
|
+
"agent_protection_card_id",
|
|
68
|
+
"card_id",
|
|
69
|
+
"card_version",
|
|
70
|
+
"composed_at",
|
|
71
|
+
"content_hash",
|
|
72
|
+
"created_at",
|
|
73
|
+
"issued_at",
|
|
74
|
+
"needs_recompose",
|
|
75
|
+
"updated_at",
|
|
76
|
+
"version",
|
|
77
|
+
];
|
|
78
|
+
function isPlainObject(v) {
|
|
79
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
80
|
+
}
|
|
81
|
+
/** Structural equality good enough for card values (scalars, arrays, nested objects). */
|
|
82
|
+
function deepEqual(a, b) {
|
|
83
|
+
if (a === b)
|
|
84
|
+
return true;
|
|
85
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
86
|
+
return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
|
|
87
|
+
}
|
|
88
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
89
|
+
const ka = Object.keys(a).sort();
|
|
90
|
+
const kb = Object.keys(b).sort();
|
|
91
|
+
return ka.length === kb.length && ka.every((k, i) => k === kb[i] && deepEqual(a[k], b[k]));
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Recurse into nested objects so a one-surface flip reports as
|
|
97
|
+
* `screen_surfaces.tool_calls` rather than dumping both whole objects.
|
|
98
|
+
* Keys the live side has but the snapshot does not, BELOW the top level, are
|
|
99
|
+
* reported as nested `unrecorded` paths (same coverage-hole semantics).
|
|
100
|
+
*/
|
|
101
|
+
function walk(committed, live, prefix, out) {
|
|
102
|
+
for (const key of Object.keys(committed)) {
|
|
103
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
104
|
+
if (!prefix && VOLATILE_FIELDS.includes(key)) {
|
|
105
|
+
out.ignored.push(path);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const c = committed[key];
|
|
109
|
+
const l = live[key];
|
|
110
|
+
if (!(key in live)) {
|
|
111
|
+
out.missingLive.push(path);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (isPlainObject(c) && isPlainObject(l)) {
|
|
115
|
+
walk(c, l, path, out);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!deepEqual(c, l)) {
|
|
119
|
+
out.mismatches.push({ path, committed: c, live: l });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const key of Object.keys(live)) {
|
|
123
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
124
|
+
if (!prefix && VOLATILE_FIELDS.includes(key)) {
|
|
125
|
+
if (!out.ignored.includes(path))
|
|
126
|
+
out.ignored.push(path);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!(key in committed))
|
|
130
|
+
out.unrecorded.push(path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Compare a committed snapshot against the live canonical card.
|
|
135
|
+
*
|
|
136
|
+
* Pure: no I/O, no clock, no env. Both arguments are already-parsed objects.
|
|
137
|
+
*/
|
|
138
|
+
export function compareProtectionCards(committed, live) {
|
|
139
|
+
const out = { mismatches: [], unrecorded: [], missingLive: [], ignored: [] };
|
|
140
|
+
walk(committed, live, "", out);
|
|
141
|
+
out.mismatches.sort((a, b) => a.path.localeCompare(b.path));
|
|
142
|
+
out.unrecorded.sort();
|
|
143
|
+
out.missingLive.sort();
|
|
144
|
+
out.ignored.sort();
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Policy: does this result fail the check?
|
|
149
|
+
*
|
|
150
|
+
* `mismatches` and `missingLive` always fail — the repo says one thing and the
|
|
151
|
+
* live posture is another. `unrecorded` is a coverage gap: reported by default,
|
|
152
|
+
* fatal only under `--strict`, so the check can go green today on a partial
|
|
153
|
+
* snapshot while still surfacing exactly which parts of the live posture remain
|
|
154
|
+
* unversioned. See `cards/README.md`.
|
|
155
|
+
*/
|
|
156
|
+
export function isDrift(result, opts = {}) {
|
|
157
|
+
if (result.mismatches.length > 0 || result.missingLive.length > 0)
|
|
158
|
+
return true;
|
|
159
|
+
return opts.strict === true && result.unrecorded.length > 0;
|
|
160
|
+
}
|
|
161
|
+
/** Human-readable multi-line report. Returned, not printed, so it is testable. */
|
|
162
|
+
export function formatDriftReport(result, opts = {}) {
|
|
163
|
+
const lines = [];
|
|
164
|
+
for (const m of result.mismatches) {
|
|
165
|
+
lines.push(`MISMATCH ${m.path}: committed=${JSON.stringify(m.committed)} live=${JSON.stringify(m.live)}`);
|
|
166
|
+
}
|
|
167
|
+
for (const p of result.missingLive) {
|
|
168
|
+
lines.push(`MISSING ${p}: declared in the committed snapshot, absent from the live card`);
|
|
169
|
+
}
|
|
170
|
+
for (const p of result.unrecorded) {
|
|
171
|
+
const tag = opts.strict === true ? "UNRECORDED" : "unrecorded";
|
|
172
|
+
lines.push(`${tag.padEnd(9)} ${p}: present on the live card, not represented in the snapshot`);
|
|
173
|
+
}
|
|
174
|
+
if (result.ignored.length > 0) {
|
|
175
|
+
lines.push(`ignored volatile/derived fields: ${result.ignored.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
if (lines.length === 0)
|
|
178
|
+
lines.push("no differences");
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|