mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-10-19 01:31:59 +00:00
Design: reversal integrity for link rows and delete payloads (#568)
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:
parent
3a4c5bb4b8
commit
01f9e956a1
9 changed files with 466 additions and 162 deletions
|
|
@ -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
|
||||
|
|
|
|||
270
docs/explorations/2026-10-04-reversal-integrity.md
Normal file
270
docs/explorations/2026-10-04-reversal-integrity.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue