pi-fold 1.0.4 → 3.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 +75 -28
- package/extensions/active-context.ts +1213 -2756
- package/extensions/evidence.js +0 -10
- package/extensions/index.js +25 -32
- package/extensions/json.ts +3 -29
- package/extensions/lib/canonical.ts +0 -8
- package/extensions/lib/curation.ts +52 -313
- package/extensions/lib/editor-ui.ts +739 -0
- package/extensions/lib/folding.ts +57 -446
- package/extensions/lib/instrumentation.ts +13 -127
- package/extensions/lib/measurement.ts +6 -83
- package/extensions/lib/persistence.ts +272 -229
- package/extensions/lib/policy.ts +165 -612
- package/extensions/lib/rollback.ts +0 -53
- package/extensions/lib/scheduling.ts +352 -450
- package/extensions/lib/selection.ts +212 -390
- package/extensions/lib/tool-surface.ts +29 -46
- package/extensions/lib/transcript.ts +47 -166
- package/extensions/settings.ts +481 -0
- package/package.json +4 -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,13 +52,11 @@ 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;
|
|
66
58
|
export const PENDING_REFOLD_MARK_KEYS = ["mark", "id", "origin", "ordinal"] as const;
|
|
67
|
-
export const MARK_ORIGINS = Object.freeze(["agent", "ladder"] as const);
|
|
59
|
+
export const MARK_ORIGINS = Object.freeze(["agent", "ladder", "user"] as const);
|
|
68
60
|
export const STATE_CHECKPOINT_V2_KEYS = [
|
|
69
61
|
"version", "kind", "sessionId", "revision", "foldRefs", "expanded", "protected", "prepared", "stateSha256",
|
|
70
62
|
] 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
|
}
|
|
@@ -121,23 +91,11 @@ export function validPendingMark(value: unknown): value is PendingMark {
|
|
|
121
91
|
const kind = ownValue(value, "kind");
|
|
122
92
|
const parts = denseOwnArrayValues(ownValue(value, "parts"));
|
|
123
93
|
return (kind === "tool-result" || kind === "chapter" || kind === "consolidation") &&
|
|
124
|
-
Boolean(parts?.length) && parts!.
|
|
125
|
-
parts!.every(validFoldPart) && structurallyValidBrief(ownValue(value, "brief")) &&
|
|
94
|
+
Boolean(parts?.length) && parts!.every(validFoldPart) && structurallyValidBrief(ownValue(value, "brief")) &&
|
|
126
95
|
validProvenance(ownValue(value, "briefProvenance")) &&
|
|
127
96
|
id === foldIdFor(kind as FoldKind, parts as FoldPart[]);
|
|
128
97
|
}
|
|
129
98
|
|
|
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
99
|
export function briefOverrideText(override: unknown): string | null {
|
|
142
100
|
if (typeof override === "string") return override;
|
|
143
101
|
const brief = ownValue(override, "brief");
|
|
@@ -169,7 +127,6 @@ export function parseBriefOverrides(
|
|
|
169
127
|
) as Record<string, BriefOverride>;
|
|
170
128
|
}
|
|
171
129
|
|
|
172
|
-
/** The brief a fold PRESENTS: the agent's correction when it made one, else the record's. */
|
|
173
130
|
export function foldBrief(
|
|
174
131
|
fold: Pick<ActiveFold, "id" | "brief">,
|
|
175
132
|
state: Pick<ActiveContextState, "briefs">,
|
|
@@ -177,11 +134,6 @@ export function foldBrief(
|
|
|
177
134
|
return briefOverrideText(state.briefs?.[fold.id]) ?? fold.brief;
|
|
178
135
|
}
|
|
179
136
|
|
|
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
137
|
export function foldProvenance(
|
|
186
138
|
fold: Pick<ActiveFold, "id" | "provenance">,
|
|
187
139
|
state: Pick<ActiveContextState, "briefs">,
|
|
@@ -218,7 +170,6 @@ export function validProvenance(value: unknown): value is BriefProvenance {
|
|
|
218
170
|
(ownValue(value, "kind") === "supplied" || ownValue(value, "kind") === "deterministic")) return true;
|
|
219
171
|
const hasDigest = Boolean(value && typeof value === "object" &&
|
|
220
172
|
Object.prototype.hasOwnProperty.call(value, "launchContractDigest"));
|
|
221
|
-
// legacy provenance kind written by pre-release sessions; normalized to the model kind at presentation
|
|
222
173
|
if (!exactRecord(value, ["kind", "provider", "model", "effort", ...(hasDigest ? ["launchContractDigest"] : [])]) ||
|
|
223
174
|
(ownValue(value, "kind") !== "model" && ownValue(value, "kind") !== "luna") ||
|
|
224
175
|
typeof ownValue(value, "provider") !== "string" || !ownValue(value, "provider") ||
|
|
@@ -258,7 +209,6 @@ export function validFoldShape(value: unknown): value is ActiveFold {
|
|
|
258
209
|
return (kind === "tool-result" || kind === "chapter" || kind === "consolidation") &&
|
|
259
210
|
typeof ownValue(value, "id") === "string" && Boolean(ownValue(value, "id")) &&
|
|
260
211
|
(parentId === null || typeof parentId === "string") && Boolean(parts?.length) &&
|
|
261
|
-
parts!.length <= ACTIVE_CONTEXT_POLICY.maxFoldSourceRefs &&
|
|
262
212
|
parts!.every(validFoldPart) && structurallyValidBrief(ownValue(value, "brief")) &&
|
|
263
213
|
validProvenance(ownValue(value, "provenance")) && typeof sourceSha256 === "string" &&
|
|
264
214
|
/^[a-f0-9]{64}$/.test(sourceSha256) && Number.isSafeInteger(ownValue(value, "sourceChars")) &&
|
|
@@ -281,11 +231,6 @@ export function foldMap(state: Pick<ActiveContextState, "folds">): Map<string, A
|
|
|
281
231
|
return new Map(state.folds.map((fold) => [fold.id, fold]));
|
|
282
232
|
}
|
|
283
233
|
|
|
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
234
|
export function childFoldIds(fold: Pick<ActiveFold, "parts">): string[] {
|
|
290
235
|
return fold.parts.filter((part): part is Extract<FoldPart, { kind: "fold" }> => part.kind === "fold")
|
|
291
236
|
.map((part) => part.foldId);
|
|
@@ -309,20 +254,6 @@ export function flattenFoldRefs(fold: ActiveFold, state: Pick<ActiveContextState
|
|
|
309
254
|
return visit(fold);
|
|
310
255
|
}
|
|
311
256
|
|
|
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
257
|
export function directFoldOwners(folds: readonly ActiveFold[]): Map<string, string> {
|
|
327
258
|
const owner = new Map<string, string>();
|
|
328
259
|
for (const fold of folds) {
|
|
@@ -351,18 +282,6 @@ export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
|
|
|
351
282
|
(!fold.parts.length || fold.parts.some((part) => part.kind !== "raw" || part.ref.role !== "toolResult"))) {
|
|
352
283
|
throw new Error("Tool-result fold must own one validated assistant batch of tool results");
|
|
353
284
|
}
|
|
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
285
|
if (fold.kind === "consolidation" && !fold.parts.some((part) => part.kind === "fold")) {
|
|
367
286
|
throw new Error("Consolidation folds must contain at least one child fold");
|
|
368
287
|
}
|
|
@@ -401,29 +320,22 @@ export function parseActiveContextState(
|
|
|
401
320
|
const hasTokensSinceToolFold = recordLike &&
|
|
402
321
|
Object.prototype.hasOwnProperty.call(value, "tokensSinceToolFold");
|
|
403
322
|
const hasLeases = recordLike && Object.prototype.hasOwnProperty.call(value, "leases");
|
|
404
|
-
const hasSurfacing = recordLike && Object.prototype.hasOwnProperty.call(value, "surfacing");
|
|
405
323
|
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
324
|
const hasPinnedPeeks = recordLike && Object.prototype.hasOwnProperty.call(value, "pinnedPeeks");
|
|
411
325
|
const hasBriefs = recordLike && Object.prototype.hasOwnProperty.call(value, "briefs");
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
326
|
+
// Read and dropped, never stored: see legacyRiderStateSha256. A v1 state has no digest
|
|
327
|
+
// to reproduce, so here the tolerance is the whole of it.
|
|
328
|
+
const hasLegacyRider = recordLike && Object.prototype.hasOwnProperty.call(value, "rider");
|
|
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
|
-
...(
|
|
425
|
-
...(hasLastCall ? ["lastCall"] : []),
|
|
426
|
-
...(hasNotices ? ["notices"] : []),
|
|
338
|
+
...(hasLegacyRider ? ["rider"] : []),
|
|
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"));
|
|
@@ -445,29 +357,16 @@ export function parseActiveContextState(
|
|
|
445
357
|
if (hasAdvisory && !validAdvisoryState(ownValue(value, "advisory"))) {
|
|
446
358
|
throw new Error("Invalid active-context advisory state");
|
|
447
359
|
}
|
|
448
|
-
if (hasRider && !validRiderState(ownValue(value, "rider"))) {
|
|
449
|
-
throw new Error("Invalid active-context rider state");
|
|
450
|
-
}
|
|
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
360
|
if (hasTokensSinceToolFold && !validTokensSinceToolFold(ownValue(value, "tokensSinceToolFold"))) {
|
|
458
361
|
throw new Error("Invalid active-context tool-fold cadence");
|
|
459
362
|
}
|
|
460
363
|
const leases = hasLeases ? parseLeases(ownValue(value, "leases"), ids) : {};
|
|
461
|
-
const surfacing = hasSurfacing ? parseSurfacingLog(ownValue(value, "surfacing")) : [];
|
|
462
364
|
const marks = hasPendingMarks ? parsePendingMarks(ownValue(value, "pendingMarks")) : [];
|
|
463
365
|
if (marks.some((mark) => mark.mark === "refold" && !ids.has(mark.id))) {
|
|
464
366
|
throw new Error("Invalid active-context pending marks");
|
|
465
367
|
}
|
|
466
368
|
const briefs = hasBriefs ? parseBriefOverrides(ownValue(value, "briefs"), ids) : {};
|
|
467
369
|
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
370
|
return {
|
|
472
371
|
version: 1,
|
|
473
372
|
sessionId: source.sessionId,
|
|
@@ -479,17 +378,11 @@ export function parseActiveContextState(
|
|
|
479
378
|
? Number(ownValue(value, "tokensSinceToolFold"))
|
|
480
379
|
: 0,
|
|
481
380
|
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
381
|
...(marks.length ? { pendingMarks: marks } : {}),
|
|
486
382
|
...(Object.keys(briefs).length ? { briefs } : {}),
|
|
487
383
|
...(hasAdvisory
|
|
488
384
|
? { advisory: clone(source.advisory!) }
|
|
489
385
|
: defaultAdvisory ? { advisory: { highWater: 0, delivered: {} } } : {}),
|
|
490
|
-
...(hasRider ? { rider: clone(source.rider!) } : {}),
|
|
491
|
-
...(hasLastCall ? { lastCall: clone(source.lastCall!) } : {}),
|
|
492
|
-
...(hasNotices ? { notices: clone(source.notices!) } : {}),
|
|
493
386
|
...(hasPrepared ? { prepared: clone(source.prepared!) } : {}),
|
|
494
387
|
};
|
|
495
388
|
}
|
|
@@ -544,6 +437,29 @@ export function legacyCadenceOmittedStateSha256(state: ActiveContextState): stri
|
|
|
544
437
|
return sha256Value(legacy);
|
|
545
438
|
}
|
|
546
439
|
|
|
440
|
+
/**
|
|
441
|
+
* THE DIGEST A RIDER-ERA BUILD WROTE, reproduced without the subsystem.
|
|
442
|
+
*
|
|
443
|
+
* `semanticStateSha256` hashes the materialized state object, and `stableStringify` walks
|
|
444
|
+
* own keys in INSERTION order, so a field that is gone changes the digest even though it
|
|
445
|
+
* changed nothing else. The rider sat immediately before `prepared` in the materialized
|
|
446
|
+
* literal, and last when there was no prepared, so that is where it goes back for the one
|
|
447
|
+
* moment it is needed. It joins the two legacy derivations already here, which exist for
|
|
448
|
+
* exactly this reason: a state stays verifiable across the build that stopped writing it.
|
|
449
|
+
*
|
|
450
|
+
* Consulted ONLY when the wire actually carried a rider, so a current state whose digest
|
|
451
|
+
* has genuinely drifted still fails rather than finding a second way to pass.
|
|
452
|
+
*/
|
|
453
|
+
export function legacyRiderStateSha256(state: ActiveContextState, rider: unknown): string {
|
|
454
|
+
const legacy: Record<string, unknown> = {};
|
|
455
|
+
for (const [key, value] of Object.entries(state)) {
|
|
456
|
+
if (key === "prepared") legacy.rider = rider;
|
|
457
|
+
legacy[key] = value;
|
|
458
|
+
}
|
|
459
|
+
if (!Object.prototype.hasOwnProperty.call(legacy, "rider")) legacy.rider = rider;
|
|
460
|
+
return sha256Value(legacy);
|
|
461
|
+
}
|
|
462
|
+
|
|
547
463
|
export function legacyReplayOrderStateSha256(state: ActiveContextState): string {
|
|
548
464
|
return sha256Value({
|
|
549
465
|
version: state.version,
|
|
@@ -562,7 +478,6 @@ export function sameStateProjection(left: ActiveContextState, right: ActiveConte
|
|
|
562
478
|
const normalizedState = { ...clone(value), revision: 0 } as Partial<ActiveContextState>;
|
|
563
479
|
if (normalizedState.tokensSinceToolFold === 0) delete normalizedState.tokensSinceToolFold;
|
|
564
480
|
if (normalizedState.leases && Object.keys(normalizedState.leases).length === 0) delete normalizedState.leases;
|
|
565
|
-
if (normalizedState.surfacing && normalizedState.surfacing.length === 0) delete normalizedState.surfacing;
|
|
566
481
|
if (normalizedState.pendingMarks && normalizedState.pendingMarks.length === 0) delete normalizedState.pendingMarks;
|
|
567
482
|
if (normalizedState.briefs && !Object.keys(normalizedState.briefs).length) delete normalizedState.briefs;
|
|
568
483
|
return normalizedState;
|
|
@@ -605,23 +520,15 @@ export function validateV2ProjectionFields(
|
|
|
605
520
|
advisoryValue: unknown,
|
|
606
521
|
tokensSinceToolFoldValue: unknown,
|
|
607
522
|
leasesValue: unknown,
|
|
608
|
-
surfacingValue?: unknown,
|
|
609
523
|
pendingMarksValue?: unknown,
|
|
610
524
|
briefsValue?: unknown,
|
|
611
|
-
riderValue?: unknown,
|
|
612
|
-
lastCallValue?: unknown,
|
|
613
|
-
noticesValue?: unknown,
|
|
614
525
|
): {
|
|
615
526
|
expanded: string[];
|
|
616
527
|
protected: EvidenceRef[];
|
|
617
528
|
prepared?: PreparedFold;
|
|
618
529
|
advisory?: NonNullable<ActiveContextState["advisory"]>;
|
|
619
|
-
rider?: NonNullable<ActiveContextState["rider"]>;
|
|
620
|
-
lastCall?: NonNullable<ActiveContextState["lastCall"]>;
|
|
621
|
-
notices?: NonNullable<ActiveContextState["notices"]>;
|
|
622
530
|
tokensSinceToolFold: number;
|
|
623
531
|
leases: Record<string, number>;
|
|
624
|
-
surfacing: SurfacingRecord[];
|
|
625
532
|
pendingMarks: PendingMark[];
|
|
626
533
|
briefs: Record<string, string>;
|
|
627
534
|
} {
|
|
@@ -637,20 +544,10 @@ export function validateV2ProjectionFields(
|
|
|
637
544
|
if (advisoryValue !== undefined && !validAdvisoryState(advisoryValue)) {
|
|
638
545
|
throw new Error("Invalid active-context v2 advisory state");
|
|
639
546
|
}
|
|
640
|
-
if (riderValue !== undefined && !validRiderState(riderValue)) {
|
|
641
|
-
throw new Error("Invalid active-context v2 rider state");
|
|
642
|
-
}
|
|
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
547
|
if (tokensSinceToolFoldValue !== undefined && !validTokensSinceToolFold(tokensSinceToolFoldValue)) {
|
|
650
548
|
throw new Error("Invalid active-context v2 tool-fold cadence");
|
|
651
549
|
}
|
|
652
550
|
const leases = leasesValue === undefined ? {} : parseLeases(leasesValue);
|
|
653
|
-
const surfacing = surfacingValue === undefined ? [] : parseSurfacingLog(surfacingValue);
|
|
654
551
|
const pendingMarks = pendingMarksValue === undefined ? [] : parsePendingMarks(pendingMarksValue);
|
|
655
552
|
const briefs = briefsValue === undefined ? {} : parseBriefOverrides(briefsValue);
|
|
656
553
|
return {
|
|
@@ -660,18 +557,8 @@ export function validateV2ProjectionFields(
|
|
|
660
557
|
...(advisoryValue === undefined ? {} : {
|
|
661
558
|
advisory: clone(advisoryValue) as NonNullable<ActiveContextState["advisory"]>,
|
|
662
559
|
}),
|
|
663
|
-
...(riderValue === undefined ? {} : {
|
|
664
|
-
rider: clone(riderValue) as NonNullable<ActiveContextState["rider"]>,
|
|
665
|
-
}),
|
|
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
560
|
tokensSinceToolFold: tokensSinceToolFoldValue === undefined ? 0 : Number(tokensSinceToolFoldValue),
|
|
673
561
|
leases,
|
|
674
|
-
surfacing,
|
|
675
562
|
pendingMarks,
|
|
676
563
|
briefs,
|
|
677
564
|
};
|
|
@@ -685,35 +572,37 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
|
|
|
685
572
|
Object.prototype.hasOwnProperty.call(value, "tokensSinceToolFold"));
|
|
686
573
|
const hasLeases = Boolean(value && typeof value === "object" &&
|
|
687
574
|
Object.prototype.hasOwnProperty.call(value, "leases"));
|
|
688
|
-
const hasSurfacing = Boolean(value && typeof value === "object" &&
|
|
689
|
-
Object.prototype.hasOwnProperty.call(value, "surfacing"));
|
|
690
575
|
const hasPendingMarks = Boolean(value && typeof value === "object" &&
|
|
691
576
|
Object.prototype.hasOwnProperty.call(value, "pendingMarks"));
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
577
|
+
const hasAddPendingMarks = Boolean(value && typeof value === "object" &&
|
|
578
|
+
Object.prototype.hasOwnProperty.call(value, "addPendingMarks"));
|
|
579
|
+
const hasPendingMarkOrder = Boolean(value && typeof value === "object" &&
|
|
580
|
+
Object.prototype.hasOwnProperty.call(value, "pendingMarkOrder"));
|
|
696
581
|
const hasPinnedPeeks = Boolean(value && typeof value === "object" &&
|
|
697
582
|
Object.prototype.hasOwnProperty.call(value, "pinnedPeeks"));
|
|
698
583
|
const hasBriefs = Boolean(value && typeof value === "object" &&
|
|
699
584
|
Object.prototype.hasOwnProperty.call(value, "briefs"));
|
|
700
|
-
const
|
|
585
|
+
const hasAddBriefs = Boolean(value && typeof value === "object" &&
|
|
586
|
+
Object.prototype.hasOwnProperty.call(value, "addBriefs"));
|
|
587
|
+
const hasRemoveBriefIds = Boolean(value && typeof value === "object" &&
|
|
588
|
+
Object.prototype.hasOwnProperty.call(value, "removeBriefIds"));
|
|
589
|
+
const hasLegacyRider = Boolean(value && typeof value === "object" &&
|
|
701
590
|
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"));
|
|
591
|
+
refuseRetiredStateFields(value);
|
|
706
592
|
const optionalKeys = [
|
|
707
593
|
...(hasAdvisory ? ["advisory"] : []),
|
|
708
594
|
...(hasTokensSinceToolFold ? ["tokensSinceToolFold"] : []),
|
|
709
595
|
...(hasLeases ? ["leases"] : []),
|
|
710
|
-
...(hasSurfacing ? ["surfacing"] : []),
|
|
711
596
|
...(hasPendingMarks ? ["pendingMarks"] : []),
|
|
597
|
+
...(hasAddPendingMarks ? ["addPendingMarks"] : []),
|
|
598
|
+
...(hasPendingMarkOrder ? ["pendingMarkOrder"] : []),
|
|
712
599
|
...(hasPinnedPeeks ? ["pinnedPeeks"] : []),
|
|
713
600
|
...(hasBriefs ? ["briefs"] : []),
|
|
714
|
-
...(
|
|
715
|
-
...(
|
|
716
|
-
|
|
601
|
+
...(hasAddBriefs ? ["addBriefs"] : []),
|
|
602
|
+
...(hasRemoveBriefIds ? ["removeBriefIds"] : []),
|
|
603
|
+
// Carried no further than the digest check in `materializeStatePersistence`, which is
|
|
604
|
+
// the only thing on a load that the rider's absence can change.
|
|
605
|
+
...(hasLegacyRider ? ["rider"] : []),
|
|
717
606
|
];
|
|
718
607
|
const checkpoint = kind === "checkpoint" &&
|
|
719
608
|
exactRecord(value, [...STATE_CHECKPOINT_V2_KEYS, ...optionalKeys]);
|
|
@@ -728,8 +617,7 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
|
|
|
728
617
|
validateV2ProjectionFields(
|
|
729
618
|
ownValue(value, "expanded"), ownValue(value, "protected"), ownValue(value, "prepared"),
|
|
730
619
|
ownValue(value, "advisory"), ownValue(value, "tokensSinceToolFold"), ownValue(value, "leases"),
|
|
731
|
-
ownValue(value, "
|
|
732
|
-
ownValue(value, "rider"), ownValue(value, "lastCall"), ownValue(value, "notices"),
|
|
620
|
+
ownValue(value, "pendingMarks"), ownValue(value, "briefs"),
|
|
733
621
|
);
|
|
734
622
|
if (checkpoint) {
|
|
735
623
|
const refs = denseOwnArrayValues(ownValue(value, "foldRefs"));
|
|
@@ -754,6 +642,36 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
|
|
|
754
642
|
if (new Set(parsed.map((ref) => ref.id)).size !== parsed.length || new Set(removeIds).size !== removeIds.length) {
|
|
755
643
|
throw new Error("Duplicate active-context delta change");
|
|
756
644
|
}
|
|
645
|
+
if (hasAddBriefs) parseBriefOverrides(ownValue(value, "addBriefs"));
|
|
646
|
+
if (hasRemoveBriefIds) {
|
|
647
|
+
const removeBriefs = denseOwnArrayValues(ownValue(value, "removeBriefIds"));
|
|
648
|
+
if (!removeBriefs || removeBriefs.length > MAX_ACTIVE_FOLD_RECORDS ||
|
|
649
|
+
removeBriefs.some((id) => typeof id !== "string" || !id) ||
|
|
650
|
+
new Set(removeBriefs).size !== removeBriefs.length) {
|
|
651
|
+
throw new Error("Invalid active-context delta brief removals");
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (hasBriefs && (hasAddBriefs || hasRemoveBriefIds)) {
|
|
655
|
+
throw new Error("Active-context delta states the whole brief map and a change to it");
|
|
656
|
+
}
|
|
657
|
+
if (hasAddPendingMarks) parsePendingMarks(ownValue(value, "addPendingMarks"));
|
|
658
|
+
if (hasPendingMarkOrder) {
|
|
659
|
+
const order = denseOwnArrayValues(ownValue(value, "pendingMarkOrder"));
|
|
660
|
+
if (!order || order.length > MAX_PENDING_MARKS ||
|
|
661
|
+
order.some((key) => typeof key !== "string" || !key) ||
|
|
662
|
+
new Set(order).size !== order.length) {
|
|
663
|
+
throw new Error("Invalid active-context delta pending mark order");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// The order is what the replay is BUILT from, so additions without it name marks
|
|
667
|
+
// nothing will place. Refused rather than absorbed, because absorbing it would drop
|
|
668
|
+
// the marks silently and a dropped mark is evidence that never gets folded.
|
|
669
|
+
if (hasAddPendingMarks && !hasPendingMarkOrder) {
|
|
670
|
+
throw new Error("Active-context delta adds pending marks and states no order for them");
|
|
671
|
+
}
|
|
672
|
+
if (hasPendingMarks && (hasAddPendingMarks || hasPendingMarkOrder)) {
|
|
673
|
+
throw new Error("Active-context delta states the whole mark array and a change to it");
|
|
674
|
+
}
|
|
757
675
|
}
|
|
758
676
|
const parsed = clone(value) as unknown as ActiveContextStateWireV2;
|
|
759
677
|
if (bytes(parsed) > MAX_ACTIVE_STATE_EVENT_BYTES) throw new Error("Active-context state event exceeds wire limit");
|
|
@@ -794,8 +712,7 @@ export function stateFromFoldRefs(
|
|
|
794
712
|
wire: Pick<
|
|
795
713
|
ActiveContextCheckpointV2,
|
|
796
714
|
"sessionId" | "revision" | "expanded" | "protected" | "prepared" | "advisory" |
|
|
797
|
-
"tokensSinceToolFold" | "leases" | "
|
|
798
|
-
"lastCall" | "notices"
|
|
715
|
+
"tokensSinceToolFold" | "leases" | "pendingMarks" | "briefs"
|
|
799
716
|
>,
|
|
800
717
|
refs: FoldRecordRef[],
|
|
801
718
|
records: Map<string, FoldRecordEntry>,
|
|
@@ -815,24 +732,37 @@ export function stateFromFoldRefs(
|
|
|
815
732
|
protected: clone(wire.protected),
|
|
816
733
|
tokensSinceToolFold: wire.tokensSinceToolFold ?? 0,
|
|
817
734
|
leases: clone(wire.leases ?? {}),
|
|
818
|
-
...(wire.surfacing?.length ? { surfacing: clone(wire.surfacing) } : {}),
|
|
819
735
|
...(wire.pendingMarks?.length ? { pendingMarks: clone(wire.pendingMarks) } : {}),
|
|
820
736
|
...(wire.briefs && Object.keys(wire.briefs).length ? { briefs: clone(wire.briefs) } : {}),
|
|
821
737
|
...(wire.advisory === undefined ? {} : { advisory: clone(wire.advisory) }),
|
|
822
|
-
...(wire.rider === undefined ? {} : { rider: clone(wire.rider) }),
|
|
823
|
-
...(wire.lastCall === undefined ? {} : { lastCall: clone(wire.lastCall) }),
|
|
824
|
-
...(wire.notices === undefined ? {} : { notices: clone(wire.notices) }),
|
|
825
738
|
...(wire.prepared === null || wire.prepared === undefined ? {} : { prepared: clone(wire.prepared) }),
|
|
826
739
|
};
|
|
827
740
|
return parseActiveContextState(state, wire.sessionId, false);
|
|
828
741
|
}
|
|
829
742
|
|
|
743
|
+
export interface ProjectionFingerprint {
|
|
744
|
+
topologySha256: string;
|
|
745
|
+
protectionSha256: string;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* A REVISION'S FINGERPRINTS, COMPUTED WHEN ONE IS ASKED FOR.
|
|
750
|
+
*
|
|
751
|
+
* A `Map` satisfies this, which is what it was until 2026-08-13. The narrower shape is the
|
|
752
|
+
* point: the only reader takes ONE revision on the restore path, and the eager map hashed
|
|
753
|
+
* the fold forest twice for every revision the ledger ever held, on every materialization,
|
|
754
|
+
* including the ones that discard the map entirely.
|
|
755
|
+
*/
|
|
756
|
+
export interface ProjectionFingerprints {
|
|
757
|
+
get(revision: number): ProjectionFingerprint | undefined;
|
|
758
|
+
}
|
|
759
|
+
|
|
830
760
|
export interface MaterializedStatePersistence {
|
|
831
761
|
state: ActiveContextState;
|
|
832
762
|
wireVersion: 0 | 1 | 2;
|
|
833
763
|
records: Map<string, FoldRecordEntry>;
|
|
834
764
|
stateSha256: string;
|
|
835
|
-
projectionFingerprints:
|
|
765
|
+
projectionFingerprints: ProjectionFingerprints;
|
|
836
766
|
}
|
|
837
767
|
|
|
838
768
|
export function materializeStatePersistence(
|
|
@@ -844,16 +774,49 @@ export function materializeStatePersistence(
|
|
|
844
774
|
let state = emptyActiveContextState(sessionId);
|
|
845
775
|
let wireVersion: 0 | 1 | 2 = 0;
|
|
846
776
|
const records = new Map<string, FoldRecordEntry>();
|
|
847
|
-
|
|
777
|
+
// WHAT A FINGERPRINT IS MADE OF, HELD BY REFERENCE UNTIL ONE IS ASKED FOR.
|
|
778
|
+
//
|
|
779
|
+
// Replaying a ledger hashed the whole fold forest twice per revision, and one revision is
|
|
780
|
+
// ever read, by one caller, on the restore path. `materializeActiveContextState` takes
|
|
781
|
+
// `.state` and drops the map, so every request on a folding session paid for hundreds of
|
|
782
|
+
// digests it could not reach. Profiled against sol-20260813-paired rep 1 at its full
|
|
783
|
+
// 1,276 entries, materialization was 98% of a derivation, and canonicalizing and hashing
|
|
784
|
+
// was 72% of that.
|
|
785
|
+
//
|
|
786
|
+
// Holding the fields costs no copy: `state` is REPLACED on every delta rather than
|
|
787
|
+
// mutated (see `stateFromFoldRefs` below), so a reference taken here keeps that
|
|
788
|
+
// revision's own values, and the fold objects inside are shared with `records`.
|
|
789
|
+
const fingerprintSources = new Map<number, Pick<ActiveContextState,
|
|
790
|
+
"folds" | "expanded" | "protected">>();
|
|
791
|
+
const fingerprintCache = new Map<number, ProjectionFingerprint>();
|
|
792
|
+
const projectionFingerprints: ProjectionFingerprints = {
|
|
793
|
+
get(revision: number): ProjectionFingerprint | undefined {
|
|
794
|
+
const cached = fingerprintCache.get(revision);
|
|
795
|
+
if (cached) return cached;
|
|
796
|
+
const source = fingerprintSources.get(revision);
|
|
797
|
+
if (!source) return undefined;
|
|
798
|
+
const computed = {
|
|
799
|
+
topologySha256: topologySha256(source),
|
|
800
|
+
protectionSha256: protectionSha256(source),
|
|
801
|
+
};
|
|
802
|
+
fingerprintCache.set(revision, computed);
|
|
803
|
+
return computed;
|
|
804
|
+
},
|
|
805
|
+
};
|
|
848
806
|
let stateSha256 = semanticStateSha256(state);
|
|
807
|
+
// WHAT THE LAST ACCEPTED ENTRY ACTUALLY RECORDED, when that is not what this build
|
|
808
|
+
// would compute. A state accepted through one of the legacy derivations below was
|
|
809
|
+
// written by a build that hashed it differently, and the delta that follows it names
|
|
810
|
+
// its base by THAT digest, so a chain of them is unreadable unless the reading carries
|
|
811
|
+
// both: the modern digest for what this build writes next, and the written one for what
|
|
812
|
+
// the next entry will name. Null whenever they agree, which is every state this build
|
|
813
|
+
// wrote itself, so the chain check below is unchanged for them.
|
|
814
|
+
let writtenSha256: string | null = null;
|
|
849
815
|
let stateStart = -1;
|
|
850
816
|
let checkpointIndex = -1;
|
|
851
817
|
let v2Seen = false;
|
|
852
818
|
const rememberProjection = (): void => {
|
|
853
|
-
|
|
854
|
-
topologySha256: topologySha256(state),
|
|
855
|
-
protectionSha256: protectionSha256(state),
|
|
856
|
-
});
|
|
819
|
+
fingerprintSources.set(state.revision, state);
|
|
857
820
|
};
|
|
858
821
|
rememberProjection();
|
|
859
822
|
for (let index = 0; index < entries.length; index += 1) {
|
|
@@ -901,7 +864,8 @@ export function materializeStatePersistence(
|
|
|
901
864
|
if (wire.kind === "checkpoint") {
|
|
902
865
|
state = stateFromFoldRefs(wire, wire.foldRefs, records);
|
|
903
866
|
} else {
|
|
904
|
-
if (wireVersion === 0 || wire.baseRevision !== state.revision ||
|
|
867
|
+
if (wireVersion === 0 || wire.baseRevision !== state.revision ||
|
|
868
|
+
(wire.baseStateSha256 !== stateSha256 && wire.baseStateSha256 !== writtenSha256) ||
|
|
905
869
|
wire.revision <= wire.baseRevision) {
|
|
906
870
|
throw new Error("Broken active-context delta chain");
|
|
907
871
|
}
|
|
@@ -916,20 +880,73 @@ export function materializeStatePersistence(
|
|
|
916
880
|
if (existing) throw new Error(`Duplicate active-context delta addition ${ref.id}`);
|
|
917
881
|
byId.set(ref.id, ref);
|
|
918
882
|
}
|
|
919
|
-
|
|
883
|
+
// A delta written before 2026-08-13 carries the whole brief map; a newer one carries
|
|
884
|
+
// only what changed. The presence of `briefs` is the discriminator, not a fallback:
|
|
885
|
+
// a wire that states the whole map is replayed as the whole map.
|
|
886
|
+
const briefs = wire.briefs !== undefined ? clone(wire.briefs) : { ...(state.briefs ?? {}) };
|
|
887
|
+
if (wire.briefs === undefined) {
|
|
888
|
+
for (const id of wire.removeBriefIds ?? []) {
|
|
889
|
+
if (!Object.prototype.hasOwnProperty.call(briefs, id)) {
|
|
890
|
+
throw new Error(`Unknown active-context brief removal ${id}`);
|
|
891
|
+
}
|
|
892
|
+
delete briefs[id];
|
|
893
|
+
}
|
|
894
|
+
for (const [id, brief] of Object.entries(wire.addBriefs ?? {})) {
|
|
895
|
+
if (Object.prototype.hasOwnProperty.call(briefs, id) &&
|
|
896
|
+
sha256Value(briefs[id]) === sha256Value(brief)) {
|
|
897
|
+
throw new Error(`Redundant active-context brief ${id}`);
|
|
898
|
+
}
|
|
899
|
+
briefs[id] = clone(brief);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// Same rule for the marks, but the discriminator is the ORDER's presence rather than
|
|
903
|
+
// the array's absence. A delta written before 2026-08-13 states the whole array when
|
|
904
|
+
// it has one and states nothing when it does not, so "no array" means "no marks"
|
|
905
|
+
// there; reading it as "keep the base's marks" would resurrect marks a sealed session
|
|
906
|
+
// had cleared. The order list is what a NEW delta's replay is built from, so a key it
|
|
907
|
+
// does not name is removed by not being named.
|
|
908
|
+
let marks: PendingMark[];
|
|
909
|
+
if (wire.pendingMarkOrder === undefined) {
|
|
910
|
+
marks = wire.pendingMarks !== undefined ? clone(wire.pendingMarks) : [];
|
|
911
|
+
} else {
|
|
912
|
+
const held = new Map((state.pendingMarks ?? []).map((mark) =>
|
|
913
|
+
[pendingMarkKey(mark), mark] as const));
|
|
914
|
+
for (const mark of wire.addPendingMarks ?? []) {
|
|
915
|
+
const key = pendingMarkKey(mark);
|
|
916
|
+
const standing = held.get(key);
|
|
917
|
+
if (standing && sha256Value(standing) === sha256Value(mark)) {
|
|
918
|
+
throw new Error(`Redundant active-context pending mark ${key}`);
|
|
919
|
+
}
|
|
920
|
+
held.set(key, clone(mark));
|
|
921
|
+
}
|
|
922
|
+
marks = wire.pendingMarkOrder.map((key) => {
|
|
923
|
+
const mark = held.get(key);
|
|
924
|
+
if (!mark) throw new Error(`Unknown active-context pending mark ${key}`);
|
|
925
|
+
return mark;
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
state = stateFromFoldRefs(
|
|
929
|
+
{
|
|
930
|
+
...wire,
|
|
931
|
+
briefs: Object.keys(briefs).length ? briefs : undefined,
|
|
932
|
+
pendingMarks: marks.length ? marks : undefined,
|
|
933
|
+
},
|
|
934
|
+
[...byId.values()],
|
|
935
|
+
records,
|
|
936
|
+
);
|
|
920
937
|
}
|
|
921
938
|
const calculated = semanticStateSha256(state);
|
|
922
|
-
|
|
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.
|
|
939
|
+
const legacyRider = (wire as { rider?: unknown }).rider;
|
|
926
940
|
if (calculated !== wire.stateSha256 &&
|
|
927
941
|
legacyCadenceOmittedStateSha256(state) !== wire.stateSha256 &&
|
|
928
|
-
legacyReplayOrderStateSha256(state) !== wire.stateSha256
|
|
942
|
+
legacyReplayOrderStateSha256(state) !== wire.stateSha256 &&
|
|
943
|
+
(legacyRider === undefined ||
|
|
944
|
+
legacyRiderStateSha256(state, legacyRider) !== wire.stateSha256)) {
|
|
929
945
|
throw new Error("Active-context v2 state digest drift");
|
|
930
946
|
}
|
|
931
947
|
wireVersion = 2;
|
|
932
948
|
stateSha256 = calculated;
|
|
949
|
+
writtenSha256 = wire.stateSha256 === calculated ? null : wire.stateSha256;
|
|
933
950
|
rememberProjection();
|
|
934
951
|
}
|
|
935
952
|
return { state, wireVersion, records, stateSha256, projectionFingerprints };
|
|
@@ -970,13 +987,9 @@ export function makeStateCheckpoint(state: ActiveContextState): ActiveContextChe
|
|
|
970
987
|
prepared: state.prepared ? clone(state.prepared) : null,
|
|
971
988
|
tokensSinceToolFold: state.tokensSinceToolFold,
|
|
972
989
|
leases: clone(state.leases),
|
|
973
|
-
...(state.surfacing?.length ? { surfacing: clone(state.surfacing) } : {}),
|
|
974
990
|
...(state.pendingMarks?.length ? { pendingMarks: clone(state.pendingMarks) } : {}),
|
|
975
991
|
...(state.briefs && Object.keys(state.briefs).length ? { briefs: clone(state.briefs) } : {}),
|
|
976
992
|
...(state.advisory ? { advisory: clone(state.advisory) } : {}),
|
|
977
|
-
...(state.rider ? { rider: clone(state.rider) } : {}),
|
|
978
|
-
...(state.lastCall ? { lastCall: clone(state.lastCall) } : {}),
|
|
979
|
-
...(state.notices ? { notices: clone(state.notices) } : {}),
|
|
980
993
|
stateSha256: semanticStateSha256(state),
|
|
981
994
|
}, state.sessionId) as ActiveContextCheckpointV2;
|
|
982
995
|
}
|
|
@@ -996,6 +1009,40 @@ export function makeStateDelta(previous: ActiveContextState, next: ActiveContext
|
|
|
996
1009
|
throw new Error(`Active-context fold record changed for ${id}`);
|
|
997
1010
|
}
|
|
998
1011
|
}
|
|
1012
|
+
// A delta carries the CHANGE, never a value its base already holds. The brief map used
|
|
1013
|
+
// to ship whole on every write: 81% of all state bytes on sol-20260812 rep 9, 17.7 MB of
|
|
1014
|
+
// it unchanged, which then made every later turn slower because the session file is what
|
|
1015
|
+
// the runtime derives over.
|
|
1016
|
+
const previousBriefs = previous.briefs ?? {};
|
|
1017
|
+
const nextBriefs = next.briefs ?? {};
|
|
1018
|
+
const addBriefs: Record<string, BriefOverride> = {};
|
|
1019
|
+
for (const [id, brief] of Object.entries(nextBriefs)) {
|
|
1020
|
+
if (!Object.prototype.hasOwnProperty.call(previousBriefs, id)) { addBriefs[id] = clone(brief); continue; }
|
|
1021
|
+
if (sha256Value(previousBriefs[id]) !== sha256Value(brief)) addBriefs[id] = clone(brief);
|
|
1022
|
+
}
|
|
1023
|
+
const removeBriefIds = Object.keys(previousBriefs)
|
|
1024
|
+
.filter((id) => !Object.prototype.hasOwnProperty.call(nextBriefs, id));
|
|
1025
|
+
// The marks are the same rule one shape along, and the next largest field after the
|
|
1026
|
+
// briefs: 2.55 MB of rep 9's 21.6 MB ledger, which a diff takes to 0.16 MB. They carry an
|
|
1027
|
+
// ORDER as well as a membership, and appending after removing does not always reproduce
|
|
1028
|
+
// it (four of that run's 309 writes), so the key order travels whole and states the
|
|
1029
|
+
// removals with it. It is short next to the marks themselves, so there is no case to
|
|
1030
|
+
// split and no branch that can pick the wrong one.
|
|
1031
|
+
const previousMarks = previous.pendingMarks ?? [];
|
|
1032
|
+
const nextMarks = next.pendingMarks ?? [];
|
|
1033
|
+
const previousByKey = new Map(previousMarks.map((mark) => [pendingMarkKey(mark), mark] as const));
|
|
1034
|
+
const addPendingMarks = nextMarks.filter((mark) => {
|
|
1035
|
+
const held = previousByKey.get(pendingMarkKey(mark));
|
|
1036
|
+
return !held || sha256Value(held) !== sha256Value(mark);
|
|
1037
|
+
});
|
|
1038
|
+
const marksTravel = previousMarks.length > 0 || nextMarks.length > 0;
|
|
1039
|
+
// The rest travel whole and stay that way. Measured on the same run, the two that were
|
|
1040
|
+
// worth a diff were 20.0 MB of the 21.6 MB ledger; what remained were bounded objects
|
|
1041
|
+
// with their own caps rather than collections that grow with the epoch (the largest,
|
|
1042
|
+
// the since-retired `notices`, was 0.93 MB). A diff for a capped
|
|
1043
|
+
// value is a second encoding to keep honest for a few hundred kilobytes a session, and
|
|
1044
|
+
// the reader's own compatibility rule is the part that is easy to get wrong: this build
|
|
1045
|
+
// shipped one such bug and the gate caught it.
|
|
999
1046
|
return parseActiveContextStateV2({
|
|
1000
1047
|
version: 2,
|
|
1001
1048
|
kind: "delta",
|
|
@@ -1010,13 +1057,11 @@ export function makeStateDelta(previous: ActiveContextState, next: ActiveContext
|
|
|
1010
1057
|
prepared: next.prepared ? clone(next.prepared) : null,
|
|
1011
1058
|
tokensSinceToolFold: next.tokensSinceToolFold,
|
|
1012
1059
|
leases: clone(next.leases),
|
|
1013
|
-
...(
|
|
1014
|
-
...(
|
|
1015
|
-
...(
|
|
1060
|
+
...(marksTravel ? { pendingMarkOrder: nextMarks.map(pendingMarkKey) } : {}),
|
|
1061
|
+
...(addPendingMarks.length ? { addPendingMarks: clone(addPendingMarks) } : {}),
|
|
1062
|
+
...(Object.keys(addBriefs).length ? { addBriefs } : {}),
|
|
1063
|
+
...(removeBriefIds.length ? { removeBriefIds } : {}),
|
|
1016
1064
|
...(next.advisory ? { advisory: clone(next.advisory) } : {}),
|
|
1017
|
-
...(next.rider ? { rider: clone(next.rider) } : {}),
|
|
1018
|
-
...(next.lastCall ? { lastCall: clone(next.lastCall) } : {}),
|
|
1019
|
-
...(next.notices ? { notices: clone(next.notices) } : {}),
|
|
1020
1065
|
stateSha256: semanticStateSha256(next),
|
|
1021
1066
|
}, next.sessionId) as ActiveContextDeltaV2;
|
|
1022
1067
|
}
|
|
@@ -1029,49 +1074,48 @@ export function protectionSha256(state: Pick<ActiveContextState, "protected">):
|
|
|
1029
1074
|
return sha256Value([...state.protected].sort((left, right) => objectRefKey(left).localeCompare(objectRefKey(right))));
|
|
1030
1075
|
}
|
|
1031
1076
|
|
|
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
1077
|
export const ADVISORY_MILESTONES = Object.freeze(
|
|
1040
1078
|
["orientation", "notice", "tools", "chapters", "urgent"] as AdvisoryMilestone[],
|
|
1041
1079
|
);
|
|
1042
1080
|
|
|
1043
|
-
/**
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1081
|
+
/**
|
|
1082
|
+
* THE RETIRED STATE FIELDS, REFUSED BY NAME.
|
|
1083
|
+
*
|
|
1084
|
+
* `lastCall` and `notices` were the occupancy thermostat's two announcements, and `rider`
|
|
1085
|
+
* was the post-commit invitation to create marks, deleted 2026-08-23 with the curation
|
|
1086
|
+
* redesign: the runtime cuts and the agent edits, so an invitation to create is a carrier
|
|
1087
|
+
* with nothing left to invite. Nothing
|
|
1088
|
+
* writes them any more and nothing reads them, so a durable state that still carries one
|
|
1089
|
+
* was written by a build that had them, and the exact-record readers would reject it with
|
|
1090
|
+
* "Invalid active-context state keys" -- true, and useless to whoever has to act on it.
|
|
1091
|
+
*
|
|
1092
|
+
* Refusing by name is the whole behaviour. Tolerating the field and dropping it is the
|
|
1093
|
+
* silent path: it makes a version boundary look like a successful load, and the state
|
|
1094
|
+
* that comes back is not the state that was written. Gate 17 already states the wire's
|
|
1095
|
+
* policy in the other direction -- an older exact-record reader rejects newer fields, and
|
|
1096
|
+
* a bump is explicit -- and this is that same policy read from the newer side.
|
|
1097
|
+
*
|
|
1098
|
+
* `rider` IS NOT ON THIS LIST, and the distinction is the whole point of the list. The
|
|
1099
|
+
* refusal's own reason is "a state it cannot reproduce", so a field the build CAN
|
|
1100
|
+
* reproduce is out of its scope: the rider was projection carrier text, it changed no
|
|
1101
|
+
* fold, no ref and no brief, and the only thing it ever touched that survives a load is
|
|
1102
|
+
* the state digest. So it is read, spent on the digest, and dropped, which is what
|
|
1103
|
+
* `legacyRiderStateSha256` below is for. Retiring it by name instead made every state the
|
|
1104
|
+
* sol-20260815-hidden campaign sealed unreadable and cost the hidden-mass result its 102
|
|
1105
|
+
* attributed carriage rows, which is a high price for a field that carried no behaviour.
|
|
1106
|
+
*/
|
|
1107
|
+
const RETIRED_STATE_FIELDS: readonly string[] = Object.freeze(["lastCall", "notices", "surfacing"]);
|
|
1061
1108
|
|
|
1062
|
-
export function
|
|
1063
|
-
if (!
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
if (!
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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);
|
|
1109
|
+
export function refuseRetiredStateFields(value: unknown): void {
|
|
1110
|
+
if (!value || typeof value !== "object") return;
|
|
1111
|
+
const carried = RETIRED_STATE_FIELDS.filter((field) =>
|
|
1112
|
+
Object.prototype.hasOwnProperty.call(value, field));
|
|
1113
|
+
if (!carried.length) return;
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
`Active-context state carries the retired field(s) ${carried.join(", ")}: it was written ` +
|
|
1116
|
+
"by a build that still carried the subsystem behind it. Nothing writes or reads them " +
|
|
1117
|
+
"now, and this build will not load a state it cannot reproduce.",
|
|
1118
|
+
);
|
|
1075
1119
|
}
|
|
1076
1120
|
|
|
1077
1121
|
export function validAdvisoryState(value: unknown): value is NonNullable<ActiveContextState["advisory"]> {
|
|
@@ -1094,6 +1138,5 @@ export function validAdvisoryState(value: unknown): value is NonNullable<ActiveC
|
|
|
1094
1138
|
const delivered = ownValue(value, "delivered") as Record<string, unknown>;
|
|
1095
1139
|
return Reflect.ownKeys(delivered).every((key) => typeof key === "string" &&
|
|
1096
1140
|
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);
|
|
1141
|
+
Number.isSafeInteger(ownValue(delivered, key)) && Number(ownValue(delivered, key)) >= 0);
|
|
1099
1142
|
}
|