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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +498 -3
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +69 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/BackupManager.d.ts +10 -0
- package/dist/core/BackupManager.d.ts.map +1 -1
- package/dist/core/BackupManager.js +16 -0
- package/dist/core/BackupManager.js.map +1 -1
- package/dist/core/BoundedJsonlAudit.d.ts +37 -0
- package/dist/core/BoundedJsonlAudit.d.ts.map +1 -0
- package/dist/core/BoundedJsonlAudit.js +87 -0
- package/dist/core/BoundedJsonlAudit.js.map +1 -0
- package/dist/core/JudgmentProvenanceLog.d.ts +144 -0
- package/dist/core/JudgmentProvenanceLog.d.ts.map +1 -0
- package/dist/core/JudgmentProvenanceLog.js +301 -0
- package/dist/core/JudgmentProvenanceLog.js.map +1 -0
- package/dist/core/MachineIdentity.d.ts.map +1 -1
- package/dist/core/MachineIdentity.js +2 -0
- package/dist/core/MachineIdentity.js.map +1 -1
- package/dist/core/OwnerDarkLadder.d.ts +108 -0
- package/dist/core/OwnerDarkLadder.d.ts.map +1 -0
- package/dist/core/OwnerDarkLadder.js +229 -0
- package/dist/core/OwnerDarkLadder.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts +10 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +106 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SpawnAdmission.d.ts +218 -0
- package/dist/core/SpawnAdmission.d.ts.map +1 -0
- package/dist/core/SpawnAdmission.js +440 -0
- package/dist/core/SpawnAdmission.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +24 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/machineCoherenceManifest.d.ts.map +1 -1
- package/dist/core/machineCoherenceManifest.js +30 -0
- package/dist/core/machineCoherenceManifest.js.map +1 -1
- package/dist/core/types.d.ts +87 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/DuplicateSessionReconciler.d.ts +225 -0
- package/dist/monitoring/DuplicateSessionReconciler.d.ts.map +1 -0
- package/dist/monitoring/DuplicateSessionReconciler.js +544 -0
- package/dist/monitoring/DuplicateSessionReconciler.js.map +1 -0
- package/dist/monitoring/FailureAnalyzer.d.ts.map +1 -1
- package/dist/monitoring/FailureAnalyzer.js +40 -1
- package/dist/monitoring/FailureAnalyzer.js.map +1 -1
- package/dist/monitoring/FailureLedger.d.ts +9 -0
- package/dist/monitoring/FailureLedger.d.ts.map +1 -1
- package/dist/monitoring/FailureLedger.js +21 -4
- package/dist/monitoring/FailureLedger.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +2 -1
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +5 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +4 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +9 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/fileRoutes.d.ts +16 -0
- package/dist/server/fileRoutes.d.ts.map +1 -1
- package/dist/server/fileRoutes.js +46 -0
- package/dist/server/fileRoutes.js.map +1 -1
- package/dist/server/routes.d.ts +7 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +99 -0
- package/dist/server/routes.js.map +1 -1
- package/dist/testing/selfActionRegistry.d.ts.map +1 -1
- package/dist/testing/selfActionRegistry.js +89 -0
- package/dist/testing/selfActionRegistry.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lib/self-action-detect.mjs +2 -0
- package/skills/instar-dev/templates/side-effects-artifact.md +8 -0
- package/skills/spec-converge/SKILL.md +1 -0
- package/skills/spec-converge/scripts/write-convergence-tag.mjs +71 -0
- package/src/data/builtin-manifest.json +65 -65
- package/src/scaffold/templates.ts +2 -1
- package/upgrades/1.3.820.md +25 -0
- package/upgrades/1.3.821.md +22 -0
- package/upgrades/side-effects/ownership-gated-spawn-increment-1.md +166 -0
- 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
|