@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/agents.d.ts +14 -0
  3. package/dist/commands/agents.js +100 -2
  4. package/dist/commands/card.d.ts +43 -0
  5. package/dist/commands/card.js +153 -102
  6. package/dist/commands/code-config.d.ts +17 -0
  7. package/dist/commands/code-config.js +147 -0
  8. package/dist/commands/code-doctor.d.ts +18 -0
  9. package/dist/commands/code-doctor.js +138 -0
  10. package/dist/commands/code-setup.d.ts +97 -0
  11. package/dist/commands/code-setup.js +330 -0
  12. package/dist/commands/code.d.ts +133 -0
  13. package/dist/commands/code.js +661 -0
  14. package/dist/commands/logs.js +11 -1
  15. package/dist/commands/onboard.d.ts +59 -0
  16. package/dist/commands/onboard.js +395 -0
  17. package/dist/commands/org.d.ts +13 -0
  18. package/dist/commands/org.js +63 -2
  19. package/dist/commands/protection.d.ts +10 -0
  20. package/dist/commands/protection.js +109 -0
  21. package/dist/commands/status.js +5 -0
  22. package/dist/commands/try-me.js +16 -1
  23. package/dist/commands/usage.d.ts +35 -0
  24. package/dist/commands/usage.js +265 -0
  25. package/dist/commands/wrap.d.ts +28 -0
  26. package/dist/commands/wrap.js +331 -0
  27. package/dist/index.js +315 -7
  28. package/dist/lib/agent-config.d.ts +27 -0
  29. package/dist/lib/agent-config.js +86 -0
  30. package/dist/lib/api.d.ts +139 -1
  31. package/dist/lib/api.js +132 -183
  32. package/dist/lib/cli-config.d.ts +33 -0
  33. package/dist/lib/cli-config.js +70 -0
  34. package/dist/lib/code-config.d.ts +78 -0
  35. package/dist/lib/code-config.js +281 -0
  36. package/dist/lib/code.d.ts +154 -0
  37. package/dist/lib/code.js +252 -0
  38. package/dist/lib/config.d.ts +12 -0
  39. package/dist/lib/config.js +55 -3
  40. package/dist/lib/keyed-identity.d.ts +35 -0
  41. package/dist/lib/keyed-identity.js +363 -0
  42. package/dist/lib/protection-drift.d.ts +117 -0
  43. package/dist/lib/protection-drift.js +180 -0
  44. package/dist/lib/skills.js +25 -12
  45. package/dist/lib/version-gate.d.ts +37 -0
  46. package/dist/lib/version-gate.js +84 -0
  47. package/dist/rc-proxy.mjs +341 -0
  48. 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
+ }