pi-fold 1.0.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -8
- package/extensions/active-context.ts +245 -2355
- package/extensions/evidence.js +0 -10
- package/extensions/index.js +10 -30
- package/extensions/json.ts +3 -29
- package/extensions/lib/canonical.ts +0 -8
- package/extensions/lib/curation.ts +1 -258
- package/extensions/lib/folding.ts +33 -406
- package/extensions/lib/instrumentation.ts +3 -124
- package/extensions/lib/measurement.ts +0 -71
- package/extensions/lib/persistence.ts +216 -197
- package/extensions/lib/policy.ts +29 -558
- package/extensions/lib/rollback.ts +0 -53
- package/extensions/lib/scheduling.ts +36 -367
- package/extensions/lib/selection.ts +206 -312
- package/extensions/lib/tool-surface.ts +5 -18
- package/extensions/lib/transcript.ts +32 -108
- package/package.json +1 -1
- package/extensions/lib/surfacing.ts +0 -604
- package/extensions/summarizer.js +0 -291
|
@@ -20,13 +20,8 @@ import {
|
|
|
20
20
|
ACTIVE_CONTEXT_POLICY,
|
|
21
21
|
ACTIVE_CONTEXT_STATE_ENTRY,
|
|
22
22
|
EXPAND_LEASE_GENERATIONS,
|
|
23
|
-
MAX_ADVISORY_DELIVERIES_PER_MILESTONE,
|
|
24
23
|
MAX_EXPAND_LEASES,
|
|
25
|
-
MAX_LAST_CALL_TEXT_BYTES,
|
|
26
24
|
MAX_PENDING_MARKS,
|
|
27
|
-
MAX_THRESHOLD_NOTICE_TEXT_BYTES,
|
|
28
|
-
MAX_THRESHOLD_NOTICES,
|
|
29
|
-
SURFACING_MAX_LEDGER_RECORDS,
|
|
30
25
|
} from "./policy.ts";
|
|
31
26
|
import type {
|
|
32
27
|
ActiveContextCheckpointV2,
|
|
@@ -44,7 +39,6 @@ import type {
|
|
|
44
39
|
MarkOrigin,
|
|
45
40
|
PendingMark,
|
|
46
41
|
PreparedFold,
|
|
47
|
-
SurfacingRecord,
|
|
48
42
|
} from "./policy.ts";
|
|
49
43
|
|
|
50
44
|
export const ACTIVE_FOLD_KEYS = [
|
|
@@ -58,8 +52,6 @@ export const PREPARED_FOLD_KEYS = [
|
|
|
58
52
|
export const ACTIVE_STATE_KEYS = ["version", "sessionId", "revision", "folds", "expanded", "protected"] as const;
|
|
59
53
|
export const FOLD_RECORD_REF_KEYS = ["id", "sha256"] as const;
|
|
60
54
|
export const FOLD_RECORD_ENTRY_KEYS = ["version", "sessionId", "foldId", "recordSha256", "fold"] as const;
|
|
61
|
-
export const SURFACING_RECORD_KEYS = ["id", "surfaced", "taken", "ordinal", "outcome"] as const;
|
|
62
|
-
export const SURFACING_OUTCOMES = Object.freeze(["shown", "acted", "used", "ignored"] as const);
|
|
63
55
|
export const PENDING_FOLD_MARK_KEYS = [
|
|
64
56
|
"mark", "id", "kind", "parts", "brief", "briefProvenance", "origin", "ordinal",
|
|
65
57
|
] as const;
|
|
@@ -83,28 +75,6 @@ export function validTokensSinceToolFold(value: unknown): value is number {
|
|
|
83
75
|
return Number.isSafeInteger(value) && Number(value) >= 0;
|
|
84
76
|
}
|
|
85
77
|
|
|
86
|
-
export function validSurfacingRecord(value: unknown): value is SurfacingRecord {
|
|
87
|
-
if (!exactRecord(value, SURFACING_RECORD_KEYS)) return false;
|
|
88
|
-
const surfaced = ownValue(value, "surfaced");
|
|
89
|
-
const taken = ownValue(value, "taken");
|
|
90
|
-
return typeof ownValue(value, "id") === "string" && Boolean(ownValue(value, "id")) &&
|
|
91
|
-
Number.isSafeInteger(surfaced) && Number(surfaced) > 0 &&
|
|
92
|
-
Number.isSafeInteger(taken) && Number(taken) >= 0 && Number(taken) <= Number(surfaced) &&
|
|
93
|
-
Number.isSafeInteger(ownValue(value, "ordinal")) && Number(ownValue(value, "ordinal")) >= 0 &&
|
|
94
|
-
SURFACING_OUTCOMES.includes(ownValue(value, "outcome") as SurfacingRecord["outcome"]);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** One record per fold: a duplicate id is a ledger that forgot an ignore. */
|
|
98
|
-
export function parseSurfacingLog(value: unknown): SurfacingRecord[] {
|
|
99
|
-
const records = denseOwnArrayValues(value);
|
|
100
|
-
if (!records || records.length > SURFACING_MAX_LEDGER_RECORDS || !records.every(validSurfacingRecord) ||
|
|
101
|
-
new Set(records.map((record) => (record as SurfacingRecord).id)).size !== records.length) {
|
|
102
|
-
throw new Error("Invalid active-context surfacing ledger");
|
|
103
|
-
}
|
|
104
|
-
return clone(records) as SurfacingRecord[];
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Stable identity of one pending mark: its phase plus the fold it names. */
|
|
108
78
|
export function pendingMarkKey(mark: Pick<PendingMark, "mark" | "id">): string {
|
|
109
79
|
return `${mark.mark}:${mark.id}`;
|
|
110
80
|
}
|
|
@@ -127,17 +97,6 @@ export function validPendingMark(value: unknown): value is PendingMark {
|
|
|
127
97
|
id === foldIdFor(kind as FoldKind, parts as FoldPart[]);
|
|
128
98
|
}
|
|
129
99
|
|
|
130
|
-
/**
|
|
131
|
-
* Agent-corrected fold briefs, keyed by fold id. Bounded by the same brief cap a
|
|
132
|
-
* supplied brief obeys, and refused for a fold that is not in the forest, exactly
|
|
133
|
-
* like a pin or a refold mark naming a missing fold.
|
|
134
|
-
*/
|
|
135
|
-
/**
|
|
136
|
-
* A model brief written after the fold committed carries its generator with it, so an
|
|
137
|
-
* override is either the bare corrected string the agent wrote or that string beside the
|
|
138
|
-
* attribution. Two shapes, one map: the string form is what every state written before
|
|
139
|
-
* the upgrade lane existed holds, and it must keep parsing and keep its exact digest.
|
|
140
|
-
*/
|
|
141
100
|
export function briefOverrideText(override: unknown): string | null {
|
|
142
101
|
if (typeof override === "string") return override;
|
|
143
102
|
const brief = ownValue(override, "brief");
|
|
@@ -169,7 +128,6 @@ export function parseBriefOverrides(
|
|
|
169
128
|
) as Record<string, BriefOverride>;
|
|
170
129
|
}
|
|
171
130
|
|
|
172
|
-
/** The brief a fold PRESENTS: the agent's correction when it made one, else the record's. */
|
|
173
131
|
export function foldBrief(
|
|
174
132
|
fold: Pick<ActiveFold, "id" | "brief">,
|
|
175
133
|
state: Pick<ActiveContextState, "briefs">,
|
|
@@ -177,11 +135,6 @@ export function foldBrief(
|
|
|
177
135
|
return briefOverrideText(state.briefs?.[fold.id]) ?? fold.brief;
|
|
178
136
|
}
|
|
179
137
|
|
|
180
|
-
/**
|
|
181
|
-
* The provenance a fold PRESENTS. A fold RECORD is immutable, so a brief written after
|
|
182
|
-
* the commit lives in the override map and its attribution travels with it; without this
|
|
183
|
-
* lens a model-written upgrade would report the provenance of the brief it replaced.
|
|
184
|
-
*/
|
|
185
138
|
export function foldProvenance(
|
|
186
139
|
fold: Pick<ActiveFold, "id" | "provenance">,
|
|
187
140
|
state: Pick<ActiveContextState, "briefs">,
|
|
@@ -218,7 +171,6 @@ export function validProvenance(value: unknown): value is BriefProvenance {
|
|
|
218
171
|
(ownValue(value, "kind") === "supplied" || ownValue(value, "kind") === "deterministic")) return true;
|
|
219
172
|
const hasDigest = Boolean(value && typeof value === "object" &&
|
|
220
173
|
Object.prototype.hasOwnProperty.call(value, "launchContractDigest"));
|
|
221
|
-
// legacy provenance kind written by pre-release sessions; normalized to the model kind at presentation
|
|
222
174
|
if (!exactRecord(value, ["kind", "provider", "model", "effort", ...(hasDigest ? ["launchContractDigest"] : [])]) ||
|
|
223
175
|
(ownValue(value, "kind") !== "model" && ownValue(value, "kind") !== "luna") ||
|
|
224
176
|
typeof ownValue(value, "provider") !== "string" || !ownValue(value, "provider") ||
|
|
@@ -281,11 +233,6 @@ export function foldMap(state: Pick<ActiveContextState, "folds">): Map<string, A
|
|
|
281
233
|
return new Map(state.folds.map((fold) => [fold.id, fold]));
|
|
282
234
|
}
|
|
283
235
|
|
|
284
|
-
/**
|
|
285
|
-
* The folds a span names as children. Read off the parts alone, so a PENDING fold mark
|
|
286
|
-
* answers it too: a mark carries the id its committed fold will get, so the ids here are
|
|
287
|
-
* exactly the folds that must already exist when this span applies.
|
|
288
|
-
*/
|
|
289
236
|
export function childFoldIds(fold: Pick<ActiveFold, "parts">): string[] {
|
|
290
237
|
return fold.parts.filter((part): part is Extract<FoldPart, { kind: "fold" }> => part.kind === "fold")
|
|
291
238
|
.map((part) => part.foldId);
|
|
@@ -309,20 +256,6 @@ export function flattenFoldRefs(fold: ActiveFold, state: Pick<ActiveContextState
|
|
|
309
256
|
return visit(fold);
|
|
310
257
|
}
|
|
311
258
|
|
|
312
|
-
/**
|
|
313
|
-
* Which fold DIRECTLY owns each raw evidence key.
|
|
314
|
-
*
|
|
315
|
-
* One derivation, two readers (Shane 2026-08-11). The forest invariant below reads it to
|
|
316
|
-
* reject a second owner, and the agent-facing candidate builder reads it BEFORE it builds
|
|
317
|
-
* anything, so a span naming evidence some fold already holds comes back as a refusal
|
|
318
|
-
* that names the owner instead of this invariant escaping the tool call as an exception.
|
|
319
|
-
*
|
|
320
|
-
* The two readings stay distinguishable because the input differs. Called on the folds
|
|
321
|
-
* already in state it can only throw when the stored forest is itself corrupt, which no
|
|
322
|
-
* agent input can produce: every state reaches storage through `validateFoldForest`.
|
|
323
|
-
* Called on a forest that includes a candidate the caller just built, a duplicate means
|
|
324
|
-
* the REQUEST collided with an existing fold, and that is the refusal case.
|
|
325
|
-
*/
|
|
326
259
|
export function directFoldOwners(folds: readonly ActiveFold[]): Map<string, string> {
|
|
327
260
|
const owner = new Map<string, string>();
|
|
328
261
|
for (const fold of folds) {
|
|
@@ -351,18 +284,6 @@ export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
|
|
|
351
284
|
(!fold.parts.length || fold.parts.some((part) => part.kind !== "raw" || part.ref.role !== "toolResult"))) {
|
|
352
285
|
throw new Error("Tool-result fold must own one validated assistant batch of tool results");
|
|
353
286
|
}
|
|
354
|
-
// WHAT A PARENT IS, RE-DERIVED UNDER THE COUNT LAW (Shane 2026-08-10).
|
|
355
|
-
//
|
|
356
|
-
// Two shape rules stood here and both described the old approximation. "Only child
|
|
357
|
-
// folds" forbade the raw gaps between children, and a parent's span runs from its
|
|
358
|
-
// first child to its last one, so the gap material inside it is the parent's or the
|
|
359
|
-
// span is not a span. "Only chapter or consolidation children" forbade tool-result
|
|
360
|
-
// children, which was true while a bare tool batch nested through a chapter span;
|
|
361
|
-
// automatic chapters no longer take placeholders at any count, so that rule would
|
|
362
|
-
// now leave every tool fold as an obstacle no parent could ever reclaim.
|
|
363
|
-
//
|
|
364
|
-
// What survives is the one thing that makes a consolidation a consolidation: it
|
|
365
|
-
// groups folds. A parent with no child fold is a chapter wearing the wrong name.
|
|
366
287
|
if (fold.kind === "consolidation" && !fold.parts.some((part) => part.kind === "fold")) {
|
|
367
288
|
throw new Error("Consolidation folds must contain at least one child fold");
|
|
368
289
|
}
|
|
@@ -401,29 +322,20 @@ export function parseActiveContextState(
|
|
|
401
322
|
const hasTokensSinceToolFold = recordLike &&
|
|
402
323
|
Object.prototype.hasOwnProperty.call(value, "tokensSinceToolFold");
|
|
403
324
|
const hasLeases = recordLike && Object.prototype.hasOwnProperty.call(value, "leases");
|
|
404
|
-
const hasSurfacing = recordLike && Object.prototype.hasOwnProperty.call(value, "surfacing");
|
|
405
325
|
const hasPendingMarks = recordLike && Object.prototype.hasOwnProperty.call(value, "pendingMarks");
|
|
406
|
-
// Accepted, never read, never written. `pinnedPeeks` pinned a peek result against
|
|
407
|
-
// per-projection reclamation, which was deleted after rep 22 measured it rewriting
|
|
408
|
-
// the prefix mid-window. State written before that cut still carries the key, and
|
|
409
|
-
// rejecting it here would suspend automatic management on upgrade.
|
|
410
326
|
const hasPinnedPeeks = recordLike && Object.prototype.hasOwnProperty.call(value, "pinnedPeeks");
|
|
411
327
|
const hasBriefs = recordLike && Object.prototype.hasOwnProperty.call(value, "briefs");
|
|
412
328
|
const hasRider = recordLike && Object.prototype.hasOwnProperty.call(value, "rider");
|
|
413
|
-
|
|
414
|
-
const hasNotices = recordLike && Object.prototype.hasOwnProperty.call(value, "notices");
|
|
329
|
+
refuseRetiredStateFields(value);
|
|
415
330
|
const extraKeys = [
|
|
416
331
|
...(hasPrepared ? ["prepared"] : []),
|
|
417
332
|
...(hasAdvisory ? ["advisory"] : []),
|
|
418
333
|
...(hasTokensSinceToolFold ? ["tokensSinceToolFold"] : []),
|
|
419
334
|
...(hasLeases ? ["leases"] : []),
|
|
420
|
-
...(hasSurfacing ? ["surfacing"] : []),
|
|
421
335
|
...(hasPendingMarks ? ["pendingMarks"] : []),
|
|
422
336
|
...(hasPinnedPeeks ? ["pinnedPeeks"] : []),
|
|
423
337
|
...(hasBriefs ? ["briefs"] : []),
|
|
424
338
|
...(hasRider ? ["rider"] : []),
|
|
425
|
-
...(hasLastCall ? ["lastCall"] : []),
|
|
426
|
-
...(hasNotices ? ["notices"] : []),
|
|
427
339
|
];
|
|
428
340
|
if (!exactRecord(value, [...ACTIVE_STATE_KEYS, ...extraKeys])) throw new Error("Invalid active-context state keys");
|
|
429
341
|
const folds = denseOwnArrayValues(ownValue(value, "folds"));
|
|
@@ -448,26 +360,16 @@ export function parseActiveContextState(
|
|
|
448
360
|
if (hasRider && !validRiderState(ownValue(value, "rider"))) {
|
|
449
361
|
throw new Error("Invalid active-context rider state");
|
|
450
362
|
}
|
|
451
|
-
if (hasLastCall && !validLastCallState(ownValue(value, "lastCall"))) {
|
|
452
|
-
throw new Error("Invalid active-context last-call state");
|
|
453
|
-
}
|
|
454
|
-
if (hasNotices && !validNoticesState(ownValue(value, "notices"))) {
|
|
455
|
-
throw new Error("Invalid active-context notices state");
|
|
456
|
-
}
|
|
457
363
|
if (hasTokensSinceToolFold && !validTokensSinceToolFold(ownValue(value, "tokensSinceToolFold"))) {
|
|
458
364
|
throw new Error("Invalid active-context tool-fold cadence");
|
|
459
365
|
}
|
|
460
366
|
const leases = hasLeases ? parseLeases(ownValue(value, "leases"), ids) : {};
|
|
461
|
-
const surfacing = hasSurfacing ? parseSurfacingLog(ownValue(value, "surfacing")) : [];
|
|
462
367
|
const marks = hasPendingMarks ? parsePendingMarks(ownValue(value, "pendingMarks")) : [];
|
|
463
368
|
if (marks.some((mark) => mark.mark === "refold" && !ids.has(mark.id))) {
|
|
464
369
|
throw new Error("Invalid active-context pending marks");
|
|
465
370
|
}
|
|
466
371
|
const briefs = hasBriefs ? parseBriefOverrides(ownValue(value, "briefs"), ids) : {};
|
|
467
372
|
const source = clone(value) as unknown as ActiveContextState;
|
|
468
|
-
// Provenance normalization is presentation-only; never mutate a durable
|
|
469
|
-
// content-addressed fold record: changing its bytes causes the next re-persist
|
|
470
|
-
// to report a conflicting durable fold and suspend automatic management.
|
|
471
373
|
return {
|
|
472
374
|
version: 1,
|
|
473
375
|
sessionId: source.sessionId,
|
|
@@ -479,17 +381,12 @@ export function parseActiveContextState(
|
|
|
479
381
|
? Number(ownValue(value, "tokensSinceToolFold"))
|
|
480
382
|
: 0,
|
|
481
383
|
leases,
|
|
482
|
-
// Omitted when empty so every state written before surfacing existed keeps its
|
|
483
|
-
// exact digest; the replay digest is order- and presence-sensitive by design.
|
|
484
|
-
...(surfacing.length ? { surfacing } : {}),
|
|
485
384
|
...(marks.length ? { pendingMarks: marks } : {}),
|
|
486
385
|
...(Object.keys(briefs).length ? { briefs } : {}),
|
|
487
386
|
...(hasAdvisory
|
|
488
387
|
? { advisory: clone(source.advisory!) }
|
|
489
388
|
: defaultAdvisory ? { advisory: { highWater: 0, delivered: {} } } : {}),
|
|
490
389
|
...(hasRider ? { rider: clone(source.rider!) } : {}),
|
|
491
|
-
...(hasLastCall ? { lastCall: clone(source.lastCall!) } : {}),
|
|
492
|
-
...(hasNotices ? { notices: clone(source.notices!) } : {}),
|
|
493
390
|
...(hasPrepared ? { prepared: clone(source.prepared!) } : {}),
|
|
494
391
|
};
|
|
495
392
|
}
|
|
@@ -562,7 +459,6 @@ export function sameStateProjection(left: ActiveContextState, right: ActiveConte
|
|
|
562
459
|
const normalizedState = { ...clone(value), revision: 0 } as Partial<ActiveContextState>;
|
|
563
460
|
if (normalizedState.tokensSinceToolFold === 0) delete normalizedState.tokensSinceToolFold;
|
|
564
461
|
if (normalizedState.leases && Object.keys(normalizedState.leases).length === 0) delete normalizedState.leases;
|
|
565
|
-
if (normalizedState.surfacing && normalizedState.surfacing.length === 0) delete normalizedState.surfacing;
|
|
566
462
|
if (normalizedState.pendingMarks && normalizedState.pendingMarks.length === 0) delete normalizedState.pendingMarks;
|
|
567
463
|
if (normalizedState.briefs && !Object.keys(normalizedState.briefs).length) delete normalizedState.briefs;
|
|
568
464
|
return normalizedState;
|
|
@@ -605,23 +501,17 @@ export function validateV2ProjectionFields(
|
|
|
605
501
|
advisoryValue: unknown,
|
|
606
502
|
tokensSinceToolFoldValue: unknown,
|
|
607
503
|
leasesValue: unknown,
|
|
608
|
-
surfacingValue?: unknown,
|
|
609
504
|
pendingMarksValue?: unknown,
|
|
610
505
|
briefsValue?: unknown,
|
|
611
506
|
riderValue?: unknown,
|
|
612
|
-
lastCallValue?: unknown,
|
|
613
|
-
noticesValue?: unknown,
|
|
614
507
|
): {
|
|
615
508
|
expanded: string[];
|
|
616
509
|
protected: EvidenceRef[];
|
|
617
510
|
prepared?: PreparedFold;
|
|
618
511
|
advisory?: NonNullable<ActiveContextState["advisory"]>;
|
|
619
512
|
rider?: NonNullable<ActiveContextState["rider"]>;
|
|
620
|
-
lastCall?: NonNullable<ActiveContextState["lastCall"]>;
|
|
621
|
-
notices?: NonNullable<ActiveContextState["notices"]>;
|
|
622
513
|
tokensSinceToolFold: number;
|
|
623
514
|
leases: Record<string, number>;
|
|
624
|
-
surfacing: SurfacingRecord[];
|
|
625
515
|
pendingMarks: PendingMark[];
|
|
626
516
|
briefs: Record<string, string>;
|
|
627
517
|
} {
|
|
@@ -640,17 +530,10 @@ export function validateV2ProjectionFields(
|
|
|
640
530
|
if (riderValue !== undefined && !validRiderState(riderValue)) {
|
|
641
531
|
throw new Error("Invalid active-context v2 rider state");
|
|
642
532
|
}
|
|
643
|
-
if (lastCallValue !== undefined && !validLastCallState(lastCallValue)) {
|
|
644
|
-
throw new Error("Invalid active-context v2 last-call state");
|
|
645
|
-
}
|
|
646
|
-
if (noticesValue !== undefined && !validNoticesState(noticesValue)) {
|
|
647
|
-
throw new Error("Invalid active-context v2 notices state");
|
|
648
|
-
}
|
|
649
533
|
if (tokensSinceToolFoldValue !== undefined && !validTokensSinceToolFold(tokensSinceToolFoldValue)) {
|
|
650
534
|
throw new Error("Invalid active-context v2 tool-fold cadence");
|
|
651
535
|
}
|
|
652
536
|
const leases = leasesValue === undefined ? {} : parseLeases(leasesValue);
|
|
653
|
-
const surfacing = surfacingValue === undefined ? [] : parseSurfacingLog(surfacingValue);
|
|
654
537
|
const pendingMarks = pendingMarksValue === undefined ? [] : parsePendingMarks(pendingMarksValue);
|
|
655
538
|
const briefs = briefsValue === undefined ? {} : parseBriefOverrides(briefsValue);
|
|
656
539
|
return {
|
|
@@ -663,15 +546,8 @@ export function validateV2ProjectionFields(
|
|
|
663
546
|
...(riderValue === undefined ? {} : {
|
|
664
547
|
rider: clone(riderValue) as NonNullable<ActiveContextState["rider"]>,
|
|
665
548
|
}),
|
|
666
|
-
...(lastCallValue === undefined ? {} : {
|
|
667
|
-
lastCall: clone(lastCallValue) as NonNullable<ActiveContextState["lastCall"]>,
|
|
668
|
-
}),
|
|
669
|
-
...(noticesValue === undefined ? {} : {
|
|
670
|
-
notices: clone(noticesValue) as NonNullable<ActiveContextState["notices"]>,
|
|
671
|
-
}),
|
|
672
549
|
tokensSinceToolFold: tokensSinceToolFoldValue === undefined ? 0 : Number(tokensSinceToolFoldValue),
|
|
673
550
|
leases,
|
|
674
|
-
surfacing,
|
|
675
551
|
pendingMarks,
|
|
676
552
|
briefs,
|
|
677
553
|
};
|
|
@@ -685,35 +561,35 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
|
|
|
685
561
|
Object.prototype.hasOwnProperty.call(value, "tokensSinceToolFold"));
|
|
686
562
|
const hasLeases = Boolean(value && typeof value === "object" &&
|
|
687
563
|
Object.prototype.hasOwnProperty.call(value, "leases"));
|
|
688
|
-
const hasSurfacing = Boolean(value && typeof value === "object" &&
|
|
689
|
-
Object.prototype.hasOwnProperty.call(value, "surfacing"));
|
|
690
564
|
const hasPendingMarks = Boolean(value && typeof value === "object" &&
|
|
691
565
|
Object.prototype.hasOwnProperty.call(value, "pendingMarks"));
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
566
|
+
const hasAddPendingMarks = Boolean(value && typeof value === "object" &&
|
|
567
|
+
Object.prototype.hasOwnProperty.call(value, "addPendingMarks"));
|
|
568
|
+
const hasPendingMarkOrder = Boolean(value && typeof value === "object" &&
|
|
569
|
+
Object.prototype.hasOwnProperty.call(value, "pendingMarkOrder"));
|
|
696
570
|
const hasPinnedPeeks = Boolean(value && typeof value === "object" &&
|
|
697
571
|
Object.prototype.hasOwnProperty.call(value, "pinnedPeeks"));
|
|
698
572
|
const hasBriefs = Boolean(value && typeof value === "object" &&
|
|
699
573
|
Object.prototype.hasOwnProperty.call(value, "briefs"));
|
|
574
|
+
const hasAddBriefs = Boolean(value && typeof value === "object" &&
|
|
575
|
+
Object.prototype.hasOwnProperty.call(value, "addBriefs"));
|
|
576
|
+
const hasRemoveBriefIds = Boolean(value && typeof value === "object" &&
|
|
577
|
+
Object.prototype.hasOwnProperty.call(value, "removeBriefIds"));
|
|
700
578
|
const hasRider = Boolean(value && typeof value === "object" &&
|
|
701
579
|
Object.prototype.hasOwnProperty.call(value, "rider"));
|
|
702
|
-
|
|
703
|
-
Object.prototype.hasOwnProperty.call(value, "lastCall"));
|
|
704
|
-
const hasNotices = Boolean(value && typeof value === "object" &&
|
|
705
|
-
Object.prototype.hasOwnProperty.call(value, "notices"));
|
|
580
|
+
refuseRetiredStateFields(value);
|
|
706
581
|
const optionalKeys = [
|
|
707
582
|
...(hasAdvisory ? ["advisory"] : []),
|
|
708
583
|
...(hasTokensSinceToolFold ? ["tokensSinceToolFold"] : []),
|
|
709
584
|
...(hasLeases ? ["leases"] : []),
|
|
710
|
-
...(hasSurfacing ? ["surfacing"] : []),
|
|
711
585
|
...(hasPendingMarks ? ["pendingMarks"] : []),
|
|
586
|
+
...(hasAddPendingMarks ? ["addPendingMarks"] : []),
|
|
587
|
+
...(hasPendingMarkOrder ? ["pendingMarkOrder"] : []),
|
|
712
588
|
...(hasPinnedPeeks ? ["pinnedPeeks"] : []),
|
|
713
589
|
...(hasBriefs ? ["briefs"] : []),
|
|
590
|
+
...(hasAddBriefs ? ["addBriefs"] : []),
|
|
591
|
+
...(hasRemoveBriefIds ? ["removeBriefIds"] : []),
|
|
714
592
|
...(hasRider ? ["rider"] : []),
|
|
715
|
-
...(hasLastCall ? ["lastCall"] : []),
|
|
716
|
-
...(hasNotices ? ["notices"] : []),
|
|
717
593
|
];
|
|
718
594
|
const checkpoint = kind === "checkpoint" &&
|
|
719
595
|
exactRecord(value, [...STATE_CHECKPOINT_V2_KEYS, ...optionalKeys]);
|
|
@@ -728,8 +604,8 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
|
|
|
728
604
|
validateV2ProjectionFields(
|
|
729
605
|
ownValue(value, "expanded"), ownValue(value, "protected"), ownValue(value, "prepared"),
|
|
730
606
|
ownValue(value, "advisory"), ownValue(value, "tokensSinceToolFold"), ownValue(value, "leases"),
|
|
731
|
-
ownValue(value, "
|
|
732
|
-
ownValue(value, "rider"),
|
|
607
|
+
ownValue(value, "pendingMarks"), ownValue(value, "briefs"),
|
|
608
|
+
ownValue(value, "rider"),
|
|
733
609
|
);
|
|
734
610
|
if (checkpoint) {
|
|
735
611
|
const refs = denseOwnArrayValues(ownValue(value, "foldRefs"));
|
|
@@ -754,6 +630,36 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
|
|
|
754
630
|
if (new Set(parsed.map((ref) => ref.id)).size !== parsed.length || new Set(removeIds).size !== removeIds.length) {
|
|
755
631
|
throw new Error("Duplicate active-context delta change");
|
|
756
632
|
}
|
|
633
|
+
if (hasAddBriefs) parseBriefOverrides(ownValue(value, "addBriefs"));
|
|
634
|
+
if (hasRemoveBriefIds) {
|
|
635
|
+
const removeBriefs = denseOwnArrayValues(ownValue(value, "removeBriefIds"));
|
|
636
|
+
if (!removeBriefs || removeBriefs.length > MAX_ACTIVE_FOLD_RECORDS ||
|
|
637
|
+
removeBriefs.some((id) => typeof id !== "string" || !id) ||
|
|
638
|
+
new Set(removeBriefs).size !== removeBriefs.length) {
|
|
639
|
+
throw new Error("Invalid active-context delta brief removals");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (hasBriefs && (hasAddBriefs || hasRemoveBriefIds)) {
|
|
643
|
+
throw new Error("Active-context delta states the whole brief map and a change to it");
|
|
644
|
+
}
|
|
645
|
+
if (hasAddPendingMarks) parsePendingMarks(ownValue(value, "addPendingMarks"));
|
|
646
|
+
if (hasPendingMarkOrder) {
|
|
647
|
+
const order = denseOwnArrayValues(ownValue(value, "pendingMarkOrder"));
|
|
648
|
+
if (!order || order.length > MAX_PENDING_MARKS ||
|
|
649
|
+
order.some((key) => typeof key !== "string" || !key) ||
|
|
650
|
+
new Set(order).size !== order.length) {
|
|
651
|
+
throw new Error("Invalid active-context delta pending mark order");
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// The order is what the replay is BUILT from, so additions without it name marks
|
|
655
|
+
// nothing will place. Refused rather than absorbed, because absorbing it would drop
|
|
656
|
+
// the marks silently and a dropped mark is evidence that never gets folded.
|
|
657
|
+
if (hasAddPendingMarks && !hasPendingMarkOrder) {
|
|
658
|
+
throw new Error("Active-context delta adds pending marks and states no order for them");
|
|
659
|
+
}
|
|
660
|
+
if (hasPendingMarks && (hasAddPendingMarks || hasPendingMarkOrder)) {
|
|
661
|
+
throw new Error("Active-context delta states the whole mark array and a change to it");
|
|
662
|
+
}
|
|
757
663
|
}
|
|
758
664
|
const parsed = clone(value) as unknown as ActiveContextStateWireV2;
|
|
759
665
|
if (bytes(parsed) > MAX_ACTIVE_STATE_EVENT_BYTES) throw new Error("Active-context state event exceeds wire limit");
|
|
@@ -794,8 +700,7 @@ export function stateFromFoldRefs(
|
|
|
794
700
|
wire: Pick<
|
|
795
701
|
ActiveContextCheckpointV2,
|
|
796
702
|
"sessionId" | "revision" | "expanded" | "protected" | "prepared" | "advisory" |
|
|
797
|
-
"tokensSinceToolFold" | "leases" | "
|
|
798
|
-
"lastCall" | "notices"
|
|
703
|
+
"tokensSinceToolFold" | "leases" | "pendingMarks" | "briefs" | "rider"
|
|
799
704
|
>,
|
|
800
705
|
refs: FoldRecordRef[],
|
|
801
706
|
records: Map<string, FoldRecordEntry>,
|
|
@@ -815,24 +720,38 @@ export function stateFromFoldRefs(
|
|
|
815
720
|
protected: clone(wire.protected),
|
|
816
721
|
tokensSinceToolFold: wire.tokensSinceToolFold ?? 0,
|
|
817
722
|
leases: clone(wire.leases ?? {}),
|
|
818
|
-
...(wire.surfacing?.length ? { surfacing: clone(wire.surfacing) } : {}),
|
|
819
723
|
...(wire.pendingMarks?.length ? { pendingMarks: clone(wire.pendingMarks) } : {}),
|
|
820
724
|
...(wire.briefs && Object.keys(wire.briefs).length ? { briefs: clone(wire.briefs) } : {}),
|
|
821
725
|
...(wire.advisory === undefined ? {} : { advisory: clone(wire.advisory) }),
|
|
822
726
|
...(wire.rider === undefined ? {} : { rider: clone(wire.rider) }),
|
|
823
|
-
...(wire.lastCall === undefined ? {} : { lastCall: clone(wire.lastCall) }),
|
|
824
|
-
...(wire.notices === undefined ? {} : { notices: clone(wire.notices) }),
|
|
825
727
|
...(wire.prepared === null || wire.prepared === undefined ? {} : { prepared: clone(wire.prepared) }),
|
|
826
728
|
};
|
|
827
729
|
return parseActiveContextState(state, wire.sessionId, false);
|
|
828
730
|
}
|
|
829
731
|
|
|
732
|
+
export interface ProjectionFingerprint {
|
|
733
|
+
topologySha256: string;
|
|
734
|
+
protectionSha256: string;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* A REVISION'S FINGERPRINTS, COMPUTED WHEN ONE IS ASKED FOR.
|
|
739
|
+
*
|
|
740
|
+
* A `Map` satisfies this, which is what it was until 2026-08-13. The narrower shape is the
|
|
741
|
+
* point: the only reader takes ONE revision on the restore path, and the eager map hashed
|
|
742
|
+
* the fold forest twice for every revision the ledger ever held, on every materialization,
|
|
743
|
+
* including the ones that discard the map entirely.
|
|
744
|
+
*/
|
|
745
|
+
export interface ProjectionFingerprints {
|
|
746
|
+
get(revision: number): ProjectionFingerprint | undefined;
|
|
747
|
+
}
|
|
748
|
+
|
|
830
749
|
export interface MaterializedStatePersistence {
|
|
831
750
|
state: ActiveContextState;
|
|
832
751
|
wireVersion: 0 | 1 | 2;
|
|
833
752
|
records: Map<string, FoldRecordEntry>;
|
|
834
753
|
stateSha256: string;
|
|
835
|
-
projectionFingerprints:
|
|
754
|
+
projectionFingerprints: ProjectionFingerprints;
|
|
836
755
|
}
|
|
837
756
|
|
|
838
757
|
export function materializeStatePersistence(
|
|
@@ -844,16 +763,41 @@ export function materializeStatePersistence(
|
|
|
844
763
|
let state = emptyActiveContextState(sessionId);
|
|
845
764
|
let wireVersion: 0 | 1 | 2 = 0;
|
|
846
765
|
const records = new Map<string, FoldRecordEntry>();
|
|
847
|
-
|
|
766
|
+
// WHAT A FINGERPRINT IS MADE OF, HELD BY REFERENCE UNTIL ONE IS ASKED FOR.
|
|
767
|
+
//
|
|
768
|
+
// Replaying a ledger hashed the whole fold forest twice per revision, and one revision is
|
|
769
|
+
// ever read, by one caller, on the restore path. `materializeActiveContextState` takes
|
|
770
|
+
// `.state` and drops the map, so every request on a folding session paid for hundreds of
|
|
771
|
+
// digests it could not reach. Profiled against sol-20260813-paired rep 1 at its full
|
|
772
|
+
// 1,276 entries, materialization was 98% of a derivation, and canonicalizing and hashing
|
|
773
|
+
// was 72% of that.
|
|
774
|
+
//
|
|
775
|
+
// Holding the fields costs no copy: `state` is REPLACED on every delta rather than
|
|
776
|
+
// mutated (see `stateFromFoldRefs` below), so a reference taken here keeps that
|
|
777
|
+
// revision's own values, and the fold objects inside are shared with `records`.
|
|
778
|
+
const fingerprintSources = new Map<number, Pick<ActiveContextState,
|
|
779
|
+
"folds" | "expanded" | "protected">>();
|
|
780
|
+
const fingerprintCache = new Map<number, ProjectionFingerprint>();
|
|
781
|
+
const projectionFingerprints: ProjectionFingerprints = {
|
|
782
|
+
get(revision: number): ProjectionFingerprint | undefined {
|
|
783
|
+
const cached = fingerprintCache.get(revision);
|
|
784
|
+
if (cached) return cached;
|
|
785
|
+
const source = fingerprintSources.get(revision);
|
|
786
|
+
if (!source) return undefined;
|
|
787
|
+
const computed = {
|
|
788
|
+
topologySha256: topologySha256(source),
|
|
789
|
+
protectionSha256: protectionSha256(source),
|
|
790
|
+
};
|
|
791
|
+
fingerprintCache.set(revision, computed);
|
|
792
|
+
return computed;
|
|
793
|
+
},
|
|
794
|
+
};
|
|
848
795
|
let stateSha256 = semanticStateSha256(state);
|
|
849
796
|
let stateStart = -1;
|
|
850
797
|
let checkpointIndex = -1;
|
|
851
798
|
let v2Seen = false;
|
|
852
799
|
const rememberProjection = (): void => {
|
|
853
|
-
|
|
854
|
-
topologySha256: topologySha256(state),
|
|
855
|
-
protectionSha256: protectionSha256(state),
|
|
856
|
-
});
|
|
800
|
+
fingerprintSources.set(state.revision, state);
|
|
857
801
|
};
|
|
858
802
|
rememberProjection();
|
|
859
803
|
for (let index = 0; index < entries.length; index += 1) {
|
|
@@ -916,13 +860,62 @@ export function materializeStatePersistence(
|
|
|
916
860
|
if (existing) throw new Error(`Duplicate active-context delta addition ${ref.id}`);
|
|
917
861
|
byId.set(ref.id, ref);
|
|
918
862
|
}
|
|
919
|
-
|
|
863
|
+
// A delta written before 2026-08-13 carries the whole brief map; a newer one carries
|
|
864
|
+
// only what changed. The presence of `briefs` is the discriminator, not a fallback:
|
|
865
|
+
// a wire that states the whole map is replayed as the whole map.
|
|
866
|
+
const briefs = wire.briefs !== undefined ? clone(wire.briefs) : { ...(state.briefs ?? {}) };
|
|
867
|
+
if (wire.briefs === undefined) {
|
|
868
|
+
for (const id of wire.removeBriefIds ?? []) {
|
|
869
|
+
if (!Object.prototype.hasOwnProperty.call(briefs, id)) {
|
|
870
|
+
throw new Error(`Unknown active-context brief removal ${id}`);
|
|
871
|
+
}
|
|
872
|
+
delete briefs[id];
|
|
873
|
+
}
|
|
874
|
+
for (const [id, brief] of Object.entries(wire.addBriefs ?? {})) {
|
|
875
|
+
if (Object.prototype.hasOwnProperty.call(briefs, id) &&
|
|
876
|
+
sha256Value(briefs[id]) === sha256Value(brief)) {
|
|
877
|
+
throw new Error(`Redundant active-context brief ${id}`);
|
|
878
|
+
}
|
|
879
|
+
briefs[id] = clone(brief);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// Same rule for the marks, but the discriminator is the ORDER's presence rather than
|
|
883
|
+
// the array's absence. A delta written before 2026-08-13 states the whole array when
|
|
884
|
+
// it has one and states nothing when it does not, so "no array" means "no marks"
|
|
885
|
+
// there; reading it as "keep the base's marks" would resurrect marks a sealed session
|
|
886
|
+
// had cleared. The order list is what a NEW delta's replay is built from, so a key it
|
|
887
|
+
// does not name is removed by not being named.
|
|
888
|
+
let marks: PendingMark[];
|
|
889
|
+
if (wire.pendingMarkOrder === undefined) {
|
|
890
|
+
marks = wire.pendingMarks !== undefined ? clone(wire.pendingMarks) : [];
|
|
891
|
+
} else {
|
|
892
|
+
const held = new Map((state.pendingMarks ?? []).map((mark) =>
|
|
893
|
+
[pendingMarkKey(mark), mark] as const));
|
|
894
|
+
for (const mark of wire.addPendingMarks ?? []) {
|
|
895
|
+
const key = pendingMarkKey(mark);
|
|
896
|
+
const standing = held.get(key);
|
|
897
|
+
if (standing && sha256Value(standing) === sha256Value(mark)) {
|
|
898
|
+
throw new Error(`Redundant active-context pending mark ${key}`);
|
|
899
|
+
}
|
|
900
|
+
held.set(key, clone(mark));
|
|
901
|
+
}
|
|
902
|
+
marks = wire.pendingMarkOrder.map((key) => {
|
|
903
|
+
const mark = held.get(key);
|
|
904
|
+
if (!mark) throw new Error(`Unknown active-context pending mark ${key}`);
|
|
905
|
+
return mark;
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
state = stateFromFoldRefs(
|
|
909
|
+
{
|
|
910
|
+
...wire,
|
|
911
|
+
briefs: Object.keys(briefs).length ? briefs : undefined,
|
|
912
|
+
pendingMarks: marks.length ? marks : undefined,
|
|
913
|
+
},
|
|
914
|
+
[...byId.values()],
|
|
915
|
+
records,
|
|
916
|
+
);
|
|
920
917
|
}
|
|
921
918
|
const calculated = semanticStateSha256(state);
|
|
922
|
-
// Compatibility window: accept the current digest plus two legacy shapes: one
|
|
923
|
-
// omitted zero cadence/empty leases; the other used a different replay-property
|
|
924
|
-
// order. These shims keep already-written v2 events readable and can be removed
|
|
925
|
-
// at the next wire-version bump.
|
|
926
919
|
if (calculated !== wire.stateSha256 &&
|
|
927
920
|
legacyCadenceOmittedStateSha256(state) !== wire.stateSha256 &&
|
|
928
921
|
legacyReplayOrderStateSha256(state) !== wire.stateSha256) {
|
|
@@ -970,13 +963,10 @@ export function makeStateCheckpoint(state: ActiveContextState): ActiveContextChe
|
|
|
970
963
|
prepared: state.prepared ? clone(state.prepared) : null,
|
|
971
964
|
tokensSinceToolFold: state.tokensSinceToolFold,
|
|
972
965
|
leases: clone(state.leases),
|
|
973
|
-
...(state.surfacing?.length ? { surfacing: clone(state.surfacing) } : {}),
|
|
974
966
|
...(state.pendingMarks?.length ? { pendingMarks: clone(state.pendingMarks) } : {}),
|
|
975
967
|
...(state.briefs && Object.keys(state.briefs).length ? { briefs: clone(state.briefs) } : {}),
|
|
976
968
|
...(state.advisory ? { advisory: clone(state.advisory) } : {}),
|
|
977
969
|
...(state.rider ? { rider: clone(state.rider) } : {}),
|
|
978
|
-
...(state.lastCall ? { lastCall: clone(state.lastCall) } : {}),
|
|
979
|
-
...(state.notices ? { notices: clone(state.notices) } : {}),
|
|
980
970
|
stateSha256: semanticStateSha256(state),
|
|
981
971
|
}, state.sessionId) as ActiveContextCheckpointV2;
|
|
982
972
|
}
|
|
@@ -996,6 +986,40 @@ export function makeStateDelta(previous: ActiveContextState, next: ActiveContext
|
|
|
996
986
|
throw new Error(`Active-context fold record changed for ${id}`);
|
|
997
987
|
}
|
|
998
988
|
}
|
|
989
|
+
// A delta carries the CHANGE, never a value its base already holds. The brief map used
|
|
990
|
+
// to ship whole on every write: 81% of all state bytes on sol-20260812 rep 9, 17.7 MB of
|
|
991
|
+
// it unchanged, which then made every later turn slower because the session file is what
|
|
992
|
+
// the runtime derives over.
|
|
993
|
+
const previousBriefs = previous.briefs ?? {};
|
|
994
|
+
const nextBriefs = next.briefs ?? {};
|
|
995
|
+
const addBriefs: Record<string, BriefOverride> = {};
|
|
996
|
+
for (const [id, brief] of Object.entries(nextBriefs)) {
|
|
997
|
+
if (!Object.prototype.hasOwnProperty.call(previousBriefs, id)) { addBriefs[id] = clone(brief); continue; }
|
|
998
|
+
if (sha256Value(previousBriefs[id]) !== sha256Value(brief)) addBriefs[id] = clone(brief);
|
|
999
|
+
}
|
|
1000
|
+
const removeBriefIds = Object.keys(previousBriefs)
|
|
1001
|
+
.filter((id) => !Object.prototype.hasOwnProperty.call(nextBriefs, id));
|
|
1002
|
+
// The marks are the same rule one shape along, and the next largest field after the
|
|
1003
|
+
// briefs: 2.55 MB of rep 9's 21.6 MB ledger, which a diff takes to 0.16 MB. They carry an
|
|
1004
|
+
// ORDER as well as a membership, and appending after removing does not always reproduce
|
|
1005
|
+
// it (four of that run's 309 writes), so the key order travels whole and states the
|
|
1006
|
+
// removals with it. It is short next to the marks themselves, so there is no case to
|
|
1007
|
+
// split and no branch that can pick the wrong one.
|
|
1008
|
+
const previousMarks = previous.pendingMarks ?? [];
|
|
1009
|
+
const nextMarks = next.pendingMarks ?? [];
|
|
1010
|
+
const previousByKey = new Map(previousMarks.map((mark) => [pendingMarkKey(mark), mark] as const));
|
|
1011
|
+
const addPendingMarks = nextMarks.filter((mark) => {
|
|
1012
|
+
const held = previousByKey.get(pendingMarkKey(mark));
|
|
1013
|
+
return !held || sha256Value(held) !== sha256Value(mark);
|
|
1014
|
+
});
|
|
1015
|
+
const marksTravel = previousMarks.length > 0 || nextMarks.length > 0;
|
|
1016
|
+
// The rest travel whole and stay that way. Measured on the same run, the two that were
|
|
1017
|
+
// worth a diff were 20.0 MB of the 21.6 MB ledger; what remained were bounded objects
|
|
1018
|
+
// with their own caps rather than collections that grow with the epoch (the largest,
|
|
1019
|
+
// the since-retired `notices`, was 0.93 MB). A diff for a capped
|
|
1020
|
+
// value is a second encoding to keep honest for a few hundred kilobytes a session, and
|
|
1021
|
+
// the reader's own compatibility rule is the part that is easy to get wrong: this build
|
|
1022
|
+
// shipped one such bug and the gate caught it.
|
|
999
1023
|
return parseActiveContextStateV2({
|
|
1000
1024
|
version: 2,
|
|
1001
1025
|
kind: "delta",
|
|
@@ -1010,13 +1034,12 @@ export function makeStateDelta(previous: ActiveContextState, next: ActiveContext
|
|
|
1010
1034
|
prepared: next.prepared ? clone(next.prepared) : null,
|
|
1011
1035
|
tokensSinceToolFold: next.tokensSinceToolFold,
|
|
1012
1036
|
leases: clone(next.leases),
|
|
1013
|
-
...(
|
|
1014
|
-
...(
|
|
1015
|
-
...(
|
|
1037
|
+
...(marksTravel ? { pendingMarkOrder: nextMarks.map(pendingMarkKey) } : {}),
|
|
1038
|
+
...(addPendingMarks.length ? { addPendingMarks: clone(addPendingMarks) } : {}),
|
|
1039
|
+
...(Object.keys(addBriefs).length ? { addBriefs } : {}),
|
|
1040
|
+
...(removeBriefIds.length ? { removeBriefIds } : {}),
|
|
1016
1041
|
...(next.advisory ? { advisory: clone(next.advisory) } : {}),
|
|
1017
1042
|
...(next.rider ? { rider: clone(next.rider) } : {}),
|
|
1018
|
-
...(next.lastCall ? { lastCall: clone(next.lastCall) } : {}),
|
|
1019
|
-
...(next.notices ? { notices: clone(next.notices) } : {}),
|
|
1020
1043
|
stateSha256: semanticStateSha256(next),
|
|
1021
1044
|
}, next.sessionId) as ActiveContextDeltaV2;
|
|
1022
1045
|
}
|
|
@@ -1029,20 +1052,40 @@ export function protectionSha256(state: Pick<ActiveContextState, "protected">):
|
|
|
1029
1052
|
return sha256Value([...state.protected].sort((left, right) => objectRefKey(left).localeCompare(objectRefKey(right))));
|
|
1030
1053
|
}
|
|
1031
1054
|
|
|
1032
|
-
/**
|
|
1033
|
-
* The milestone vocabulary a persisted `advisory` state field may carry. The delivery
|
|
1034
|
-
* schedule that spent a per-milestone budget is deleted; this list stays because the
|
|
1035
|
-
* state field is written into every state and covered by the state digest, so sealed
|
|
1036
|
-
* runs have to keep parsing. It is its own literal now rather than the key set of the
|
|
1037
|
-
* deleted budget table.
|
|
1038
|
-
*/
|
|
1039
1055
|
export const ADVISORY_MILESTONES = Object.freeze(
|
|
1040
1056
|
["orientation", "notice", "tools", "chapters", "urgent"] as AdvisoryMilestone[],
|
|
1041
1057
|
);
|
|
1042
1058
|
|
|
1043
|
-
/** The rider is LITERAL persisted bytes, so the bound is on the stored text itself. */
|
|
1044
1059
|
export const MAX_RIDER_TEXT_BYTES = 4_096;
|
|
1045
1060
|
|
|
1061
|
+
/**
|
|
1062
|
+
* THE RETIRED STATE FIELDS, REFUSED BY NAME.
|
|
1063
|
+
*
|
|
1064
|
+
* `lastCall` and `notices` were the occupancy thermostat's two announcements. Nothing
|
|
1065
|
+
* writes them any more and nothing reads them, so a durable state that still carries one
|
|
1066
|
+
* was written by a build that had them, and the exact-record readers would reject it with
|
|
1067
|
+
* "Invalid active-context state keys" -- true, and useless to whoever has to act on it.
|
|
1068
|
+
*
|
|
1069
|
+
* Refusing by name is the whole behaviour. Tolerating the field and dropping it is the
|
|
1070
|
+
* silent path: it makes a version boundary look like a successful load, and the state
|
|
1071
|
+
* that comes back is not the state that was written. Gate 17 already states the wire's
|
|
1072
|
+
* policy in the other direction -- an older exact-record reader rejects newer fields, and
|
|
1073
|
+
* a bump is explicit -- and this is that same policy read from the newer side.
|
|
1074
|
+
*/
|
|
1075
|
+
const RETIRED_STATE_FIELDS: readonly string[] = Object.freeze(["lastCall", "notices", "surfacing"]);
|
|
1076
|
+
|
|
1077
|
+
export function refuseRetiredStateFields(value: unknown): void {
|
|
1078
|
+
if (!value || typeof value !== "object") return;
|
|
1079
|
+
const carried = RETIRED_STATE_FIELDS.filter((field) =>
|
|
1080
|
+
Object.prototype.hasOwnProperty.call(value, field));
|
|
1081
|
+
if (!carried.length) return;
|
|
1082
|
+
throw new Error(
|
|
1083
|
+
`Active-context state carries the retired field(s) ${carried.join(", ")}: it was written ` +
|
|
1084
|
+
"by a build that still carried the subsystem behind it. Nothing writes or reads them " +
|
|
1085
|
+
"now, and this build will not load a state it cannot reproduce.",
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1046
1089
|
export function validRiderState(value: unknown): value is NonNullable<ActiveContextState["rider"]> {
|
|
1047
1090
|
if (!exactRecord(value, ["epoch", "text"])) return false;
|
|
1048
1091
|
const epoch = ownValue(value, "epoch");
|
|
@@ -1051,29 +1094,6 @@ export function validRiderState(value: unknown): value is NonNullable<ActiveCont
|
|
|
1051
1094
|
typeof text === "string" && text.length > 0 && text.length <= MAX_RIDER_TEXT_BYTES;
|
|
1052
1095
|
}
|
|
1053
1096
|
|
|
1054
|
-
export function validLastCallState(value: unknown): value is NonNullable<ActiveContextState["lastCall"]> {
|
|
1055
|
-
if (!exactRecord(value, ["exposure", "ordinal", "contextCalls", "agentMarks", "text"])) return false;
|
|
1056
|
-
const text = ownValue(value, "text");
|
|
1057
|
-
return ["exposure", "ordinal", "contextCalls", "agentMarks"].every((field) =>
|
|
1058
|
-
Number.isSafeInteger(ownValue(value, field)) && Number(ownValue(value, field)) >= 0) &&
|
|
1059
|
-
typeof text === "string" && text.length > 0 && text.length <= MAX_LAST_CALL_TEXT_BYTES;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
export function validNoticesState(value: unknown): value is NonNullable<ActiveContextState["notices"]> {
|
|
1063
|
-
if (!exactRecord(value, ["fired", "ring"])) return false;
|
|
1064
|
-
const fired = denseOwnArrayValues(ownValue(value, "fired"));
|
|
1065
|
-
const ring = denseOwnArrayValues(ownValue(value, "ring"));
|
|
1066
|
-
if (!fired || !ring || ring.length > MAX_THRESHOLD_NOTICES) return false;
|
|
1067
|
-
if (fired.some((share) => typeof share !== "number" || !Number.isFinite(share) || share <= 0 || share >= 1) ||
|
|
1068
|
-
new Set(fired).size !== fired.length) return false;
|
|
1069
|
-
return ring.every((notice) => exactRecord(notice, ["share", "ordinal", "text"]) &&
|
|
1070
|
-
typeof ownValue(notice, "share") === "number" && Number.isFinite(ownValue(notice, "share")) &&
|
|
1071
|
-
Number(ownValue(notice, "share")) > 0 && Number(ownValue(notice, "share")) < 1 &&
|
|
1072
|
-
Number.isSafeInteger(ownValue(notice, "ordinal")) && Number(ownValue(notice, "ordinal")) >= 0 &&
|
|
1073
|
-
typeof ownValue(notice, "text") === "string" && String(ownValue(notice, "text")).length > 0 &&
|
|
1074
|
-
String(ownValue(notice, "text")).length <= MAX_THRESHOLD_NOTICE_TEXT_BYTES);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
1097
|
export function validAdvisoryState(value: unknown): value is NonNullable<ActiveContextState["advisory"]> {
|
|
1078
1098
|
const hasArmed = Boolean(value && typeof value === "object" &&
|
|
1079
1099
|
Object.prototype.hasOwnProperty.call(value, "armed"));
|
|
@@ -1094,6 +1114,5 @@ export function validAdvisoryState(value: unknown): value is NonNullable<ActiveC
|
|
|
1094
1114
|
const delivered = ownValue(value, "delivered") as Record<string, unknown>;
|
|
1095
1115
|
return Reflect.ownKeys(delivered).every((key) => typeof key === "string" &&
|
|
1096
1116
|
ADVISORY_MILESTONES.includes(key as AdvisoryMilestone) &&
|
|
1097
|
-
Number.isSafeInteger(ownValue(delivered, key)) && Number(ownValue(delivered, key)) >= 0
|
|
1098
|
-
Number(ownValue(delivered, key)) <= MAX_ADVISORY_DELIVERIES_PER_MILESTONE);
|
|
1117
|
+
Number.isSafeInteger(ownValue(delivered, key)) && Number(ownValue(delivered, key)) >= 0);
|
|
1099
1118
|
}
|