wendkeep 0.66.1 → 0.66.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.66.3] — 2026-07-30
8
+
9
+ ### Fixed
10
+
11
+ - **O replay reavalia candidates transitórios contra a fonte moderna final.** Um Stop da mesma
12
+ sessão/activation/epoch e turno maior agora avança depois que a correção causal já presente se
13
+ torna ativa; turno menor fica superseded e divergências reais continuam para curadoria.
14
+ - **`memory repair` migra o checkpoint antigo somente com prova e CAS.** O repair compara a
15
+ semântica anterior e a atual, faz backup, atualiza attempt e espelho e registra auditoria sem
16
+ reordenar, reescrever ou acrescentar evento ao ledger. Prova incompleta continua bloqueada. A
17
+ 0.66.2 não deve ser publicada no npm; publique e instale a 0.66.3.
18
+
19
+ ## [0.66.2] — 2026-07-29
20
+
21
+ ### Fixed
22
+
23
+ - **Promoção preserva valor e identidade causal do evento escolhido.** `memory promote` não
24
+ converte mais objetos/arrays JSON em texto e mantém sessão, activation/epoch, turno de origem e
25
+ sequência; o próximo `SessionStop` da mesma cadeia avança o handoff sem recriar candidate.
26
+ - **A recuperação 0.66.1 cobre inversão física/temporal sem ampliar autoridade.** Quando uma
27
+ promoção legada projetada fica fora do candidate por `observed_at`, `memory promote` só a inclui
28
+ em `supersedes` sob prova de ancestralidade e mesma linhagem; fonte moderna ou alheia falha antes
29
+ de anexar. O E2E percorre o dispatcher público e prova Stop posterior e duplicado idempotentes.
30
+
7
31
  ## [0.66.1] — 2026-07-29
8
32
 
9
33
  ### Fixed
package/README.en.md CHANGED
@@ -256,7 +256,7 @@ Hot memory now separates human authorship, operational state, and evidence:
256
256
  - **`CORE.md` is canonical.** It is the short, hand-curated nucleus for durable preferences, active patterns, and open issues; no projector may infer or overwrite it.
257
257
  - **`SHARED_MEMORY.md` is generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
258
258
  - **`MEMORY_EVENTS.jsonl` is the append-only authority.** `Stop` makes events durable in the outbox before acknowledging the attempt; the projector runs outside the registry lock and retries reuse the same IDs. Repeating an identical `event_id`/payload is a no-op; reusing the ID with different bytes is observable corruption.
259
- - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event.
259
+ - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event; promotion preserves the selected event's JSON type, session, activation/epoch, and source turn.
260
260
 
261
261
  Artifacts stay under `.brain/` only. Sanitization strips secrets, tokens, local paths, transcripts, and harness payloads both before persistence and before injection. Events carry a `project_id`, and one vault never accepts another project's events.
262
262
 
@@ -295,9 +295,17 @@ ambiguity uses `memory reconcile <session> --by-session <successor>
295
295
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
296
296
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
297
297
  explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
298
- candidate, while `memory reject <id>` keeps the current value. Decisions are idempotent and
299
- survive repair/replay; `blocked_by_core` cannot override CORE. Doctor only diagnoses. See
300
- [memory and curation](docs/en/commands/memory.md).
298
+ candidate, while `memory reject <id>` keeps the current value. The decision is idempotent, and a
299
+ new promotion accepts a later Stop from the same session/activation without recreating a conflict.
300
+ A promotion written by 0.66.1 remains historical: if the next Stop forms a new candidate, update
301
+ to 0.66.3 and run `memory repair`. During replay, a transient candidate is re-evaluated against the
302
+ final modern source: the same session/activation/epoch and a higher turn advances; a lower turn is
303
+ superseded. Repair migrates the checkpoint and mirror only when it proves the exact previous
304
+ replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
305
+ appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
306
+ install 0.66.2; use 0.66.3. Decisions survive repair/replay;
307
+ `blocked_by_core` cannot override CORE. Doctor only
308
+ diagnoses. See [memory and curation](docs/en/commands/memory.md).
301
309
 
302
310
  Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
303
311
 
package/README.md CHANGED
@@ -256,7 +256,7 @@ Hot memory now separates human authorship, operational state, and evidence:
256
256
  - **`CORE.md` is canonical.** It is the short, hand-curated nucleus for durable preferences, active patterns, and open issues; no projector may infer or overwrite it.
257
257
  - **`SHARED_MEMORY.md` is generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
258
258
  - **`MEMORY_EVENTS.jsonl` is the append-only authority.** `Stop` makes events durable in the outbox before acknowledging the attempt; the projector runs outside the registry lock and retries reuse the same IDs. Repeating an identical `event_id`/payload is a no-op; reusing the ID with different bytes is observable corruption.
259
- - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event.
259
+ - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event; promotion preserves the selected event's JSON type, session, activation/epoch, and source turn.
260
260
 
261
261
  Artifacts stay under `.brain/` only. Sanitization strips secrets, tokens, local paths, transcripts, and harness payloads both before persistence and before injection. Events carry a `project_id`, and one vault never accepts another project's events.
262
262
 
@@ -295,9 +295,17 @@ ambiguity uses `memory reconcile <session> --by-session <successor>
295
295
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
296
296
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
297
297
  explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
298
- candidate, while `memory reject <id>` keeps the current value. Decisions are idempotent and
299
- survive repair/replay; `blocked_by_core` cannot override CORE. Doctor only diagnoses. See
300
- [memory and curation](docs/en/commands/memory.md).
298
+ candidate, while `memory reject <id>` keeps the current value. The decision is idempotent, and a
299
+ new promotion accepts a later Stop from the same session/activation without recreating a conflict.
300
+ A promotion written by 0.66.1 remains historical: if the next Stop forms a new candidate, update
301
+ to 0.66.3 and run `memory repair`. During replay, a transient candidate is re-evaluated against the
302
+ final modern source: the same session/activation/epoch and a higher turn advances; a lower turn is
303
+ superseded. Repair migrates the checkpoint and mirror only when it proves the exact previous
304
+ replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
305
+ appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
306
+ install 0.66.2; use 0.66.3. Decisions survive repair/replay;
307
+ `blocked_by_core` cannot override CORE. Doctor only
308
+ diagnoses. See [memory and curation](docs/en/commands/memory.md).
301
309
 
302
310
  Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
303
311
 
@@ -70,7 +70,15 @@ npx wendkeep validate-memory --vault <v2-vault>
70
70
  `blocked_by_core` candidate can only be rejected: promotion first requires canonical CORE
71
71
  curation. If the selected event still belongs to the matching latest `projected` attempt,
72
72
  promotion also refreshes its checkpoint and mirror causally; JSON reports
73
- `checkpointRefreshed`, and a newer concurrent attempt remains untouched.
73
+ `checkpointRefreshed`, and a newer concurrent attempt remains untouched. The decision keeps
74
+ the already validated JSON value without string coercion and copies the selected event's
75
+ `canonical_session_id`, activation/epoch, `source_turn_id`, and `turn_sequence`. A later Stop
76
+ from the same session/activation therefore advances the value instead of opening another candidate.
77
+ During replay, a transient candidate is re-evaluated against the final modern source. The same
78
+ session/activation/epoch and a higher turn applies the Stop; a lower turn is superseded. A
79
+ different, incomplete, or ambiguous identity keeps the candidate queued for curation. `memory
80
+ repair` compares the old and current replay and migrates checkpoint+mirror only with exact
81
+ identity, backup, audit, and CAS; it does not reorder, rewrite, or append a ledger event.
74
82
  - `validate-memory <CORE.md>` checks the 25-line cap, required sections, and secrets.
75
83
  - `validate-memory --vault` requires a complete v2 bundle and is not the legacy-vault gate.
76
84
 
@@ -108,6 +116,12 @@ of a global projection that has already advanced with concurrent events.
108
116
  - `promote` reports that `--event` is required: inspect the candidate `event_ids`, compare their
109
117
  provenance/value, and name the winner explicitly. An ID outside the candidate fails without
110
118
  mutating the ledger or projections.
119
+ - A promotion made by 0.66.1 followed by a transient candidate: update to 0.66.3, preserve a backup,
120
+ and run `memory repair`. Do not publish or install 0.66.2. Repair migrates the checkpoint only
121
+ when the old replay, attempt identity, and new event match exactly; a real conflict stays blocked
122
+ for a human `promote`/`reject` choice.
123
+ - `promote` says that the candidate no longer matches the causal projection: no event was appended.
124
+ Run `memory status`, inspect the current candidate again, and do not force a different lineage.
111
125
  - Missing `event_cursor` or mismatched v2 hash: preserve the bundle and assess `memory repair`.
112
126
  - `validate-memory --vault` fails on legacy: validate CORE only or migrate first.
113
127
 
@@ -68,6 +68,14 @@ npx wendkeep validate-memory --vault <cofre-v2>
68
68
  promover exige antes alterar CORE pela curadoria canônica. Se o evento escolhido ainda pertence
69
69
  ao último attempt `projected` correspondente, a promoção também atualiza causalmente checkpoint
70
70
  e espelho; o JSON retorna `checkpointRefreshed`, e um attempt concorrente mais novo não é tocado.
71
+ A decisão conserva, sem coerção para string, o valor JSON já validado e copia do evento escolhido
72
+ `canonical_session_id`, activation/epoch, `source_turn_id` e `turn_sequence`. Por isso, um Stop
73
+ posterior da mesma sessão/activation avança o valor em vez de abrir outro candidate. Durante o
74
+ replay, um candidate transitório é reavaliado contra a fonte moderna final. Mesma
75
+ sessão/activation/epoch e turno maior aplica o Stop; turno menor fica superseded. Identidade
76
+ divergente, incompleta ou ambígua mantém o candidate para curadoria. `memory repair` compara o
77
+ replay anterior e o atual e só migra checkpoint+espelho com identidade exata, backup, audit e
78
+ CAS; ele não reordena, reescreve nem acrescenta evento ao ledger.
71
79
  - `validate-memory <CORE.md>` valida cap de 25 linhas, seções e segredos.
72
80
  - `validate-memory --vault` exige bundle v2 completo; não é o gate correto para vault legado.
73
81
 
@@ -104,6 +112,12 @@ prefixo válido de uma projeção global que já avançou com eventos concorrent
104
112
  - `promote` informa que `--event` é obrigatório: leia os `event_ids` do candidate, compare a
105
113
  proveniência/valor e indique explicitamente o vencedor. ID que não pertence ao candidate falha
106
114
  sem mutar ledger ou projeções.
115
+ - Promoção feita pela 0.66.1 seguida de candidate transitório: atualize para a 0.66.3, preserve um
116
+ backup e rode `memory repair`. Não publique nem instale a 0.66.2. O repair só migra o checkpoint
117
+ quando o replay anterior, a identidade do attempt e o evento novo coincidem exatamente; conflito
118
+ real continua bloqueado para escolha humana com `promote`/`reject`.
119
+ - `promote` informa que o candidate não corresponde mais à projeção causal: nenhum evento foi
120
+ anexado. Rode `memory status`, releia o candidate atual e não force uma linhagem diferente.
107
121
  - `event_cursor` ausente ou hash divergente em v2: preserve o bundle e avalie `memory repair`.
108
122
  - `validate-memory --vault` falha no legado: valide apenas CORE ou migre primeiro.
109
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.66.1",
3
+ "version": "0.66.3",
4
4
  "description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -344,6 +344,22 @@ function sameCausalActivation(left, right) {
344
344
  && left.activation_id === right?.activation_id;
345
345
  }
346
346
 
347
+ function hasCompleteCausalIdentity(event) {
348
+ return Boolean(event?.canonical_session_id)
349
+ && Boolean(event?.activation_id)
350
+ && Boolean(event?.source_turn_id)
351
+ && Number.isInteger(event?.activation_epoch)
352
+ && Number.isInteger(event?.turn_sequence);
353
+ }
354
+
355
+ function sameCompleteCausalLineage(left, right) {
356
+ return hasCompleteCausalIdentity(left)
357
+ && hasCompleteCausalIdentity(right)
358
+ && left.canonical_session_id === right.canonical_session_id
359
+ && left.activation_id === right.activation_id
360
+ && left.activation_epoch === right.activation_epoch;
361
+ }
362
+
347
363
  function comparable(left, right) {
348
364
  if (sameCausalActivation(left, right)) return true;
349
365
  const leftSupersedes = left.supersedes_event_id || left.supersedes;
@@ -447,7 +463,9 @@ function isCausallyOlder(event, current) {
447
463
  * Pure deterministic reducer. It pre-detects incomparable scalar siblings so replay order
448
464
  * never turns one concurrent writer into an accidental winner.
449
465
  */
450
- export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map() } = {}) {
466
+ export function reduceMemoryEvents(inputEvents = [], {
467
+ coreInvariants = new Map(), resolveDeferredAsserts = true,
468
+ } = {}) {
451
469
  const protectedValues = coreInvariants instanceof Map
452
470
  ? coreInvariants
453
471
  : new Map(Object.entries(coreInvariants || {}));
@@ -495,6 +513,8 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
495
513
  const records = new Map();
496
514
  const tombstones = new Map();
497
515
  const candidates = [];
516
+ const pendingAssertConflicts = [];
517
+ const resolvedCandidateIds = new Set();
498
518
  const emittedGroups = new Set();
499
519
  const appliedEventIds = [];
500
520
  const superseded = [];
@@ -543,7 +563,9 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
543
563
  appliedEventIds.push(item.event_id);
544
564
  continue;
545
565
  }
546
- candidates.push(conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]));
566
+ const candidate = conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]);
567
+ candidates.push(candidate);
568
+ pendingAssertConflicts.push({ candidate, event: item });
547
569
  continue;
548
570
  }
549
571
  if (!current) {
@@ -616,6 +638,46 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
616
638
  appliedEventIds.push(item.event_id);
617
639
  }
618
640
 
641
+ if (resolveDeferredAsserts) {
642
+ // A physically late assert can sort before a corrective promotion because effective time
643
+ // precedes CLI decision time. Revisit only scalar assert conflicts left without an explicit
644
+ // decision, against the final complete causal source; the ledger and global ordering stay put.
645
+ const deferredAsserts = pendingAssertConflicts
646
+ .filter((pending) => !candidateDecisions.has(pending.candidate.candidate_id))
647
+ .sort((left, right) => String(left.event.memory_key).localeCompare(String(right.event.memory_key))
648
+ || String(left.event.canonical_session_id || '').localeCompare(String(right.event.canonical_session_id || ''))
649
+ || String(left.event.activation_id || '').localeCompare(String(right.event.activation_id || ''))
650
+ || Number(left.event.activation_epoch ?? -1) - Number(right.event.activation_epoch ?? -1)
651
+ || Number(left.event.turn_sequence ?? -1) - Number(right.event.turn_sequence ?? -1)
652
+ || eventOrder(left.event, right.event));
653
+ let advanced = true;
654
+ while (advanced) {
655
+ advanced = false;
656
+ for (const pending of deferredAsserts) {
657
+ if (resolvedCandidateIds.has(pending.candidate.candidate_id)) continue;
658
+ const current = records.get(pending.event.memory_key);
659
+ const currentSource = current?.source;
660
+ if (!sameCompleteCausalLineage(pending.event, currentSource)) continue;
661
+ if (pending.event.turn_sequence > currentSource.turn_sequence) {
662
+ records.set(pending.event.memory_key, {
663
+ value: pending.event.value,
664
+ revision: current.revision + 1,
665
+ source: pending.event,
666
+ });
667
+ tombstones.delete(pending.event.memory_key);
668
+ superseded.push({ event_id: currentSource.event_id, by_event_id: pending.event.event_id });
669
+ revision += 1;
670
+ appliedEventIds.push(pending.event.event_id);
671
+ resolvedCandidateIds.add(pending.candidate.candidate_id);
672
+ advanced = true;
673
+ } else if (pending.event.turn_sequence < currentSource.turn_sequence) {
674
+ superseded.push({ event_id: pending.event.event_id, by_event_id: currentSource.event_id });
675
+ resolvedCandidateIds.add(pending.candidate.candidate_id);
676
+ }
677
+ }
678
+ }
679
+ }
680
+
619
681
  const stateEntries = [...records].map(([key, record]) => [key, record.value]);
620
682
  const recordEntries = [...records].map(([key, record]) => [key, record]);
621
683
  const tombstoneEntries = [...tombstones];
@@ -623,7 +685,8 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
623
685
  const recordObject = sortedObject(recordEntries);
624
686
  const tombstoneObject = sortedObject(tombstoneEntries);
625
687
  const unresolvedCandidates = candidates
626
- .filter((item) => !candidateDecisions.has(item.candidate_id));
688
+ .filter((item) => !candidateDecisions.has(item.candidate_id)
689
+ && !resolvedCandidateIds.has(item.candidate_id));
627
690
  unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
628
691
  superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
629
692
  const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
@@ -655,8 +718,12 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
655
718
  * `eventCursor` is the reducer's deterministic causal cursor; `ledgerCursor` is the
656
719
  * physical prefix boundary used by durable checkpoints.
657
720
  */
658
- export function deriveMemoryProjection(vaultBase, inputEvents = []) {
659
- const reduced = reduceMemoryEvents(inputEvents, { coreInvariants: readCoreInvariants(vaultBase) });
721
+ export function deriveMemoryProjection(vaultBase, inputEvents = [], {
722
+ resolveDeferredAsserts = true,
723
+ } = {}) {
724
+ const reduced = reduceMemoryEvents(inputEvents, {
725
+ coreInvariants: readCoreInvariants(vaultBase), resolveDeferredAsserts,
726
+ });
660
727
  const ledgerCursor = inputEvents.at(-1)?.event_id || 'none';
661
728
  const checkpoint = {
662
729
  revision: reduced.revision,
package/src/memory.mjs CHANGED
@@ -263,6 +263,80 @@ function assertCompatibleDecision(prior, { action, eventId }) {
263
263
  }
264
264
  }
265
265
 
266
+ function promotedMemoryValue(value) {
267
+ return typeof value === 'string' ? sanitizeMemoryText(value) : cloneJson(value);
268
+ }
269
+
270
+ function eventSupersedes(event) {
271
+ if (Array.isArray(event?.supersedes)) return event.supersedes;
272
+ return event?.supersedes_event_id ? [event.supersedes_event_id] : [];
273
+ }
274
+
275
+ function hasCompleteCausalIdentity(event) {
276
+ return Boolean(event?.canonical_session_id && event?.activation_id && event?.source_turn_id)
277
+ && Number.isInteger(event.activation_epoch)
278
+ && Number.isInteger(event.turn_sequence);
279
+ }
280
+
281
+ function sameCausalLineage(left, right) {
282
+ return hasCompleteCausalIdentity(left) && hasCompleteCausalIdentity(right)
283
+ && left.canonical_session_id === right.canonical_session_id
284
+ && left.activation_id === right.activation_id
285
+ && left.activation_epoch === right.activation_epoch;
286
+ }
287
+
288
+ function promotedSupersedes(vault, candidate, selected) {
289
+ const memberIds = new Set(Array.isArray(candidate.event_ids) ? candidate.event_ids : []);
290
+ const ledger = readMemoryLedger(vault);
291
+ if (ledger.status !== 'ok') throw new Error('Ledger de memória inválido durante a promoção.');
292
+ const current = deriveMemoryProjection(vault, ledger.events)
293
+ .records?.[candidate.memory_key]?.source;
294
+ if (!current?.event_id || memberIds.has(current.event_id)) return [...memberIds].sort();
295
+
296
+ const currentSelectedId = current.candidate_decision?.selected_event_id;
297
+ const currentSelected = currentSelectedId
298
+ ? ledger.events.find((event) => event.event_id === currentSelectedId)
299
+ : null;
300
+ const selectedLedger = ledger.events.find((event) => event.event_id === selected.event_id);
301
+ const currentAncestors = eventSupersedes(current);
302
+ const decisionMembers = Array.isArray(current.candidate_decision?.event_ids)
303
+ ? current.candidate_decision.event_ids
304
+ : [];
305
+ const canonicalAncestors = [...new Set(currentAncestors)].sort();
306
+ const canonicalDecisionMembers = [...new Set(decisionMembers)].sort();
307
+ const currentObserved = String(current.effective_at || current.observed_at || '');
308
+ const selectedObserved = String(selectedLedger?.effective_at || selectedLedger?.observed_at || '');
309
+ const currentPhysicalIndex = ledger.events.findIndex((event) => event.event_id === current.event_id);
310
+ const selectedPhysicalIndex = ledger.events.findIndex((event) => event.event_id === selected.event_id);
311
+ const bridgeChecks = {
312
+ legacyShape: current.operation === 'replace' && current.authority === 'verified'
313
+ && !current.canonical_session_id && !current.source_turn_id,
314
+ promotion: current.candidate_decision?.action === 'promote',
315
+ selectedPresent: Boolean(currentSelected),
316
+ selectedPersisted: Boolean(selectedLedger),
317
+ exactDecisionMembers: canonicalAncestors.length === currentAncestors.length
318
+ && canonicalDecisionMembers.length === decisionMembers.length
319
+ && canonicalAncestors.length === canonicalDecisionMembers.length
320
+ && canonicalAncestors.every((eventId, index) => eventId === canonicalDecisionMembers[index]),
321
+ selectedSuperseded: currentAncestors.includes(currentSelectedId)
322
+ && decisionMembers.includes(currentSelectedId),
323
+ candidateAncestor: currentAncestors.some((eventId) => memberIds.has(eventId)),
324
+ sameLineage: sameCausalLineage(currentSelected, selectedLedger),
325
+ laterTurn: selectedLedger?.turn_sequence > currentSelected?.turn_sequence,
326
+ appendedAfterCurrent: selectedPhysicalIndex > currentPhysicalIndex && currentPhysicalIndex >= 0,
327
+ observedBeforeCurrent: selectedObserved < currentObserved,
328
+ };
329
+ const bridgesObservedOrder = Object.values(bridgeChecks).every(Boolean);
330
+ if (!bridgesObservedOrder) {
331
+ throw new Error(
332
+ `Candidate ${candidate.candidate_id} não corresponde mais à projeção causal atual; `
333
+ + 'recarregue os candidates antes de promover.',
334
+ );
335
+ }
336
+ memberIds.add(current.event_id);
337
+ return [...memberIds].sort();
338
+ }
339
+
266
340
  function matchesPromotedAttempt(attempt, selected) {
267
341
  if (attempt?.memory_mode !== 'v2' || attempt.state !== 'projected'
268
342
  || !Array.isArray(attempt.event_ids) || !attempt.event_ids.includes(selected.event_id)) return false;
@@ -378,6 +452,9 @@ export function decideMemoryCandidate(vault, {
378
452
  if (action === 'promote' && selectedValue === undefined) {
379
453
  throw new Error(`Candidate ${candidateId} não contém valor promovível.`);
380
454
  }
455
+ const supersedes = action === 'promote' && selected
456
+ ? promotedSupersedes(vault, candidate, selected)
457
+ : [];
381
458
  const now = new Date().toISOString();
382
459
  const decision = {
383
460
  candidate_id: candidateId,
@@ -391,15 +468,19 @@ export function decideMemoryCandidate(vault, {
391
468
  project_id: projectId(vault),
392
469
  memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
393
470
  operation: action === 'promote' && selected ? 'replace' : 'assert',
394
- value: sanitizeMemoryText(action === 'promote' ? selectedValue : 'rejected'),
471
+ value: action === 'promote' ? promotedMemoryValue(selectedValue) : 'rejected',
395
472
  authority: 'verified',
473
+ ...(selected?.canonical_session_id
474
+ ? { canonical_session_id: selected.canonical_session_id }
475
+ : {}),
396
476
  activation_id: selected?.activation_id || 'wendkeep-memory-cli',
397
477
  ...(Number.isInteger(selected?.activation_epoch) ? { activation_epoch: selected.activation_epoch } : {}),
398
478
  turn_sequence: selected?.turn_sequence ?? 0,
479
+ ...(selected?.source_turn_id ? { source_turn_id: selected.source_turn_id } : {}),
399
480
  observed_at: now,
400
481
  evidence: [`candidate:${candidateId}`],
401
482
  candidate_decision: decision,
402
- ...(selected ? { supersedes: decision.event_ids } : {}),
483
+ ...(selected ? { supersedes } : {}),
403
484
  };
404
485
  enqueueMemoryEvent(vault, event);
405
486
  const projection = projectMemoryOutbox(vault);
@@ -932,6 +1013,67 @@ function historicalAssertOnlyCheckpoint(vault, attempt, authority, checkpoint) {
932
1013
  return currentPrefix.checkpoint;
933
1014
  }
934
1015
 
1016
+ function deferredAssertReplayCheckpointMigration(
1017
+ sessionId, entry, authority, legacyReplay, fullReplay,
1018
+ ) {
1019
+ const attempt = entry?.last_memory_attempt;
1020
+ const checkpoint = attempt?.checkpoint;
1021
+ if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'projected'
1022
+ || attempt?.disposition !== 'applied' || !checkpointShape(checkpoint)) return null;
1023
+ if (!Object.prototype.hasOwnProperty.call(entry, 'memory_checkpoint')
1024
+ || !sameCheckpoint(entry.memory_checkpoint, checkpoint)
1025
+ || !sameCheckpoint(checkpoint, legacyReplay.checkpoint)
1026
+ || sameCheckpoint(checkpoint, fullReplay.checkpoint)) return null;
1027
+
1028
+ const requiredEventIds = Array.isArray(attempt.event_ids) ? [...attempt.event_ids] : [];
1029
+ if (!requiredEventIds.length || new Set(requiredEventIds).size !== requiredEventIds.length) return null;
1030
+ const current = fullReplay.records?.['handoff.latest']?.source;
1031
+ if (!current?.event_id || !requiredEventIds.includes(current.event_id)) return null;
1032
+ const relevantLegacyCandidate = legacyReplay.candidates.some(
1033
+ (candidate) => candidate.memory_key === 'handoff.latest'
1034
+ && candidate.event_ids.includes(current.event_id),
1035
+ );
1036
+ const relevantCandidateStillReal = fullReplay.candidates.some(
1037
+ (candidate) => candidate.memory_key === 'handoff.latest',
1038
+ );
1039
+ if (!relevantLegacyCandidate || relevantCandidateStillReal) return null;
1040
+
1041
+ const currentIdentity = {
1042
+ canonical_session_id: current.canonical_session_id,
1043
+ activation_id: current.activation_id,
1044
+ activation_epoch: current.activation_epoch,
1045
+ source_turn_id: current.source_turn_id,
1046
+ turn_sequence: current.turn_sequence,
1047
+ };
1048
+ if (currentIdentity.canonical_session_id !== sessionId
1049
+ || attempt.canonical_session_id !== currentIdentity.canonical_session_id
1050
+ || attempt.activation_id !== currentIdentity.activation_id
1051
+ || attempt.activation_epoch !== currentIdentity.activation_epoch
1052
+ || attempt.turn_id !== currentIdentity.source_turn_id
1053
+ || attempt.turn_sequence !== currentIdentity.turn_sequence) return null;
1054
+
1055
+ const newerAttemptEvent = authority.ledgerEvents.some((event) => (
1056
+ event.canonical_session_id === currentIdentity.canonical_session_id
1057
+ && event.activation_id === currentIdentity.activation_id
1058
+ && event.activation_epoch === currentIdentity.activation_epoch
1059
+ && Number.isInteger(event.turn_sequence)
1060
+ && event.turn_sequence > currentIdentity.turn_sequence
1061
+ ));
1062
+ if (newerAttemptEvent) return null;
1063
+
1064
+ const proof = validateSuccessorProof({ bySessionId: sessionId }, attempt, authority);
1065
+ return {
1066
+ sessionId,
1067
+ expectedFingerprint: attemptFingerprint(attempt),
1068
+ expectedMemoryCheckpointFingerprint: memoryCheckpointFingerprint(entry),
1069
+ originalCheckpoint: cloneJson(checkpoint),
1070
+ checkpoint: cloneJson(fullReplay.checkpoint),
1071
+ proof,
1072
+ migrationType: 'deferred_assert_replay_migrated',
1073
+ eventIds: requiredEventIds,
1074
+ };
1075
+ }
1076
+
935
1077
  function legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay) {
936
1078
  const attempt = entry?.last_memory_attempt;
937
1079
  const checkpoint = attempt?.checkpoint;
@@ -961,6 +1103,8 @@ function legacyCheckpointMigration(vault, sessionId, entry, authority, fullRepla
961
1103
  originalCheckpoint: cloneJson(checkpoint),
962
1104
  checkpoint: cloneJson(nextCheckpoint),
963
1105
  proof,
1106
+ migrationType: 'legacy_causal_checkpoint_migrated',
1107
+ eventIds: requiredEventIds,
964
1108
  };
965
1109
  }
966
1110
 
@@ -972,11 +1116,14 @@ export function migrateLegacyMemoryCheckpoints(vault, {
972
1116
  const authority = readMemoryAuthority(vault);
973
1117
  assertAuthorityMatches(expectedAuthority, authority);
974
1118
  const inspected = readSessionRegistry(vault);
1119
+ const legacyReplay = deriveMemoryProjection(vault, authority.ledgerEvents, {
1120
+ resolveDeferredAsserts: false,
1121
+ });
975
1122
  const fullReplay = deriveMemoryProjection(vault, authority.ledgerEvents);
976
1123
  const plans = Object.entries(inspected.sessions || {})
977
- .map(([sessionId, entry]) => legacyCheckpointMigration(
978
- vault, sessionId, entry, authority, fullReplay,
979
- ))
1124
+ .map(([sessionId, entry]) => deferredAssertReplayCheckpointMigration(
1125
+ sessionId, entry, authority, legacyReplay, fullReplay,
1126
+ ) || legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay))
980
1127
  .filter(Boolean);
981
1128
  assertAuthorityMatches(expectedAuthority, readMemoryAuthority(vault));
982
1129
  if (!plans.length) return {
@@ -1015,14 +1162,20 @@ export function migrateLegacyMemoryCheckpoints(vault, {
1015
1162
 
1016
1163
  for (const plan of plans) {
1017
1164
  const entry = registry.sessions[plan.sessionId];
1018
- const reconciliationId = `memcp-${hash(`${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`).slice(0, 20)}`;
1165
+ const reconciliationSeed = plan.migrationType === 'legacy_causal_checkpoint_migrated'
1166
+ ? `${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`
1167
+ : `${plan.migrationType}\0${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`;
1168
+ const reconciliationId = `memcp-${hash(reconciliationSeed).slice(0, 20)}`;
1019
1169
  entry.memory_reconciliations = [
1020
1170
  ...(Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : []),
1021
1171
  {
1022
1172
  v: 1,
1023
1173
  reconciliation_id: reconciliationId,
1024
- type: 'legacy_causal_checkpoint_migrated',
1174
+ type: plan.migrationType,
1025
1175
  reconciled_at: now,
1176
+ ...(plan.migrationType === 'deferred_assert_replay_migrated'
1177
+ ? { event_ids: [...plan.eventIds] }
1178
+ : {}),
1026
1179
  causal_proof: cloneJson(plan.proof),
1027
1180
  original_checkpoint: cloneJson(plan.originalCheckpoint),
1028
1181
  checkpoint: cloneJson(plan.checkpoint),