@cohortapp/agent-sdk 2.5.1 → 2.6.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 (106) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/deliver.mjs +314 -0
  91. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  92. package/scripts/daemon/dispatcher.mjs +64 -6
  93. package/scripts/daemon/responder-cost.test.mjs +68 -0
  94. package/scripts/daemon/responder.mjs +351 -298
  95. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  96. package/scripts/maintenance/backup-run.mjs +415 -0
  97. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  98. package/scripts/org/send-orgmail.mjs +16 -0
  99. package/scripts/record-receipt.sh +63 -0
  100. package/scripts/restore-from-backup.sh +14 -3
  101. package/scripts/restore-from-backup.test.mjs +8 -5
  102. package/scripts/send-email-threaded.py +47 -0
  103. package/scripts/send-sms.sh +4 -0
  104. package/scripts/send-whatsapp.sh +4 -0
  105. package/scripts/setup/init-backup.mjs +93 -38
  106. package/scripts/slack-send.sh +12 -0
@@ -338,6 +338,93 @@ export function writeProposal(decision, o = {}) {
338
338
  return { ok, ref: { proposed: true }, error: ok ? null : "proposal append failed" };
339
339
  }
340
340
 
341
+ /**
342
+ * Compact the engagement run result for the outcome journal. Ids, surface and
343
+ * the machine reason — never the body, never a room name.
344
+ */
345
+ function engagementRef(eng) {
346
+ if (!eng || typeof eng !== "object") return null;
347
+ const d = eng.decision || {};
348
+ return {
349
+ engaged: eng.engaged === true,
350
+ outcome: eng.outcome || null,
351
+ surface: eng.surface || d.surface || "none",
352
+ reasonCode: d.reasonCode || null,
353
+ targets: Array.isArray(d.targets) ? d.targets.map((t) => t.memberId) : [],
354
+ rungs: Array.isArray(d.targets) ? d.targets.map((t) => t.rung) : [],
355
+ };
356
+ }
357
+
358
+ /**
359
+ * An engagement problem the operator must see. A DELIBERATE non-engagement
360
+ * (`self_contained`, `already_engaged`, a rate limit) is not an error — it is
361
+ * the judgement working — so it returns null and lives in the ledger instead.
362
+ */
363
+ function engagementError(eng) {
364
+ if (!eng || eng.engaged) return null;
365
+ const hard = new Set(["send_failed", "send_blocked", "engage_threw", "unroutable"]);
366
+ if (!hard.has(String(eng.outcome))) return null;
367
+ return `engagement ${eng.outcome}${eng.error ? `: ${eng.error}` : ""}`;
368
+ }
369
+
370
+ /**
371
+ * Turn a ladder decision into the `work` shape `lib/org/engagement.mjs` judges.
372
+ *
373
+ * ── WHY AN ESCALATION HAS TO ENGAGE SOMEBODY ──
374
+ * `escalate` used to end at `escalation.ask` / `escalation.create`: a row in a
375
+ * table. The live journal shows nine escalations, every one `spoke:false`, and
376
+ * three of them raised nothing at all ("Nothing was raised."). The ladder could
377
+ * decide a human was needed and then not tell a human. A record of a blockage
378
+ * that nobody is told about is a blockage nobody clears.
379
+ *
380
+ * So the record is still written — it is the auditable artefact and the thing a
381
+ * human can resolve in one click — and then the same decision is put in front of
382
+ * a person, through the judgement in engagement.mjs rather than by broadcasting.
383
+ * The judgement is what stops this becoming a firehose: it picks the addressee
384
+ * from the directory, picks the cheapest surface that reaches them, dedupes, and
385
+ * rate-limits. When it decides nobody needs to be involved, that is recorded too.
386
+ *
387
+ * `selfServeExhausted: true` is asserted here and it is honest: reaching the
388
+ * escalate rung IS the ladder having exhausted every disposition it can execute
389
+ * alone. This is the one caller entitled to assert it.
390
+ *
391
+ * @param {object} decision
392
+ * @param {object} candidate
393
+ * @param {object} [o] { me, escalationTarget }
394
+ * @returns {object} work
395
+ */
396
+ function workFromDecision(decision, candidate, o = {}) {
397
+ const ids = (candidate && candidate.ids) || {};
398
+ const options = (decision.options || []).map((l) => String(l || "").trim()).filter(Boolean);
399
+ return {
400
+ id: String(decision.key || `${decision.surface || "inbound"}:${decision.obligationKey || "uncovered"}`),
401
+ title: `${decision.surface || "inbound"}: ${decision.reason || "needs a decision"}`,
402
+ // REDACTION HOLDS. The escalation lane never carries message bodies, room
403
+ // names or addresses — ids, surfaces and machine reasons only. The engagement
404
+ // body is assembled from the decision, exactly as the escalation text is.
405
+ summary: `The execution ladder stopped at \`${decision.reason}\` on ${decision.surface || "an inbound event"} (rung ${decision.rung ?? "?"}).`,
406
+ scope: decision.surface || "",
407
+ state: "blocked",
408
+ blocker: {
409
+ kind: options.length >= 2 ? "decision" : "approval",
410
+ scope: decision.surface || "",
411
+ detail: (decision.why || []).join("; ") || String(decision.reason || ""),
412
+ ownerId: decision.escalateTo || o.escalationTarget || null,
413
+ },
414
+ // Reaching this rung IS the exhaustion of what the agent can do alone.
415
+ selfServeExhausted: true,
416
+ origin: {
417
+ surface: decision.surface || "",
418
+ channelId: ids.channelId || null,
419
+ threadRootId: ids.threadRootId || ids.messageId || null,
420
+ requesterId: ids.fromMemberId || ids.actorId || null,
421
+ },
422
+ question: options.length >= 2 ? "Which way should this go?" : "",
423
+ options,
424
+ tried: (decision.why || []).slice(0, 4),
425
+ };
426
+ }
427
+
341
428
  /**
342
429
  * Append a PROPOSED REACT obligation for an event the compiled plan did not
343
430
  * cover (§6.1: "emit `PlanDrift{uncovered_event}` **and** a proposed REACT
@@ -403,8 +490,48 @@ export function defaultEffects(o = {}) {
403
490
  schedule: async ({ decision, candidate, item }) =>
404
491
  scheduleToQueue(decision, { ...common, candidate, item: item || o.item || null }),
405
492
 
493
+ /**
494
+ * Hand the work to someone else — and TELL them.
495
+ *
496
+ * `sendHandoff` writes an A2A envelope. That is the right machine-to-machine
497
+ * artefact and it is also, on its own, invisible: the journal shows the
498
+ * delegate lane has never once produced `spoke:true`, because an envelope is
499
+ * not a message. A human delegatee learns nothing from it, and an agent
500
+ * delegatee learns it only when it next drains its queue.
501
+ *
502
+ * So the envelope still goes, and the delegatee is also told directly. The
503
+ * engagement judgement is used rather than an unconditional DM, so the
504
+ * dedupe and the rate caps apply here exactly as they do to escalation — a
505
+ * retried handoff must not re-ping.
506
+ */
406
507
  delegate: async ({ decision, candidate }) => {
407
508
  const { sendHandoff } = await import("../org/handoff.mjs");
509
+ const notifyLeg = (async () => {
510
+ if (!decision.delegateTo) return { ok: true, engaged: false, outcome: "no_delegatee" };
511
+ try {
512
+ const eng = o.engageImpl || (await import("../org/engagement.mjs")).engage;
513
+ const work = workFromDecision(decision, candidate, { me: o.me, escalationTarget: o.escalationTarget });
514
+ return await eng(
515
+ {
516
+ ...work,
517
+ title: `Handed to you: ${work.title}`,
518
+ blocker: { ...work.blocker, kind: "decision", ownerId: String(decision.delegateTo) },
519
+ question: "This is now yours — shout if it should come back to me.",
520
+ options: [],
521
+ },
522
+ {
523
+ cfg: o.cfg,
524
+ agentRoot,
525
+ me: o.me || "",
526
+ fetchImpl: o.fetchImpl,
527
+ nowMs: o.nowMs,
528
+ ctx: o.engagementCtx,
529
+ },
530
+ );
531
+ } catch (err) {
532
+ return { ok: false, engaged: false, outcome: "engage_threw", error: (err && err.message) || String(err) };
533
+ }
534
+ })();
408
535
  const r = await sendHandoff({
409
536
  agentRoot,
410
537
  from: o.me || "",
@@ -419,7 +546,12 @@ export function defaultEffects(o = {}) {
419
546
  },
420
547
  ...(o.transport ? { transport: o.transport } : {}),
421
548
  });
422
- return { ok: r && r.ok !== false, ref: r, error: r && r.error ? String(r.error) : null };
549
+ const eng = await notifyLeg;
550
+ return {
551
+ ok: r && r.ok !== false,
552
+ ref: { handoff: r, engagement: engagementRef(eng), spoke: eng.engaged === true },
553
+ error: r && r.error ? String(r.error) : engagementError(eng),
554
+ };
423
555
  },
424
556
 
425
557
  /**
@@ -461,6 +593,30 @@ export function defaultEffects(o = {}) {
461
593
  return { ok: false, ref: null, error: "org not configured (no base) — cannot escalate" };
462
594
  }
463
595
 
596
+ // THE PERSON LEG. Runs regardless of whether the record leg below can find
597
+ // a target, because "escalation.create needs a taskId or channelId and this
598
+ // event carries neither" must no longer mean nobody hears about it. The
599
+ // judgement decides who and where; it is entitled to decide nobody, and it
600
+ // records that decision either way.
601
+ const engageLeg = (async () => {
602
+ try {
603
+ const eng = o.engageImpl || (await import("../org/engagement.mjs")).engage;
604
+ const cfg = o.cfg || { org: { cohort: { enabled: true, base: conn.base, token: conn.token, agentId: o.me } } };
605
+ return await eng(workFromDecision(decision, candidate, { me: o.me, escalationTarget: o.escalationTarget }), {
606
+ cfg,
607
+ agentRoot,
608
+ me: o.me || "",
609
+ supervisorId: o.escalationTarget || undefined,
610
+ fetchImpl: o.fetchImpl,
611
+ nowMs: o.nowMs,
612
+ ctx: o.engagementCtx,
613
+ });
614
+ } catch (err) {
615
+ // FAIL-OPEN IS FINE; SILENT IS NOT.
616
+ return { ok: false, engaged: false, outcome: "engage_threw", error: (err && err.message) || String(err) };
617
+ }
618
+ })();
619
+
464
620
  const surface = decision.surface || "inbound";
465
621
  const ids = (candidate && candidate.ids) || {};
466
622
  const options = (decision.options || [])
@@ -488,10 +644,13 @@ export function defaultEffects(o = {}) {
488
644
  },
489
645
  { ...conn, agentRoot },
490
646
  );
647
+ const eng = await engageLeg;
491
648
  return {
492
- ok: !!r && r.ok !== false,
493
- ref: { method: "escalation.ask", frame: r },
494
- error: r && r.error ? errText(r.error) : null,
649
+ ok: (!!r && r.ok !== false) || eng.engaged === true,
650
+ // `spoke` is read by drive.mjs. It is TRUE only when a message actually
651
+ // reached a person — never merely because a row was written.
652
+ ref: { method: "escalation.ask", frame: r, engagement: engagementRef(eng), spoke: eng.engaged === true },
653
+ error: r && r.error ? errText(r.error) : engagementError(eng),
495
654
  };
496
655
  }
497
656
 
@@ -501,13 +660,32 @@ export function defaultEffects(o = {}) {
501
660
  const taskId = ids.taskId || ids.itemId || null;
502
661
  const channelId = ids.channelId || null;
503
662
  if (!taskId && !channelId) {
663
+ // No RECORD could be raised. That used to be the end of it — an honest
664
+ // error string and a human who never heard. The person leg still ran, so
665
+ // check it before declaring failure.
666
+ const eng = await engageLeg;
667
+ const cannotRaise =
668
+ `escalation.create needs a taskId or channelId and ${surface} event ` +
669
+ `${decision.key} carries neither; escalation.ask needs ≥2 options and ` +
670
+ `the decision offered ${options.length}. No escalation ROW was raised`;
671
+ if (eng.engaged) {
672
+ return {
673
+ ok: true,
674
+ ref: {
675
+ method: "engagement-only",
676
+ engagement: engagementRef(eng),
677
+ spoke: true,
678
+ // Still a degradation: the audit ROW is missing even though the
679
+ // person was reached. Both facts travel together on the outcome.
680
+ degraded: [`escalation_record_unraisable:${decision.key}`],
681
+ },
682
+ error: null,
683
+ };
684
+ }
504
685
  return {
505
686
  ok: false,
506
- ref: null,
507
- error:
508
- `escalation.create needs a taskId or channelId and ${surface} event ` +
509
- `${decision.key} carries neither; escalation.ask needs ≥2 options and ` +
510
- `the decision offered ${options.length}. Nothing was raised.`,
687
+ ref: { engagement: engagementRef(eng), spoke: false },
688
+ error: `${cannotRaise}, and nobody was engaged either (${eng.outcome || "no outcome"}${eng.error ? `: ${eng.error}` : ""}). Nothing reached a human.`,
511
689
  };
512
690
  }
513
691
  const r = await call(
@@ -522,10 +700,11 @@ export function defaultEffects(o = {}) {
522
700
  },
523
701
  { ...conn, agentRoot },
524
702
  );
703
+ const eng = await engageLeg;
525
704
  return {
526
- ok: !!r && r.ok !== false,
527
- ref: { method: "escalation.create", frame: r },
528
- error: r && r.error ? errText(r.error) : null,
705
+ ok: (!!r && r.ok !== false) || eng.engaged === true,
706
+ ref: { method: "escalation.create", frame: r, engagement: engagementRef(eng), spoke: eng.engaged === true },
707
+ error: r && r.error ? errText(r.error) : engagementError(eng),
529
708
  };
530
709
  },
531
710
 
@@ -237,12 +237,16 @@ test("escalate resolves the org binding from the agent root — `agentRoot` alon
237
237
  });
238
238
  {
239
239
  assert.equal(r.ok, true, r.error || "escalate failed");
240
- assert.equal(seen.length, 1, "no request was made");
241
- assert.match(seen[0].url, /escalation\.ask$/, "≥2 options must go to ask, not create");
242
- assert.equal(seen[0].body.options.length, 2);
243
- assert.equal(typeof seen[0].body.context, "string", "hq's `context` is a STRING");
244
- assert.equal(seen[0].body.trigger, "cannot_self_decide");
245
- assert.ok(seen[0].body.question, "hq requires a question");
240
+ // The escalation now has TWO legs — the record, and the person. The
241
+ // person leg reads the directory + rooms first, so the request this test
242
+ // is about is found by name rather than by being the only one.
243
+ const ask = seen.find((s) => /escalation\.ask$/.test(s.url));
244
+ assert.ok(ask, "no escalation.ask request was made");
245
+ assert.equal(ask.body.options.length, 2);
246
+ assert.equal(typeof ask.body.context, "string", "hq's `context` is a STRING");
247
+ assert.equal(ask.body.trigger, "cannot_self_decide");
248
+ assert.ok(ask.body.question, "hq requires a question");
249
+ assert.ok(r.ref.engagement, "the escalation must report what it did about telling a human");
246
250
  }
247
251
  } finally {
248
252
  globalThis.fetch = orig;
@@ -277,11 +281,11 @@ test("fewer than two options falls back to escalation.create WITH a target", asy
277
281
  }
278
282
  });
279
283
 
280
- test("no options AND no target REFUSES loudly rather than 400ing on the wire", async () => {
284
+ test("no options AND no target: no escalation ROW is attempted, and the failure names the human who was not reached", async () => {
281
285
  const root = enrolledRoot();
282
286
  const orig = globalThis.fetch;
283
- let called = 0;
284
- globalThis.fetch = async () => { called += 1; return new Response("{}", { status: 200 }); };
287
+ const urls = [];
288
+ globalThis.fetch = async (url) => { urls.push(String(url)); return new Response("{}", { status: 200 }); };
285
289
  try {
286
290
  const eff = defaultEffects({ agentRoot: root, me: "M-1" });
287
291
  const r = await eff
@@ -289,14 +293,49 @@ test("no options AND no target REFUSES loudly rather than 400ing on the wire", a
289
293
  {
290
294
  assert.equal(r.ok, false);
291
295
  assert.match(r.error, /needs a taskId or channelId/);
292
- assert.match(r.error, /Nothing was raised/);
293
- assert.equal(called, 0, "a call that cannot succeed must not be made");
296
+ assert.match(r.error, /No escalation ROW was raised/);
297
+ // THE POINT OF THE CHANGE. It used to end at "Nothing was raised" — a
298
+ // true statement about a table that told nobody anything. The failure now
299
+ // has to account for the person as well, and only claims total failure
300
+ // when neither leg reached anyone.
301
+ assert.match(r.error, /nobody was engaged either/);
302
+ assert.match(r.error, /Nothing reached a human/);
303
+ assert.equal(r.ref.spoke, false);
304
+ assert.ok(
305
+ urls.every((u) => !/escalation\./.test(u)),
306
+ "an escalation.* call that cannot succeed must still not be made",
307
+ );
294
308
  }
295
309
  } finally {
296
310
  globalThis.fetch = orig;
297
311
  }
298
312
  });
299
313
 
314
+ test("when the record cannot be raised but a PERSON is reached, the escalation succeeds — and says the row is missing", async () => {
315
+ const root = enrolledRoot();
316
+ const orig = globalThis.fetch;
317
+ globalThis.fetch = async () => new Response("{}", { status: 200 });
318
+ try {
319
+ const eff = defaultEffects({
320
+ agentRoot: root,
321
+ me: "M-1",
322
+ // The judgement is injected here; it is proved on its own terms in
323
+ // lib/org/engagement.test.mjs.
324
+ engageImpl: async () => ({ ok: true, engaged: true, outcome: "engaged", surface: "dm", decision: { targets: [{ memberId: "M-2", rung: "supervisory" }], reasonCode: "blocked:approval" } }),
325
+ });
326
+ const r = await eff.escalate({
327
+ decision: { key: "k", surface: "approval", reason: "r", why: [], options: [] },
328
+ candidate: { ids: {} },
329
+ });
330
+ assert.equal(r.ok, true, "a human heard about it — that is not a failure");
331
+ assert.equal(r.ref.spoke, true);
332
+ assert.deepEqual(r.ref.engagement.targets, ["M-2"]);
333
+ assert.deepEqual(r.ref.degraded, ["escalation_record_unraisable:k"]);
334
+ } finally {
335
+ globalThis.fetch = orig;
336
+ }
337
+ });
338
+
300
339
  test("errText renders an RPC error frame as a line, never `[object Object]`", () => {
301
340
  assert.equal(errText({ code: "BAD_REQUEST", message: "title is required" }), "BAD_REQUEST: title is required");
302
341
  assert.equal(errText("plain"), "plain");
@@ -33,6 +33,7 @@
33
33
  "use strict";
34
34
 
35
35
  import { WORK_CREATING_SOURCES } from "../kpi.mjs";
36
+ import { adoptionDefect } from "../mandate/model.mjs";
36
37
 
37
38
  /** Ordered gate names — evaluated in this order, first failure wins. */
38
39
  export const GATES = Object.freeze([
@@ -122,11 +123,22 @@ export function admit(candidate = {}, ctx = {}) {
122
123
  pass("freshness", "mandate cache is fresh");
123
124
 
124
125
  // --- adoption -------------------------------------------------------------
126
+ // The gate's contract is "adopted by SOMEBODY WHO IS NOT ITS OWNER", but for a
127
+ // long time it only tested the state column — so a hand-edited cache with
128
+ // `state:"active"` and no `adoptedById` (or the owner's own id) admitted work.
129
+ // Same law as the compiler; one implementation, in lib/mandate/model.mjs.
125
130
  const obj = ctx.objective || {};
126
131
  if (obj.state !== "active") {
127
132
  return fail("adoption", `objective "${obj.key || "?"}" is ${obj.state || "unknown"}, not active — a proposed objective admits no work`);
128
133
  }
129
- pass("adoption", `objective "${obj.key}" is active`);
134
+ const defect = adoptionDefect(obj, ctx.memberId || obj.ownerMemberId || obj.memberId || null);
135
+ if (defect === "self_adopted") {
136
+ return fail("adoption", `objective "${obj.key || "?"}" was adopted by its own owning seat — a seat cannot define, measure and be graded on the same number`);
137
+ }
138
+ if (defect === "missing_adoption_provenance") {
139
+ return fail("adoption", `objective "${obj.key || "?"}" is active but carries no adoptedById — nobody is on record as having handed this seat the number`);
140
+ }
141
+ pass("adoption", `objective "${obj.key}" is active and adopted by ${obj.adoptedById}`);
130
142
 
131
143
  // --- honesty --------------------------------------------------------------
132
144
  const source = ctx.latestSample && ctx.latestSample.source;
@@ -10,7 +10,10 @@ import assert from "node:assert/strict";
10
10
 
11
11
  import { admit, decideForGap, titleSimilarity, GATES, DUPLICATE_THRESHOLD } from "./admission.mjs";
12
12
 
13
- const OBJ = { key: "pipeline", state: "active", metric: "pipeline_coverage_x", target: 3, cadence: "weekly" };
13
+ // `adoptedById` is load-bearing, not decoration: an active objective with no
14
+ // adopter on record admits no work (the gate's contract has always been "adopted
15
+ // by SOMEBODY WHO IS NOT ITS OWNER", and it used to check only the state column).
16
+ const OBJ = { key: "pipeline", state: "active", metric: "pipeline_coverage_x", target: 3, cadence: "weekly", memberId: "M-OWNER", adoptedById: "M-SUPERVISOR" };
14
17
  const GAP = { gap: 1, target: 3, value: 2, withinTolerance: false, trend: "flat", normalizedGap: 0.33, stale: false };
15
18
  const SAMPLE = { source: "method", value: 2, window: "2026-W33" };
16
19
 
@@ -137,3 +140,25 @@ test("every rejection carries its gate and the full check trail — nothing is d
137
140
  assert.equal(v.checks[v.checks.length - 1].ok, false);
138
141
  assert.equal(v.checks[v.checks.length - 1].gate, v.gate);
139
142
  });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // the adoption gate reads the LAW, not the state column
146
+ // ---------------------------------------------------------------------------
147
+
148
+ test("GATE adoption — an active objective with NO adoptedById admits nothing", () => {
149
+ // The cheap tamper. Forging a supervisor id is work; DELETING the field was one
150
+ // keystroke, and `isSelfAdopted` returned false for a missing field, so the row
151
+ // sailed through both the compiler and this gate.
152
+ const { adoptedById, ...noProvenance } = OBJ;
153
+ const v = admit(candidate(), ctx({ objective: noProvenance }));
154
+ assert.equal(v.admitted, false);
155
+ assert.equal(v.gate, "adoption");
156
+ assert.match(v.reason, /no adoptedById/);
157
+ });
158
+
159
+ test("GATE adoption — a SELF-adopted objective admits nothing", () => {
160
+ const v = admit(candidate(), ctx({ objective: { ...OBJ, adoptedById: "M-OWNER" } }));
161
+ assert.equal(v.admitted, false);
162
+ assert.equal(v.gate, "adoption");
163
+ assert.match(v.reason, /own owning seat/);
164
+ });
@@ -104,6 +104,19 @@ export function renderQueueItems(items) {
104
104
  L.push(` disposition: ${it.disposition || "SELF"}`);
105
105
  L.push(` rung: ${it.rung == null ? "null" : it.rung}`);
106
106
  L.push(` why: "${String((it.why && it.why.reason) || "kpi-gap").replace(/"/g, "'")}"`);
107
+ // The provenance an operator needs to answer "why did the agent do that?"
108
+ // from the queue file alone, with no network and no LLM. `why` used to be
109
+ // flattened to the bare string "kpi-gap", which named the CLASS of reason
110
+ // and none of the facts: which charter clause the item advances, which
111
+ // obligation it discharges, how big the gap was, and — the governance one —
112
+ // who adopted the objective it serves. A machine-created task that cannot
113
+ // say who authorised the number behind it is exactly the thing the mandate
114
+ // split exists to prevent.
115
+ const w = it.why || {};
116
+ if (w.clause) L.push(` charter_clause: ${w.clause}`);
117
+ if (w.adoptedBy) L.push(` adopted_by: ${w.adoptedBy}`);
118
+ if (Number.isFinite(w.gap)) L.push(` gap: ${w.gap}`);
119
+ if (w.trend) L.push(` trend: ${w.trend}`);
107
120
  L.push(` created: ${it.created}`);
108
121
  }
109
122
  return L.join("\n");
@@ -52,6 +52,9 @@ function fakeExec(table, calls = []) {
52
52
  const OBJ = (capability, params = {}) => ({
53
53
  key: "k", kind: "GOAL", state: "active", metric: "m", direction: "up",
54
54
  target: 90, tolerance: 2, cadence: "weekly",
55
+ // `adoptedById` is part of the law the admission gate enforces, not decoration:
56
+ // an active objective with nobody on record as having adopted it admits no work.
57
+ memberId: "M-OWNER", adoptedById: "M-SUPERVISOR",
55
58
  sensor: { capability, params, source: "method" },
56
59
  });
57
60
 
@@ -33,6 +33,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "
33
33
  import { join } from "node:path";
34
34
  import { createHash } from "node:crypto";
35
35
 
36
+ import { adoptedObjectives as modelAdoptedObjectives } from "./model.mjs";
37
+
36
38
  /** Relative path of the cache (git-ignored runtime state). */
37
39
  export const CACHE_REL = join("state", "mandate", "cache.json");
38
40
 
@@ -143,11 +145,17 @@ export function permissionsForTier(tier) {
143
145
  }
144
146
  }
145
147
 
146
- /** Are the adopted objectives in this cache usable to compile obligations? */
147
- export function adoptedObjectives(record) {
148
- const body = (record && record.body) || {};
149
- const list = Array.isArray(body.objectives) ? body.objectives : [];
150
- return list.filter((o) => o && o.state === "active");
148
+ /**
149
+ * Are the adopted objectives in this cache usable to compile obligations?
150
+ *
151
+ * DELEGATES to `lib/mandate/model.adoptedObjectives`, which carries the whole
152
+ * anti-Goodhart law (measurable kind, active, real adoption provenance, not
153
+ * self-adopted). This used to be a second, LAW-FREE implementation that filtered
154
+ * on `state === "active"` alone — so whichever module a caller happened to
155
+ * import decided whether the law applied. One law, one implementation.
156
+ */
157
+ export function adoptedObjectives(record, opts = {}) {
158
+ return modelAdoptedObjectives(record, opts);
151
159
  }
152
160
 
153
161
  /** Every objective in the cache, adopted or not (the proposal surface). */