NEC-269: P2 · An explicit per-file lifecycle state machine for sync #308

Merged
nectenda-agent merged 19 commits from worktree-nec-269-an-explicit-per-file-lifecycle-state-mac into main 2026-10-20 09:43:02 +01:00
Collaborator

Task: NEC-269 — https://projectron.nerchure.com/tasks/280

Give every synced file, note or structured, one explicit lifecycle state (SAFE-A45). Each lifecycle event is legal only in certain states, and every change of state is logged. A refused event is logged and leaves the file on the disk path, where copies are kept: nothing is written over it, and no view is bound. A structured file's connect whose first write gives up (the file never appears, or the write throws) now enters a visible stalled state, retried on a back-off from 30 s to 5 min, instead of staying off the live path until some other write. NEC-310 rides along: a write whose backup copy could not be kept no longer writes over the file. It leaves the file alone and retries. NEC-317 rides along: a canvas hardness harness fix. Hardness runs, an hour each on mixed versions, on the branch: Markdown 346 rounds, Excalidraw 289, canvas 255. Every other failure on the way was reproduced on main or was the harness bug, and each is filed: NEC-312 to NEC-316, NEC-318, FLAKE-20. Full record in docs/changes/NEC-269-file-lifecycle/spec.md. Tested by hand in the user's vaults.

Spec: docs/changes/NEC-269-file-lifecycle/spec.md

Also fixes NEC-310: A failed backup is ignored and the file written over with no copy
Also fixes NEC-317: The canvas hardness run expects a revived card to keep its file's old path after a rename

Changelog

A canvas or drawing whose first save after connecting failed now goes live again once that save is retried, instead of staying on the slower disk path until something else changed.

Task: NEC-269 — https://projectron.nerchure.com/tasks/280 Give every synced file, note or structured, one explicit lifecycle state (SAFE-A45). Each lifecycle event is legal only in certain states, and every change of state is logged. A refused event is logged and leaves the file on the disk path, where copies are kept: nothing is written over it, and no view is bound. A structured file's connect whose first write gives up (the file never appears, or the write throws) now enters a visible stalled state, retried on a back-off from 30 s to 5 min, instead of staying off the live path until some other write. NEC-310 rides along: a write whose backup copy could not be kept no longer writes over the file. It leaves the file alone and retries. NEC-317 rides along: a canvas hardness harness fix. Hardness runs, an hour each on mixed versions, on the branch: Markdown 346 rounds, Excalidraw 289, canvas 255. Every other failure on the way was reproduced on main or was the harness bug, and each is filed: NEC-312 to NEC-316, NEC-318, FLAKE-20. Full record in docs/changes/NEC-269-file-lifecycle/spec.md. Tested by hand in the user's vaults. Spec: `docs/changes/NEC-269-file-lifecycle/spec.md` Also fixes NEC-310: A failed backup is ignored and the file written over with no copy Also fixes NEC-317: The canvas hardness run expects a revived card to keep its file's old path after a rename ## Changelog A canvas or drawing whose first save after connecting failed now goes live again once that save is retried, instead of staying on the slower disk path until something else changed.
A file's place in its life was spread over flags, and each ordering they did
not forbid was closed with one more guard. file-lifecycle.ts holds the place
as one value per file and a table per kind, notes and structured files: an
event the phase does not allow is refused and logged, and only a write
beginning or a bind can act on the file, so a refused event leaves it on the
disk path. Nothing uses it yet; the engines move onto it in the next commits.

SAFE-A45 states the rule. The tests enumerate every refused cell of both
tables and walk random orders of events.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Characterisation tests that pass on the code as it is, each mutation-checked:
a structured view refused while its connect's first write runs and bound
after; a structured file or a note torn down with a view or editor bound,
leaving no write timer armed; a connect overtaken by stop opening nothing; a
note bound before its first write, which runs once its last editor lets go;
and an owner a note never had unable to let it go.

The teardown cases count write timers left armed rather than writes made: a
write left armed today finds no document and stops, so the file alone cannot
show the ordering it depends on.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Each structured file's place in its life is now its lifecycle
(file-lifecycle.ts) rather than flags: whether the server has been heard
from, whether the connect's first write has settled the file, which views are
bound, whether it is handed off. A view binds only where the table has a bind
cell, the disk path or live; a write over the file begins only where it has a
write-begin cell; teardown lets go before telling the views, so the write each
view's unbind asks for is never armed rather than armed and then cleared.

Two orderings the flags allowed are now refused, both toward doing nothing:
a connect whose document name arrived after sync stopped no longer makes a
document on the stopped instance, and a write whose file was handed off to a
newer version while it waited is abandoned rather than made. Each has a test
that fails without it.

The whole plugin suite passes with no lifecycle refusal outside the table's
own tests (checked with a probe that records every refusal).

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
An open canvas or drawing binds live only once its connect's first write has
reconciled the file (SAFE-A30). Three ways that write could stop with nothing
to schedule another left the view on the disk path for good, polled every
second and a half, until another vault happened to change the file:

- the file never appearing in the vault, its retries spent;
- any throw reaching the write's catch-all;
- the document filled from the file after the first sync, which agreed and
  asked for no write.

The first two now stall the file's lifecycle: logged, never bound from, and
tried again after a wait that starts at 30 seconds and doubles to five
minutes, or at once when anything asks for a write. The retried write is the
whole first write, its backups and all. The fill now asks for the write that
reconciles it. Once reconciled, a write that gives up is the disk path's, as
before.

The fill's stall was suspected from the code and confirmed by its test failing
on the code before this. Each new path has a test that fails without it,
through StructuredSync and through the canvas and drawing managers' polling.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Each note's place in its life is now its lifecycle: whether an editor holds
it (the pane, and any canvas card showing it), whether its connect's first
write has settled the file, whether the server has been heard from. The three
flags are read from it and set only by its events; the owner map is its
owners. An editor still binds whatever the note's connect has reached, since
the reconcile before every bind is the note's reconciliation, and the first
write the note owes runs once the last editor lets go.

One ordering changes: a write asked for while the note is still loading — an
editor letting go of it before it has subscribed, as EditorBridge's sweep
after a sync restart does — now waits for the write its subscribe asks for.
It used to run before the note had read what its file last agreed on, and an
edit made while sync was stopped went to a backup and out of the note. The
guard is at the start of the write, not at write-begin: by then the write had
already decided the file and kept it aside. Its test fails on the code before
this, and still fails with only the lifecycle's refusal at write-begin.

Test state built whole takes its lifecycle from the flags it states; the four
suites that set a flag directly now fire the event, or restore a lifecycle
that has not reconciled.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
client-sync-engine.md gains a section on the lifecycle beside the one on the
record: its phases, what the table refuses and why a refusal is safe, and what
changed against the flags it replaced. SAFE-A45 names the fill and the note
still loading as well, with the tests for each.

sync-limitations.md closes "a bind refused until the next write", found by
NEC-213's review, as two fixed entries: the stalled first write of a
structured file, including the two further ways found while working this, and
a write asked for while a note was still loading. The open entry's other
cause, "a view that would not save", was not one, and says so.

The spec records what implementing it found that the plan did not say.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
The correctness review of the lifecycle change found, each confirmed:

- The first write's keep block took the file as settled before keeping it,
  as the flag it replaced was set. A backup, record or write that threw
  then found the file on the disk path with nothing to retry it, where a
  view showing the document could bind and save over a file nobody had
  kept. The file is now settled only once its copy is kept, and a first
  write that gives up after that is tried again on the same wait.
- A first write that gave up cancelled a write asked for while it failed,
  putting it off by up to five minutes. That write is now the retry.
- A structured file's write asked for while it was still loading ran before
  its record was read, the race already fixed for notes: an edit made here
  while sync was stopped went to a backup and out of the file. It now waits
  for the write the subscribe asks for. The note's guard also covers a note
  an editor holds while it loads.
- Two tests could not fail: teardown's now counts every write timer armed,
  and the refused bind's checks the document took nothing of the view.

Each new test fails without its part. SAFE-A45 now says what the code does:
a first write that gave up after settling the file is retried, not stalled.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Before replacing a file whose content the document might not hold, a write
keeps it in .nectenda-backups. backupLocalFile reports a copy it could not
make by returning false, never by throwing, and the write paths ignored that:
a vault joining with its own edit, whose first copy failed on a full disk,
had the edit replaced by the document's text and kept nowhere.

Now a write whose copy failed is not made. The file is left as it is, the
change waits in the document, and the write is tried again after 30 seconds,
doubling to five minutes, or sooner when a write is asked for. A structured
file's first write stalls meanwhile, so no view binds over a file nobody has
kept. The first writes that answered their connect's question before keeping
the file answer it only once the copy is made, so a retry asks again and keeps
again. Both engines; each check has a test that fails without it.

The copies made outside the write — before a note's editor binds, and on the
read-in paths — are NEC-311.

Task: NEC-269
Also-fixes: NEC-310
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
A second review of the copy checks found most had no test that failed
without them. Each site now has one, in both engines: a synced deletion
emptying a note, a first-ever sync's conflict, a merge whose copy failed, an
unreadable file, both sides moved before a first write, and a first write that
threw after keeping and settling the file. Each was mutation-checked.

It also found a drawing save holding another file's content written over when
its copy failed: the write waited on that copy without asking whether it was
made. A copy that failed is now forgotten, so the file is left as it is and
the save kept again when next met. backupLocalFile could throw from its check
for a free name; it now returns false for every failure, as its callers rely
on. The back-off counters reset when a write lands.

Task: NEC-269
Also-fixes: NEC-310
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
A file's lifecycle refuses an event only when an engine met an ordering it
did not expect: the engines ask before anything routine. A hardness run
passing said nothing about whether one happened, since a passing run keeps no
diagnostic log. The refusal now joins the lines each round fails on, so the
runs gating this card show there were none.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
The branch's Markdown run passed on the baseline seed. Its Excalidraw run
failed twice on the baseline seed, both for causes already on main: a tab
crash that main's own replay of the seed hit at the same round (FLAKE-20,
NEC-312), and a let-go for an image link refused by its 30-second window and
never retried, which leaves a file not naming an image (NEC-313, recorded
open in sync-limitations). The lone run's logs held no lifecycle refusal; the
crashed run's could not be read.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
The branch's canvas run made a conflict copy no edit caused: two vaults
rewrote 36 order keys at once (NEC-314; the order code, which this change
does not touch). Its Excalidraw run found drawing edits saved with sync
stopped written over, with no copy, by the next connect's first write.
Main fails that seed the same way at the same round, so it is recorded open
in sync-limitations and filed as NEC-315, in Ready at P1. None of the eight
diagnostic logs held a lifecycle refusal or stall.

Task: NEC-269
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Task: NEC-269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Task: NEC-269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
The canvas hardness run left a card a delete-vs-move conflict had brought
back out of a rename later in the same round, and failed seed 1983370379
at round 138 on a path both vaults had rightly followed.

Task: NEC-269
Also-fixes: NEC-317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Task: NEC-269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Task: NEC-269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Record the drawing a live view never drew, found testing by hand
Some checks failed
CI / promote (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
Release note / release-note (pull_request) Successful in 20s
5a421919e4
Task: NEC-269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Add the release notes for the lifecycle and the kept copies
All checks were successful
Release note / release-note (pull_request) Successful in 17s
CI / build (pull_request) Successful in 5m45s
CI / e2e (pull_request) Successful in 12m30s
CI / promote (pull_request) Has been skipped
CI / build (push) Successful in 6m7s
CI / e2e (push) Successful in 12m22s
CI / promote (push) Successful in 32s
da40868840
Task: NEC-269
Also-fixes: NEC-310

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LjXuuGdrMgBR89bMz8oTJ
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Nectenda/nectenda!308
No description provided.