Skip to content

fix(engine): persistence-robustness cluster — pure reads stop flipping the live pointer (F08-2/3/4/5) - #853

Merged
100yenadmin merged 1 commit into
mainfrom
fix/f08-persistence-robustness
Jun 14, 2026
Merged

fix(engine): persistence-robustness cluster — pure reads stop flipping the live pointer (F08-2/3/4/5)#853
100yenadmin merged 1 commit into
mainfrom
fix/f08-persistence-robustness

Conversation

@100yenadmin

Copy link
Copy Markdown
Member

What

Fixes the P2 persistence-robustness cluster (#804) from the WorldOS full-engine adversarial audit (2026-06-11). Four skeptic-verified defects in the single-writer persistence layer where a pure read silently corrupted recency / dropped data / bricked resume.

Source: docs/audits/ENGINE-AUDIT-2026-06-11.md (Part C, unit 08). Closes #804. Refs #640, #165, #741.

Findings fixed

F08-2 — pure reads flipped the #640 live-campaign pointer

save_campaign saved unconditionally. check_consequences / check_companion_arc / check_faction_arcs / world_tick — and scene_context, which delegates to check_companion_arc every beat — all load → maybe-mutate → save, so a call that mutated nothing still bumped updated_at and flipped the active_campaign_id resolver ("most-recently-updated == live") onto the inspected campaign. This is the silent Rolan↔Liara character-switch class.

Fix (as filed): a dirty-skip chokepoint in save_campaign — serialize with the current (pre-stamp) updated_at, byte-compare to the on-disk snapshot; byte-identical ⇒ return without writing or re-stamping. A genuine mutation differs ⇒ still stamps + writes (a real write is never dropped). Fixes all 92 call sites at the chokepoint for free. world_tick legitimately mutates last_tick_day on its first per-day call (so it saves once), then no-ops on a same-day repeat — exactly why the fix is a byte-compare, not a blanket "never save".

F08-3 — tolerant load dropped unknown keys, next save destroyed them

The tolerant load drops unknown top-level keys then the next save rewrites current-schema-only, destroying them permanently. Now the original bytes are stashed write-once into campaigns/<id>/snapshot.pre-tolerant.json (fixed name + skip-if-exists — first-skew bytes are the valuable ones; _atomic_write; degrade-not-abort on backup failure), and the dropped key names are surfaced on the resume path (start_session / start_world resume return a schema_drift block) instead of being log-only.

F08-4 — enumerators used the STRICT parse only

list_campaigns / campaigns_for_world / active_campaign_id used a bare strict parse, so a tolerant-loadable campaign was invisible to listings and to the #640 resolver (which then silently resolved the other, strict-only campaign). They now share the same tolerant loader (_load_summary: strict first, tolerant retry on failure) while staying pure reads — no backup write, no module-state mutation.

F08-5 — one torn session-log line bricked recap/resume

A truncated final line (OOM-killed half-write) raised out of read_log → read_log_all → recap_from_store → start_session's recap, hand-repair the only fix. read_log now skips-and-warns the unparseable line; append_log newline-heals when the file's last byte isn't a newline, so the poison can't grow by concatenating onto the torn fragment.

Invariants upheld

  • Engine stays sole writer (atomic temp + os.replace); pure reads do not save / bump updated_at (the whole point of this wave).
  • Additive-by-default: old snapshots round-trip byte-identical through the chokepoint; schema_drift is an additive optional output field; the pre-tolerant sibling file is inert to every glob (list_slots globs slots/*.json; listings read snapshot.json by name). No schema or wire-contract change.
  • This wave reduces per-beat write surface (no growth in scene_context).

Tests

New servers/engine/tests/test_store_persistence_robustness.py (24, red→green):

  • F08-2: pure check_* / same-day world_tick repeat does NOT change updated_at and does NOT flip the live pointer (two-campaign end-to-end); a firing check_consequences / first world_tick / any real mutation DOES still save + bump.
  • F08-3: tolerant load writes the backup once, preserves the unknown key in the backup, surfaces dropped keys; start_session returns schema_drift; clean loads carry none.
  • F08-4: enumerators see the tolerant campaign and the G3 #1 blocker: is_live_view freeze/lockout from multi-campaign divergence (3/5 personas) #640 resolver picks it; enumeration stays read-only; genuine corruption is still skipped.
  • F08-5: torn lines are skipped not fatal in read_log/read_log_all; append_log newline-heals without injecting a blank line on a clean append.

Verification

  • New cluster file: 24 passed.
  • test_store.py + test_slots.py (existing persistence): no regressions.
  • Full engine suite: 2183 passed.
  • qa/fast_gate.sh (Tier-0): PASS (188 deterministic).

DO NOT MERGE — awaiting review.

…g the live pointer (F08-2/3/4/5)

P2 persistence-robustness cluster (issue #804) from the WorldOS full-engine
adversarial audit. Four skeptic-verified defects in the single-writer layer:

F08-2 — save_campaign saved UNCONDITIONALLY, so a pure read (check_consequences /
  check_companion_arc / check_faction_arcs / world_tick, and scene_context which
  delegates to check_companion_arc every beat) bumped updated_at and flipped the
  #640 "most-recently-updated == live" pointer onto the inspected campaign (the
  silent Rolan<->Liara character-switch class). Fix as filed: a dirty-skip
  chokepoint in save_campaign — serialize with the CURRENT (pre-stamp) updated_at,
  byte-compare to disk; identical => return without write/stamp. A genuine mutation
  still differs => still saves (a real write is never dropped). This fixes all 92
  call sites at the chokepoint, including world_tick (which legitimately mutates
  last_tick_day on its first per-day call, so it saves once then no-ops on repeat).

F08-3 — the tolerant load dropped unknown top-level keys then the next save
  destroyed them permanently. Now stash the ORIGINAL bytes write-once into
  campaigns/<id>/snapshot.pre-tolerant.json (fixed name + skip-if-exists: first-skew
  bytes are the valuable ones; degrade-not-abort on backup failure) and surface the
  dropped key names on the resume path (start_session / start_world resume return a
  schema_drift block) instead of only logging.

F08-4 — list_campaigns / campaigns_for_world / active_campaign_id used the STRICT
  parse only, so a tolerant-loadable campaign was invisible to listings and to the
  #640 resolver (which would silently pick the OTHER, strict-only campaign). They now
  share the same tolerant loader (_load_summary) — strict first, tolerant retry on
  failure — while staying PURE reads (no backup write, no module-state mutation).

F08-5 — one torn session-log line (an OOM-killed half-write) raised out of read_log
  -> read_log_all -> recap_from_store -> start_session's recap, bricking resume until
  hand-repair. read_log now skips-and-warns the unparseable line; append_log
  newline-heals when the file's last byte isn't a newline so the poison can't grow by
  concatenation onto the torn fragment.

Invariants upheld: engine stays sole writer; pure reads do NOT save/bump updated_at
(the whole point); additive-by-default (old snapshots round-trip byte-identical
through the chokepoint; schema_drift is an additive optional output field; no schema
or wire-contract change); the pre-tolerant sibling file is inert to every glob.

Red->green tests (servers/engine/tests/test_store_persistence_robustness.py, 24):
a pure check_*/world_tick-repeat does NOT change updated_at and does NOT flip the
live pointer; a firing check_consequences / first world_tick / any real mutation
DOES still save; tolerant load writes the backup once and surfaces dropped keys;
enumerators see the tolerant campaign while staying read-only; torn log lines are
skipped not fatal; append newline-heals.

Closes #804
Refs #640, #165, #741
Findings: F08-2, F08-3, F08-4, F08-5
Source: docs/audits/ENGINE-AUDIT-2026-06-11.md
@100yenadmin 100yenadmin added this to the v1.0.5 milestone Jun 14, 2026
@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@100yenadmin, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 20 minutes and 51 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cac66b5e-f060-4b57-9344-6fd2446accc9

📥 Commits

Reviewing files that changed from the base of the PR and between 6b09160 and 8771f92.

📒 Files selected for processing (3)
  • servers/engine/server.py
  • servers/engine/store.py
  • servers/engine/tests/test_store_persistence_robustness.py

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8771f9260e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread servers/engine/store.py
# is NEVER dropped). Note (F08-3 composition): after a TOLERANT load the in-memory dump differs
# from the on-disk bytes (dropped keys are gone), so the candidate != disk and we DO write —
# which is exactly why F08-3 stashes the original bytes independently BEFORE the drop.
candidate = campaign.model_dump_json(indent=2)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare snapshots before changing engine_sha

When a campaign was last written by a previous engine SHA, save_campaign overwrites campaign.engine_sha before building this byte-identity candidate. A no-op load/save then differs only by the SHA, so the new dirty-skip path still rewrites the inspected campaign, bumps updated_at, and can flip active_campaign_id after an engine update—the same pure-read pointer-stealing class this change is trying to remove.

Useful? React with 👍 / 👎.

@100yenadmin
100yenadmin merged commit 7fcb760 into main Jun 14, 2026
16 checks passed
@100yenadmin
100yenadmin deleted the fix/f08-persistence-robustness branch June 14, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[engine-audit][P2-cluster] Persistence robustness

1 participant