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.
@@ -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
- const hasLastCall = recordLike && Object.prototype.hasOwnProperty.call(value, "lastCall");
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
- // Accepted, never read, never written. `pinnedPeeks` pinned a peek result against
693
- // per-projection reclamation, which was deleted after rep 22 measured it rewriting
694
- // the prefix mid-window. State written before that cut still carries the key, and
695
- // rejecting it here would suspend automatic management on upgrade.
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
- const hasLastCall = Boolean(value && typeof value === "object" &&
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, "surfacing"), ownValue(value, "pendingMarks"), ownValue(value, "briefs"),
732
- ownValue(value, "rider"), ownValue(value, "lastCall"), ownValue(value, "notices"),
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" | "surfacing" | "pendingMarks" | "briefs" | "rider" |
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: Map<number, { topologySha256: string; protectionSha256: string }>;
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
- const projectionFingerprints = new Map<number, { topologySha256: string; protectionSha256: string }>();
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
- projectionFingerprints.set(state.revision, {
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
- state = stateFromFoldRefs(wire, [...byId.values()], records);
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
- ...(next.surfacing?.length ? { surfacing: clone(next.surfacing) } : {}),
1014
- ...(next.pendingMarks?.length ? { pendingMarks: clone(next.pendingMarks) } : {}),
1015
- ...(next.briefs && Object.keys(next.briefs).length ? { briefs: clone(next.briefs) } : {}),
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
  }