Design: reversal integrity for link rows and delete payloads (#568)
Some checks are pending
CI / Lint, format, typecheck, docs (push) Waiting to run
CI / Storybook tests (vitest + playwright) (push) Waiting to run
CI / E2E (Playwright + Electron, packaged) (push) Waiting to run

Settles the `4.2c planning gate — Reversals leave link rows and delete
payloads out of step with the log` followup in a design session. Docs
only; the implementation is a new followup, a standalone PR before
4.2c's merge.

## What was wrong (reproduced on `main` after #567)

- **Dangling links.** CTRL-Z of a user-created character leaves the
pass's involvement, awareness and relationship rows naming her. CTRL-Z,
regenerate and rollback run no closure, and where a closure ran it
followed only a happening's links.
- **Rows back with no history.** A reversal of a write to a row held
only in a delete's `undo_payload` touched nothing, was pruned, and the
payload kept the stale copy. Undoing the delete then restored a
relationship, an awareness row, or the deleted happening itself with no
delta. Reproduced through abort and through a prose edit's sweep.

## The contract

- **Every reversal closes over the rows its creates delete, and the rows
naming them**, on every path, through one reference registry (link
tables and translations), live or held in a payload. Reference removal
beats `rowKeepingColumns`; a fact taken by reference loses its
survival-anchor shield.
- **A reversal applies to a row a delete holds:** a create's undo strips
it from the payload, or prunes the delete when it was the target; an
update's undo patches the payload copy. Captured facts follow live ones:
swept with the prose, re-derived when it returns. Accepted cost: a pass
running between undoing the edit and undoing the delete loses links
naming the still-deleted row.
- **Two integrity refusals**, both unreachable today: pruning a delete
whose group would write the removed id back (entity deletes), and a
captured row in a CTRL-Z redo snapshot.
- **Redo** restores the closure's rows; a sweep's rows stay re-derived.
- **No-gate write phase:** a periodic pass's writes hold a per-branch
write lock exclusively, so no other write can interleave with the burst.

## Files

- `generation-pipeline.md` → Reverse-replay (rewritten closure
paragraphs, new payload and refusal paragraphs, sketch), Concurrency
model (new `### No-gate write phase`).
- `data-model.md` → Entry mutability & rollback, Survival anchor, Branch
model (fork), Translation.
- `memory/cadence.md`, `memory/classifier.md`, `rollback-confirm.md`,
`roadmap.md` (M6.1), `02c-collision-review.md` (merge question, History
refresh input).
- `followups.md`: the gate entry replaced by the implementation entry.
- Record: `docs/explorations/2026-10-04-reversal-integrity.md` (probes,
decisions, rejected options, adversarial pass).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
failerko 2026-10-04 23:06:48 +02:00 • committed by GitHub
parent 3a4c5bb4b8
commit 01f9e956a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 466 additions and 162 deletions

View file

@ -456,7 +456,11 @@ the latest entry):
silently dropping facts about kept turns (see
[Entry mutability & rollback → Survival anchor](#survival-anchor)).
The new branch thus carries the complete history up to the fork point
and rollback on the new branch can reach any entry 1..N.
and rollback on the new branch can reach any entry 1..N. A lagging
fact that names a row step 3 removes is the exception: the reversal's
closure moves it to step 3, so the fork never holds a link to a row
that exists nowhere
([`generation-pipeline.md → Reverse-replay`](./generation-pipeline.md#reverse-replay)).
3. Reverse-apply parent's deltas with `log_position >= L` **except those
copied in step 2** (i.e. `entry_id IS NULL OR position(entry_id) >=
position(N+1)`) onto the new branch's copied rows. These rewind the
@ -2286,7 +2290,13 @@ and every run the pipeline reverses itself — an aborted or failed run,
a crash-recovered orphan, a turn refused at admission. Deltas are never
kept and marked reversed: a kept row would read as the next undo head
and be re-reversed by a later rollback. A reversed run's only trace is
its `pipeline_runs.outcome`; a refused admission leaves none.
its `pipeline_runs.outcome`; a refused admission leaves none. A
reversal can also change a delta it does not replay: reversing a write
to a row that a delete still in the log holds applies the undo to that
delete's `undo_payload` copy, and prunes the delete when the row was
its target
([`generation-pipeline.md → Reverse-replay`](./generation-pipeline.md#reverse-replay)).
Forward writes only ever append; only a reversal prunes or edits.
User editing an entry's text does **not** auto-trigger re-classification.
Text edits are separate from state edits; state stays put unless the user
@ -2393,15 +2403,17 @@ arbitrary editing.
keys are identifiers. `$invalidationScope` is the first such key.
`$firstLoggedAt` is the second: the position a redone `user_edit`
delta held before its first undo (see `log_position` assignment).
- **The reversal set closes over the happening → link-row relation**, not
over the anchor alone. Undoing a `create` is a plain row delete with no
cascade — only the explicit `deleteHappening` action carries one — and a
link row does not share its happening's anchor: awareness anchors to the
turn that narrated the learning, which can sit either side of the
happening's own provenance entry. Reversing by anchor alone would delete a
happening and leave its awareness rows pointing at nothing. A suffix
rollback never meets this, because it reverses a whole tail; an
entry-scoped reversal has to close the set by hand.
- **The reversal set closes over the rows its creates delete, and the
rows naming them**, not over the anchor alone. Undoing a `create` is a
plain row delete with no cascade — only an explicit delete action
carries one — and a link row does not share its happening's anchor:
awareness anchors to the turn that narrated the learning, which can sit
either side of the happening's own provenance entry. Reversing by
anchor alone would delete a happening and leave its awareness rows
pointing at nothing. A suffix rollback closes its set the same way,
since a fact anchored to a surviving turn can name a row the suffix
removes
([`generation-pipeline.md → Reverse-replay`](./generation-pipeline.md#reverse-replay)).
- **A first-introduction entity survives the edit that removed its prose.**
It is not a fact about that turn but a row the rest of the branch now
references — `sceneEntities` arrays, later happenings' involvements,
@ -2646,15 +2658,18 @@ Algorithm:
- **Otherwise** (no `story_entries` create — a field edit, a
chapter-close batch) — reverse just that `action_id` group.
Periodic-classifier deltas above it describe unrelated turns and
stay put.
stay put, except any that name a row the group created, which the
reversal's closure takes with it.
3. Reverse the selected deltas newest-to-oldest via their `undo_payload`,
move them onto an in-memory redo stack (not persisted), and remove
them from the `deltas` table.
Redo re-applies from the in-memory stack. The stack clears on any new
action. Redo is runtime-only; no schema support needed. If the app
restarts, redo history is lost (acceptable — this matches editor
conventions).
Redo re-applies from the in-memory stack, including the rows the
closure took with the group, so undo and redo stay exact inverses; a
sweep's rows stay out, since the clamped watermark re-derives them. The
stack clears on any new action. Redo is runtime-only; no schema
support needed. If the app restarts, redo history is lost (acceptable
— this matches editor conventions).
This co-exists cleanly with entry-level rollback: CTRL-Z is
action-granular ("undo my last thing"), rollback is entry-granular
@ -2683,7 +2698,11 @@ anchor to their own turn (≥ B), and `null`-anchor deltas always reverse,
exactly as before; only a lagging background
delta whose anchor is below `B` is spared. A background delta therefore
survives only if its anchor entry survives — no orphans or dangling
anchors by construction. The same predicate drives the
anchors by construction. The one exception runs the other way: a
background delta naming a row the reversal removes goes with it
whatever its anchor, so a fact about a surviving turn cannot name a
row that exists nowhere
([`generation-pipeline.md → Reverse-replay`](./generation-pipeline.md#reverse-replay)). The same predicate drives the
[fork reverse-apply partition](#branch-model), and is orthogonal to the
in-flight
[classifier barrier](./generation-pipeline.md#prose-reversals-and-the-classifier-barrier),
@ -2886,12 +2905,13 @@ that didn't land never existed; nothing to reverse.
Retry-created rows from the `translation-retry` pipeline carry the
retry pipeline's own `action_id`, not the originator's. CTRL-Z of
the originating turn does not reverse retry-created translations —
they become orphan rows, harmless under keyed lookup (a lookup
against a reversed target finds nothing). Forward / redo of the
originator restores the target; the orphan becomes a live translation
again. Hard-delete of a source row cleans up via existing branch-
cascade rules and per-row action-layer cleanup writes.
the originating turn does not select them by `action_id`, but one
whose target the turn created goes with that target: the reversal's
closure takes every row naming a removed row, a translation by its
`target_kind` included
([`generation-pipeline.md → Reverse-replay`](./generation-pipeline.md#reverse-replay)),
and redo restores both. Hard-delete of a source row cleans up via
existing branch-cascade rules and per-row action-layer cleanup writes.
**Runtime:** Zustand loads translations into an index
`Map<(kind, id, field, lang), string>` for O(1) render-time lookup. UI

View file

@ -0,0 +1,270 @@
# Reversal integrity — link rows and delete payloads
Resolves the `4.2c planning gate — Reversals leave link rows and delete
payloads out of step with the log` entry in
[`followups.md → Data-model`](../followups.md#data-model). The commit
that lands this record replaces that entry with a one-line
implementation entry: the design is settled, the code is not.
The substrate is the reverse-replay contract in
[`generation-pipeline.md → Reverse-replay`](../generation-pipeline.md#reverse-replay)
and the log rules in
[`data-model.md → Entry mutability & rollback`](../data-model.md#entry-mutability--rollback).
## What this closes vs. defers
Closes:
- **Dangling links.** A reversal that removes a row now removes every
row naming it, on every reversal path, CTRL-Z included.
- **Rows coming back with no history.** A reversal that reaches a row
held only in a delete's `undo_payload` applies to that copy, so
undoing the delete can no longer restore a reversed write.
- The two calls the 2026-10-04 triage pass left inside the entry:
CTRL-Z runs the closure and redo restores what it removed; a
reversal reaching a deleted row prunes the delete, and refuses when
the delete's group would write the row's id back.
Defers:
- **Implementation**, to a standalone PR that lands before 4.2c's
merge, tracked in `followups.md`.
- **Ids inside entity `state` and entry metadata.** Out of scope, for
the reasons in [Scope](#scope).
## Ground truth
Probed with scratch vitest files (not committed) against the `main`
head after #567, through the real actions: `applyDeltaAction`,
`deleteEntityRow`, `deleteRow`, `updateStoryEntryContent`,
`undoLastAction`, `redoLastAction` and `reverseReplayDeltas`.
| Probe | Trigger | Result |
| ----- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| P1 | CTRL-Z of a user-created character after a pass linked her | Involvement, awareness and relationship stay, naming her; redo makes them valid |
| P2 | Abort of a pass that created a character the user linked mid-burst | The user's relationship stays naming a character that exists nowhere |
| P3 | Captured relationship, both ends live: abort, then CTRL-Z the delete | The relationship is live with no delta |
| P4 | P3 through a happening delete (awareness) | Same |
| P5 | The deleted row itself, a happening and a character: abort, then CTRL-Z both deletes | Both live with no delta |
| P6 | A pass changed a captured relationship from `ally` to `rival`: abort, then CTRL-Z the delete | Back as `rival`; the log holds only the user's `ally` create |
| S1 | Prose-edit sweep: delete a character and a happening, edit the head turn, CTRL-Z all three | Relationship, awareness and happening all live with no delta |
P4 and S1 create the happening in an earlier pass than the awareness
under it, which the classifier does not do: it links only happenings it
creates in the same pass
([`parked.md → A dirty links array on Save overwrites concurrent link writes`](../parked.md#a-dirty-links-array-on-save-overwrites-concurrent-link-writes)).
S1's shape still arises inside one pass, since a happening can anchor
to an older turn than awareness of it
([`classifier.md → Provenance attribution`](../memory/classifier.md#provenance-attribution));
the 4.2b probe reproduced it that way. P4 is the general rule only.
Which paths ran a closure: `reverseReplayDeltas` (abort, boot
recovery, a turn refused at admission) and
`resolveClassifierFactDeltas` (the prose-edit sweep). CTRL-Z of a
group reversed through `reverseAndPruneDeltaRows` alone; CTRL-Z of a
turn, regenerate and rollback select by `resolveSweep`'s window, with
no closure. Where a closure ran, it followed only a happening's
involvement and awareness rows (`CHILD_TABLES`).
## The finding
The planner knows a row's lifetime only through the deltas on that
row. Rows are connected two other ways it did not follow:
- **References.** A link row names an entity or a happening. The
columns are already listed once, in `live-refs.ts`, for the
live-link filter and the forward guard `missingRef`.
- **Delete payloads.** A deleted row is not gone. It lives in its
delete's `undo_payload`, as the target or a captured child, until
that delete is undone. The planner read "absent from its table" as
"absent".
So the first gap is _removing a row must remove what references it_,
and the second is _an undo must apply to the row wherever it lives_.
P3 to P6 and S1 are all one failure: an undo reaches a row held only
in a payload, touches nothing, is pruned, and the payload keeps the
stale copy. The deleted-row case is both at once.
## Decisions
### Target: strict
After every reversal, two rules hold:
1. **No dangling reference.** No row names a row that exists nowhere,
where a row exists in its table or held in a delete's payload.
2. **Nothing reversed comes back.** A reversed delta's effect survives
nowhere, in the table or in a payload. Nothing returns without a
delta that can remove it again.
Rejected: fixing dangling references and only the rows returning with
no history, accepting stale values like P6, which leaves a log that
cannot explain what a delete's undo restores; and tolerating dangling
references at read sites, which costs every present and future read
site and leaves P2 unrepaired.
### A write-phase lock, not a single transaction
The periodic pass makes one model call, but commits each planned
action as its own `applyDeltaAction` transaction, and its watermark
write follows separately. The developer's point holds for P2 and the
`state`-reference case: a human cannot see a row the pass just created
and act on it within the burst's milliseconds. It does not hold for
the abort trigger of the second gap, which needs no perception: a
delete of a row that predates the pass can commit between two writes
by timing alone, and a later failure in the same run then reverses
around it.
A lock the developer proposed closes all of it. First considered was
committing the pass as one `applyDeltaActionGroup`, rejected for its
cost: group handlers read pre-group state, so the planner would have
to merge same-row writes, and the group runner would need per-action
noop skipping.
### Captured rows follow live rows
Undoing a prose edit does not restore the facts it swept. It clamps
the classifier watermark and the next pass re-derives them. CTRL-Z is
newest-first, so a delete older than a prose edit can be undone only
after that edit is, when the prose its captured rows came from is
back.
Chosen: the sweep applies to a captured row as it does to a live one,
and the facts come back by re-derivation as new, tracked rows. Its
costs, accepted:
- A pruned delete leaves undo history.
- A happening the user deleted comes back re-derived, as any
user-deleted fact does after an edit and its undo.
- **Link loss in rare timing.** A pass that runs between undoing the
edit and undoing the delete cannot re-derive links naming the
still-deleted row: the write is a `noop` on a missing ref. That
needs a completed turn between the two undos (which the user then
undoes too), a pending retry timer, or a manual run. Only
classifier-derived links are lost; a relationship view the user
wrote keeps its row by the `rowKeepingColumns` rule. Editing that
turn again re-derives them.
Rejected: leaving captured rows untouched. The pass re-deriving after
the edit's undo removes nothing first, since the captured happening
escaped that undo's reversal, so every restored happening duplicates.
It also breaks on rollback: a content edit's delta is anchored to its
own turn and survives a rollback to it, while the rollback restores
the deletes after it, so captured facts from the replaced prose come
back beside the new prose, and the watermark clamp stops short of
re-deriving that turn.
Rejected: a hybrid keeping links (upserts cannot duplicate) and
stripping happenings. The rollback case defeats it the same way.
### One closure, every path; redo restores its rows
The closure belongs to the one step that selects a reversal set, so a
path cannot forget it, which is how CTRL-Z, regenerate and rollback
came to skip it. Redo restores the rows it removed, keeping undo and
redo exact inverses and today's visible behaviour, where P1's links
work again after a redo.
Rejected: a closure call per caller; redo restoring the group alone,
which loses the links on every undo and redo of a create since a
non-content undo clamps nothing; and redo clamping the watermark to
re-derive them, a whole-window pass to rebuild rows the snapshot holds.
### Implementation lands first, on its own
A standalone PR before 4.2c. It is substrate work, independent of
4.2c's UI, and 4.2c's merge depends on the contract. Rejected: folding
it into 4.2c, and a new M4 slice for the same outcome.
## Design
### Scope
Every reversal path: CTRL-Z of a group or a turn and their sweeps,
redo's invalidation, regenerate, rollback, a branch fork's
reverse-apply, abort, boot recovery, a turn refused at admission, and
the prose-edit sweep.
References: the three link tables, and translations by `target_kind`.
Translations are delta-tracked (`createTranslation`) but nothing writes
them yet.
Out of scope:
- **Ids inside entity `state`** (`current_location_id`, `faction_id`,
`inventory`, `equipped_items`, `parent_location_id`,
`at_location_id`). Only the per-turn piggyback and user edits write
them. The lock closes their one reversal path, a user edit inside a
burst; everything else is newest-first ordered, and sweeps never
reverse an entity create (`isReversible`).
- **Scene ids in entry metadata.** A dead id in an older entry is
already permanent by canon
([`entry-card.md → Emitted vs. applied`](../ui/patterns/entry-card.md#emitted-vs-applied));
the tail's follows the `state` reasoning.
- **The story lead.** Deleting it is refused; M6 reworks it.
### Where the spec lives
The lock is specified in
[`generation-pipeline.md → No-gate write phase`](../generation-pipeline.md#no-gate-write-phase);
the closure, the reversal of rows a delete holds, both refusals and
the redo labels in
[`generation-pipeline.md → Reverse-replay`](../generation-pipeline.md#reverse-replay);
the log, CTRL-Z, redo and survival-anchor consequences in
[`data-model.md → Entry mutability & rollback`](../data-model.md#entry-mutability--rollback).
Integration added one path to the list: a branch fork's reverse-apply,
which reverses a suffix like rollback does; fork itself is not built
yet.
## Adversarial pass
- **Load-bearing assumption.** Newest-first undo plus the lock confine
payload edits to sweeps, and keep captured rows out of redo
snapshots and entity targets out of reach. Where that fails, the
planner refuses rather than corrupts.
- **Chapter-close.** The first refusal draft covered entity deletes
only, assuming every other delete is alone in its group.
[`chapter-close.md → 3e`](../memory/chapter-close.md#3e--happenings-consolidation)
breaks that once built; the group-scoped write-back check replaced
it. A consolidated description can still carry facts from prose
later replaced; that is content, not log integrity, and no ledger
tracks it.
- **Live-link filter.** With stripping, no payload holds a row naming
a removed one; the filter stays as a backstop for payloads written
before the change. No migration: the app has not shipped, and dev
databases reseed (assumed).
- **4.2c History.** A payload edit emits no store patch, so History
reading link rows from payloads cannot rely on store patches to
refresh after a reversal edits one. An input to 02c's refresh
question.
Verified: every probe above; the write loop and the orchestrator's
dispatch; the scheduler's triggers; that every delta-logged write
clears the redo stack; the metadata lock's users; that translations
are delta-tracked. Assumed: the burst does no slow work; the closure's
query cost; the classifier does not dedupe happenings; no user data
needs migrating; the 400–600 line estimate for the implementation.
## Integration plan
- `generation-pipeline.md → Concurrency model`: the `no-gate` bullet,
and a new `### No-gate write phase` subsection.
- `generation-pipeline.md → Reverse-replay`: the closure paragraphs
rewritten for the registry, every path, the labels, the
survival-anchor exception and the `rowKeepingColumns` override; a
new paragraph on a reversal reaching a deleted row, with both
refusals.
- `data-model.md → Entry mutability & rollback`: payload edits under
"a reversal takes its deltas with it"; CTRL-Z step 2's exception;
redo restoring the closure's rows; the survival-anchor exception.
- `memory/cadence.md → User edits and classifier writes` and the
`gateBehavior` row in `memory/classifier.md`: edits wait out the
write phase.
- `ui/screens/reader-composer/rollback-confirm/rollback-confirm.md`:
the world-state count covers the closed set.
- `02c-collision-review.md → Open questions`: the merge question's
link text names the implementation entry and the canon.
- `followups.md`: the entry is replaced by the implementation entry,
so `#data-model` keeps its one inbound link.
No heading renames, no pattern adoptions, no wireframes.

View file

@ -13,99 +13,13 @@ for the placement rule.
## Data-model
- **4.2c planning gate — Reversals leave link rows and delete payloads
out of step with the log.** Two mirrored gaps in one design item:
live link rows still naming a row a reversal removed, and rows a
delete captured in its payload whose history a reversal pruned.
Settle both in a design session and close this entry before 4.2c is
planned: its merge re-key question
([`02c-collision-review.md → Open questions`](./implementation/milestones/04-world-plot-read-surfaces/slices/02c-collision-review.md#open-questions))
adds captured rows if it picks new rows plus the cascade. The
developer chose this over landing option (a) alone (triage,
2026-10-04). The two triage entries follow as filed.
**`row-closure.ts`'s `CHILD_TABLES` widens a reversal set only for
happenings' children.** A reversal deleting a character a create made
orphans relationship, involvement or awareness rows naming it that
were written in a different action group. Read, not reproduced;
pre-existing (2026-09-28, raised by 4.2b).
**A link an entity delete captured can come back with no delta.**
An entity delete keeps the link rows it cascades only in its own undo
payload. When a later reversal removes the machine write that created
one of them, that create-undo finds the row already gone and prunes
its delta; undoing the entity delete then restores the row through
the live-link filter, since both its ends are live, and nothing left
in the log can reverse it. Follow-up (developer decision on the 4.2b
stack review, R1 option a): a create-undo of a link row that is
already absent also strips that row from the delete payload that
captured it — the contract between the reverse-replay closure and
the delete cascade payload
([generation-pipeline.md → Reverse-replay](./generation-pipeline.md#reverse-replay)).
Both triggers were probed with a scratch test on the 4.2b head:
- **A periodic pass reversed mid-burst.** The pass is abort-free
once its single model call returns, so the window is its write
burst: the user deletes a character between two of its writes,
then a later write fails (any rejection but a `noop`, or a throw,
aborts the run and reverse-replays it) or boot recovery reverses
the pass.
Reproduced for a relationship between two characters the pass
didn't create. Involvement and awareness rows can't hit it: the
classifier writes them only under a happening created in the same
pass, which the reversal deletes, so the live-link filter drops
them. CTRL-Z never reverses a pass's writes: its bracket cancels
an in-flight pass, which either stops before its first write or
lands its whole burst first.
- **A prose edit's sweep, with no timing window.** The user deletes
a character, then edits the head turn's prose, whose sweep reverses
the periodic facts anchored to it; CTRL-Z the edit, then CTRL-Z
the delete. Reproduced for a relationship anchored to the head turn
between characters that survive, and for awareness anchored to the
head turn under a happening anchored to an older turn, which the
sweep keeps. Not for an involvement: it shares its happening's
anchor, so the sweep deletes the happening too.
Option (a) as worded covers creates only. A swept pass update to a
captured row is pruned the same way: a relationship view the pass
changed from `ally` to `rival` comes back as `rival` when the
delete is undone, though that row keeps its older create delta. A
rerun doesn't duplicate a restored row: awareness and relationship
writes are upserts behind unique indexes (`haw_natural_uniq`,
`char_rel_pair_uniq`) and update it in place. `happening_involvements`
has no such index, and `createHappeningInvolvement` doesn't check for
an existing link, but the classifier emits involvements only under a
happening it creates (2026-09-30, raised by the 4.2b stack review).
The deleted row itself has the same gap, which option (a) doesn't
reach. A happening the pass wrote from the head turn, deleted in
Plot: edit that turn's prose, CTRL-Z the edit, CTRL-Z the delete. The
happening and its links come back with no delta, the pass's rerun
writes the happening a second time, and a later rollback removes only
one. In the prose-edit trigger above, the same rollback also leaves
the untracked awareness row naming a happening that is gone. Probed
through the pipeline and the delete, edit, undo and rollback actions
at the 4.2b review head (`1428e5b8`), not re-run on `main`
(2026-09-30, from a slice-review skill test).
**Corrections from the 2026-10-04 triage pass** (read on `main`, not
re-reproduced):
- The first gap's live trigger is CTRL-Z, not the closure path.
`undo.ts` reverses through `reverseAndPruneDeltaRows`, which never
runs `closeOverRemovedRows`, and `selectUndoTarget` skips
periodic-classifier groups: create a character, let a pass link
her, CTRL-Z, and the pass's relationship, awareness and involvement
rows still name her. A prose edit's sweep never deletes an entity
(`isReversible` drops entity creates). Widening `CHILD_TABLES`
alone misses the CTRL-Z case.
- "CTRL-Z never reverses a pass's writes" is false as worded: undoing
a turn sweeps the pass facts anchored to it. What holds is that
CTRL-Z never reverses a pass mid-burst.
- The second gap also reaches happening deletes, which capture their
links the same way (`register-happenings.ts`), and the deleted-row
case reaches entities too, through an abort.
- About 200–400 lines across `row-closure.ts`, `reverse-replay.ts`,
`live-link-filter.ts`, `delete-cascade.ts` and canon. Two calls
inside: whether CTRL-Z runs the closure (redo replays only the
group, so links it sweeps wouldn't come back on redo), and whether
a reversal reaching a deleted row pulls in the whole entity-delete
group or prunes only the delete.
- **Implement the reversal-integrity contract before 4.2c's merge.**
The no-gate write lock, the reference closure on every reversal
path with its redo labels, the reversal of rows a delete holds, the
two integrity refusals, and a rollback preview that counts the closed
set, per
[`generation-pipeline.md → Reverse-replay`](./generation-pipeline.md#reverse-replay)
and [`→ No-gate write phase`](./generation-pipeline.md#no-gate-write-phase).
A standalone PR; the merge in
[`02c-collision-review.md`](./implementation/milestones/04-world-plot-read-surfaces/slices/02c-collision-review.md#open-questions)
depends on it.

View file

@ -1240,9 +1240,11 @@ async function reverseReplayDeltas(
actionId: string,
settleOps: (deltaCount: number) => SqlOp[] = () => [],
): Promise<number> {
const deltas = await db.query(
'SELECT * FROM deltas WHERE action_id = ? ORDER BY log_position DESC',
[actionId],
// Closed over the rows its creates delete and the rows naming them (below).
const deltas = closeOverRemovedRows(
await db.query('SELECT * FROM deltas WHERE action_id = ? ORDER BY log_position DESC', [
actionId,
]),
)
// Called even at zero deltas: the caller may still have a marker to settle.
const settle = settleOps(deltas.length)
@ -1251,9 +1253,10 @@ async function reverseReplayDeltas(
await db.exec('BEGIN')
try {
for (const delta of deltas) {
// A row a delete holds takes the undo in that delete's payload (below).
applyUndo(delta.target_table, delta.target_id, delta.undo_payload)
}
await db.exec('DELETE FROM deltas WHERE action_id = ?', [actionId])
await db.exec('DELETE FROM deltas WHERE id IN (?)', [deltas.map((d) => d.id)])
for (const op of settle) await db.exec(op.sql, op.params)
await db.exec('COMMIT')
} catch (e) {
@ -1335,34 +1338,96 @@ are pruned from the log with the parent's. A cascade that deleted the rows
would leave their deltas behind, pointing at nothing, and a later redo
would re-insert children under a parent that is gone. The reversal still
sweeps the row's vectors, which carry no deltas and would be orphaned
otherwise. The closure gathers child rows only for a happening, and only
its involvement and awareness rows; any other child the set doesn't
already hold — an entity's relationship, involvement or awareness rows,
any row's translations — outlives the reversal.
otherwise. Child rows reach the set through the closure below, by
reference, never through the cascade hook.
**Abort, boot recovery and a prose edit close over the rows their
creates delete.** Neither selection scope guarantees the set holds every write to such a row. An
`actionId` scope holds a run's own writes, but not a user edit made to
a row the run created while a no-gate pass was running — a rename, or
an involvement added under its happening. An `entryId` scope misses
child rows even from the same pass, because a child does not have to
share its parent's anchor: awareness anchors to the turn that narrated
the learning, which can sit either side of the happening's own
provenance entry
**Every reversal closes over the rows its creates delete, and the rows
naming them.** Every path selects its set through one step that
applies the closure, so no path can skip it: CTRL-Z of a group or a
turn and the sweeps it runs, redo's sweep, regenerate, rollback, a
branch fork's reverse-apply, abort, boot recovery, a turn refused at
admission, and a prose edit's sweep. No selection scope guarantees the
set holds every write to a removed row, or every row naming one. An
`actionId` scope holds a run's own writes, but not a user edit to a
row the run created. An `entryId` scope misses child rows even from
the same pass, because a child does not have to share its parent's
anchor: awareness anchors to the turn that narrated the learning,
which can sit either side of the happening's own provenance entry
([`classifier.md → Provenance attribution`](./memory/classifier.md#provenance-attribution)).
So both scopes widen their set before replaying it: for each `create`
it holds, every later delta on that row, and for a happening every
delta on its involvement and awareness rows, whatever their source —
a child the user already deleted included, found through its delete's
`undo_payload`.
Left out, a user edit would stay in the log pointing at a row that is
gone, and CTRL-Z of it would report an undo that changed nothing. Two
exclusions: a later `delete` of the row, whose undo would restore
children under a parent the create's undo then deletes; and a table
registering `rowKeepingColumns`, whose create-undo already keeps a row
the user wrote to. `closeOverRemovedRows`
(`lib/actions/delta/row-closure.ts`) is the closure; `reverseReplayDeltas`
and `resolveClassifierFactDeltas` both apply it.
CTRL-Z of a group steps over periodic-classifier groups, so a pass's
links to a row the user created sit above the group it undoes. A
window spares a fact anchored to a surviving turn even when the fact
names a row the window removes.
The closure is a fixed point. The removed set starts as the rows whose
`create` the set holds, and any row naming a removed row joins it —
live in its table, held in a delete's `undo_payload`, or deleted on its
own — until nothing new joins. One registry of references serves the
closure; the forward guard `missingRef`, which turns a link write
naming a dead row into a no-op
([`cadence.md → User edits and classifier writes`](./memory/cadence.md#user-edits-and-classifier-writes));
and the live-link filter, which drops a restored link whose far end is
gone, now a backstop for payloads written before this rule: an
involvement names its happening and its entity, awareness its
happening and its character, a relationship both its characters, and a
translation its target by `target_kind`. Every `create` and `update`
of a removed row joins the set, whatever its source; a `delete` of it
never does, as the next paragraph covers. A table registering
`rowKeepingColumns` keeps its exemption only when the row's own create
is reversed: a relationship the closure takes because a character it
names is removed goes whatever views the user set, since it would
otherwise name a row that exists nowhere. A fact taken by reference
goes whatever its survival anchor
([`data-model.md → Survival anchor`](./data-model.md#survival-anchor)).
Left out, a link would name a row that exists nowhere, and a user edit
would stay in the log pointing at a row that is gone, its CTRL-Z
reporting an undo that changed nothing. `closeOverRemovedRows`
(`lib/actions/delta/row-closure.ts`) is the closure.
**A reversal applies to a row a delete holds.** A deleted row lives on
in its delete's `undo_payload`, as the delete's target or a captured
child, until that delete is undone. When the set reverses a write to a
row absent from its table, the planner finds the delete still in the
log that holds it — at most one, since deleting the row again needs
this delete undone first, which prunes it — and applies the undo
there. A holding delete inside the set is reversed first, newest-first,
so the row is back in its table by then. Otherwise a `create`'s undo
strips a captured child from the payload, and prunes the delete when
the row is the delete's own target: its captured children name the
target, so the closure takes them and their deltas. An `update`'s undo
patches the payload copy under the rules a live row follows — user
precedence, schema-backed sub-fields, and a relationship left with no
view is stripped. Each changed payload is one write to that delta's
`undo_payload` in the reversal's transaction; stores hold no deleted
rows, so no patch follows. A captured fact so follows a live one: a
prose edit's sweep removes it, and undoing the edit leaves the next
pass to re-derive it. CTRL-Z is newest-first, so the delete can be
undone only after the edit is, once the prose the fact came from is
back. The cost is narrow: a pass that runs between the two undos
cannot re-derive a link naming the still-deleted row, and that link
stays lost.
**Two states are refused as integrity errors, writing nothing.** A
delete the planner would prune that shares its action group with a
delta still in the log whose undo would write the removed id back: an
entity delete's `state` and tail-scene updates do, so a later CTRL-Z of
the group would restore a dead id. A chapter-close consolidation's
upserts name the surviving happening, so its deletes prune. And a
captured row among the rows a CTRL-Z's redo would restore (below): it
would need a newer non-classifier delete still in the log, which
CTRL-Z picks first. Neither is reachable today — an entity's create is
never reversed while a delete holds it, given the
[no-gate write phase](#no-gate-write-phase), newest-first undo, every
window holding a delete with the create it follows, and sweeps sparing
entity creates — and refusing keeps a broken assumption loud.
**The set labels each delta for redo:** part of the action being undone,
reached by the closure from it, or a sweep's row and what the closure
reaches only from one. CTRL-Z's redo snapshot covers the first two, so
redo restores the links the closure removed with the group; a sweep's
rows stay out, since the clamped watermark re-derives them. A delta
reached both ways counts as the closure's. Every delta-logged write
clears the redo stack, so redo always meets the state its undo left.
Abort is conceptually identical to user CTRL-Z — same
`undo_payload` primitive, same reverse-replay path, and the replayed
@ -1564,7 +1629,9 @@ to `detail` alone otherwise.
`gateBehavior` controls user-edit gating:
- `'hard-gate'` — all user-source writes blocked while I'm running.
- `'no-gate'` — user-source writes proceed; my deltas land alongside.
- `'no-gate'` — user-source writes proceed; my deltas land alongside,
except that every other write waits out my write phase
([No-gate write phase](#no-gate-write-phase)).
- `'scoped-gate'` — deferred (would gate only user writes
overlapping my `writeSet`; ships when `writeSet` does).
@ -1795,6 +1862,31 @@ survival-anchor predicate in
not here. The two are orthogonal — barrier for racing runs, anchor for
committed positions.
### No-gate write phase
A `no-gate` run's writes must not interleave with any other write to
its branch. The periodic classifier commits each planned action as its
own transaction, so a user's delete of a row that predates the pass
could otherwise commit between two of them by timing alone, and a later
failure in the same run would reverse the pass around it, pruning a
link the delete's `undo_payload` still holds.
Every `applyDeltaAction` and `applyDeltaActionGroup` takes a per-branch
write lock in shared mode, inside the entry metadata lock and before
its row keys. A `no-gate` run takes it exclusive at its first emitted
write — after its model call, embedding and reconciliation, so World
stays editable through those — and waits for every shared holder, so a
write already in flight lands first. The run's own writes go through
inside the hold, which ends when the run settles: after the watermark
write on success, after the abort's reversal commits on failure. A
write arriving meanwhile waits a few milliseconds; nothing is disabled
or refused. Prose reversals need nothing more, since the barrier above
already waits a burst out, and boot recovery runs before any branch
loads. The lock order holds because a burst never asks for the
metadata lock: only the scene-field, world-time and entity-delete
actions take it, and the orchestrator commits a pass's writes through
`applyDeltaAction` directly.
### Chained start bypasses concurrencyPolicy
Per-turn's `chainsTo` returning `'chapter-close'` triggers a

View file

@ -251,9 +251,10 @@ to clear before wiring: `collision-resolve-diff.ts` declares
some other shape? Whatever 4.2c settles on,
[`world.md → Reversibility`](../../../../ui/screens/world/world.md#reversibility)'s
merge write list must be amended to match. New rows plus the cascade
means more rows a delete captures, so settle
[the reversal and delete-payload gap](../../../../followups.md#data-model)
before planning this slice.
means more rows a delete captures. Captured rows follow
[`generation-pipeline.md → Reverse-replay`](../../../../generation-pipeline.md#reverse-replay),
and [the reversal-integrity implementation](../../../../followups.md#data-model)
lands before this slice's merge.
- **Reuse the delete confirm's link counts** (4.2b):
`components/world/delete-impact.ts` repeats the entity cascade's
awareness / involvement / relationship predicates against the
@ -304,7 +305,10 @@ to clear before wiring: `collision-resolve-diff.ts` declares
- **The tab's refresh version.** `HistoryTab` refetches when its
target row or `settleCount` changes; a user edit to a link row
changes neither, so the union folds the link stores it reads into
that version (2026-10-04 triage pass).
that version (2026-10-04 triage pass). A reversal that edits a
delete's payload emits no store patch, so link rows the union reads
from payloads need a refresh signal of their own
([`generation-pipeline.md → Reverse-replay`](../../../../generation-pipeline.md#reverse-replay)).
- **Overlap with the merge.** The merge reattaches the loser's link
rows to the canonical, in a shape the re-key question above leaves
open. Moved in place, a row names the canonical and brings its

View file

@ -347,7 +347,8 @@ churn.
incl. `classifier_status`, landed in M1.5; this slice is the fork
orchestration), including the survival-anchor partition of post-fork
deltas (copy lagging `periodic_classifier` facts about kept entries
instead of rewinding them) per
instead of rewinding them, unless they name a row the rewind
removes) per
[`data-model.md → Branch model`](../data-model.md#branch-model).
- M6.2 — Delta-log branch filtering (reads scope to current
branch's lineage).

View file

@ -222,7 +222,10 @@ The periodic classifier is `no-gate`, so World stays editable while a
pass runs. A user edit can meet a classifier write in two ways. It can
land during the pass: the pass reads its entity snapshot before the
model call and writes only after the model call and reconciliation
return, which can be minutes later. Or it can predate the pass but
return, which can be minutes later. It cannot land inside those writes,
which hold the branch's write lock for their few milliseconds
([`generation-pipeline.md → No-gate write phase`](../generation-pipeline.md#no-gate-write-phase)).
Or it can predate the pass but
postdate the prose the pass processes, since a pass works through the
backlog of turns written since the last one. The classifier's writes
to an existing entity's status and keywords, and to a relationship

View file

@ -183,7 +183,7 @@ chapter-close but with different concurrency / gating values:
| Field | Value |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `'periodic-classifier'` |
| `gateBehavior` | `'no-gate'` — doesn't block user-source writes |
| `gateBehavior` | `'no-gate'` — doesn't block user-source writes; they wait out its write phase ([no-gate write phase](../generation-pipeline.md#no-gate-write-phase)) |
| `concurrencyPolicy` | `{ blockedBy: ['periodic-classifier', 'chapter-close'] }` — no double passes; blocked from starting during chapter-close |
| `affordance` | `'pill-only'` — folds into the generation indicator at low priority (see below) |
| Write set (prose, not declared) | happenings, happening_involvements, happening_awareness, new character entities (first-introduction description, keywords, collision flag), entity status flips with retired reasons, entity keyword appends, character relationships |

View file

@ -80,11 +80,11 @@ the modal carries the consequence in numeric form, not visual:
Three buckets, in commit order:
| Count | Source |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **N entries** | `op=create` deltas on `story_entries` in range |
| **M chapters re-opened** | `op=create` deltas on `chapters` in range (line omitted when `M = 0`) |
| **K world-state changes** | All other narrative deltas in range (entities, lore, threads, happenings, entry_assets). Every `story_entries` delta is excluded, not just the creates: a non-create one amends an entry the sweep is deleting, which the entries line already reports. The discriminator is the target table, not `entry_id` — a delta on another table stays counted whether or not it carries an anchor. |
| Count | Source |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **N entries** | `op=create` deltas on `story_entries` in range |
| **M chapters re-opened** | `op=create` deltas on `chapters` in range (line omitted when `M = 0`) |
| **K world-state changes** | All other narrative deltas in range (entities, lore, threads, happenings, entry_assets). Every `story_entries` delta is excluded, not just the creates: a non-create one amends an entry the sweep is deleting, which the entries line already reports. The discriminator is the target table, not `entry_id` — a delta on another table stays counted whether or not it carries an anchor. The range is the closed set the sweep reverses, so it also counts a fact anchored to a surviving turn that names a row the sweep removes ([`generation-pipeline.md → Reverse-replay`](../../../../generation-pipeline.md#reverse-replay)). |
Buckets are coarse-grained on purpose. Per-table breakdown reads as
pedantic in a consent modal; "world-state changes" is the