instar 1.3.819 → 1.3.821

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 (85) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +498 -3
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +69 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/BackupManager.d.ts +10 -0
  8. package/dist/core/BackupManager.d.ts.map +1 -1
  9. package/dist/core/BackupManager.js +16 -0
  10. package/dist/core/BackupManager.js.map +1 -1
  11. package/dist/core/BoundedJsonlAudit.d.ts +37 -0
  12. package/dist/core/BoundedJsonlAudit.d.ts.map +1 -0
  13. package/dist/core/BoundedJsonlAudit.js +87 -0
  14. package/dist/core/BoundedJsonlAudit.js.map +1 -0
  15. package/dist/core/JudgmentProvenanceLog.d.ts +144 -0
  16. package/dist/core/JudgmentProvenanceLog.d.ts.map +1 -0
  17. package/dist/core/JudgmentProvenanceLog.js +301 -0
  18. package/dist/core/JudgmentProvenanceLog.js.map +1 -0
  19. package/dist/core/MachineIdentity.d.ts.map +1 -1
  20. package/dist/core/MachineIdentity.js +2 -0
  21. package/dist/core/MachineIdentity.js.map +1 -1
  22. package/dist/core/OwnerDarkLadder.d.ts +108 -0
  23. package/dist/core/OwnerDarkLadder.d.ts.map +1 -0
  24. package/dist/core/OwnerDarkLadder.js +229 -0
  25. package/dist/core/OwnerDarkLadder.js.map +1 -0
  26. package/dist/core/PostUpdateMigrator.d.ts +10 -0
  27. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  28. package/dist/core/PostUpdateMigrator.js +106 -0
  29. package/dist/core/PostUpdateMigrator.js.map +1 -1
  30. package/dist/core/SpawnAdmission.d.ts +218 -0
  31. package/dist/core/SpawnAdmission.d.ts.map +1 -0
  32. package/dist/core/SpawnAdmission.js +440 -0
  33. package/dist/core/SpawnAdmission.js.map +1 -0
  34. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  35. package/dist/core/devGatedFeatures.js +24 -0
  36. package/dist/core/devGatedFeatures.js.map +1 -1
  37. package/dist/core/machineCoherenceManifest.d.ts.map +1 -1
  38. package/dist/core/machineCoherenceManifest.js +30 -0
  39. package/dist/core/machineCoherenceManifest.js.map +1 -1
  40. package/dist/core/types.d.ts +87 -0
  41. package/dist/core/types.d.ts.map +1 -1
  42. package/dist/core/types.js.map +1 -1
  43. package/dist/monitoring/DuplicateSessionReconciler.d.ts +225 -0
  44. package/dist/monitoring/DuplicateSessionReconciler.d.ts.map +1 -0
  45. package/dist/monitoring/DuplicateSessionReconciler.js +544 -0
  46. package/dist/monitoring/DuplicateSessionReconciler.js.map +1 -0
  47. package/dist/monitoring/FailureAnalyzer.d.ts.map +1 -1
  48. package/dist/monitoring/FailureAnalyzer.js +40 -1
  49. package/dist/monitoring/FailureAnalyzer.js.map +1 -1
  50. package/dist/monitoring/FailureLedger.d.ts +9 -0
  51. package/dist/monitoring/FailureLedger.d.ts.map +1 -1
  52. package/dist/monitoring/FailureLedger.js +21 -4
  53. package/dist/monitoring/FailureLedger.js.map +1 -1
  54. package/dist/scaffold/templates.d.ts.map +1 -1
  55. package/dist/scaffold/templates.js +2 -1
  56. package/dist/scaffold/templates.js.map +1 -1
  57. package/dist/server/AgentServer.d.ts +5 -0
  58. package/dist/server/AgentServer.d.ts.map +1 -1
  59. package/dist/server/AgentServer.js +4 -0
  60. package/dist/server/AgentServer.js.map +1 -1
  61. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  62. package/dist/server/CapabilityIndex.js +9 -0
  63. package/dist/server/CapabilityIndex.js.map +1 -1
  64. package/dist/server/fileRoutes.d.ts +16 -0
  65. package/dist/server/fileRoutes.d.ts.map +1 -1
  66. package/dist/server/fileRoutes.js +46 -0
  67. package/dist/server/fileRoutes.js.map +1 -1
  68. package/dist/server/routes.d.ts +7 -0
  69. package/dist/server/routes.d.ts.map +1 -1
  70. package/dist/server/routes.js +99 -0
  71. package/dist/server/routes.js.map +1 -1
  72. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  73. package/dist/testing/selfActionRegistry.js +89 -0
  74. package/dist/testing/selfActionRegistry.js.map +1 -1
  75. package/package.json +1 -1
  76. package/scripts/lib/self-action-detect.mjs +2 -0
  77. package/skills/instar-dev/templates/side-effects-artifact.md +8 -0
  78. package/skills/spec-converge/SKILL.md +1 -0
  79. package/skills/spec-converge/scripts/write-convergence-tag.mjs +71 -0
  80. package/src/data/builtin-manifest.json +65 -65
  81. package/src/scaffold/templates.ts +2 -1
  82. package/upgrades/1.3.820.md +25 -0
  83. package/upgrades/1.3.821.md +22 -0
  84. package/upgrades/side-effects/ownership-gated-spawn-increment-1.md +166 -0
  85. package/upgrades/side-effects/two-node-harness-increment2-entry-gate.md +64 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * SpawnAdmission — the binding-verdict seam at every session-creating callsite
3
+ * for a conversation-bound topic (ownership-gated-spawn-and-judgment-within-floors
4
+ * spec §3.1, Layer A; the runtime arm of the Ownership-Gated Side Effects standard).
5
+ *
6
+ * The 2026-07-10 incident in one line: the router computed the right verdict
7
+ * ("owner dark → queue") and the inbound handler's fall-through spawned locally
8
+ * anyway, 6ms later — the verdict was advice, and the code that creates sessions
9
+ * never asked. This seam makes the verdict BINDING: every uninstrumented
10
+ * session-creating callsite (Telegram cold-spawn/respawn, Slack inbound/recovery
11
+ * spawn) consults `admit()` before creating a session.
12
+ *
13
+ * Deterministic floor — the admission table (§3.1, rows lettered):
14
+ * (a) `self` → spawn (today's behavior).
15
+ * (b) `other-alive` → forward; never a local spawn.
16
+ * (c) `other-dark` → NEVER spawn locally — the owner-dark ladder (§3.3).
17
+ * (d) `unowned` → spawn only as the claimed owner (the router's
18
+ * placeAndClaim already does the claim; the seam makes its
19
+ * result binding by consuming the router verdict).
20
+ * (e) `error` → spawn locally (reachability wins over a broken store),
21
+ * BOUNDED: once-per-topic-per-episode journal row + ONE
22
+ * deduped attention item + a windowed breaker.
23
+ *
24
+ * Enforcement invariant (§3.1 item 6, round-4): with the durable inbound queue
25
+ * dark, this seam CANNOT block a spawn outside dryRun — rows (c) and the
26
+ * queued-suppression are enforceable only where durable custody exists.
27
+ * `effectiveMode()` encodes that structurally: `enforce` requires
28
+ * enabled && !dryRun && durableCustodyLive(). Everywhere else the seam observes.
29
+ *
30
+ * Single-machine installs / pool 'dark': `admit()` short-circuits to allow —
31
+ * byte-identical behavior, zero writes, zero regression (§3.1 item 5).
32
+ *
33
+ * Error-arm breaker bounds are CODE CONSTANTS, not config (§3.1 row e — the
34
+ * self-action-backpressure precedent): tunable only by PR.
35
+ *
36
+ * Signal vs. Authority: this is a deterministic floor in the documented
37
+ * exemption class (enumerable-domain invariant — one owner per conversation —
38
+ * plus a safety guard on an action with irreversible external side effects).
39
+ * It consumes the router's already-computed verdict rather than adding a new
40
+ * brittle detector with authority; every ambiguous arm fails toward
41
+ * reachability, loudly and boundedly.
42
+ */
43
+ export type OwnershipKind = 'self' | 'other-alive' | 'other-dark' | 'unowned' | 'error';
44
+ export interface OwnershipResolution {
45
+ kind: OwnershipKind;
46
+ owner: string | null;
47
+ epoch: number;
48
+ /** Present only for kind 'error' — the resolution failure, message-only. */
49
+ error?: string;
50
+ }
51
+ /**
52
+ * Error-arm breaker bounds (§3.1 row e / FD11) — CODE CONSTANTS, never config.
53
+ * A safety bound in config is a safety bound an emergency edit can silently
54
+ * remove (the 2026-06-05 load-shed lesson); these change only by reviewed PR.
55
+ */
56
+ export declare const ERROR_ARM_CONSTANTS: {
57
+ /** Breaker trips on K consecutive resolution errors. */
58
+ readonly CONSECUTIVE_TRIP: 5;
59
+ /** …OR a windowed rate: ≥ N errors in the window, regardless of interleaved successes. */
60
+ readonly WINDOWED_TRIP_COUNT: 8;
61
+ readonly WINDOWED_TRIP_WINDOW_MS: 600000;
62
+ /** An episode closes only after J consecutive clean resolutions (hysteresis). */
63
+ readonly HYSTERESIS_CLEAN_CLOSES: 10;
64
+ /** ≥ N episodes per machine per 24h escalates the attention item to HIGH. */
65
+ readonly EPISODES_HIGH_THRESHOLD: 3;
66
+ readonly EPISODES_WINDOW_MS: 86400000;
67
+ };
68
+ export type AdmissionMode = 'off' | 'dry-run' | 'enforce';
69
+ export type AdmissionRow = 'self' | 'other-alive' | 'other-dark' | 'unowned' | 'error' | 'short-circuit' | 'router-queued-suppress' | 'router-consumed';
70
+ /** What the caller must do when `allow` is false (enforce mode only). */
71
+ export type RefusalAction = 'forward' | 'owner-dark-ladder' | 'rung3-notice';
72
+ export interface AdmissionDecision {
73
+ /** May the caller create a local session NOW. Always true outside enforce mode. */
74
+ allow: boolean;
75
+ mode: AdmissionMode;
76
+ row: AdmissionRow;
77
+ /** Dry-run observability: enforcement WOULD have refused this spawn. */
78
+ wouldBlock: boolean;
79
+ /** Present when allow === false — the deterministic refusal path. */
80
+ refusalAction?: RefusalAction;
81
+ reason: string;
82
+ ownership?: OwnershipResolution;
83
+ /** The router verdict action consumed via the TOCTOU guard, when one was supplied. */
84
+ consumedRouterVerdict?: string;
85
+ }
86
+ export interface AdmitInput {
87
+ /** The conversation session key (Telegram: String(topicId); Slack: routing key). */
88
+ sessionKey: string;
89
+ /** Which session-creating callsite is asking — journaled for provenance. */
90
+ callsite: 'telegram-cold-spawn' | 'telegram-respawn-context-exhausted' | 'telegram-respawn-dead' | 'slack-inbound-spawn' | 'slack-recovery-spawn';
91
+ /**
92
+ * TOCTOU guard (§3.1 item 2): when the router already produced a verdict for
93
+ * this message, the seam CONSUMES it rather than re-resolving — the admission
94
+ * decision and the routing decision cannot disagree mid-dispatch.
95
+ */
96
+ routerVerdict?: {
97
+ messageId: string;
98
+ action: string;
99
+ acked: boolean;
100
+ };
101
+ }
102
+ export interface SpawnAdmissionFlag {
103
+ enabled: boolean;
104
+ dryRun: boolean;
105
+ }
106
+ export interface SpawnAdmissionDeps {
107
+ /** Mesh self machine id; null/undefined = pool not wired (single machine). */
108
+ selfMachineId: () => string | null | undefined;
109
+ /** Session-pool rollout stage; 'dark' = the pool is off → short-circuit. */
110
+ poolStage: () => string;
111
+ /**
112
+ * Raw ownership read over the IN-MEMORY/CACHED registry view — never a
113
+ * synchronous durable read on the inbound path (§3.1 item 1). May throw;
114
+ * resolveOwnershipSafe wraps it.
115
+ */
116
+ readOwnership: (sessionKey: string) => {
117
+ owner: string | null;
118
+ epoch: number;
119
+ status: string | null;
120
+ } | null;
121
+ /** The pool's existing liveness input (heartbeat-fresh view). */
122
+ isMachineAlive: (machineId: string) => boolean;
123
+ /** Durable inbound-queue custody live on this machine (enforcement precondition). */
124
+ durableCustodyLive: () => boolean;
125
+ /** Appender for logs/owner-dark-ladder.jsonl (scrubbed, metadata-only rows). */
126
+ journal: (row: Record<string, unknown>) => void;
127
+ /** Deduped attention raise (dedupe key supplied by the seam). */
128
+ raiseAttention: (item: {
129
+ id: string;
130
+ title: string;
131
+ body: string;
132
+ priority: 'high' | 'medium';
133
+ }) => void;
134
+ /** Deterministic-verdict provenance row (JudgmentProvenanceLog), wired at boot. */
135
+ provenance?: (row: {
136
+ component: string;
137
+ decisionPoint: string;
138
+ context: Record<string, unknown>;
139
+ optionsPresented: string[];
140
+ decision: string;
141
+ reason: string;
142
+ floor: string;
143
+ fallbackRung: 'deterministic';
144
+ }) => void;
145
+ log: (msg: string) => void;
146
+ now?: () => number;
147
+ }
148
+ /**
149
+ * resolveOwnershipSafe — the non-throwing tri-state wrapper over the ownership
150
+ * registry (§3.1 item 1). Closes the §2.1 ambiguity: callers today cannot
151
+ * distinguish owner-dark vs unowned vs error (registry reads THROW into the
152
+ * handler's fail-open catch). Reads the cached view only.
153
+ */
154
+ export declare function resolveOwnershipSafe(sessionKey: string, deps: Pick<SpawnAdmissionDeps, 'selfMachineId' | 'readOwnership' | 'isMachineAlive'>): OwnershipResolution;
155
+ export interface SpawnAdmissionStatus {
156
+ mode: AdmissionMode;
157
+ enforceBlockedBy: 'flag-disabled' | 'dry-run' | 'durable-custody-dark' | null;
158
+ errorEpisode: {
159
+ open: boolean;
160
+ openedAt: string | null;
161
+ episodeId: string | null;
162
+ breakerOpen: boolean;
163
+ consecutiveErrors: number;
164
+ consecutiveClean: number;
165
+ episodesIn24h: number;
166
+ };
167
+ counters: {
168
+ admitted: number;
169
+ wouldBlock: number;
170
+ refused: number;
171
+ errorArmSpawns: number;
172
+ shortCircuits: number;
173
+ routerVerdictsConsumed: number;
174
+ };
175
+ }
176
+ export declare class SpawnAdmission {
177
+ private readonly deps;
178
+ private flag;
179
+ private readonly nowFn;
180
+ private episode;
181
+ private counters;
182
+ constructor(flag: SpawnAdmissionFlag, deps: SpawnAdmissionDeps);
183
+ /** Boot-read flag, but hot-updatable for tests and config reload paths. */
184
+ setFlag(flag: SpawnAdmissionFlag): void;
185
+ /**
186
+ * §3.1 item 6 (round-4 admission-table invariant): `enforce` REQUIRES durable
187
+ * custody — with the inbound queue dark this seam cannot block a spawn
188
+ * outside dryRun, so notice-only refusal can never be wired on the fleet by
189
+ * accident.
190
+ */
191
+ effectiveMode(): AdmissionMode;
192
+ /**
193
+ * The reconciler + closeout FREEZE while a registry-error episode is open
194
+ * (§3.1 row e — same fault domain; the error arm must not be exploitable to
195
+ * both mint and "heal" duplicates).
196
+ */
197
+ isErrorEpisodeOpen(): boolean;
198
+ admit(input: AdmitInput): AdmissionDecision;
199
+ /** Shared dry-run/enforce decision shaping for the blocking rows. */
200
+ private decide;
201
+ /**
202
+ * Row (e): reachability wins over a broken store — spawn locally, BOUNDED:
203
+ * once-per-topic-per-episode journal row + ONE deduped attention item +
204
+ * windowed breaker. Breaker-open degrades the arm to the rung-3 notice floor
205
+ * (enforce mode only — dry-run always allows).
206
+ */
207
+ private admitErrorArm;
208
+ private openEpisode;
209
+ /** Hysteresis: an episode closes only after J consecutive clean resolutions. */
210
+ private recordCleanResolution;
211
+ /** FD11 wording; dedupe key `spawn-admission-error:<machineId>:<episode>`. */
212
+ private raiseErrorEpisodeAttention;
213
+ private journalDecision;
214
+ /** Deterministic-verdict provenance rows from the seam (§3.5, Increment 1). */
215
+ private provenanceRow;
216
+ status(): SpawnAdmissionStatus;
217
+ }
218
+ //# sourceMappingURL=SpawnAdmission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SpawnAdmission.d.ts","sourceRoot":"","sources":["../../src/core/SpawnAdmission.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,440 @@
1
+ /**
2
+ * SpawnAdmission — the binding-verdict seam at every session-creating callsite
3
+ * for a conversation-bound topic (ownership-gated-spawn-and-judgment-within-floors
4
+ * spec §3.1, Layer A; the runtime arm of the Ownership-Gated Side Effects standard).
5
+ *
6
+ * The 2026-07-10 incident in one line: the router computed the right verdict
7
+ * ("owner dark → queue") and the inbound handler's fall-through spawned locally
8
+ * anyway, 6ms later — the verdict was advice, and the code that creates sessions
9
+ * never asked. This seam makes the verdict BINDING: every uninstrumented
10
+ * session-creating callsite (Telegram cold-spawn/respawn, Slack inbound/recovery
11
+ * spawn) consults `admit()` before creating a session.
12
+ *
13
+ * Deterministic floor — the admission table (§3.1, rows lettered):
14
+ * (a) `self` → spawn (today's behavior).
15
+ * (b) `other-alive` → forward; never a local spawn.
16
+ * (c) `other-dark` → NEVER spawn locally — the owner-dark ladder (§3.3).
17
+ * (d) `unowned` → spawn only as the claimed owner (the router's
18
+ * placeAndClaim already does the claim; the seam makes its
19
+ * result binding by consuming the router verdict).
20
+ * (e) `error` → spawn locally (reachability wins over a broken store),
21
+ * BOUNDED: once-per-topic-per-episode journal row + ONE
22
+ * deduped attention item + a windowed breaker.
23
+ *
24
+ * Enforcement invariant (§3.1 item 6, round-4): with the durable inbound queue
25
+ * dark, this seam CANNOT block a spawn outside dryRun — rows (c) and the
26
+ * queued-suppression are enforceable only where durable custody exists.
27
+ * `effectiveMode()` encodes that structurally: `enforce` requires
28
+ * enabled && !dryRun && durableCustodyLive(). Everywhere else the seam observes.
29
+ *
30
+ * Single-machine installs / pool 'dark': `admit()` short-circuits to allow —
31
+ * byte-identical behavior, zero writes, zero regression (§3.1 item 5).
32
+ *
33
+ * Error-arm breaker bounds are CODE CONSTANTS, not config (§3.1 row e — the
34
+ * self-action-backpressure precedent): tunable only by PR.
35
+ *
36
+ * Signal vs. Authority: this is a deterministic floor in the documented
37
+ * exemption class (enumerable-domain invariant — one owner per conversation —
38
+ * plus a safety guard on an action with irreversible external side effects).
39
+ * It consumes the router's already-computed verdict rather than adding a new
40
+ * brittle detector with authority; every ambiguous arm fails toward
41
+ * reachability, loudly and boundedly.
42
+ */
43
+ /**
44
+ * Error-arm breaker bounds (§3.1 row e / FD11) — CODE CONSTANTS, never config.
45
+ * A safety bound in config is a safety bound an emergency edit can silently
46
+ * remove (the 2026-06-05 load-shed lesson); these change only by reviewed PR.
47
+ */
48
+ export const ERROR_ARM_CONSTANTS = {
49
+ /** Breaker trips on K consecutive resolution errors. */
50
+ CONSECUTIVE_TRIP: 5,
51
+ /** …OR a windowed rate: ≥ N errors in the window, regardless of interleaved successes. */
52
+ WINDOWED_TRIP_COUNT: 8,
53
+ WINDOWED_TRIP_WINDOW_MS: 600_000,
54
+ /** An episode closes only after J consecutive clean resolutions (hysteresis). */
55
+ HYSTERESIS_CLEAN_CLOSES: 10,
56
+ /** ≥ N episodes per machine per 24h escalates the attention item to HIGH. */
57
+ EPISODES_HIGH_THRESHOLD: 3,
58
+ EPISODES_WINDOW_MS: 86_400_000,
59
+ };
60
+ /**
61
+ * resolveOwnershipSafe — the non-throwing tri-state wrapper over the ownership
62
+ * registry (§3.1 item 1). Closes the §2.1 ambiguity: callers today cannot
63
+ * distinguish owner-dark vs unowned vs error (registry reads THROW into the
64
+ * handler's fail-open catch). Reads the cached view only.
65
+ */
66
+ export function resolveOwnershipSafe(sessionKey, deps) {
67
+ try {
68
+ const self = deps.selfMachineId();
69
+ const rec = deps.readOwnership(sessionKey);
70
+ if (!rec || !rec.owner)
71
+ return { kind: 'unowned', owner: null, epoch: rec?.epoch ?? 0 };
72
+ if (self && rec.owner === self)
73
+ return { kind: 'self', owner: rec.owner, epoch: rec.epoch };
74
+ let alive = false;
75
+ try {
76
+ alive = deps.isMachineAlive(rec.owner);
77
+ }
78
+ catch (err) {
79
+ return {
80
+ kind: 'error',
81
+ owner: rec.owner,
82
+ epoch: rec.epoch,
83
+ error: `liveness-read-failed: ${err?.message ?? String(err)}`,
84
+ };
85
+ }
86
+ return { kind: alive ? 'other-alive' : 'other-dark', owner: rec.owner, epoch: rec.epoch };
87
+ }
88
+ catch (err) {
89
+ return {
90
+ kind: 'error',
91
+ owner: null,
92
+ epoch: 0,
93
+ error: `registry-read-failed: ${err?.message ?? String(err)}`,
94
+ };
95
+ }
96
+ }
97
+ export class SpawnAdmission {
98
+ deps;
99
+ flag;
100
+ nowFn;
101
+ episode = {
102
+ open: false,
103
+ openedAt: null,
104
+ episodeId: null,
105
+ consecutiveErrors: 0,
106
+ consecutiveClean: 0,
107
+ recentErrorsAt: [],
108
+ journaledTopics: new Set(),
109
+ episodeOpensAt: [],
110
+ breakerOpen: false,
111
+ };
112
+ counters = {
113
+ admitted: 0,
114
+ wouldBlock: 0,
115
+ refused: 0,
116
+ errorArmSpawns: 0,
117
+ shortCircuits: 0,
118
+ routerVerdictsConsumed: 0,
119
+ };
120
+ constructor(flag, deps) {
121
+ this.flag = flag;
122
+ this.deps = deps;
123
+ this.nowFn = deps.now ?? (() => Date.now());
124
+ }
125
+ /** Boot-read flag, but hot-updatable for tests and config reload paths. */
126
+ setFlag(flag) {
127
+ this.flag = flag;
128
+ }
129
+ /**
130
+ * §3.1 item 6 (round-4 admission-table invariant): `enforce` REQUIRES durable
131
+ * custody — with the inbound queue dark this seam cannot block a spawn
132
+ * outside dryRun, so notice-only refusal can never be wired on the fleet by
133
+ * accident.
134
+ */
135
+ effectiveMode() {
136
+ if (!this.flag.enabled)
137
+ return 'off';
138
+ if (this.flag.dryRun)
139
+ return 'dry-run';
140
+ return this.deps.durableCustodyLive() ? 'enforce' : 'dry-run';
141
+ }
142
+ /**
143
+ * The reconciler + closeout FREEZE while a registry-error episode is open
144
+ * (§3.1 row e — same fault domain; the error arm must not be exploitable to
145
+ * both mint and "heal" duplicates).
146
+ */
147
+ isErrorEpisodeOpen() {
148
+ return this.episode.open;
149
+ }
150
+ admit(input) {
151
+ const mode = this.effectiveMode();
152
+ // Row: single-machine / pool-dark / flag-off short-circuit — byte-identical,
153
+ // zero writes (§3.1 item 5).
154
+ if (mode === 'off' || this.deps.poolStage() === 'dark' || !this.deps.selfMachineId()) {
155
+ this.counters.shortCircuits++;
156
+ return {
157
+ allow: true,
158
+ mode,
159
+ row: 'short-circuit',
160
+ wouldBlock: false,
161
+ reason: mode === 'off' ? 'flag-disabled' : 'single-machine-or-pool-dark',
162
+ };
163
+ }
164
+ // TOCTOU guard (§3.1 item 2): consume the router's verdict for this message
165
+ // instead of re-resolving. `queued`/`placement-blocked` suppress local spawn
166
+ // INDEPENDENTLY of `acked` (§3.1 item 4).
167
+ if (input.routerVerdict) {
168
+ this.counters.routerVerdictsConsumed++;
169
+ const action = input.routerVerdict.action;
170
+ if (action === 'queued' || action === 'placement-blocked') {
171
+ return this.decide(input, mode, {
172
+ row: 'router-queued-suppress',
173
+ refusalAction: 'rung3-notice',
174
+ reason: `router-verdict=${action} (acked=${input.routerVerdict.acked}) suppresses local spawn independently of acked`,
175
+ consumedRouterVerdict: action,
176
+ });
177
+ }
178
+ // Any other consumed verdict fell through the handler by design
179
+ // (handled-locally / spawned-self / rejected-already-returned) — allow.
180
+ this.counters.admitted++;
181
+ return {
182
+ allow: true,
183
+ mode,
184
+ row: 'router-consumed',
185
+ wouldBlock: false,
186
+ reason: `router-verdict=${action} fell through to local dispatch by design`,
187
+ consumedRouterVerdict: action,
188
+ };
189
+ }
190
+ const ownership = resolveOwnershipSafe(input.sessionKey, this.deps);
191
+ switch (ownership.kind) {
192
+ case 'self':
193
+ this.recordCleanResolution();
194
+ this.counters.admitted++;
195
+ return { allow: true, mode, row: 'self', wouldBlock: false, reason: 'this machine owns the conversation', ownership };
196
+ case 'unowned':
197
+ // Row (d): the router's placeAndClaim owns the claim; a seam-level spawn
198
+ // on a genuinely unowned key is today's behavior (the claim result, when
199
+ // one happened, arrives as a consumed router verdict above).
200
+ this.recordCleanResolution();
201
+ this.counters.admitted++;
202
+ return { allow: true, mode, row: 'unowned', wouldBlock: false, reason: 'no ownership record — claim rides the router placeAndClaim path', ownership };
203
+ case 'other-alive':
204
+ this.recordCleanResolution();
205
+ return this.decide(input, mode, {
206
+ row: 'other-alive',
207
+ refusalAction: 'forward',
208
+ reason: `owner ${ownership.owner} is alive — forward, never a local spawn`,
209
+ ownership,
210
+ });
211
+ case 'other-dark':
212
+ this.recordCleanResolution();
213
+ return this.decide(input, mode, {
214
+ row: 'other-dark',
215
+ refusalAction: 'owner-dark-ladder',
216
+ reason: `owner ${ownership.owner} is dark — owner-dark ladder (§3.3), never a bootleg copy`,
217
+ ownership,
218
+ });
219
+ case 'error':
220
+ return this.admitErrorArm(input, mode, ownership);
221
+ }
222
+ }
223
+ /** Shared dry-run/enforce decision shaping for the blocking rows. */
224
+ decide(input, mode, d) {
225
+ const decision = mode === 'enforce'
226
+ ? {
227
+ allow: false,
228
+ mode,
229
+ row: d.row,
230
+ wouldBlock: true,
231
+ refusalAction: d.refusalAction,
232
+ reason: d.reason,
233
+ ownership: d.ownership,
234
+ consumedRouterVerdict: d.consumedRouterVerdict,
235
+ }
236
+ : {
237
+ allow: true,
238
+ mode,
239
+ row: d.row,
240
+ wouldBlock: true,
241
+ reason: `[dry-run would-block] ${d.reason}`,
242
+ ownership: d.ownership,
243
+ consumedRouterVerdict: d.consumedRouterVerdict,
244
+ };
245
+ if (decision.allow)
246
+ this.counters.wouldBlock++;
247
+ else
248
+ this.counters.refused++;
249
+ this.journalDecision(input, decision);
250
+ this.provenanceRow(input, decision);
251
+ return decision;
252
+ }
253
+ /**
254
+ * Row (e): reachability wins over a broken store — spawn locally, BOUNDED:
255
+ * once-per-topic-per-episode journal row + ONE deduped attention item +
256
+ * windowed breaker. Breaker-open degrades the arm to the rung-3 notice floor
257
+ * (enforce mode only — dry-run always allows).
258
+ */
259
+ admitErrorArm(input, mode, ownership) {
260
+ const now = this.nowFn();
261
+ this.episode.consecutiveErrors++;
262
+ this.episode.consecutiveClean = 0;
263
+ this.episode.recentErrorsAt.push(now);
264
+ const windowFloor = now - ERROR_ARM_CONSTANTS.WINDOWED_TRIP_WINDOW_MS;
265
+ this.episode.recentErrorsAt = this.episode.recentErrorsAt.filter((t) => t >= windowFloor);
266
+ if (!this.episode.open) {
267
+ this.openEpisode(now);
268
+ }
269
+ const tripped = this.episode.consecutiveErrors >= ERROR_ARM_CONSTANTS.CONSECUTIVE_TRIP ||
270
+ this.episode.recentErrorsAt.length >= ERROR_ARM_CONSTANTS.WINDOWED_TRIP_COUNT;
271
+ if (tripped && !this.episode.breakerOpen) {
272
+ this.episode.breakerOpen = true;
273
+ this.deps.log(`[SpawnAdmission] error-arm breaker OPEN (${this.episode.consecutiveErrors} consecutive / ${this.episode.recentErrorsAt.length} in window) — episode ${this.episode.episodeId}`);
274
+ }
275
+ // Once-per-topic-per-episode journal row (§3.1 row e).
276
+ if (!this.episode.journaledTopics.has(input.sessionKey)) {
277
+ this.episode.journaledTopics.add(input.sessionKey);
278
+ this.deps.journal({
279
+ ts: new Date(now).toISOString(),
280
+ kind: 'spawn-admission-error',
281
+ episodeId: this.episode.episodeId,
282
+ sessionKey: input.sessionKey,
283
+ callsite: input.callsite,
284
+ error: ownership.error ?? 'unknown',
285
+ breakerOpen: this.episode.breakerOpen,
286
+ mode,
287
+ });
288
+ this.raiseErrorEpisodeAttention(now);
289
+ }
290
+ // Breaker-open + enforce → degrade to the rung-3 notice floor. Dry-run and
291
+ // pre-trip enforce keep failing toward the spawn (reachability wins).
292
+ if (this.episode.breakerOpen && mode === 'enforce') {
293
+ const decision = {
294
+ allow: false,
295
+ mode,
296
+ row: 'error',
297
+ wouldBlock: true,
298
+ refusalAction: 'rung3-notice',
299
+ reason: 'registry-error breaker open — degraded to rung-3 notice floor until the episode closes',
300
+ ownership,
301
+ };
302
+ this.counters.refused++;
303
+ this.provenanceRow(input, decision);
304
+ return decision;
305
+ }
306
+ this.counters.errorArmSpawns++;
307
+ const decision = {
308
+ allow: true,
309
+ mode,
310
+ row: 'error',
311
+ wouldBlock: this.episode.breakerOpen,
312
+ reason: `ownership resolution failed (${ownership.error ?? 'unknown'}) — reachability wins over a broken store, bounded + loud`,
313
+ ownership,
314
+ };
315
+ this.provenanceRow(input, decision);
316
+ return decision;
317
+ }
318
+ openEpisode(now) {
319
+ const winFloor = now - ERROR_ARM_CONSTANTS.EPISODES_WINDOW_MS;
320
+ this.episode.episodeOpensAt = this.episode.episodeOpensAt.filter((t) => t >= winFloor);
321
+ this.episode.episodeOpensAt.push(now);
322
+ this.episode.open = true;
323
+ this.episode.openedAt = now;
324
+ this.episode.episodeId = `err-${now.toString(36)}`;
325
+ this.episode.journaledTopics = new Set();
326
+ this.deps.log(`[SpawnAdmission] registry-error episode OPEN (${this.episode.episodeId})`);
327
+ }
328
+ /** Hysteresis: an episode closes only after J consecutive clean resolutions. */
329
+ recordCleanResolution() {
330
+ if (!this.episode.open)
331
+ return;
332
+ this.episode.consecutiveErrors = 0;
333
+ this.episode.consecutiveClean++;
334
+ if (this.episode.consecutiveClean >= ERROR_ARM_CONSTANTS.HYSTERESIS_CLEAN_CLOSES) {
335
+ this.deps.log(`[SpawnAdmission] registry-error episode CLOSED after ${this.episode.consecutiveClean} clean resolutions (${this.episode.episodeId})`);
336
+ this.episode.open = false;
337
+ this.episode.openedAt = null;
338
+ this.episode.episodeId = null;
339
+ this.episode.breakerOpen = false;
340
+ this.episode.consecutiveClean = 0;
341
+ this.episode.recentErrorsAt = [];
342
+ this.episode.journaledTopics = new Set();
343
+ }
344
+ }
345
+ /** FD11 wording; dedupe key `spawn-admission-error:<machineId>:<episode>`. */
346
+ raiseErrorEpisodeAttention(now) {
347
+ const machine = this.deps.selfMachineId() ?? 'this machine';
348
+ const winFloor = now - ERROR_ARM_CONSTANTS.EPISODES_WINDOW_MS;
349
+ const episodesIn24h = this.episode.episodeOpensAt.filter((t) => t >= winFloor).length;
350
+ const priority = episodesIn24h >= ERROR_ARM_CONSTANTS.EPISODES_HIGH_THRESHOLD ? 'high' : 'medium';
351
+ const errCount = this.episode.recentErrorsAt.length;
352
+ try {
353
+ this.deps.raiseAttention({
354
+ id: `spawn-admission-error:${machine}:${this.episode.episodeId}`,
355
+ title: 'Conversation-ownership records unreadable — duplicates possible',
356
+ body: `I couldn't read conversation-ownership records on ${machine} ` +
357
+ `(${errCount} failures in ${Math.round(ERROR_ARM_CONSTANTS.WINDOWED_TRIP_WINDOW_MS / 60000)} min), ` +
358
+ `so new conversations there are answered locally and duplicates are possible until this clears. ` +
359
+ `Details: logs/owner-dark-ladder.jsonl (episode ${this.episode.episodeId}).`,
360
+ priority,
361
+ });
362
+ }
363
+ catch (err) {
364
+ this.deps.log(`[SpawnAdmission] attention raise failed (non-fatal): ${err?.message ?? err}`);
365
+ }
366
+ }
367
+ journalDecision(input, decision) {
368
+ try {
369
+ this.deps.journal({
370
+ ts: new Date(this.nowFn()).toISOString(),
371
+ kind: 'spawn-admission-decision',
372
+ sessionKey: input.sessionKey,
373
+ callsite: input.callsite,
374
+ row: decision.row,
375
+ mode: decision.mode,
376
+ allow: decision.allow,
377
+ wouldBlock: decision.wouldBlock,
378
+ refusalAction: decision.refusalAction ?? null,
379
+ owner: decision.ownership?.owner ?? null,
380
+ consumedRouterVerdict: decision.consumedRouterVerdict ?? null,
381
+ });
382
+ }
383
+ catch {
384
+ /* @silent-fallback-ok: the journal is observability — a write failure must never affect admission. */
385
+ }
386
+ }
387
+ /** Deterministic-verdict provenance rows from the seam (§3.5, Increment 1). */
388
+ provenanceRow(input, decision) {
389
+ if (!this.deps.provenance)
390
+ return;
391
+ try {
392
+ this.deps.provenance({
393
+ component: 'SpawnAdmission',
394
+ decisionPoint: 'may-this-machine-spawn-for-this-topic',
395
+ context: {
396
+ sessionKey: input.sessionKey,
397
+ callsite: input.callsite,
398
+ ownershipKind: decision.ownership?.kind ?? null,
399
+ owner: decision.ownership?.owner ?? null,
400
+ consumedRouterVerdict: decision.consumedRouterVerdict ?? null,
401
+ mode: decision.mode,
402
+ },
403
+ optionsPresented: ['spawn', 'forward', 'owner-dark-ladder', 'rung3-notice'],
404
+ decision: decision.allow ? 'spawn' : decision.refusalAction,
405
+ reason: decision.reason,
406
+ floor: 'admission-table-a-e (deterministic; invariant row — never delegated)',
407
+ fallbackRung: 'deterministic',
408
+ });
409
+ }
410
+ catch {
411
+ /* @silent-fallback-ok: provenance is observability — a write failure must never affect admission. */
412
+ }
413
+ }
414
+ status() {
415
+ const now = this.nowFn();
416
+ const winFloor = now - ERROR_ARM_CONSTANTS.EPISODES_WINDOW_MS;
417
+ const mode = this.effectiveMode();
418
+ return {
419
+ mode,
420
+ enforceBlockedBy: !this.flag.enabled
421
+ ? 'flag-disabled'
422
+ : this.flag.dryRun
423
+ ? 'dry-run'
424
+ : this.deps.durableCustodyLive()
425
+ ? null
426
+ : 'durable-custody-dark',
427
+ errorEpisode: {
428
+ open: this.episode.open,
429
+ openedAt: this.episode.openedAt ? new Date(this.episode.openedAt).toISOString() : null,
430
+ episodeId: this.episode.episodeId,
431
+ breakerOpen: this.episode.breakerOpen,
432
+ consecutiveErrors: this.episode.consecutiveErrors,
433
+ consecutiveClean: this.episode.consecutiveClean,
434
+ episodesIn24h: this.episode.episodeOpensAt.filter((t) => t >= winFloor).length,
435
+ },
436
+ counters: { ...this.counters },
437
+ };
438
+ }
439
+ }
440
+ //# sourceMappingURL=SpawnAdmission.js.map