instar 1.3.800 → 1.3.802

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 (83) hide show
  1. package/dashboard/index.html +25 -0
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +63 -1
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/BackupManager.d.ts.map +1 -1
  6. package/dist/core/BackupManager.js +8 -0
  7. package/dist/core/BackupManager.js.map +1 -1
  8. package/dist/core/PostUpdateMigrator.d.ts +8 -0
  9. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  10. package/dist/core/PostUpdateMigrator.js +45 -0
  11. package/dist/core/PostUpdateMigrator.js.map +1 -1
  12. package/dist/core/ProactiveSwapMonitor.d.ts +10 -0
  13. package/dist/core/ProactiveSwapMonitor.d.ts.map +1 -1
  14. package/dist/core/ProactiveSwapMonitor.js +39 -0
  15. package/dist/core/ProactiveSwapMonitor.js.map +1 -1
  16. package/dist/core/SessionManager.d.ts +26 -4
  17. package/dist/core/SessionManager.d.ts.map +1 -1
  18. package/dist/core/SessionManager.js +76 -22
  19. package/dist/core/SessionManager.js.map +1 -1
  20. package/dist/core/machineCoherenceManifest.d.ts +10 -0
  21. package/dist/core/machineCoherenceManifest.d.ts.map +1 -1
  22. package/dist/core/machineCoherenceManifest.js +58 -0
  23. package/dist/core/machineCoherenceManifest.js.map +1 -1
  24. package/dist/core/types.d.ts +53 -0
  25. package/dist/core/types.d.ts.map +1 -1
  26. package/dist/core/types.js.map +1 -1
  27. package/dist/monitoring/ExternalHogScanTick.d.ts +9 -0
  28. package/dist/monitoring/ExternalHogScanTick.d.ts.map +1 -1
  29. package/dist/monitoring/ExternalHogScanTick.js +29 -0
  30. package/dist/monitoring/ExternalHogScanTick.js.map +1 -1
  31. package/dist/monitoring/PromiseBeacon.d.ts +6 -0
  32. package/dist/monitoring/PromiseBeacon.d.ts.map +1 -1
  33. package/dist/monitoring/PromiseBeacon.js +42 -6
  34. package/dist/monitoring/PromiseBeacon.js.map +1 -1
  35. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  36. package/dist/monitoring/guardManifest.js +31 -0
  37. package/dist/monitoring/guardManifest.js.map +1 -1
  38. package/dist/monitoring/guardPosture.d.ts.map +1 -1
  39. package/dist/monitoring/guardPosture.js +22 -0
  40. package/dist/monitoring/guardPosture.js.map +1 -1
  41. package/dist/monitoring/selfaction/anchor.d.ts +82 -0
  42. package/dist/monitoring/selfaction/anchor.d.ts.map +1 -0
  43. package/dist/monitoring/selfaction/anchor.js +113 -0
  44. package/dist/monitoring/selfaction/anchor.js.map +1 -0
  45. package/dist/monitoring/selfaction/governor.d.ts +242 -0
  46. package/dist/monitoring/selfaction/governor.d.ts.map +1 -0
  47. package/dist/monitoring/selfaction/governor.js +1475 -0
  48. package/dist/monitoring/selfaction/governor.js.map +1 -0
  49. package/dist/monitoring/selfaction/policies.d.ts +86 -0
  50. package/dist/monitoring/selfaction/policies.d.ts.map +1 -0
  51. package/dist/monitoring/selfaction/policies.js +339 -0
  52. package/dist/monitoring/selfaction/policies.js.map +1 -0
  53. package/dist/monitoring/selfaction/types.d.ts +208 -0
  54. package/dist/monitoring/selfaction/types.d.ts.map +1 -0
  55. package/dist/monitoring/selfaction/types.js +11 -0
  56. package/dist/monitoring/selfaction/types.js.map +1 -0
  57. package/dist/scaffold/templates.d.ts.map +1 -1
  58. package/dist/scaffold/templates.js +8 -1
  59. package/dist/scaffold/templates.js.map +1 -1
  60. package/dist/server/AgentServer.d.ts.map +1 -1
  61. package/dist/server/AgentServer.js +6 -0
  62. package/dist/server/AgentServer.js.map +1 -1
  63. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  64. package/dist/server/CapabilityIndex.js +9 -0
  65. package/dist/server/CapabilityIndex.js.map +1 -1
  66. package/dist/server/routes.d.ts +4 -0
  67. package/dist/server/routes.d.ts.map +1 -1
  68. package/dist/server/routes.js +271 -3
  69. package/dist/server/routes.js.map +1 -1
  70. package/dist/testing/selfActionRegistry.d.ts +16 -0
  71. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  72. package/dist/testing/selfActionRegistry.js +16 -0
  73. package/dist/testing/selfActionRegistry.js.map +1 -1
  74. package/package.json +2 -2
  75. package/scripts/lint-emit-without-admit.js +303 -0
  76. package/src/data/builtin-manifest.json +66 -66
  77. package/src/data/state-coherence-registry.json +1296 -1241
  78. package/src/scaffold/templates.ts +8 -1
  79. package/upgrades/1.3.801.md +38 -0
  80. package/upgrades/1.3.802.md +36 -0
  81. package/upgrades/session-listing-hygiene.eli16.md +26 -0
  82. package/upgrades/side-effects/self-action-governor.md +111 -0
  83. package/upgrades/side-effects/session-listing-hygiene.md +93 -0
@@ -0,0 +1,1475 @@
1
+ /**
2
+ * selfaction/governor.ts — the SelfActionGovernor runtime primitive
3
+ * (Increment B of docs/specs/unified-self-action-backpressure.md; the
4
+ * normative companion `unified-self-action-backpressure.companion.md` is the
5
+ * implementation authority).
6
+ *
7
+ * ONE in-process chokepoint every self-triggered, cost- or disruption-bearing
8
+ * action passes through to acquire permission to fire, keyed on a controller
9
+ * id. Ships OBSERVE-ONLY on every class, fleet-dark per the FD1 ladder: in
10
+ * observe mode admit() records would-verdicts and ALWAYS allows; no
11
+ * enforcement behavior is live by default. The enforce flip is the operator's
12
+ * per-class action later (FD8), gated by FD12 — and for pool-shared classes
13
+ * additionally by FD9/FD15 (this increment never enforces pool-shared with
14
+ * registered machine count > 1).
15
+ *
16
+ * This IS the governor module — it rides the usage-scan lint's SELF-SCOPE
17
+ * allowlist (companion INT9-2), never a controller marker.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import crypto from 'node:crypto';
22
+ import { claimSharedState, getAnchor, mintController, } from './anchor.js';
23
+ import { CENSUS_FRACTION_K, DEMOTE_EXHAUSTION_N, EAGER_FLUSH_ADMISSION_DELTA, EAGER_FLUSH_DEBOUNCE_MS, ERRORED_AUDIT_FIRST_N, FLIP_EPISODE_LATCH_WINDOW_MS, MAX_READMIT_CYCLES, OBSERVE_LIMBO_DAYS, PRINCIPAL_VOLUME_THRESHOLD, PRINCIPAL_VOLUME_WINDOW_MS, RATE_FLOOR_MS_CODE_FLOOR, RESPAWN_RECOVERY_LANE_MEMBERS, WINDOW_BUCKETS, censusAbsoluteMax, defaultPolicyFor, lastResortFloorPerWindow, resolvePolicies, } from './policies.js';
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // The governor implementation
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ const SNAPSHOT_FILE = 'self-action-governor.json';
28
+ const AGGREGATES_FILE = 'self-action-governor-aggregates.json';
29
+ const AUDIT_FILE = 'self-action-governor-audit.jsonl';
30
+ const AUDIT_MAX_ROWS = 5_000;
31
+ function freshWindow(nowMs, windowMs) {
32
+ const width = Math.max(1, Math.floor(windowMs / WINDOW_BUCKETS));
33
+ return {
34
+ bucketStartMs: nowMs,
35
+ bucketWidthMs: width,
36
+ buckets: new Array(WINDOW_BUCKETS).fill(0),
37
+ head: 0,
38
+ rehydratedCarry: 0,
39
+ };
40
+ }
41
+ function freshClassState(policy, nowMs) {
42
+ return {
43
+ policy,
44
+ mode: 'observe',
45
+ preDemoteMode: 'observe',
46
+ window: freshWindow(nowMs, policy.windowMs),
47
+ perTarget: new Map(),
48
+ windowTotal: 0,
49
+ rate: { tokens: Number.isFinite(policy.rateBucket.ratePerWindow) ? policy.rateBucket.ratePerWindow : Number.POSITIVE_INFINITY, lastRefillMs: nowMs },
50
+ breaker: { failures: 0, openUntilMs: 0, openedCount: 0, lastTransitionMs: 0 },
51
+ demote: { demoted: false, episodeId: null, failedCooldowns: 0, cooldownEndsMs: 0, alarmed: false },
52
+ queue: new Map(),
53
+ errored: { open: false, id: null, verbatimRows: 0, aggregated: 0, openedMs: 0 },
54
+ floorWindowStartMs: nowMs,
55
+ floorCount: 0,
56
+ reliefCeiling: Number.isFinite(policy.totalCountCeiling) ? policy.totalCountCeiling : Number.POSITIVE_INFINITY,
57
+ lastEmitMs: Number.NEGATIVE_INFINITY,
58
+ counters: { admits: 0, coalesces: 0, queues: 0, wouldDeny: 0, denies: 0, erroredOpens: 0 },
59
+ bySubMechanism: new Map(),
60
+ rehydratedWindow: false,
61
+ criterionMetSinceMs: null,
62
+ limboNudged: false,
63
+ overridden: null,
64
+ shedThisWindow: { count: 0, classes: new Set(), oldestMs: 0, noticed: false },
65
+ };
66
+ }
67
+ export class SelfActionGovernorCore {
68
+ state;
69
+ role;
70
+ constructor() {
71
+ const claim = claimSharedState(() => ({
72
+ initializedAtMs: Date.now(),
73
+ classes: new Map(),
74
+ tokens: new Map(),
75
+ auditBuffer: [],
76
+ auditFlushedRows: 0,
77
+ census: null,
78
+ lastDisableState: null,
79
+ flipEpisode: { id: null, windowStartMs: 0, flips: 0, noticed: false },
80
+ principal: new Map(),
81
+ unflushedDelta: 0,
82
+ lastEagerFlushMs: 0,
83
+ firstPostRehydrateFlushDone: true, // becomes false after a rehydrate at init
84
+ halfCeilingFlushedWindow: new Map(),
85
+ priorFlushEvidence: false,
86
+ conservativeUntilMs: new Map(),
87
+ flushTimer: null,
88
+ flusherOwned: false,
89
+ deps: null,
90
+ machineCountCache: 1,
91
+ disposed: false,
92
+ }));
93
+ this.state = claim.state;
94
+ this.role = claim.role;
95
+ const anchor = getAnchor();
96
+ if (!anchor.onMintCollision) {
97
+ anchor.onMintCollision = (controllerId) => {
98
+ this.audit({ ts: this.nowIso(), type: 'mint-collision', controllerId, detail: 'duplicate governor.for() mint — losing handle is dead (controller-scoped errored posture)' });
99
+ };
100
+ }
101
+ }
102
+ // ── Init / lifecycle ──────────────────────────────────────────────────────
103
+ /**
104
+ * INIT-ONCE (companion §5.3): the FIRST initializer rehydrates the durable
105
+ * snapshot and owns the single flush loop; a later init call ATTACHES —
106
+ * never re-initializes, never starts a second flusher.
107
+ */
108
+ init(deps) {
109
+ if (this.state.deps) {
110
+ return 'attached';
111
+ }
112
+ this.state.deps = deps;
113
+ this.loadPolicies();
114
+ this.rehydrate();
115
+ if (!this.state.flusherOwned) {
116
+ this.state.flusherOwned = true;
117
+ const interval = deps.flushIntervalMs ?? 60_000;
118
+ this.state.flushTimer = setInterval(() => this.slowTick(), interval);
119
+ this.state.flushTimer.unref?.();
120
+ }
121
+ return 'initialized';
122
+ }
123
+ /** Graceful shutdown — flush + stop the flusher (FD14 graceful-shutdown flush). */
124
+ dispose() {
125
+ if (this.state.flushTimer) {
126
+ clearInterval(this.state.flushTimer);
127
+ this.state.flushTimer = null;
128
+ }
129
+ if (this.state.deps && !this.state.disposed) {
130
+ try {
131
+ this.flushSnapshot(true);
132
+ this.flushAudit();
133
+ }
134
+ catch {
135
+ /* shutdown flush is best-effort */
136
+ }
137
+ }
138
+ this.state.disposed = true;
139
+ }
140
+ now() {
141
+ return this.state.deps?.now?.() ?? Date.now();
142
+ }
143
+ nowIso() {
144
+ return new Date(this.now()).toISOString();
145
+ }
146
+ loadPolicies() {
147
+ const deps = this.state.deps;
148
+ const classesCfg = (() => {
149
+ try {
150
+ return deps?.readClassesConfig();
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ })();
156
+ const resolved = resolvePolicies(classesCfg, () => this.nowIso());
157
+ for (const row of resolved.auditRows)
158
+ this.audit(row);
159
+ const nowMs = this.now();
160
+ for (const [id, policy] of resolved.policies) {
161
+ let cls = this.state.classes.get(id);
162
+ if (!cls) {
163
+ cls = freshClassState(policy, nowMs);
164
+ this.state.classes.set(id, cls);
165
+ }
166
+ else {
167
+ cls.policy = policy;
168
+ }
169
+ cls.overridden = resolved.overridden.get(id) ?? null;
170
+ const modeOverride = resolved.modeOverrides.get(id);
171
+ if (modeOverride && modeOverride !== cls.preDemoteMode) {
172
+ cls.preDemoteMode = modeOverride;
173
+ if (!cls.demote.demoted) {
174
+ const prior = cls.mode;
175
+ cls.mode = modeOverride;
176
+ if (prior !== cls.mode) {
177
+ this.audit({ ts: this.nowIso(), type: 'class-enforce-flip', controllerId: id, detail: `${prior} -> ${cls.mode}` });
178
+ }
179
+ }
180
+ }
181
+ }
182
+ this.applyPoolShareGate();
183
+ }
184
+ /** FD9 level gate: a pool-shared class may not ENFORCE when the REGISTERED
185
+ * machine count > 1 (no pool-wide ceiling in this increment). Evaluated on
186
+ * the slow tick / registration change — never per-admit. */
187
+ applyPoolShareGate() {
188
+ const n = (() => {
189
+ try {
190
+ return this.state.deps?.registeredMachineCount?.() ?? 1;
191
+ }
192
+ catch {
193
+ return this.state.machineCountCache;
194
+ }
195
+ })();
196
+ this.state.machineCountCache = n;
197
+ for (const [id, cls] of this.state.classes) {
198
+ if (cls.policy.resource !== 'pool-shared')
199
+ continue;
200
+ if (cls.mode === 'enforce' && n > 1) {
201
+ cls.mode = 'demoted';
202
+ this.audit({
203
+ ts: this.nowIso(),
204
+ type: 'auto-demote-pool-gate',
205
+ controllerId: id,
206
+ detail: `registered machine count ${n} > 1 — pool-shared enforce auto-demotes to observe until the pool-wide ceiling exists (FD9)`,
207
+ });
208
+ }
209
+ else if (cls.mode === 'demoted' && cls.preDemoteMode === 'enforce' && n <= 1 && !cls.demote.demoted) {
210
+ // Re-promote ONLY on genuine de-enrollment (registered count back to 1).
211
+ cls.mode = 'enforce';
212
+ this.audit({ ts: this.nowIso(), type: 'class-enforce-flip', controllerId: id, detail: 'de-enrollment re-promotes the N=1 carve-out (FD9)' });
213
+ }
214
+ }
215
+ }
216
+ // ── Handles (companion §2) ───────────────────────────────────────────────
217
+ /**
218
+ * Mint the per-controller handle — module scope, once per controller file.
219
+ * A duplicate mint (dual-load / copy-pasted marker) yields a DEAD handle
220
+ * whose admits resolve through the per-class fail direction, loudly.
221
+ */
222
+ for(controllerId) {
223
+ const mint = mintController(controllerId);
224
+ if (!mint.ok) {
225
+ return new SelfActionHandle(this, controllerId, /* dead */ true);
226
+ }
227
+ return new SelfActionHandle(this, controllerId, false);
228
+ }
229
+ // ── Emergency-disable live read + flip episode (spec ADV5-9) ─────────────
230
+ /** Cache guard so a per-admit live read can never become per-admit I/O
231
+ * (the injected reader may consult a disk-backed liveConfig). */
232
+ disableReadCache = null;
233
+ emergencyDisabled() {
234
+ const nowMs = Date.now();
235
+ if (this.disableReadCache && nowMs - this.disableReadCache.atMs < 1_000) {
236
+ return this.disableReadCache.value;
237
+ }
238
+ let disabled = false;
239
+ try {
240
+ disabled = this.state.deps?.readEmergencyDisable() === true;
241
+ }
242
+ catch {
243
+ disabled = false;
244
+ }
245
+ this.disableReadCache = { atMs: nowMs, value: disabled };
246
+ const prev = this.state.lastDisableState;
247
+ if (prev !== null && prev !== disabled)
248
+ this.onDisableFlip(disabled);
249
+ this.state.lastDisableState = disabled;
250
+ return disabled;
251
+ }
252
+ onDisableFlip(nowDisabled) {
253
+ const nowMs = this.now();
254
+ this.audit({
255
+ ts: this.nowIso(),
256
+ type: 'emergency-disable-flip',
257
+ detail: `emergencyDisable -> ${nowDisabled} (principal: unknown(config))`,
258
+ });
259
+ const ep = this.state.flipEpisode;
260
+ if (!ep.id || nowMs - ep.windowStartMs > FLIP_EPISODE_LATCH_WINDOW_MS) {
261
+ ep.id = `flip-${nowMs}`;
262
+ ep.windowStartMs = nowMs;
263
+ ep.flips = 0;
264
+ ep.noticed = false;
265
+ }
266
+ ep.flips += 1;
267
+ // Episode-latched item: N flips within the window collapse to ONE item.
268
+ // HIGH on the DISABLE direction (the dangerous one), routine on re-enable.
269
+ if (!ep.noticed || nowDisabled) {
270
+ this.notify({
271
+ id: `agent:self-action-governor:flip:${ep.id}`,
272
+ title: nowDisabled
273
+ ? 'SelfActionGovernor emergencyDisable is ON — the flood brake is disarmed'
274
+ : 'SelfActionGovernor re-enabled',
275
+ body: `emergencyDisable flipped to ${nowDisabled} (${ep.flips} flip(s) in this episode window). A disabled capacity guard is itself an incident (2026-06-05 lesson).`,
276
+ priority: nowDisabled ? 'HIGH' : 'NORMAL',
277
+ sourceContext: 'self-action-governor',
278
+ });
279
+ ep.noticed = true;
280
+ }
281
+ }
282
+ // ── Admission core ───────────────────────────────────────────────────────
283
+ admitFor(controllerId, dead, target, opts) {
284
+ const nowMs = opts?.nowMs ?? this.now();
285
+ // Kill-switch / dark path: unconditional allow-token pass-through — the
286
+ // ONLY unconditional allow-token path (FD2).
287
+ if (!this.state.deps || this.emergencyDisabled()) {
288
+ return this.allow(controllerId, target, nowMs, 'disabled-passthrough');
289
+ }
290
+ const cls = this.classFor(controllerId, nowMs);
291
+ if (dead) {
292
+ // Dead handle (mint collision): controller-scoped errored posture — the
293
+ // losing claimant resolves through the per-class fail direction.
294
+ return this.failDisposition(cls, target, opts, nowMs, new Error('dead handle (mint-collision)'));
295
+ }
296
+ try {
297
+ const admission = this.evaluate(cls, target, opts, nowMs);
298
+ this.closeErroredEpisode(cls, nowMs);
299
+ return admission;
300
+ }
301
+ catch (err) {
302
+ return this.failDisposition(cls, target, opts, nowMs, err);
303
+ }
304
+ }
305
+ classFor(controllerId, nowMs) {
306
+ let cls = this.state.classes.get(controllerId);
307
+ if (!cls) {
308
+ cls = freshClassState(defaultPolicyFor(controllerId), nowMs);
309
+ this.state.classes.set(controllerId, cls);
310
+ }
311
+ return cls;
312
+ }
313
+ /** Test seam: force the NEXT evaluations for a controller to throw (the
314
+ * fail-matrix fixtures need a genuinely-throwing admit path). */
315
+ throwOnAdmit = new Set();
316
+ setThrowOnAdmitForTest(controllerId, on) {
317
+ if (on)
318
+ this.throwOnAdmit.add(controllerId);
319
+ else
320
+ this.throwOnAdmit.delete(controllerId);
321
+ }
322
+ /** The policy evaluation — the WOULD-verdict machinery (all modes). */
323
+ evaluate(cls, target, opts, nowMs) {
324
+ const p = cls.policy;
325
+ if (this.throwOnAdmit.has(p.controllerId)) {
326
+ throw new Error(`injected admit failure for ${p.controllerId} (test seam)`);
327
+ }
328
+ // respawn-recovery lane: NO blocking bound anywhere — always an allow
329
+ // token; give-up is delegated (ResumeQueue cap / reconciler P19).
330
+ if (p.lane === 'respawn-recovery') {
331
+ if (!RESPAWN_RECOVERY_LANE_MEMBERS.has(p.controllerId)) {
332
+ // A non-member claiming the lane is a build failure (lint); at runtime
333
+ // fail toward the conservative default policy path — but never throw.
334
+ this.bump(cls, 'lane-floor');
335
+ }
336
+ this.recordAdmit(cls, target, nowMs);
337
+ return this.allow(p.controllerId, target, nowMs, 'lane-floor', 'respawn-recovery lane (unbounded by design; delegated give-up)');
338
+ }
339
+ this.rollWindow(cls, nowMs);
340
+ // Eternal sentinel: rate-floored, never count-bounded (FD7).
341
+ if (p.eternalSentinel) {
342
+ const floor = Math.max(p.eternalSentinel.rateFloorMs, RATE_FLOOR_MS_CODE_FLOOR);
343
+ if (nowMs - cls.lastEmitMs < floor) {
344
+ return this.nonAllow(cls, target, opts, nowMs, 'lane-floor', `rate floor ${floor}ms not elapsed`);
345
+ }
346
+ this.recordAdmit(cls, target, nowMs);
347
+ cls.lastEmitMs = nowMs;
348
+ return this.allow(p.controllerId, target, nowMs, 'admitted');
349
+ }
350
+ // Deciding-layer checks, in order. First deny names the layer.
351
+ let deny = null;
352
+ // 1. Breaker.
353
+ if (cls.breaker.openUntilMs > nowMs) {
354
+ deny = { sub: 'breaker', detail: 'P19 breaker open' };
355
+ }
356
+ // 2. Stale projection (amplifying classes; one-way tightener). Applies
357
+ // only when a projection SOURCE is wired: a configured-but-stale/
358
+ // unavailable reading denies (deny-on-stale, never widen); an UNWIRED
359
+ // projection is simply not a dimension — the tightener can only ever
360
+ // TIGHTEN the base count+rate+breaker bound, never replace it.
361
+ if (!deny && p.direction === 'amplifying' && p.staleTtlMs !== undefined && typeof p.amplifying?.projectPressure === 'function') {
362
+ const reading = this.safeProjection(p);
363
+ if (reading === null || nowMs - reading.asOf > p.staleTtlMs || reading.confidence === 'low') {
364
+ deny = { sub: 'stale-projection', detail: 'pressure reading stale/unavailable — deny-on-stale (never widen)' };
365
+ }
366
+ }
367
+ // 3. Per-target ceiling — accounted on the EFFECTIVE matching key: an
368
+ // incarnation-volatile key collapses onto its pressure-stable classId
369
+ // (the ExternalHogKillLedger triple; spec ADV3-M1(2)), so respawns
370
+ // (new pid, same signature) hit the SAME ceiling entry.
371
+ const effectiveKey = target.keyIsVolatile ? `class:${target.classId}` : target.key;
372
+ let entry = cls.perTarget.get(effectiveKey);
373
+ if (entry && nowMs - entry.lastHitMs > p.perTargetEvict.ttlMs) {
374
+ // Recency decay (spec CX2): a settled/idle target's count expires — but
375
+ // an ACTIVELY-hit at-ceiling entry keeps refreshing and never decays.
376
+ cls.perTarget.delete(effectiveKey);
377
+ entry = undefined;
378
+ }
379
+ if (!deny && entry && entry.count >= p.perTargetCountCeiling) {
380
+ deny = { sub: 'per-target-ceiling', detail: `per-target ceiling ${p.perTargetCountCeiling} reached` };
381
+ }
382
+ // 4. Fan-out corner: full map + NEW distinct target ⇒ fail closed (no
383
+ // eviction of active entries — spec SC3-m2).
384
+ if (!deny && !entry && cls.perTarget.size >= p.perTargetEvict.maxEntries) {
385
+ const evicted = this.evictSettled(cls, nowMs);
386
+ if (!evicted)
387
+ deny = { sub: 'per-target-ceiling', detail: 'per-target map full with no evictable entry' };
388
+ }
389
+ // 5. Total ceiling (census-scaled for relief; conservative posture window
390
+ // after a state-reset pins the static floor).
391
+ if (!deny) {
392
+ const conservativeUntil = this.state.conservativeUntilMs.get(p.controllerId) ?? 0;
393
+ const ceiling = p.direction === 'relief' && p.censusScaled && conservativeUntil <= nowMs
394
+ ? cls.reliefCeiling
395
+ : Number.isFinite(p.totalCountCeiling)
396
+ ? p.totalCountCeiling
397
+ : Number.POSITIVE_INFINITY;
398
+ const total = this.windowCount(cls) + cls.window.rehydratedCarry;
399
+ if (total >= ceiling) {
400
+ const sub = p.direction === 'relief' && p.censusScaled && ceiling !== p.totalCountCeiling ? 'census-scale' : 'total-ceiling';
401
+ deny = { sub, detail: `window total ${total} >= ceiling ${ceiling}` };
402
+ }
403
+ }
404
+ // 6. Rate bucket (relief effectiveness only relaxes RATE — count floors
405
+ // above always bind).
406
+ if (!deny && Number.isFinite(p.rateBucket.ratePerWindow)) {
407
+ this.refillRate(cls, nowMs);
408
+ if (cls.rate.tokens < 1) {
409
+ deny = { sub: 'rate-bucket', detail: 'rate bucket exhausted' };
410
+ }
411
+ }
412
+ if (!deny) {
413
+ // Clean allow.
414
+ if (Number.isFinite(p.rateBucket.ratePerWindow))
415
+ cls.rate.tokens -= 1;
416
+ this.recordAdmit(cls, target, nowMs);
417
+ const reason = cls.window.rehydratedCarry > 0 ? 'rehydrated-window' : 'admitted';
418
+ return this.allow(p.controllerId, target, nowMs, reason, cls.window.rehydratedCarry > 0 ? 'window includes rehydrated pre-restart admissions' : undefined);
419
+ }
420
+ return this.nonAllow(cls, target, opts, nowMs, deny.sub, deny.detail);
421
+ }
422
+ /** Map a policy deny to the three-way disposition per mode + class shape. */
423
+ nonAllow(cls, target, opts, nowMs, sub, detail) {
424
+ this.bump(cls, sub);
425
+ if (cls.mode !== 'enforce') {
426
+ // OBSERVE / DEMOTED: record the would-deny, always allow (FD1).
427
+ cls.counters.wouldDeny += 1;
428
+ this.recordAdmit(cls, target, nowMs);
429
+ const carry = cls.window.rehydratedCarry > 0 ? ' (rehydrated-window)' : '';
430
+ return this.allow(cls.policy.controllerId, target, nowMs, 'observe-would-deny', `${sub}: ${detail}${carry}`);
431
+ }
432
+ cls.counters.denies += 1;
433
+ // Enforce: neutral/notify folds (coalesce); everything else queues.
434
+ if (cls.policy.direction === 'neutral' || cls.policy.failDirection === 'open-coalesce') {
435
+ cls.counters.coalesces += 1;
436
+ return { outcome: 'coalesce', reason: sub, detail };
437
+ }
438
+ return this.enqueue(cls, target, opts, nowMs, sub, detail);
439
+ }
440
+ /** Fail matrix (companion §6): the disposition when admit() itself THROWS. */
441
+ failDisposition(cls, target, opts, nowMs, err) {
442
+ const p = cls.policy;
443
+ this.openErroredEpisode(cls, nowMs, err);
444
+ this.auditErrored(cls, err); // rate-bounded: first-N verbatim, then aggregated
445
+ if (p.lane === 'respawn-recovery' || p.failDirection === 'open-unconditional') {
446
+ return this.allow(p.controllerId, target, nowMs, 'errored-open', 'respawn-recovery fails OPEN unconditionally');
447
+ }
448
+ if (cls.mode !== 'enforce') {
449
+ // Observe mode never blocks — an errored governor in observe still allows.
450
+ return this.allow(p.controllerId, target, nowMs, 'errored-open', 'observe mode');
451
+ }
452
+ switch (p.failDirection) {
453
+ case 'open-coalesce':
454
+ cls.counters.coalesces += 1;
455
+ return { outcome: 'coalesce', reason: 'errored-open', detail: 'governor error — disruption-only class coalesces' };
456
+ case 'open-audited': {
457
+ // Non-recovery relief: OPEN-with-audit, paced by the POLICY-FREE
458
+ // last-resort floor (self-origin only; reads no config).
459
+ if (nowMs - cls.floorWindowStartMs > p.windowMs) {
460
+ cls.floorWindowStartMs = nowMs;
461
+ cls.floorCount = 0;
462
+ }
463
+ cls.floorCount += 1;
464
+ const floor = lastResortFloorPerWindow(p.controllerId);
465
+ if (cls.floorCount > floor) {
466
+ this.bump(cls, 'lane-floor');
467
+ return { outcome: 'queue', reason: 'lane-floor', retryAfterMs: 5_000, detail: `last-resort errored floor ${floor}/window exceeded` };
468
+ }
469
+ return this.allow(p.controllerId, target, nowMs, 'errored-open', 'relief fails open-with-audit');
470
+ }
471
+ case 'closed-queue':
472
+ default:
473
+ // Cost/safety: CLOSED-to-QUEUE — never allow, never strand. The
474
+ // enqueue path is failure-minimal (pre-allocated, policy-free).
475
+ return this.minimalEnqueue(cls, target, opts, nowMs);
476
+ }
477
+ }
478
+ // ── Queue (companion §5.4) ───────────────────────────────────────────────
479
+ enqueue(cls, target, opts, nowMs, sub, detail) {
480
+ const existing = cls.queue.get(target.key);
481
+ if (existing) {
482
+ existing.coalescedCount += 1;
483
+ cls.counters.queues += 1;
484
+ return { outcome: 'queue', reason: sub, retryAfterMs: this.retryAfter(cls), detail: `${detail} (coalesced)` };
485
+ }
486
+ if (cls.queue.size >= cls.policy.queueMaxTargets) {
487
+ // Distinct-target overflow: the SAME loud dead-letter shed as
488
+ // maxReadmitCycles exhaustion (spec SC5-1).
489
+ this.recordShed(cls, nowMs, 'queue-overflow');
490
+ this.bump(cls, 'queue-full');
491
+ return { outcome: 'queue', reason: 'queue-full', retryAfterMs: this.retryAfter(cls), detail: 'distinct-target queue ceiling — intent shed loudly (level-triggered condition re-fires)' };
492
+ }
493
+ cls.queue.set(target.key, {
494
+ target,
495
+ enqueuedMs: nowMs,
496
+ incarnation: opts?.incarnation,
497
+ eligible: opts?.eligible,
498
+ onAdmitted: opts?.onAdmitted,
499
+ lane: opts?.lane ?? 'job',
500
+ readmitCycles: 0,
501
+ coalescedCount: 1,
502
+ });
503
+ cls.counters.queues += 1;
504
+ return { outcome: 'queue', reason: sub, retryAfterMs: this.retryAfter(cls), detail };
505
+ }
506
+ /** Failure-minimal enqueue (spec SC5-3): no policy evaluation, no I/O; a
507
+ * double failure is an AUDITED drop folded into the dead-letter notice as
508
+ * the `enqueue-drop` class. */
509
+ minimalEnqueue(cls, target, opts, nowMs) {
510
+ try {
511
+ const existing = cls.queue.get(target.key);
512
+ if (!existing && cls.queue.size < cls.policy.queueMaxTargets) {
513
+ cls.queue.set(target.key, {
514
+ target,
515
+ enqueuedMs: nowMs,
516
+ incarnation: opts?.incarnation,
517
+ eligible: opts?.eligible,
518
+ onAdmitted: opts?.onAdmitted,
519
+ lane: opts?.lane ?? 'job',
520
+ readmitCycles: 0,
521
+ coalescedCount: 1,
522
+ });
523
+ }
524
+ else if (existing) {
525
+ existing.coalescedCount += 1;
526
+ }
527
+ else {
528
+ this.recordShed(cls, nowMs, 'enqueue-drop');
529
+ }
530
+ cls.counters.queues += 1;
531
+ return { outcome: 'queue', reason: 'errored-open', retryAfterMs: 5_000, detail: 'governor error — cost/safety class fails CLOSED-to-QUEUE' };
532
+ }
533
+ catch {
534
+ try {
535
+ this.audit({ ts: this.nowIso(), type: 'enqueue-drop', controllerId: cls.policy.controllerId, detail: 'double failure — audited drop (defined terminal)' });
536
+ this.recordShed(cls, nowMs, 'enqueue-drop');
537
+ }
538
+ catch {
539
+ /* the terminal is defined even when audit fails */
540
+ }
541
+ return { outcome: 'queue', reason: 'errored-open', retryAfterMs: 5_000, detail: 'enqueue double-failure — audited drop' };
542
+ }
543
+ }
544
+ retryAfter(cls) {
545
+ return Math.max(1_000, Math.floor(cls.policy.windowMs / WINDOW_BUCKETS));
546
+ }
547
+ /**
548
+ * Drain: re-admit + re-project + re-run the controller's eligibility
549
+ * predicate + incarnation-fence check. BOTH-unavailable = audited drop.
550
+ * Fairness: interactive before jobs, FIFO within (age-based promotion).
551
+ */
552
+ drainQueues(nowMs = this.now()) {
553
+ for (const [id, cls] of this.state.classes) {
554
+ if (cls.queue.size === 0)
555
+ continue;
556
+ const intents = [...cls.queue.entries()].sort((a, b) => {
557
+ if (a[1].lane !== b[1].lane)
558
+ return a[1].lane === 'interactive' ? -1 : 1;
559
+ return a[1].enqueuedMs - b[1].enqueuedMs;
560
+ });
561
+ for (const [key, intent] of intents) {
562
+ intent.readmitCycles += 1;
563
+ if (intent.readmitCycles > MAX_READMIT_CYCLES) {
564
+ cls.queue.delete(key);
565
+ this.recordShed(cls, nowMs, 'readmit-exhausted');
566
+ continue;
567
+ }
568
+ // Incarnation fence + eligibility predicate.
569
+ let eligible = null;
570
+ try {
571
+ eligible = intent.eligible ? intent.eligible() : null;
572
+ }
573
+ catch {
574
+ eligible = null;
575
+ }
576
+ const fencePresent = intent.incarnation !== undefined;
577
+ if (!fencePresent && eligible === null) {
578
+ // BOTH safety legs unavailable: audited drop, never fire-blind.
579
+ cls.queue.delete(key);
580
+ this.audit({ ts: this.nowIso(), type: 'queue-drain-drop', controllerId: id, detail: 'no incarnation fence AND eligibility un-evaluable — audited drop' });
581
+ continue;
582
+ }
583
+ if (eligible === false) {
584
+ cls.queue.delete(key);
585
+ this.audit({ ts: this.nowIso(), type: 'queue-drain-drop', controllerId: id, detail: 'eligibility predicate no longer holds' });
586
+ continue;
587
+ }
588
+ const admission = this.admitFor(id, false, intent.target, {
589
+ incarnation: intent.incarnation,
590
+ nowMs,
591
+ lane: intent.lane,
592
+ });
593
+ if (admission.outcome === 'allow') {
594
+ cls.queue.delete(key);
595
+ try {
596
+ intent.onAdmitted?.(admission.token);
597
+ }
598
+ catch {
599
+ /* the controller's fire callback failing is its own concern */
600
+ }
601
+ }
602
+ }
603
+ }
604
+ }
605
+ /** Drain-time fence check used by retrofit sites that re-derive targets. */
606
+ fenceMatches(queued, current) {
607
+ if (queued === undefined || current === undefined)
608
+ return false;
609
+ return queued === current;
610
+ }
611
+ recordShed(cls, nowMs, shedClass) {
612
+ const shed = cls.shedThisWindow;
613
+ shed.count += 1;
614
+ shed.classes.add(shedClass);
615
+ if (shed.oldestMs === 0)
616
+ shed.oldestMs = nowMs;
617
+ this.audit({ ts: this.nowIso(), type: 'dead-letter-shed', controllerId: cls.policy.controllerId, detail: shedClass });
618
+ if (!shed.noticed) {
619
+ shed.noticed = true;
620
+ // ONE coalesced notice per (controller, window); swap sheds are HIGH.
621
+ const high = cls.policy.controllerId === 'proactive-swap-monitor';
622
+ this.notify({
623
+ id: `agent:self-action-governor:shed:${cls.policy.controllerId}:${Math.floor(nowMs / cls.policy.windowMs)}`,
624
+ title: `Self-action intents shed for ${cls.policy.controllerId}`,
625
+ body: `Dead-letter shed on ${cls.policy.controllerId}: ${shed.count} intent(s) this window (classes: ${[...shed.classes].join(', ')}; oldest ${new Date(shed.oldestMs).toISOString()}). Level-triggered conditions re-fire on their own; a shed is loud, not silent.`,
626
+ priority: high ? 'HIGH' : 'NORMAL',
627
+ sourceContext: 'self-action-governor',
628
+ });
629
+ }
630
+ }
631
+ // ── Tokens (FD6) ─────────────────────────────────────────────────────────
632
+ allow(controllerId, target, nowMs, reason, detail) {
633
+ const nonce = crypto.randomBytes(8).toString('hex');
634
+ const id = `sag-${nonce}-${crypto.randomBytes(4).toString('hex')}`;
635
+ const ttl = this.state.deps?.tokenTtlMs ?? 60_000;
636
+ this.state.tokens.set(id, {
637
+ controllerId,
638
+ targetKey: target.key,
639
+ classId: target.classId,
640
+ nonce,
641
+ expiresAtMs: nowMs + ttl,
642
+ consumed: false,
643
+ });
644
+ // Bound the token map (consumed/expired entries pruned on the slow tick;
645
+ // hard cap here for storm safety).
646
+ if (this.state.tokens.size > 4_096) {
647
+ for (const [tid, rec] of this.state.tokens) {
648
+ if (rec.consumed || rec.expiresAtMs < nowMs)
649
+ this.state.tokens.delete(tid);
650
+ if (this.state.tokens.size <= 2_048)
651
+ break;
652
+ }
653
+ }
654
+ const cls = this.state.classes.get(controllerId);
655
+ if (cls)
656
+ this.bump(cls, reason);
657
+ const token = { id };
658
+ return detail ? { outcome: 'allow', token, reason, detail } : { outcome: 'allow', token, reason };
659
+ }
660
+ /**
661
+ * Runtime consume-once at the protected sink — the AUTHORITY (FD6/CX4).
662
+ * The sink pins its expected controllerId module-side; a token minted for
663
+ * any other controller is rejected. In OBSERVE mode a rejection is recorded
664
+ * but the sink PROCEEDS (signal-only — observe never blocks); in ENFORCE
665
+ * mode `proceed: false`.
666
+ */
667
+ consumeToken(token, expectedControllerId, opts) {
668
+ const nowMs = opts?.nowMs ?? this.now();
669
+ const cls = this.state.classes.get(expectedControllerId);
670
+ const enforcing = cls?.mode === 'enforce' && !this.emergencyDisabled() && this.state.deps !== null;
671
+ const reject = (reason) => {
672
+ if (cls)
673
+ cls.counters.denies += enforcing ? 1 : 0;
674
+ return { proceed: !enforcing, valid: false, reason };
675
+ };
676
+ if (!token || typeof token.id !== 'string')
677
+ return reject('missing token');
678
+ const rec = this.state.tokens.get(token.id);
679
+ if (!rec)
680
+ return reject('unknown token');
681
+ if (rec.consumed)
682
+ return reject('token already consumed');
683
+ if (rec.expiresAtMs < nowMs)
684
+ return reject('token expired');
685
+ if (rec.controllerId !== expectedControllerId)
686
+ return reject(`token minted for ${rec.controllerId}, sink expects ${expectedControllerId}`);
687
+ if (opts?.targetKey !== undefined && rec.targetKey !== opts.targetKey)
688
+ return reject('token target mismatch');
689
+ rec.consumed = true;
690
+ return { proceed: true, valid: true };
691
+ }
692
+ // ── Principal lane (FD13 / companion §4) ─────────────────────────────────
693
+ /**
694
+ * Privileged, SEPARATE API — importable ONLY by the enumerated
695
+ * provenance-setting modules (lint-enforced). ALWAYS allow + per-admit
696
+ * audit row (the ONE deliberate per-event carve-out); NEVER paced — exempt
697
+ * from ceilings, the errored path, and the last-resort floor. A throwing
698
+ * principalAdmit resolves OPEN.
699
+ */
700
+ principalAdmit(surface, action) {
701
+ try {
702
+ const nowMs = this.now();
703
+ this.audit({
704
+ ts: this.nowIso(),
705
+ type: 'principal-admit',
706
+ detail: `surface=${surface} verb=${action.actionVerb}`,
707
+ });
708
+ this.countPrincipal(surface, nowMs);
709
+ const nonce = crypto.randomBytes(8).toString('hex');
710
+ const id = `sag-principal-${nonce}`;
711
+ this.state.tokens.set(id, {
712
+ controllerId: `principal:${surface}`,
713
+ targetKey: action.target ?? 'principal',
714
+ classId: 'principal',
715
+ nonce,
716
+ expiresAtMs: nowMs + (this.state.deps?.tokenTtlMs ?? 60_000),
717
+ consumed: false,
718
+ });
719
+ return { outcome: 'allow', token: { id }, reason: 'principal-lane' };
720
+ }
721
+ catch {
722
+ // A throwing principalAdmit resolves OPEN (spec SEC7-1) — the token is
723
+ // synthesized without bookkeeping; the CRITICAL errored alarm is the
724
+ // covering signal for the uninstrumented window.
725
+ return { outcome: 'allow', token: { id: `sag-principal-open-${Date.now()}` }, reason: 'principal-lane', detail: 'principalAdmit threw — resolves OPEN' };
726
+ }
727
+ }
728
+ countPrincipal(surface, nowMs) {
729
+ let w = this.state.principal.get(surface);
730
+ if (!w || nowMs - w.windowStartMs > PRINCIPAL_VOLUME_WINDOW_MS) {
731
+ // Clean window re-arms the anomaly page.
732
+ const hadAnomaly = w?.anomalyOpen && w.count <= PRINCIPAL_VOLUME_THRESHOLD;
733
+ w = { windowStartMs: nowMs, count: 0, anomalyOpen: hadAnomaly ? false : (w?.anomalyOpen ?? false), episodeId: w?.episodeId ?? null };
734
+ if (w.anomalyOpen === false)
735
+ w.episodeId = null;
736
+ this.state.principal.set(surface, w);
737
+ }
738
+ w.count += 1;
739
+ if (w.count > PRINCIPAL_VOLUME_THRESHOLD && !w.anomalyOpen) {
740
+ w.anomalyOpen = true;
741
+ w.episodeId = `principal-${surface}-${nowMs}`;
742
+ this.audit({ ts: this.nowIso(), type: 'principal-volume-anomaly', detail: `surface=${surface} count=${w.count}/${PRINCIPAL_VOLUME_WINDOW_MS}ms` });
743
+ // Episode-latched HIGH page — never a block (the lane stays non-blocking).
744
+ this.notify({
745
+ id: `agent:self-action-governor:principal:${w.episodeId}`,
746
+ title: `Principal-lane volume anomaly on ${surface}`,
747
+ body: `The always-allow principal lane on surface "${surface}" exceeded ${PRINCIPAL_VOLUME_THRESHOLD} admits in a 10-minute window. Principal-lane volume is either a compromised surface or a mis-stamped path — both operator-urgent. The lane stays non-blocking by design; containment is visibility.`,
748
+ priority: 'HIGH',
749
+ sourceContext: 'self-action-governor',
750
+ });
751
+ }
752
+ }
753
+ // ── Window / per-target / rate machinery ─────────────────────────────────
754
+ rollWindow(cls, nowMs) {
755
+ const w = cls.window;
756
+ const elapsed = nowMs - w.bucketStartMs;
757
+ if (elapsed < w.bucketWidthMs)
758
+ return;
759
+ const steps = Math.floor(elapsed / w.bucketWidthMs);
760
+ const advance = Math.min(steps, WINDOW_BUCKETS);
761
+ for (let i = 0; i < advance; i++) {
762
+ w.head = (w.head + 1) % WINDOW_BUCKETS;
763
+ w.buckets[w.head] = 0;
764
+ }
765
+ if (steps >= WINDOW_BUCKETS) {
766
+ // A whole window elapsed: rehydrated carry ages out; shed notice window resets.
767
+ w.rehydratedCarry = 0;
768
+ cls.rehydratedWindow = false;
769
+ cls.shedThisWindow = { count: 0, classes: new Set(), oldestMs: 0, noticed: false };
770
+ }
771
+ w.bucketStartMs += steps * w.bucketWidthMs;
772
+ // Ceiling computed AT window roll from the CACHED census (widen-only
773
+ // mid-window; sampling itself happens off the hot path — spec SC6-1).
774
+ this.recomputeReliefCeiling(cls, nowMs, /* atRoll */ true);
775
+ }
776
+ windowCount(cls) {
777
+ let total = 0;
778
+ for (const b of cls.window.buckets)
779
+ total += b;
780
+ return total;
781
+ }
782
+ recordAdmit(cls, target, nowMs) {
783
+ cls.counters.admits += 1;
784
+ cls.window.buckets[cls.window.head] += 1;
785
+ cls.windowTotal += 1;
786
+ const key = target.keyIsVolatile ? `class:${target.classId}` : target.key;
787
+ const entry = cls.perTarget.get(key);
788
+ if (entry) {
789
+ entry.count += 1;
790
+ entry.lastHitMs = nowMs; // recency-aware: an active entry is REFRESHED
791
+ }
792
+ else if (cls.perTarget.size < cls.policy.perTargetEvict.maxEntries || this.evictSettled(cls, nowMs)) {
793
+ cls.perTarget.set(key, { count: 1, lastHitMs: nowMs, firstHitMs: nowMs });
794
+ }
795
+ // Eager-flush triggers (FD14): admission-delta (debounced) + half-ceiling
796
+ // (once per window, debounce-exempt) + first post-rehydrate (leading edge).
797
+ this.state.unflushedDelta += 1;
798
+ if (!this.state.firstPostRehydrateFlushDone) {
799
+ this.state.firstPostRehydrateFlushDone = true;
800
+ this.eagerFlush(nowMs, /* debounceExempt */ true);
801
+ }
802
+ else {
803
+ const ceiling = Number.isFinite(cls.policy.totalCountCeiling) ? cls.policy.totalCountCeiling : Number.POSITIVE_INFINITY;
804
+ const halfCross = Number.isFinite(ceiling) && this.windowCount(cls) === Math.ceil(ceiling / 2);
805
+ const windowId = Math.floor(nowMs / cls.policy.windowMs);
806
+ if (halfCross && this.state.halfCeilingFlushedWindow.get(cls.policy.controllerId) !== windowId) {
807
+ this.state.halfCeilingFlushedWindow.set(cls.policy.controllerId, windowId);
808
+ this.eagerFlush(nowMs, /* debounceExempt */ true);
809
+ }
810
+ else if (this.state.unflushedDelta >= EAGER_FLUSH_ADMISSION_DELTA) {
811
+ this.eagerFlush(nowMs, /* debounceExempt */ false);
812
+ }
813
+ }
814
+ }
815
+ /** Evict ONLY settled/expired per-target entries (never an active
816
+ * at-ceiling entry — spec SC-m3). Returns true when a slot was freed. */
817
+ evictSettled(cls, nowMs) {
818
+ const ttl = cls.policy.perTargetEvict.ttlMs;
819
+ for (const [key, entry] of cls.perTarget) {
820
+ if (nowMs - entry.lastHitMs > ttl) {
821
+ cls.perTarget.delete(key);
822
+ return true;
823
+ }
824
+ }
825
+ return false;
826
+ }
827
+ refillRate(cls, nowMs) {
828
+ const rb = cls.policy.rateBucket;
829
+ if (!Number.isFinite(rb.ratePerWindow))
830
+ return;
831
+ const elapsed = nowMs - cls.rate.lastRefillMs;
832
+ if (rb.refill === 'continuous') {
833
+ const perMs = rb.ratePerWindow / rb.windowMs;
834
+ cls.rate.tokens = Math.min(rb.ratePerWindow, cls.rate.tokens + elapsed * perMs);
835
+ cls.rate.lastRefillMs = nowMs;
836
+ }
837
+ else if (elapsed >= rb.windowMs) {
838
+ cls.rate.tokens = rb.ratePerWindow;
839
+ cls.rate.lastRefillMs = nowMs;
840
+ }
841
+ }
842
+ safeProjection(p) {
843
+ try {
844
+ return p.amplifying?.projectPressure?.() ?? null;
845
+ }
846
+ catch {
847
+ return null; // a throwing callback counts as un-confirmable → deny-on-stale
848
+ }
849
+ }
850
+ // ── Census discipline (companion §7) ─────────────────────────────────────
851
+ /** Sample the census OFF the hot path (slow tick / window roll consumer). */
852
+ sampleCensus(nowMs = this.now()) {
853
+ const deps = this.state.deps;
854
+ if (!deps?.readCensus)
855
+ return;
856
+ try {
857
+ const reading = deps.readCensus();
858
+ if (reading && Number.isFinite(reading.value) && reading.value >= 0) {
859
+ this.state.census = { value: Math.floor(reading.value), asOf: reading.asOf, confidence: reading.confidence };
860
+ // Mid-window re-sample may only WIDEN (never shrink).
861
+ for (const cls of this.state.classes.values()) {
862
+ if (cls.policy.direction === 'relief' && cls.policy.censusScaled) {
863
+ this.recomputeReliefCeiling(cls, nowMs, /* atRoll */ false);
864
+ }
865
+ }
866
+ }
867
+ }
868
+ catch {
869
+ /* stale/unavailable census → the static floor applies at next roll */
870
+ }
871
+ }
872
+ recomputeReliefCeiling(cls, nowMs, atRoll) {
873
+ const p = cls.policy;
874
+ if (p.direction !== 'relief' || !p.censusScaled)
875
+ return;
876
+ const staticFloor = Number.isFinite(p.totalCountCeiling) ? p.totalCountCeiling : 60;
877
+ const census = this.state.census;
878
+ const fresh = census !== null && nowMs - census.asOf <= 5 * 60_000 && census.confidence === 'high';
879
+ let ceiling = staticFloor;
880
+ if (fresh && census) {
881
+ const scaled = Math.max(staticFloor, Math.floor(census.value * CENSUS_FRACTION_K));
882
+ const clampAt = censusAbsoluteMax(this.safeSessionCap());
883
+ if (scaled > clampAt) {
884
+ ceiling = clampAt;
885
+ this.audit({ ts: this.nowIso(), type: 'census-clamp', controllerId: p.controllerId, detail: `census-scaled ceiling ${scaled} clamped to ${clampAt} (inflated census is itself an anomaly)` });
886
+ }
887
+ else {
888
+ ceiling = scaled;
889
+ }
890
+ }
891
+ if (atRoll) {
892
+ cls.reliefCeiling = ceiling;
893
+ }
894
+ else if (ceiling > cls.reliefCeiling && fresh) {
895
+ // Mid-window: widening requires a fresh confident reading; only widen.
896
+ cls.reliefCeiling = ceiling;
897
+ }
898
+ }
899
+ safeSessionCap() {
900
+ try {
901
+ return this.state.deps?.configuredSessionCap?.();
902
+ }
903
+ catch {
904
+ return undefined;
905
+ }
906
+ }
907
+ // ── Errored episodes (spec INT5-2/ADV5-1) ────────────────────────────────
908
+ openErroredEpisode(cls, nowMs, err) {
909
+ cls.counters.erroredOpens += 1;
910
+ this.bump(cls, 'errored-open');
911
+ if (!cls.errored.open) {
912
+ cls.errored.open = true;
913
+ cls.errored.id = `err-${cls.policy.controllerId}-${nowMs}`;
914
+ cls.errored.verbatimRows = 0;
915
+ cls.errored.aggregated = 0;
916
+ cls.errored.openedMs = nowMs;
917
+ this.audit({ ts: this.nowIso(), type: 'errored-episode-open', controllerId: cls.policy.controllerId, detail: String(err instanceof Error ? err.message : err).slice(0, 200) });
918
+ const anyReliefEnforcing = [...this.state.classes.values()].some((c) => c.policy.direction === 'relief' && c.mode === 'enforce');
919
+ this.notify({
920
+ id: `agent:self-action-governor:errored:${cls.errored.id}`,
921
+ title: `SelfActionGovernor errored posture (${cls.policy.controllerId})`,
922
+ body: `The governor's admit path for ${cls.policy.controllerId} is throwing. Fail direction: ${cls.policy.failDirection}. The errored-open path is paced by the class's last-resort floor; recovery to healthy closes this episode.`,
923
+ priority: anyReliefEnforcing ? 'URGENT' : 'HIGH',
924
+ sourceContext: 'self-action-governor',
925
+ });
926
+ }
927
+ }
928
+ closeErroredEpisode(cls, _nowMs) {
929
+ if (cls.errored.open) {
930
+ cls.errored.open = false;
931
+ this.audit({ ts: this.nowIso(), type: 'errored-episode-close', controllerId: cls.policy.controllerId, detail: cls.errored.id ?? '' });
932
+ cls.errored.id = null;
933
+ }
934
+ }
935
+ /** Rate-bounded errored audit: first-N verbatim per episode, then aggregated. */
936
+ auditErrored(cls, err) {
937
+ if (cls.errored.verbatimRows < ERRORED_AUDIT_FIRST_N) {
938
+ cls.errored.verbatimRows += 1;
939
+ this.audit({ ts: this.nowIso(), type: 'errored-admit', controllerId: cls.policy.controllerId, detail: String(err instanceof Error ? err.message : err).slice(0, 200) });
940
+ }
941
+ else {
942
+ cls.errored.aggregated += 1;
943
+ }
944
+ }
945
+ // ── Demote latch + heal-exhaustion alarm (spec ADV-M4 / LA5-2) ───────────
946
+ /** Latched relief demote (rate-relaxation loss). Alarm ONLY on heal
947
+ * EXHAUSTION (N failed clean cooldowns) — transient cycles are audit-only. */
948
+ demoteReliefClass(controllerId, nowMs = this.now()) {
949
+ const cls = this.state.classes.get(controllerId);
950
+ if (!cls)
951
+ return;
952
+ if (!cls.demote.demoted) {
953
+ cls.demote.demoted = true;
954
+ cls.demote.episodeId = cls.demote.episodeId ?? `demote-${controllerId}-${nowMs}`;
955
+ cls.demote.cooldownEndsMs = nowMs + cls.policy.breaker.cooldownMs;
956
+ this.audit({ ts: this.nowIso(), type: 'demote-latch', controllerId, detail: cls.demote.episodeId });
957
+ }
958
+ else {
959
+ // Re-demote within an episode: a failed cooldown.
960
+ cls.demote.failedCooldowns += 1;
961
+ cls.demote.cooldownEndsMs = nowMs + cls.policy.breaker.cooldownMs;
962
+ if (cls.demote.failedCooldowns >= DEMOTE_EXHAUSTION_N && !cls.demote.alarmed) {
963
+ cls.demote.alarmed = true;
964
+ this.notify({
965
+ id: `agent:self-action-governor:demote:${controllerId}:${cls.demote.episodeId}`,
966
+ title: `Relief class ${controllerId} demotion is not healing`,
967
+ body: `${controllerId} lost its relief rate-relaxation and failed ${cls.demote.failedCooldowns} clean cooldowns (heal exhaustion). A demoted class still enforces its count floors — nothing is unguarded — but the sustained-pressure episode needs eyes.`,
968
+ priority: 'NORMAL',
969
+ sourceContext: 'self-action-governor',
970
+ });
971
+ }
972
+ }
973
+ }
974
+ /** Clean-cooldown re-promotion — the self-heal; audit-only (P22). */
975
+ repromoteReliefClass(controllerId, nowMs = this.now()) {
976
+ const cls = this.state.classes.get(controllerId);
977
+ if (!cls || !cls.demote.demoted)
978
+ return;
979
+ if (nowMs < cls.demote.cooldownEndsMs)
980
+ return; // never on a momentary dip
981
+ cls.demote.demoted = false;
982
+ this.audit({ ts: this.nowIso(), type: 'repromote-latch', controllerId, detail: cls.demote.episodeId ?? '' });
983
+ cls.demote.episodeId = null;
984
+ cls.demote.failedCooldowns = 0;
985
+ cls.demote.alarmed = false;
986
+ }
987
+ // ── FD12 observe-limbo (+ INVERSE storm nudge) ───────────────────────────
988
+ checkObserveLimbo(nowMs) {
989
+ const limboMs = OBSERVE_LIMBO_DAYS * 86_400_000;
990
+ const limboIds = [];
991
+ for (const [id, cls] of this.state.classes) {
992
+ if (cls.mode !== 'observe' || cls.limboNudged)
993
+ continue;
994
+ const total = cls.counters.admits;
995
+ const wouldDenyRate = total > 0 ? cls.counters.wouldDeny / total : 0;
996
+ const criterionMet = total >= 100 && wouldDenyRate < 0.01;
997
+ if (criterionMet && cls.criterionMetSinceMs === null)
998
+ cls.criterionMetSinceMs = nowMs;
999
+ if (!criterionMet)
1000
+ cls.criterionMetSinceMs = null;
1001
+ if (cls.criterionMetSinceMs !== null && nowMs - cls.criterionMetSinceMs > limboMs) {
1002
+ limboIds.push(id);
1003
+ cls.limboNudged = true;
1004
+ }
1005
+ // INVERSE nudge (ADV9-3): sustained would-deny ABOVE the flip floor on a
1006
+ // controller with no bespoke brake — "this class would be denying —
1007
+ // brake it or flip it."
1008
+ if (total >= 100 && wouldDenyRate > 0.25 && !cls.limboNudged) {
1009
+ limboIds.push(id);
1010
+ cls.limboNudged = true;
1011
+ }
1012
+ }
1013
+ if (limboIds.length > 0) {
1014
+ for (const id of limboIds) {
1015
+ this.audit({ ts: this.nowIso(), type: 'observe-limbo', controllerId: id });
1016
+ }
1017
+ // ONE coalesced routine item enumerating the limbo controllers.
1018
+ this.notify({
1019
+ id: `agent:self-action-governor:limbo:${limboIds.sort().join(',')}`,
1020
+ title: 'Self-action classes in observe limbo',
1021
+ body: `These classes have either met their promotion criterion for ${OBSERVE_LIMBO_DAYS}+ days without an enforce flip, or are storming in observe (sustained would-deny): ${limboIds.join(', ')}. The flip stays the operator's; "measured forever, enforcing never" must not drift silently (Close the Loop).`,
1022
+ priority: 'LOW',
1023
+ sourceContext: 'self-action-governor',
1024
+ });
1025
+ }
1026
+ }
1027
+ // ── Telemetry + audit ────────────────────────────────────────────────────
1028
+ bump(cls, sub) {
1029
+ cls.bySubMechanism.set(sub, (cls.bySubMechanism.get(sub) ?? 0) + 1);
1030
+ }
1031
+ audit(row) {
1032
+ this.state.auditBuffer.push(row);
1033
+ if (this.state.auditBuffer.length > 512) {
1034
+ // The buffer itself stays bounded even if flush never runs.
1035
+ this.state.auditBuffer.splice(0, this.state.auditBuffer.length - 512);
1036
+ }
1037
+ }
1038
+ auditRow(type, controllerId, detail) {
1039
+ this.audit({ ts: this.nowIso(), type, controllerId, detail });
1040
+ }
1041
+ notify(item) {
1042
+ try {
1043
+ void this.state.deps?.emitAttention?.(item);
1044
+ }
1045
+ catch {
1046
+ /* notices are signal-only; a failing funnel never blocks admission */
1047
+ }
1048
+ }
1049
+ // ── Durable snapshot (FD14 / companion §5.2) ─────────────────────────────
1050
+ snapshotPath() {
1051
+ return path.join(this.state.deps.stateDir, 'state', SNAPSHOT_FILE);
1052
+ }
1053
+ aggregatesPath() {
1054
+ return path.join(this.state.deps.stateDir, 'state', AGGREGATES_FILE);
1055
+ }
1056
+ auditPath() {
1057
+ return path.join(this.state.deps.stateDir, 'logs', AUDIT_FILE);
1058
+ }
1059
+ eagerFlush(nowMs, debounceExempt) {
1060
+ if (!debounceExempt && nowMs - this.state.lastEagerFlushMs < EAGER_FLUSH_DEBOUNCE_MS)
1061
+ return;
1062
+ this.state.lastEagerFlushMs = nowMs;
1063
+ // Async by design (never on the admit hot path synchronously) — EXCEPT the
1064
+ // leading-edge first-post-rehydrate flush, which must land even if the
1065
+ // process dies within the debounce (spec ADV7-2): that one is synchronous.
1066
+ if (debounceExempt) {
1067
+ try {
1068
+ this.flushSnapshot(false);
1069
+ }
1070
+ catch {
1071
+ /* flush failures surface via the slow tick */
1072
+ }
1073
+ return;
1074
+ }
1075
+ setImmediate(() => {
1076
+ try {
1077
+ this.flushSnapshot(false);
1078
+ }
1079
+ catch {
1080
+ /* flush failures surface via the slow tick */
1081
+ }
1082
+ });
1083
+ }
1084
+ /** The flush BARRIER for governor-process-killing actions (FD14). */
1085
+ flushBarrier() {
1086
+ try {
1087
+ this.flushSnapshot(false);
1088
+ this.flushAudit();
1089
+ }
1090
+ catch {
1091
+ /* barrier is best-effort under a dying process */
1092
+ }
1093
+ }
1094
+ flushSnapshot(clean) {
1095
+ const deps = this.state.deps;
1096
+ if (!deps)
1097
+ return;
1098
+ const nowMs = this.now();
1099
+ const classes = {};
1100
+ for (const [id, cls] of this.state.classes) {
1101
+ classes[id] = {
1102
+ windowTotal: this.windowCount(cls) + cls.window.rehydratedCarry,
1103
+ windowMs: cls.policy.windowMs,
1104
+ windowFlushedAtMs: nowMs,
1105
+ perTarget: [...cls.perTarget.entries()].slice(0, cls.policy.perTargetEvict.maxEntries).map(([k, v]) => ({ k, c: v.count, l: v.lastHitMs })),
1106
+ breaker: cls.breaker,
1107
+ demote: { demoted: cls.demote.demoted, episodeId: cls.demote.episodeId, failedCooldowns: cls.demote.failedCooldowns },
1108
+ counters: cls.counters,
1109
+ queuePopulation: cls.queue.size,
1110
+ mode: cls.mode,
1111
+ };
1112
+ }
1113
+ const snapshot = {
1114
+ version: 1,
1115
+ flushedAtMs: nowMs,
1116
+ cleanShutdown: clean,
1117
+ classes,
1118
+ };
1119
+ const file = this.snapshotPath();
1120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1121
+ const tmp = `${file}.tmp-${process.pid}`;
1122
+ fs.writeFileSync(tmp, JSON.stringify(snapshot));
1123
+ fs.renameSync(tmp, file);
1124
+ this.state.unflushedDelta = 0;
1125
+ // Aggregates file (prior-flush evidence + the flip-gate counters).
1126
+ const agg = { version: 1, flushedAtMs: nowMs, classes: {} };
1127
+ for (const [id, cls] of this.state.classes) {
1128
+ agg.classes[id] = {
1129
+ counters: cls.counters,
1130
+ bySubMechanism: Object.fromEntries(cls.bySubMechanism),
1131
+ };
1132
+ }
1133
+ const aggFile = this.aggregatesPath();
1134
+ const aggTmp = `${aggFile}.tmp-${process.pid}`;
1135
+ fs.writeFileSync(aggTmp, JSON.stringify(agg));
1136
+ fs.renameSync(aggTmp, aggFile);
1137
+ }
1138
+ flushAudit() {
1139
+ const deps = this.state.deps;
1140
+ if (!deps || this.state.auditBuffer.length === 0)
1141
+ return;
1142
+ const file = this.auditPath();
1143
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1144
+ const lines = this.state.auditBuffer.splice(0).map((r) => JSON.stringify(r)).join('\n') + '\n';
1145
+ fs.appendFileSync(file, lines);
1146
+ this.state.auditFlushedRows += lines.split('\n').length - 1;
1147
+ // Retention bound (transitions-only; FeatureMetricsLedger precedent).
1148
+ if (this.state.auditFlushedRows > AUDIT_MAX_ROWS) {
1149
+ try {
1150
+ const content = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
1151
+ const tail = content.slice(-Math.floor(AUDIT_MAX_ROWS / 2));
1152
+ const tmp = `${file}.tmp-${process.pid}`;
1153
+ fs.writeFileSync(tmp, tail.join('\n') + '\n');
1154
+ fs.renameSync(tmp, file);
1155
+ this.state.auditFlushedRows = tail.length;
1156
+ }
1157
+ catch {
1158
+ /* retention is best-effort */
1159
+ }
1160
+ }
1161
+ }
1162
+ rehydrate() {
1163
+ const deps = this.state.deps;
1164
+ if (!deps)
1165
+ return;
1166
+ const nowMs = this.now();
1167
+ const file = this.snapshotPath();
1168
+ let priorFlushEvidence = false;
1169
+ try {
1170
+ priorFlushEvidence = fs.existsSync(this.aggregatesPath());
1171
+ }
1172
+ catch {
1173
+ priorFlushEvidence = false;
1174
+ }
1175
+ this.state.priorFlushEvidence = priorFlushEvidence;
1176
+ let raw = null;
1177
+ try {
1178
+ raw = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null;
1179
+ }
1180
+ catch {
1181
+ raw = null;
1182
+ }
1183
+ if (raw === null) {
1184
+ if (priorFlushEvidence) {
1185
+ // MISSING snapshot with prior flush evidence: conservative posture
1186
+ // (static rate floor for one window) + loud state-reset row + signal.
1187
+ this.applyConservativePosture(nowMs, 'missing snapshot with prior flush evidence');
1188
+ }
1189
+ // Genuinely fresh install: silent empty.
1190
+ this.state.firstPostRehydrateFlushDone = false;
1191
+ return;
1192
+ }
1193
+ let parsed = null;
1194
+ try {
1195
+ parsed = JSON.parse(raw);
1196
+ }
1197
+ catch {
1198
+ parsed = null;
1199
+ }
1200
+ if (!parsed || typeof parsed !== 'object') {
1201
+ if (priorFlushEvidence)
1202
+ this.applyConservativePosture(nowMs, 'corrupt snapshot with prior flush evidence');
1203
+ this.state.firstPostRehydrateFlushDone = false;
1204
+ return;
1205
+ }
1206
+ const flushedAtMs = typeof parsed.flushedAtMs === 'number' ? parsed.flushedAtMs : 0;
1207
+ const clean = parsed.cleanShutdown === true;
1208
+ let queuePopulation = 0;
1209
+ let droppedNonTrivial = false;
1210
+ for (const [id, snap] of Object.entries(parsed.classes ?? {})) {
1211
+ const cls = this.classFor(id, nowMs);
1212
+ const windowMs = cls.policy.windowMs;
1213
+ const age = nowMs - flushedAtMs;
1214
+ queuePopulation += snap.queuePopulation ?? 0;
1215
+ if (age > windowMs) {
1216
+ // Recency validation: state older than the class window is dropped.
1217
+ if ((snap.windowTotal ?? 0) > 0)
1218
+ droppedNonTrivial = true;
1219
+ continue;
1220
+ }
1221
+ let carry = snap.windowTotal ?? 0;
1222
+ if (!clean) {
1223
+ // Pessimistic carry-forward: assume the lost interval consumed at the
1224
+ // last-flushed rate — with a NON-ZERO floor when the last-flushed
1225
+ // rate ≈ 0 but prior-enforcement evidence exists (spec ADV7-2).
1226
+ const rate = carry / Math.max(1, windowMs);
1227
+ let lost = Math.ceil(rate * Math.min(age, windowMs));
1228
+ if (lost === 0 && priorFlushEvidence)
1229
+ lost = 1;
1230
+ carry += lost;
1231
+ }
1232
+ cls.window.rehydratedCarry = carry;
1233
+ cls.rehydratedWindow = carry > 0;
1234
+ for (const t of snap.perTarget ?? []) {
1235
+ if (t && typeof t.k === 'string' && typeof t.c === 'number' && nowMs - t.l <= cls.policy.perTargetEvict.ttlMs) {
1236
+ cls.perTarget.set(t.k, { count: t.c, lastHitMs: t.l, firstHitMs: t.l });
1237
+ }
1238
+ }
1239
+ if (snap.breaker)
1240
+ cls.breaker = { ...cls.breaker, ...snap.breaker };
1241
+ if (snap.demote) {
1242
+ cls.demote.demoted = snap.demote.demoted === true;
1243
+ cls.demote.episodeId = snap.demote.episodeId ?? null;
1244
+ cls.demote.failedCooldowns = snap.demote.failedCooldowns ?? 0;
1245
+ }
1246
+ if (snap.counters)
1247
+ cls.counters = { ...cls.counters, ...snap.counters };
1248
+ }
1249
+ if (droppedNonTrivial) {
1250
+ this.audit({ ts: this.nowIso(), type: 'state-reset', detail: 'rehydrate dropped non-trivial state older than the class window (recency validation)' });
1251
+ }
1252
+ if (queuePopulation > 0) {
1253
+ // ANY boot with a non-zero last-known queue population: ONE restart-shed
1254
+ // row (clean/unclean-tagged) — the loss event is never silent.
1255
+ this.audit({ ts: this.nowIso(), type: 'restart-shed', detail: `queued intents shed by restart: ${queuePopulation} (${clean ? 'clean' : 'unclean'} shutdown; level-triggered conditions regenerate)` });
1256
+ }
1257
+ this.state.firstPostRehydrateFlushDone = false;
1258
+ }
1259
+ applyConservativePosture(nowMs, why) {
1260
+ for (const [id, cls] of this.state.classes) {
1261
+ this.state.conservativeUntilMs.set(id, nowMs + cls.policy.windowMs);
1262
+ }
1263
+ this.audit({ ts: this.nowIso(), type: 'state-reset', detail: `${why} — conservative posture (static floor) for one window` });
1264
+ this.notify({
1265
+ id: `agent:self-action-governor:state-reset:${nowMs}`,
1266
+ title: 'SelfActionGovernor state reset',
1267
+ body: `${why}. Affected classes start at a conservative posture (static rate floor for one full window). For count ceilings, reset is the unsafe direction — this reset is loud by design.`,
1268
+ priority: 'NORMAL',
1269
+ sourceContext: 'self-action-governor',
1270
+ });
1271
+ }
1272
+ // ── Slow tick (flush cadence + off-hot-path evaluations) ────────────────
1273
+ slowTick() {
1274
+ const nowMs = this.now();
1275
+ try {
1276
+ this.sampleCensus(nowMs);
1277
+ this.applyPoolShareGate();
1278
+ this.loadPolicies();
1279
+ this.emergencyDisabled(); // observes config flips even with no admissions
1280
+ this.drainQueues(nowMs);
1281
+ this.checkObserveLimbo(nowMs);
1282
+ // Token pruning.
1283
+ for (const [tid, rec] of this.state.tokens) {
1284
+ if (rec.consumed || rec.expiresAtMs < nowMs)
1285
+ this.state.tokens.delete(tid);
1286
+ }
1287
+ this.flushSnapshot(false);
1288
+ this.flushAudit();
1289
+ }
1290
+ catch {
1291
+ /* the slow tick must never take the process down */
1292
+ }
1293
+ }
1294
+ /** Test seam: run one slow tick deterministically. */
1295
+ runSlowTickForTest() {
1296
+ this.slowTick();
1297
+ }
1298
+ // ── Read surfaces (route + coherence + guard posture) ────────────────────
1299
+ /** Scrubbed, LOCK-FREE posture read (no target identities, no absolute
1300
+ * quota values — spec SEC6; the /test-runner-limiter PURE-read precedent). */
1301
+ getPosture() {
1302
+ const initialized = this.state.deps !== null;
1303
+ let emergencyDisable = false;
1304
+ try {
1305
+ emergencyDisable = this.state.deps?.readEmergencyDisable() === true;
1306
+ }
1307
+ catch {
1308
+ emergencyDisable = false;
1309
+ }
1310
+ const classes = [];
1311
+ for (const [id, cls] of this.state.classes) {
1312
+ classes.push({
1313
+ controllerId: id,
1314
+ actionVerb: cls.policy.actionVerb,
1315
+ direction: cls.policy.direction,
1316
+ resource: cls.policy.resource,
1317
+ mode: cls.mode,
1318
+ overridden: cls.overridden !== null,
1319
+ ...(cls.overridden ? { ceilingVsDefaultRatio: Number(cls.overridden.ratio.toFixed(2)) } : {}),
1320
+ windowCount: this.windowCount(cls) + cls.window.rehydratedCarry,
1321
+ counters: { ...cls.counters },
1322
+ bySubMechanism: Object.fromEntries(cls.bySubMechanism),
1323
+ breakerOpen: cls.breaker.openUntilMs > this.now(),
1324
+ demoted: cls.demote.demoted,
1325
+ queueDepth: [...cls.queue.values()].reduce((a, q) => a + q.coalescedCount, 0),
1326
+ queueDistinctTargets: cls.queue.size,
1327
+ });
1328
+ }
1329
+ classes.sort((a, b) => a.controllerId.localeCompare(b.controllerId));
1330
+ return { emergencyDisable, initialized, classes };
1331
+ }
1332
+ /** Coherence-advert accessor (companion §10b): per-class scalar mode for
1333
+ * pool-shared classes, read LIVE from governor runtime state. */
1334
+ getClassMode(controllerId) {
1335
+ return this.state.classes.get(controllerId)?.mode ?? 'observe';
1336
+ }
1337
+ /** GuardRegistry runtime getter (enabled = emergencyDisable !== true).
1338
+ * Deliberately does NOT report observe-only as `dryRun`: the governor's
1339
+ * guard job — registration + measurement + kill-switch visibility — IS
1340
+ * live in observe mode; per-class enforce is the separate FD8/FD12 ladder
1341
+ * with its own observe-limbo loop-closer, so painting the whole guard
1342
+ * on-dry-run would manufacture a permanent loadBearingGap against the
1343
+ * spec's designed observe-first rollout. */
1344
+ guardRuntimeStatus() {
1345
+ let disabled = false;
1346
+ try {
1347
+ disabled = this.state.deps?.readEmergencyDisable() === true;
1348
+ }
1349
+ catch {
1350
+ disabled = false;
1351
+ }
1352
+ return { enabled: !disabled && this.state.deps !== null, lastTickAt: this.now() };
1353
+ }
1354
+ /** Test seam: set a class's mode directly (enforce-path coverage — an
1355
+ * observe soak cannot certify machinery it never exercises; spec A7). */
1356
+ setModeForTest(controllerId, mode) {
1357
+ const cls = this.classFor(controllerId, this.now());
1358
+ cls.mode = mode;
1359
+ if (mode !== 'demoted')
1360
+ cls.preDemoteMode = mode;
1361
+ }
1362
+ /** Test seam: force a breaker open. */
1363
+ openBreakerForTest(controllerId, untilMs) {
1364
+ const cls = this.classFor(controllerId, this.now());
1365
+ cls.breaker.openUntilMs = untilMs;
1366
+ }
1367
+ /** Read the buffered (unflushed) audit rows — test/inspection seam. */
1368
+ peekAuditBuffer() {
1369
+ return this.state.auditBuffer;
1370
+ }
1371
+ /** Test seam: ALL audit rows — the in-memory buffer PLUS any flushed file
1372
+ * rows (a slow tick flushes the buffer; assertions must not race that). */
1373
+ readAllAuditRowsForTest() {
1374
+ const rows = [];
1375
+ try {
1376
+ const file = this.auditPath();
1377
+ if (fs.existsSync(file)) {
1378
+ for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
1379
+ if (!line.trim())
1380
+ continue;
1381
+ try {
1382
+ rows.push(JSON.parse(line));
1383
+ }
1384
+ catch {
1385
+ /* partial line */
1386
+ }
1387
+ }
1388
+ }
1389
+ }
1390
+ catch {
1391
+ /* no file yet */
1392
+ }
1393
+ rows.push(...this.state.auditBuffer);
1394
+ return rows;
1395
+ }
1396
+ }
1397
+ // ─────────────────────────────────────────────────────────────────────────────
1398
+ // The per-controller handle (companion §2)
1399
+ // ─────────────────────────────────────────────────────────────────────────────
1400
+ /**
1401
+ * Per-controller handle minted once at registration. Ordinary handles CANNOT
1402
+ * express `origin: 'principal'` — they stamp 'self' unconditionally (the
1403
+ * privileged lane is `governor.principalAdmit`, a separate API).
1404
+ */
1405
+ export class SelfActionHandle {
1406
+ core;
1407
+ controllerId;
1408
+ dead;
1409
+ constructor(core, controllerId, dead) {
1410
+ this.core = core;
1411
+ this.controllerId = controllerId;
1412
+ this.dead = dead;
1413
+ }
1414
+ /** Async admission (classes that may touch cross-process CAS later). */
1415
+ async admit(target, opts) {
1416
+ return this.core.admitFor(this.controllerId, this.dead, target, opts);
1417
+ }
1418
+ /** Zero-I/O synchronous admission (notify/age-kill-class hot paths). */
1419
+ admitSync(target, opts) {
1420
+ return this.core.admitFor(this.controllerId, this.dead, target, opts);
1421
+ }
1422
+ /** True when this handle lost a mint collision (dead-handle posture). */
1423
+ isDead() {
1424
+ return this.dead;
1425
+ }
1426
+ }
1427
+ // ─────────────────────────────────────────────────────────────────────────────
1428
+ // Module surface
1429
+ // ─────────────────────────────────────────────────────────────────────────────
1430
+ let coreInstance = null;
1431
+ function core() {
1432
+ if (!coreInstance)
1433
+ coreInstance = new SelfActionGovernorCore();
1434
+ return coreInstance;
1435
+ }
1436
+ /**
1437
+ * The governor module surface. Emit sites hold `const gov = governor.for(id)`
1438
+ * at module scope (raw string admit at emit sites is LINT-FORBIDDEN).
1439
+ */
1440
+ export const governor = {
1441
+ for(controllerId) {
1442
+ return core().for(controllerId);
1443
+ },
1444
+ /** Privileged principal lane — import-restricted by the usage-scan lint. */
1445
+ principalAdmit(surface, action) {
1446
+ return core().principalAdmit(surface, action);
1447
+ },
1448
+ };
1449
+ /** Server-boot initialization (INIT-ONCE; later callers attach). */
1450
+ export function initSelfActionGovernor(deps) {
1451
+ const c = core();
1452
+ c.init(deps);
1453
+ return c;
1454
+ }
1455
+ /** The live core (route/advert/guard read surfaces). */
1456
+ export function getSelfActionGovernor() {
1457
+ return core();
1458
+ }
1459
+ /** Sink-side consume (FD6 — the runtime authority). Sinks pin their expected
1460
+ * controllerId module-side. */
1461
+ export function consumeAdmissionToken(token, expectedControllerId, opts) {
1462
+ return core().consumeToken(token, expectedControllerId, opts);
1463
+ }
1464
+ /** Test-only: drop the module-level core so a fixture can re-instantiate
1465
+ * (pairs with anchor.resetAnchorForTest — SC9-1). */
1466
+ export function resetSelfActionGovernorModuleForTest() {
1467
+ try {
1468
+ coreInstance?.dispose();
1469
+ }
1470
+ catch {
1471
+ /* test reset is best-effort */
1472
+ }
1473
+ coreInstance = null;
1474
+ }
1475
+ //# sourceMappingURL=governor.js.map