instar 1.3.732 → 1.3.734
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 +127 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/core/ConversationRegistry.d.ts +95 -0
- package/dist/core/ConversationRegistry.d.ts.map +1 -1
- package/dist/core/ConversationRegistry.js +292 -5
- package/dist/core/ConversationRegistry.js.map +1 -1
- package/dist/core/SessionManager.d.ts +15 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +28 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/conversationBindToken.d.ts +36 -0
- package/dist/core/conversationBindToken.d.ts.map +1 -0
- package/dist/core/conversationBindToken.js +144 -0
- package/dist/core/conversationBindToken.js.map +1 -0
- package/dist/core/conversationIdentity.d.ts +14 -0
- package/dist/core/conversationIdentity.d.ts.map +1 -1
- package/dist/core/conversationIdentity.js +19 -0
- package/dist/core/conversationIdentity.js.map +1 -1
- package/dist/core/deliverToConversation.d.ts +68 -25
- package/dist/core/deliverToConversation.d.ts.map +1 -1
- package/dist/core/deliverToConversation.js +300 -11
- package/dist/core/deliverToConversation.js.map +1 -1
- package/dist/core/slackRefreshBinding.d.ts +8 -10
- package/dist/core/slackRefreshBinding.d.ts.map +1 -1
- package/dist/core/slackRefreshBinding.js +8 -16
- package/dist/core/slackRefreshBinding.js.map +1 -1
- package/dist/core/types.d.ts +9 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/CommitmentTracker.d.ts +64 -0
- package/dist/monitoring/CommitmentTracker.d.ts.map +1 -1
- package/dist/monitoring/CommitmentTracker.js +58 -0
- package/dist/monitoring/CommitmentTracker.js.map +1 -1
- package/dist/monitoring/PromiseBeacon.d.ts +70 -0
- package/dist/monitoring/PromiseBeacon.d.ts.map +1 -1
- package/dist/monitoring/PromiseBeacon.js +221 -24
- package/dist/monitoring/PromiseBeacon.js.map +1 -1
- package/dist/server/AgentServer.d.ts +9 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +8 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +12 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +128 -10
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +48 -48
- package/upgrades/1.3.733.md +108 -0
- package/upgrades/1.3.734.md +30 -0
- package/upgrades/side-effects/durable-conversation-identity-increment2.md +88 -0
- package/upgrades/side-effects/three-constitutional-standards.md +129 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ConversationTuple } from './conversationIdentity.js';
|
|
1
2
|
/** Entry origins (§3.4). `deliverToConversation` resolves ONLY the three local
|
|
2
3
|
* origins; a pure `replicated` entry is read-context (KYP, §3.5). */
|
|
3
4
|
export type ConversationOrigin = 'adopted-legacy-hash' | 'minted-probed' | 'adopted-replicated' | 'replicated';
|
|
@@ -95,6 +96,16 @@ export interface ConversationRegistryDeps {
|
|
|
95
96
|
snapshotAdaptiveStep?: number;
|
|
96
97
|
snapshotMaxIntervalMs?: number;
|
|
97
98
|
}
|
|
99
|
+
/** §5.0(a) pinned E1 constants (R3-M1/R7-M2). Exported for the §10 tests. */
|
|
100
|
+
export declare const AMBIGUOUS_DEDUP_TTL_MS = 604800000;
|
|
101
|
+
export declare const CONTENT_HASH_DEDUP_WINDOW_MS = 900000;
|
|
102
|
+
/** §5.0(a) pathological TRIPWIRE, not a correctness bound (R4-M2/R5-minor-1):
|
|
103
|
+
* reached-with-all-live raises ONE aggregated attention item while the new
|
|
104
|
+
* entry is STILL journaled + retained — nothing is ever dropped. */
|
|
105
|
+
export declare const AMBIGUOUS_SENDS_HARD_CAP = 1000;
|
|
106
|
+
/** §5.1 reachability flap dampening (R3-minor). */
|
|
107
|
+
export declare const REACHABILITY_FLAP_THRESHOLD = 3;
|
|
108
|
+
export declare const REACHABILITY_FLAP_WINDOW_MS = 86400000;
|
|
98
109
|
export declare class ConversationRegistry {
|
|
99
110
|
private readonly d;
|
|
100
111
|
private readonly snapshotPath;
|
|
@@ -109,6 +120,11 @@ export declare class ConversationRegistry {
|
|
|
109
120
|
private bindPins;
|
|
110
121
|
private ambiguousSends;
|
|
111
122
|
private sendIntents;
|
|
123
|
+
/** R9-minor-1: parseable send-intent lines with the lane field STRIPPED —
|
|
124
|
+
* resolved toward RETRY at replay, surfaced once (never a suppressor). */
|
|
125
|
+
private missingLaneIntents;
|
|
126
|
+
/** §5.1 reachability flap tracking (in-memory, advisory — R3-minor). */
|
|
127
|
+
private reachabilityFlips;
|
|
112
128
|
private workspacePin;
|
|
113
129
|
private seqCounter;
|
|
114
130
|
private snapshotHighWaterSeq;
|
|
@@ -224,6 +240,12 @@ export declare class ConversationRegistry {
|
|
|
224
240
|
resolve(id: number): ConversationDescriptor | null;
|
|
225
241
|
/** Forward lookup by canonical key OR transport sessionKey — mints NOTHING (§8). */
|
|
226
242
|
resolveByKey(keyOrSessionKey: string): ConversationDescriptor | null;
|
|
243
|
+
/**
|
|
244
|
+
* §4 READ-ONLY id for a routing key (integration-nit): the existing entry's
|
|
245
|
+
* id, else the B6 collision-checked candidate — NEVER a registration. A pure
|
|
246
|
+
* comparison callsite must not acquire a get-or-create WRITE side-effect.
|
|
247
|
+
*/
|
|
248
|
+
readIdForRoutingKey(routingKey: string): number | null;
|
|
227
249
|
/**
|
|
228
250
|
* `idForSessionKey` — GET-OR-CREATE (§6.0 #12, a named mint chokepoint).
|
|
229
251
|
* Positive numeric session keys pass through; Slack routing keys mint.
|
|
@@ -239,6 +261,79 @@ export declare class ConversationRegistry {
|
|
|
239
261
|
}>;
|
|
240
262
|
aliasTable(): Record<string, number>;
|
|
241
263
|
entryCount(): number;
|
|
264
|
+
/**
|
|
265
|
+
* Record a durable bind on a minted id (§3.5.2 property 2/4): the pin binds
|
|
266
|
+
* the id to the TUPLE it resolves to AT BIND TIME, refcounted per live
|
|
267
|
+
* binding, journaled (`op:"bind-pin"`, fsynced — the bind's own WAL line).
|
|
268
|
+
* Returns the bind-time tuple (the caller denormalizes it as `boundTuple`
|
|
269
|
+
* onto the binding record — property 5), or null when the id is unresolvable.
|
|
270
|
+
*/
|
|
271
|
+
bindPin(id: number): ConversationTuple | null;
|
|
272
|
+
/** Release one refcount on a bind-pin (`op:"bind-release"`, §3.5.2 property 4).
|
|
273
|
+
* The pin is released only at ZERO — a commitment closing never strands a
|
|
274
|
+
* still-live sibling binding on the same id. No-op on a missing pin. */
|
|
275
|
+
bindRelease(id: number): void;
|
|
276
|
+
/** The live bind-pin tuple for an id (delivery-time overlay read — §3.5.2 property 3). */
|
|
277
|
+
getBindPinTuple(id: number): ConversationTuple | null;
|
|
278
|
+
private sendGuardKey;
|
|
279
|
+
/**
|
|
280
|
+
* Durable SEND-INTENT (R6-M1): appended+fsynced BEFORE the transport send,
|
|
281
|
+
* stamped with the caller's lane (R8-M1 — the boot-conversion discriminator,
|
|
282
|
+
* never inferred from the id's shape at boot).
|
|
283
|
+
*/
|
|
284
|
+
recordSendIntent(conversationId: number, logicalSendId: string, lane: 'logical' | 'content-hash'): void;
|
|
285
|
+
/**
|
|
286
|
+
* Record the E1 suppressor entry — ONLY on a likely-posted outcome (success
|
|
287
|
+
* or ambiguous/ack-lost; NEVER a clean transient failure — R2-security-NEW-3).
|
|
288
|
+
* Durable (`op:"ambiguous-send"`, fsynced — R4-M2); resolves the pair's
|
|
289
|
+
* send-intent. The in-memory map is the loaded image of this durable state.
|
|
290
|
+
*/
|
|
291
|
+
recordLikelyPosted(conversationId: number, logicalSendId: string, lane: 'logical' | 'content-hash'): void;
|
|
292
|
+
/** Retire a logical send (`op:"send-retire"` — delivered outcome advanced the
|
|
293
|
+
* seq, or the commitment closed). Idempotent; also resolves the intent. */
|
|
294
|
+
retireSend(conversationId: number, logicalSendId: string): void;
|
|
295
|
+
/** Resolve a send-intent WITHOUT a suppressor (`op:"send-intent-resolved"`) —
|
|
296
|
+
* the clean-transient-failure path (positive evidence the message never
|
|
297
|
+
* posted), so the retry is NOT suppressed (R2-security-NEW-3/R6-M1). */
|
|
298
|
+
resolveSendIntent(conversationId: number, logicalSendId: string): void;
|
|
299
|
+
/**
|
|
300
|
+
* The E1 suppression read (§5.0(a) R3-M1/R7-M2): the LOGICAL lane is
|
|
301
|
+
* retirement-based (an unretired entry suppresses whenever the re-fire
|
|
302
|
+
* arrives, bounded only by the 7-day TTL backstop); the CONTENT-HASH lane is
|
|
303
|
+
* WINDOW-based (a windowless caller has nothing to retire — outside the
|
|
304
|
+
* 15-min window the same text delivers again).
|
|
305
|
+
*/
|
|
306
|
+
isSendSuppressed(conversationId: number, logicalSendId: string, lane: 'logical' | 'content-hash'): boolean;
|
|
307
|
+
/** Prune RETIRED/EXPIRED stragglers only (never a live unretired logical
|
|
308
|
+
* entry below its TTL — R4-M2 second arm). An expired content-hash entry
|
|
309
|
+
* prunes exactly like a retired one (R7-M2). */
|
|
310
|
+
pruneSendGuardState(): void;
|
|
311
|
+
/**
|
|
312
|
+
* R8-M1 lane-scoped boot conversion of crash-orphaned send-intents: an intent
|
|
313
|
+
* that is the LAST word for its (conversationId, logicalSendId) PAIR (R7-M3
|
|
314
|
+
* composite keying — replay already resolved every superseded pair) resolves
|
|
315
|
+
* BY ITS RECORDED LANE: `logical` (beacon) converts into an ambiguous-send
|
|
316
|
+
* suppressor (the outcome is genuinely unknown and the beacon has a next
|
|
317
|
+
* tick); `content-hash` resolves toward RETRY (the suppressed send IS the
|
|
318
|
+
* message — replay appends the missing send-intent-resolved durably, so the
|
|
319
|
+
* verdict is decided once, not re-decided every boot). Staged appends land
|
|
320
|
+
* POST-compose, fsynced before the registry begins serving (R9-low-3 /
|
|
321
|
+
* R10-minor-1).
|
|
322
|
+
*/
|
|
323
|
+
private convertOrphanedSendIntents;
|
|
324
|
+
/**
|
|
325
|
+
* Flip an entry's LOCAL-authoritative reachability (§5.1). IDEMPOTENT
|
|
326
|
+
* (already-in-state → no write) and journaled WITHOUT fsync — it rides the
|
|
327
|
+
* same batched flush, so a mass event coalesces into one write, never an
|
|
328
|
+
* O(N) write storm on the failure path (scalability-F2). Returns whether the
|
|
329
|
+
* state changed and whether the conversation is in the R3-minor FLAP-DAMPENED
|
|
330
|
+
* state (>3 flips in 24h — the caller coalesces its dead-letter attention
|
|
331
|
+
* into one per-window flap item instead of one per episode).
|
|
332
|
+
*/
|
|
333
|
+
setReachability(id: number, state: 'ok' | 'unreachable'): {
|
|
334
|
+
changed: boolean;
|
|
335
|
+
dampened: boolean;
|
|
336
|
+
};
|
|
242
337
|
/**
|
|
243
338
|
* Idempotent boot-time ensure: pre-register channel-level conversations from
|
|
244
339
|
* the channel registry with their legacy-hash ids, inside ONE batched-save
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConversationRegistry.d.ts","sourceRoot":"","sources":["../../src/core/ConversationRegistry.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ConversationRegistry.d.ts","sourceRoot":"","sources":["../../src/core/ConversationRegistry.ts"],"names":[],"mappings":"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"}
|
|
@@ -47,6 +47,16 @@ export const JOURNAL_OPS = [
|
|
|
47
47
|
'send-intent',
|
|
48
48
|
'send-intent-resolved',
|
|
49
49
|
];
|
|
50
|
+
/** §5.0(a) pinned E1 constants (R3-M1/R7-M2). Exported for the §10 tests. */
|
|
51
|
+
export const AMBIGUOUS_DEDUP_TTL_MS = 604800000; // 7 days — safety bound ONLY, never the suppression mechanism (logical lane)
|
|
52
|
+
export const CONTENT_HASH_DEDUP_WINDOW_MS = 900000; // 15 min — the fallback lane's WINDOW (mirrors the Telegram exact-duplicate window)
|
|
53
|
+
/** §5.0(a) pathological TRIPWIRE, not a correctness bound (R4-M2/R5-minor-1):
|
|
54
|
+
* reached-with-all-live raises ONE aggregated attention item while the new
|
|
55
|
+
* entry is STILL journaled + retained — nothing is ever dropped. */
|
|
56
|
+
export const AMBIGUOUS_SENDS_HARD_CAP = 1000;
|
|
57
|
+
/** §5.1 reachability flap dampening (R3-minor). */
|
|
58
|
+
export const REACHABILITY_FLAP_THRESHOLD = 3;
|
|
59
|
+
export const REACHABILITY_FLAP_WINDOW_MS = 86400000; // 24h
|
|
50
60
|
const ENTRY_CEILING = 50000; // §3.4 JSON-store design ceiling
|
|
51
61
|
const ENTRY_THRESHOLD = 40000; // 80% of ceiling — the pinned tripwire (R3-minor)
|
|
52
62
|
const FILE_BYTES_THRESHOLD = 8 * 1024 * 1024; // 80% of ~10MB
|
|
@@ -67,10 +77,15 @@ export class ConversationRegistry {
|
|
|
67
77
|
reservedCanonicals = new Map(); // cand → owning tupleKey
|
|
68
78
|
displacedAssignments = new Map(); // offset → owning tupleKey (GLOBAL — R4-C1)
|
|
69
79
|
candClaimants = new Map(); // cand → claimant tupleKeys
|
|
70
|
-
// ── §5.0(a)/§3.5.2 journal-applied state (writers
|
|
80
|
+
// ── §5.0(a)/§3.5.2 journal-applied state (writers: §6.1 increment 2) ──
|
|
71
81
|
bindPins = new Map();
|
|
72
82
|
ambiguousSends = new Map();
|
|
73
83
|
sendIntents = new Map();
|
|
84
|
+
/** R9-minor-1: parseable send-intent lines with the lane field STRIPPED —
|
|
85
|
+
* resolved toward RETRY at replay, surfaced once (never a suppressor). */
|
|
86
|
+
missingLaneIntents = 0;
|
|
87
|
+
/** §5.1 reachability flap tracking (in-memory, advisory — R3-minor). */
|
|
88
|
+
reachabilityFlips = new Map();
|
|
74
89
|
workspacePin = null;
|
|
75
90
|
// ── Journal state ──
|
|
76
91
|
seqCounter = 0;
|
|
@@ -149,6 +164,11 @@ export class ConversationRegistry {
|
|
|
149
164
|
this.replayJournal();
|
|
150
165
|
this.rebuildDerivedIndexes();
|
|
151
166
|
this.applyAliasAssignmentFilter(); // R6-M3 pin 2: the disjointness invariant holds at every BOOT fixpoint
|
|
167
|
+
this.convertOrphanedSendIntents(); // R8-M1 lane-scoped boot conversion (staged post-compose, fsynced before serving — R9-low-3/R10-minor-1)
|
|
168
|
+
this.pruneSendGuardState();
|
|
169
|
+
if (this.missingLaneIntents > 0) {
|
|
170
|
+
this.attention('conversation-registry:missing-lane-intent', 'Conversation registry: send-intent record(s) with a stripped lane field', `${this.missingLaneIntents} parseable send-intent journal record(s) carried no lane discriminator (R9-minor-1). Each was resolved toward RETRY (content-hash treatment — loss is never silent, never a suppressor). This indicates a serialization bug worth a look.`);
|
|
171
|
+
}
|
|
152
172
|
if (this.unappliedUnknownOps.length > 0) {
|
|
153
173
|
const first = this.unappliedUnknownOps[0];
|
|
154
174
|
const kinds = [...new Set(this.unappliedUnknownOps.map((u) => u.op))].join(', ');
|
|
@@ -197,8 +217,12 @@ export class ConversationRegistry {
|
|
|
197
217
|
}
|
|
198
218
|
}
|
|
199
219
|
for (const [k, v] of Object.entries(raw.ambiguousSends ?? {})) {
|
|
200
|
-
if (v && typeof v.recordedAt === 'string')
|
|
201
|
-
this.ambiguousSends.set(k,
|
|
220
|
+
if (v && typeof v.recordedAt === 'string') {
|
|
221
|
+
this.ambiguousSends.set(k, {
|
|
222
|
+
recordedAt: v.recordedAt,
|
|
223
|
+
...(v.lane === 'logical' || v.lane === 'content-hash' ? { lane: v.lane } : {}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
202
226
|
}
|
|
203
227
|
for (const [k, v] of Object.entries(raw.sendIntents ?? {})) {
|
|
204
228
|
if (v && (v.lane === 'logical' || v.lane === 'content-hash') && typeof v.seq === 'number')
|
|
@@ -411,7 +435,10 @@ export class ConversationRegistry {
|
|
|
411
435
|
case 'ambiguous-send': {
|
|
412
436
|
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
413
437
|
return;
|
|
414
|
-
this.ambiguousSends.set(`${rec.conversationId}|${rec.logicalSendId}`, {
|
|
438
|
+
this.ambiguousSends.set(`${rec.conversationId}|${rec.logicalSendId}`, {
|
|
439
|
+
recordedAt: rec.ts,
|
|
440
|
+
...(rec.lane === 'logical' || rec.lane === 'content-hash' ? { lane: rec.lane } : {}),
|
|
441
|
+
});
|
|
415
442
|
this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
416
443
|
return;
|
|
417
444
|
}
|
|
@@ -426,7 +453,10 @@ export class ConversationRegistry {
|
|
|
426
453
|
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
427
454
|
return;
|
|
428
455
|
// Malformed/unknown lane resolves toward RETRY (content-hash treatment)
|
|
429
|
-
// — R9-minor-1/R10-low-2 (loss-is-never-silent picks retry).
|
|
456
|
+
// — R9-minor-1/R10-low-2 (loss-is-never-silent picks retry). Counted so
|
|
457
|
+
// the post-replay boot pass can raise ONE deduped attention item.
|
|
458
|
+
if (rec.lane !== 'logical' && rec.lane !== 'content-hash')
|
|
459
|
+
this.missingLaneIntents++;
|
|
430
460
|
const lane = rec.lane === 'logical' ? 'logical' : 'content-hash';
|
|
431
461
|
this.sendIntents.set(`${rec.conversationId}|${rec.logicalSendId}`, { lane, seq: rec.seq });
|
|
432
462
|
return;
|
|
@@ -793,6 +823,19 @@ export class ConversationRegistry {
|
|
|
793
823
|
const existing = this.byTuple.get(tupleKeyFor(tuple));
|
|
794
824
|
if (existing) {
|
|
795
825
|
this.maybeUpgradeWorkspace(existing);
|
|
826
|
+
// WAL rule at durable-bind time (§3.3): a pre-existing SPECULATIVE entry
|
|
827
|
+
// rode the batched snapshot only — its journal line may not exist yet. A
|
|
828
|
+
// durable binding is not re-derivable after a crash, so ensure the entry
|
|
829
|
+
// is durably journaled NOW (idempotent op:"mint" re-apply; fsynced)
|
|
830
|
+
// BEFORE the id is handed to the binding.
|
|
831
|
+
this.appendJournal({
|
|
832
|
+
op: 'mint',
|
|
833
|
+
key: canonicalKeyFor(tuple, existing.workspaceId),
|
|
834
|
+
tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
|
|
835
|
+
id: existing.id,
|
|
836
|
+
origin: existing.origin,
|
|
837
|
+
hlc: existing.hlc,
|
|
838
|
+
}, { fsync: true });
|
|
796
839
|
return { ok: true, id: existing.id, created: false };
|
|
797
840
|
}
|
|
798
841
|
const w = this.breakerWindow(tuple.channelId);
|
|
@@ -845,6 +888,11 @@ export class ConversationRegistry {
|
|
|
845
888
|
existing.label = opts.label; // write-on-change (§3.4 G4)
|
|
846
889
|
this.scheduleSnapshot();
|
|
847
890
|
}
|
|
891
|
+
// §5.1 reachability auto-clear: an authenticated inbound on the tuple is
|
|
892
|
+
// positive evidence the conversation is reachable again (re-invited /
|
|
893
|
+
// un-archived channel is never stuck).
|
|
894
|
+
if (existing.reachability === 'unreachable')
|
|
895
|
+
this.setReachability(existing.id, 'ok');
|
|
848
896
|
return { id: existing.id, created: false, registered: true };
|
|
849
897
|
}
|
|
850
898
|
const routingKey = routingKeyForTuple(tuple);
|
|
@@ -997,6 +1045,22 @@ export class ConversationRegistry {
|
|
|
997
1045
|
const entry = this.byTuple.get(tupleKeyFor(tuple));
|
|
998
1046
|
return entry ? this.resolve(entry.id) : null;
|
|
999
1047
|
}
|
|
1048
|
+
/**
|
|
1049
|
+
* §4 READ-ONLY id for a routing key (integration-nit): the existing entry's
|
|
1050
|
+
* id, else the B6 collision-checked candidate — NEVER a registration. A pure
|
|
1051
|
+
* comparison callsite must not acquire a get-or-create WRITE side-effect.
|
|
1052
|
+
*/
|
|
1053
|
+
readIdForRoutingKey(routingKey) {
|
|
1054
|
+
this.load();
|
|
1055
|
+
const tuple = tupleForRoutingKey(routingKey);
|
|
1056
|
+
if (!tuple)
|
|
1057
|
+
return null;
|
|
1058
|
+
const tKey = tupleKeyFor(tuple);
|
|
1059
|
+
const existing = this.byTuple.get(tKey);
|
|
1060
|
+
if (existing)
|
|
1061
|
+
return existing.id;
|
|
1062
|
+
return this.collisionCheckedRead(candidateIdForRoutingKey(routingKey), tKey);
|
|
1063
|
+
}
|
|
1000
1064
|
/**
|
|
1001
1065
|
* `idForSessionKey` — GET-OR-CREATE (§6.0 #12, a named mint chokepoint).
|
|
1002
1066
|
* Positive numeric session keys pass through; Slack routing keys mint.
|
|
@@ -1030,6 +1094,223 @@ export class ConversationRegistry {
|
|
|
1030
1094
|
return this.conversations.size;
|
|
1031
1095
|
}
|
|
1032
1096
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1097
|
+
// Bind-pin overlay (§3.5.2 — increment 2 writers)
|
|
1098
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1099
|
+
/**
|
|
1100
|
+
* Record a durable bind on a minted id (§3.5.2 property 2/4): the pin binds
|
|
1101
|
+
* the id to the TUPLE it resolves to AT BIND TIME, refcounted per live
|
|
1102
|
+
* binding, journaled (`op:"bind-pin"`, fsynced — the bind's own WAL line).
|
|
1103
|
+
* Returns the bind-time tuple (the caller denormalizes it as `boundTuple`
|
|
1104
|
+
* onto the binding record — property 5), or null when the id is unresolvable.
|
|
1105
|
+
*/
|
|
1106
|
+
bindPin(id) {
|
|
1107
|
+
this.load();
|
|
1108
|
+
const entry = this.byId.get(this.aliases.get(id) ?? id);
|
|
1109
|
+
if (!entry)
|
|
1110
|
+
return null;
|
|
1111
|
+
const tuple = ['slack', entry.channelId, entry.threadTs];
|
|
1112
|
+
const pin = this.bindPins.get(id);
|
|
1113
|
+
const refcount = pin ? pin.refcount + 1 : 1;
|
|
1114
|
+
if (pin)
|
|
1115
|
+
pin.refcount = refcount;
|
|
1116
|
+
else
|
|
1117
|
+
this.bindPins.set(id, { tuple, refcount });
|
|
1118
|
+
this.appendJournal({ op: 'bind-pin', id, tuple, refcount }, { fsync: true });
|
|
1119
|
+
this.scheduleSnapshot();
|
|
1120
|
+
return { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
|
|
1121
|
+
}
|
|
1122
|
+
/** Release one refcount on a bind-pin (`op:"bind-release"`, §3.5.2 property 4).
|
|
1123
|
+
* The pin is released only at ZERO — a commitment closing never strands a
|
|
1124
|
+
* still-live sibling binding on the same id. No-op on a missing pin. */
|
|
1125
|
+
bindRelease(id) {
|
|
1126
|
+
this.load();
|
|
1127
|
+
const pin = this.bindPins.get(id);
|
|
1128
|
+
if (!pin)
|
|
1129
|
+
return;
|
|
1130
|
+
const refcount = pin.refcount - 1;
|
|
1131
|
+
this.appendJournal({ op: 'bind-release', id, refcount }, { fsync: true });
|
|
1132
|
+
if (refcount <= 0)
|
|
1133
|
+
this.bindPins.delete(id);
|
|
1134
|
+
else
|
|
1135
|
+
pin.refcount = refcount;
|
|
1136
|
+
this.scheduleSnapshot();
|
|
1137
|
+
}
|
|
1138
|
+
/** The live bind-pin tuple for an id (delivery-time overlay read — §3.5.2 property 3). */
|
|
1139
|
+
getBindPinTuple(id) {
|
|
1140
|
+
this.load();
|
|
1141
|
+
const pin = this.bindPins.get(id);
|
|
1142
|
+
if (!pin)
|
|
1143
|
+
return null;
|
|
1144
|
+
return { platform: 'slack', channelId: pin.tuple[1], threadTs: pin.tuple[2] ?? null };
|
|
1145
|
+
}
|
|
1146
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1147
|
+
// E1 ambiguous-outcome idempotency guard state (§5.0(a) — increment 2 writers)
|
|
1148
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1149
|
+
sendGuardKey(conversationId, logicalSendId) {
|
|
1150
|
+
return `${conversationId}|${logicalSendId}`;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Durable SEND-INTENT (R6-M1): appended+fsynced BEFORE the transport send,
|
|
1154
|
+
* stamped with the caller's lane (R8-M1 — the boot-conversion discriminator,
|
|
1155
|
+
* never inferred from the id's shape at boot).
|
|
1156
|
+
*/
|
|
1157
|
+
recordSendIntent(conversationId, logicalSendId, lane) {
|
|
1158
|
+
this.load();
|
|
1159
|
+
const seq = this.appendJournal({ op: 'send-intent', conversationId, logicalSendId, lane }, { fsync: true });
|
|
1160
|
+
this.sendIntents.set(this.sendGuardKey(conversationId, logicalSendId), { lane, seq });
|
|
1161
|
+
this.scheduleSnapshot();
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Record the E1 suppressor entry — ONLY on a likely-posted outcome (success
|
|
1165
|
+
* or ambiguous/ack-lost; NEVER a clean transient failure — R2-security-NEW-3).
|
|
1166
|
+
* Durable (`op:"ambiguous-send"`, fsynced — R4-M2); resolves the pair's
|
|
1167
|
+
* send-intent. The in-memory map is the loaded image of this durable state.
|
|
1168
|
+
*/
|
|
1169
|
+
recordLikelyPosted(conversationId, logicalSendId, lane) {
|
|
1170
|
+
this.load();
|
|
1171
|
+
this.pruneSendGuardState();
|
|
1172
|
+
const key = this.sendGuardKey(conversationId, logicalSendId);
|
|
1173
|
+
this.appendJournal({ op: 'ambiguous-send', conversationId, logicalSendId, lane }, { fsync: true });
|
|
1174
|
+
this.ambiguousSends.set(key, { recordedAt: this.now().toISOString(), lane });
|
|
1175
|
+
this.sendIntents.delete(key);
|
|
1176
|
+
if (this.ambiguousSends.size > AMBIGUOUS_SENDS_HARD_CAP) {
|
|
1177
|
+
// Pathological tripwire (R4-M2 scoped per R5-minor-1): loud, aggregated,
|
|
1178
|
+
// and the new entry is STILL journaled + retained — nothing dropped, so a
|
|
1179
|
+
// silent eviction can never re-open the double-post.
|
|
1180
|
+
this.attention('conversation-registry:send-guard-cap', 'E1 send-guard entry population exceeded its pathological tripwire', `${this.ambiguousSends.size} unretired/unexpired ambiguous-send entries exceed the ${AMBIGUOUS_SENDS_HARD_CAP} cap (§5.0(a)). Live entries are bounded by open beacon commitments (~maxActiveBeacons) by construction — this population means an upstream cap was raised or a retirement path is broken. No entry was evicted or dropped.`);
|
|
1181
|
+
}
|
|
1182
|
+
this.scheduleSnapshot();
|
|
1183
|
+
}
|
|
1184
|
+
/** Retire a logical send (`op:"send-retire"` — delivered outcome advanced the
|
|
1185
|
+
* seq, or the commitment closed). Idempotent; also resolves the intent. */
|
|
1186
|
+
retireSend(conversationId, logicalSendId) {
|
|
1187
|
+
this.load();
|
|
1188
|
+
const key = this.sendGuardKey(conversationId, logicalSendId);
|
|
1189
|
+
if (!this.ambiguousSends.has(key) && !this.sendIntents.has(key))
|
|
1190
|
+
return;
|
|
1191
|
+
this.appendJournal({ op: 'send-retire', conversationId, logicalSendId }, { fsync: true });
|
|
1192
|
+
this.ambiguousSends.delete(key);
|
|
1193
|
+
this.sendIntents.delete(key);
|
|
1194
|
+
this.scheduleSnapshot();
|
|
1195
|
+
}
|
|
1196
|
+
/** Resolve a send-intent WITHOUT a suppressor (`op:"send-intent-resolved"`) —
|
|
1197
|
+
* the clean-transient-failure path (positive evidence the message never
|
|
1198
|
+
* posted), so the retry is NOT suppressed (R2-security-NEW-3/R6-M1). */
|
|
1199
|
+
resolveSendIntent(conversationId, logicalSendId) {
|
|
1200
|
+
this.load();
|
|
1201
|
+
const key = this.sendGuardKey(conversationId, logicalSendId);
|
|
1202
|
+
if (!this.sendIntents.has(key))
|
|
1203
|
+
return;
|
|
1204
|
+
this.appendJournal({ op: 'send-intent-resolved', conversationId, logicalSendId }, { fsync: true });
|
|
1205
|
+
this.sendIntents.delete(key);
|
|
1206
|
+
this.scheduleSnapshot();
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* The E1 suppression read (§5.0(a) R3-M1/R7-M2): the LOGICAL lane is
|
|
1210
|
+
* retirement-based (an unretired entry suppresses whenever the re-fire
|
|
1211
|
+
* arrives, bounded only by the 7-day TTL backstop); the CONTENT-HASH lane is
|
|
1212
|
+
* WINDOW-based (a windowless caller has nothing to retire — outside the
|
|
1213
|
+
* 15-min window the same text delivers again).
|
|
1214
|
+
*/
|
|
1215
|
+
isSendSuppressed(conversationId, logicalSendId, lane) {
|
|
1216
|
+
this.load();
|
|
1217
|
+
const entry = this.ambiguousSends.get(this.sendGuardKey(conversationId, logicalSendId));
|
|
1218
|
+
if (!entry)
|
|
1219
|
+
return false;
|
|
1220
|
+
const age = this.now().getTime() - Date.parse(entry.recordedAt);
|
|
1221
|
+
if (!Number.isFinite(age))
|
|
1222
|
+
return false;
|
|
1223
|
+
return lane === 'content-hash' ? age < CONTENT_HASH_DEDUP_WINDOW_MS : age < AMBIGUOUS_DEDUP_TTL_MS;
|
|
1224
|
+
}
|
|
1225
|
+
/** Prune RETIRED/EXPIRED stragglers only (never a live unretired logical
|
|
1226
|
+
* entry below its TTL — R4-M2 second arm). An expired content-hash entry
|
|
1227
|
+
* prunes exactly like a retired one (R7-M2). */
|
|
1228
|
+
pruneSendGuardState() {
|
|
1229
|
+
const nowMs = this.now().getTime();
|
|
1230
|
+
let pruned = false;
|
|
1231
|
+
for (const [key, entry] of this.ambiguousSends) {
|
|
1232
|
+
const age = nowMs - Date.parse(entry.recordedAt);
|
|
1233
|
+
if (!Number.isFinite(age))
|
|
1234
|
+
continue;
|
|
1235
|
+
const horizon = entry.lane === 'content-hash' ? CONTENT_HASH_DEDUP_WINDOW_MS : AMBIGUOUS_DEDUP_TTL_MS;
|
|
1236
|
+
if (age > horizon) {
|
|
1237
|
+
this.ambiguousSends.delete(key);
|
|
1238
|
+
pruned = true;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (pruned)
|
|
1242
|
+
this.scheduleSnapshot();
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* R8-M1 lane-scoped boot conversion of crash-orphaned send-intents: an intent
|
|
1246
|
+
* that is the LAST word for its (conversationId, logicalSendId) PAIR (R7-M3
|
|
1247
|
+
* composite keying — replay already resolved every superseded pair) resolves
|
|
1248
|
+
* BY ITS RECORDED LANE: `logical` (beacon) converts into an ambiguous-send
|
|
1249
|
+
* suppressor (the outcome is genuinely unknown and the beacon has a next
|
|
1250
|
+
* tick); `content-hash` resolves toward RETRY (the suppressed send IS the
|
|
1251
|
+
* message — replay appends the missing send-intent-resolved durably, so the
|
|
1252
|
+
* verdict is decided once, not re-decided every boot). Staged appends land
|
|
1253
|
+
* POST-compose, fsynced before the registry begins serving (R9-low-3 /
|
|
1254
|
+
* R10-minor-1).
|
|
1255
|
+
*/
|
|
1256
|
+
convertOrphanedSendIntents() {
|
|
1257
|
+
if (this.sendIntents.size === 0)
|
|
1258
|
+
return;
|
|
1259
|
+
for (const [key, intent] of [...this.sendIntents]) {
|
|
1260
|
+
const sep = key.indexOf('|'); // conversationId prefix is numeric — the FIRST '|' always delimits (§3.4)
|
|
1261
|
+
if (sep <= 0) {
|
|
1262
|
+
this.sendIntents.delete(key);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
const conversationId = Number(key.slice(0, sep));
|
|
1266
|
+
const logicalSendId = key.slice(sep + 1);
|
|
1267
|
+
if (!Number.isSafeInteger(conversationId) || !logicalSendId) {
|
|
1268
|
+
this.sendIntents.delete(key);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (intent.lane === 'logical') {
|
|
1272
|
+
this.appendJournal({ op: 'ambiguous-send', conversationId, logicalSendId, lane: 'logical' }, { fsync: true });
|
|
1273
|
+
this.ambiguousSends.set(key, { recordedAt: this.now().toISOString(), lane: 'logical' });
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
this.appendJournal({ op: 'send-intent-resolved', conversationId, logicalSendId }, { fsync: true });
|
|
1277
|
+
}
|
|
1278
|
+
this.sendIntents.delete(key);
|
|
1279
|
+
}
|
|
1280
|
+
this.scheduleSnapshot();
|
|
1281
|
+
}
|
|
1282
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1283
|
+
// Reachability (§5.1 — increment 2 writer)
|
|
1284
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1285
|
+
/**
|
|
1286
|
+
* Flip an entry's LOCAL-authoritative reachability (§5.1). IDEMPOTENT
|
|
1287
|
+
* (already-in-state → no write) and journaled WITHOUT fsync — it rides the
|
|
1288
|
+
* same batched flush, so a mass event coalesces into one write, never an
|
|
1289
|
+
* O(N) write storm on the failure path (scalability-F2). Returns whether the
|
|
1290
|
+
* state changed and whether the conversation is in the R3-minor FLAP-DAMPENED
|
|
1291
|
+
* state (>3 flips in 24h — the caller coalesces its dead-letter attention
|
|
1292
|
+
* into one per-window flap item instead of one per episode).
|
|
1293
|
+
*/
|
|
1294
|
+
setReachability(id, state) {
|
|
1295
|
+
this.load();
|
|
1296
|
+
const entry = this.byId.get(this.aliases.get(id) ?? id);
|
|
1297
|
+
if (!entry)
|
|
1298
|
+
return { changed: false, dampened: false };
|
|
1299
|
+
const nowMs = this.now().getTime();
|
|
1300
|
+
const cutoff = nowMs - REACHABILITY_FLAP_WINDOW_MS;
|
|
1301
|
+
const flips = (this.reachabilityFlips.get(entry.id) ?? []).filter((t) => t > cutoff);
|
|
1302
|
+
if (entry.reachability === state) {
|
|
1303
|
+
this.reachabilityFlips.set(entry.id, flips);
|
|
1304
|
+
return { changed: false, dampened: flips.length > REACHABILITY_FLAP_THRESHOLD };
|
|
1305
|
+
}
|
|
1306
|
+
flips.push(nowMs);
|
|
1307
|
+
this.reachabilityFlips.set(entry.id, flips);
|
|
1308
|
+
entry.reachability = state;
|
|
1309
|
+
this.appendJournal({ op: 'reachability', id: entry.id, reachability: state }, { fsync: false });
|
|
1310
|
+
this.scheduleSnapshot();
|
|
1311
|
+
return { changed: true, dampened: flips.length > REACHABILITY_FLAP_THRESHOLD };
|
|
1312
|
+
}
|
|
1313
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1033
1314
|
// Adoption pass (§6.2)
|
|
1034
1315
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1035
1316
|
/**
|
|
@@ -1117,6 +1398,12 @@ export class ConversationRegistry {
|
|
|
1117
1398
|
journalQuarantinedAt: this.journalQuarantinedAt,
|
|
1118
1399
|
durabilityIncidents: this.durabilityIncidents,
|
|
1119
1400
|
},
|
|
1401
|
+
// §5.0(a)/§3.5.2 increment-2 observability.
|
|
1402
|
+
sendGuard: {
|
|
1403
|
+
unretiredEntries: this.ambiguousSends.size,
|
|
1404
|
+
unresolvedIntents: this.sendIntents.size,
|
|
1405
|
+
bindPins: this.bindPins.size,
|
|
1406
|
+
},
|
|
1120
1407
|
// Snapshot-suspension observability (R11-low-1).
|
|
1121
1408
|
snapshotSuspended: this.unappliedUnknownOps.length > 0,
|
|
1122
1409
|
firstUnappliedUnknownSeq: this.unappliedUnknownOps[0]?.seq ?? null,
|