Skip to content

web(drafts): keep the losing writer's text after a stale save - #67

Open
TusanHomichi wants to merge 1 commit into
mainfrom
codex/34-draft-refused-text
Open

TusanHomichi wants to merge 1 commit into
mainfrom
codex/34-draft-refused-text

Conversation

@TusanHomichi

Copy link
Copy Markdown
Member

Primary Issue

Closes #34

Problem And Outcome

A refused save (stale_save) reloaded the winning copy and took the losing writer's own text with it, turning an honest concurrency refusal into lost work. After this change the loser's text — what the refused save carried, plus anything typed while that request or the winner's reload was still in flight — stays on the page, read-only and copyable, until they discard it or navigate away. Nothing is persisted, merged, resubmitted, or sent anywhere.

Changes

  • web/src/lib/drafts/editor.svelte.ts — one owner for the draft working copy, the revision every save carries, the debounced autosave chain, the metadata refresh, and the refused-save recovery buffers (the refactor(web): split large workflow pages and domain API contracts #59 ownership boundary, for the draft editor only).
  • web/src/lib/drafts/RefusedTextPanel.svelte — one read-only, copyable block per refused save, newest first, labelled per field, distinct from the saved working copy.
  • web/src/routes/drafts/[id]/+page.svelte — reads the controller; reloads the winner on a refusal; keeps the recovery panel outside the block a failed reload empties; load() returns the working copy adopt replaced and refuses to publish an answer for a draft the route has left.
  • web/e2e/draft-recovery.spec.ts — seven two-context browser scenarios synchronized on held HTTP requests rather than sleeps.
  • docs/development.md — the /drafts/[id] row and the web/src/lib/drafts/ ownership.

Workflow acts wait while the writer's own text is unresolved — a failed save, or a refusal they have not dealt with — and say why; the guard lifts when they copy the text in and save, or discard it.

Scope

  • In scope: the losing writer's recovery experience on the draft page; component memory only, web-only.
  • Out of scope: any service, API, or schema change; persistence (no storage, URL, log, or server call); merging or resubmitting refused text on the writer's behalf; the rest of refactor(web): split large workflow pages and domain API contracts #59, which stays open.

Verification

  • Listed the exact verification commands run below
  • Added or updated tests when behavior changed
  • Added or updated an ADR when a durable decision changed — no durable decision changed (component state only)
  • All fixtures and examples are invented; no real agency data
- web/: npm ci, npm run check (0 errors, 0 warnings), npm run build
- cargo fmt --check
- cargo clippy --workspace --all-targets -- -D warnings
- cargo test --workspace — 26 test binaries, 0 failures
- cargo build -p consolebook-server (the binary the browser tests run)
- web/: npm run e2e (system Chromium at /usr/bin/google-chrome; CI uses Playwright's bundled chromium)
  → 16 passed, including the 7 draft-recovery scenarios

Each behavioural fix was falsified against the behaviour it replaces before it was kept: the "typed while the winner was reloading" scenario shows the older sentence without the fix, the two-refusal scenario finds one panel instead of two with a single-slot buffer, and the workflow-act scenario lets the second act reach the server when the guard is consumed by the first.

Review And Merge Notes

  • Review focus: the recovery-buffer lifetime (controller memory, dropped with the draft identity) and the workflow-act guard, which deliberately survives one refused act rather than being cleared by it.
  • User or operator impact: the loser of a save race no longer loses their typing; refusals still never write, merge, or resubmit anything.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-12T01:32:25.992110Z afed873 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 0c955ce220

ℹ️ 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 on lines +334 to +338
destroy(): void {
this.#destroyed = true;
if (this.#timer !== null) {
clearTimeout(this.#timer);
this.#timer = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Flush pending edits before destroying the controller

When a user navigates away within the 600 ms debounce window, destroy() clears the pending timer and marks the controller destroyed, so the queued autosave never reaches the server and the edit is silently lost despite the UI having shown “Saving…”. Flush the pending snapshot during navigation or warn/block navigation rather than canceling the only save attempt.

Useful? React with 👍 / 👎.

Comment on lines +275 to 279
function received(result: SaveResult): void {
if (result.status === 'stale') {
void recoverFromRefusal(result);
return;
}

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 Clear the refusal guard after a successful recovery save

When the writer follows the displayed recovery instructions by copying the refused text into the reloaded fields and waiting for autosave to succeed, the saved result is ignored and refusalUnresolved remains true. Submit and finalize therefore remain blocked until the user additionally presses the destructive-sounding discard button, contrary to the offered “copy ... and save, or discard” alternatives; successful saves that incorporate the recovered content need to resolve the guard.

Useful? React with 👍 / 👎.

Comment on lines +203 to +207
return () => {
editor.destroy();
// A different draft must not inherit this one's working copy or
// its refused text.
editor = new DraftEditorController(

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 Reset the refusal guard when the draft identity changes

When SvelteKit reuses this [id] route component for another draft after a refusal, the cleanup replaces the editor and removes its recovery panel but leaves the page-level refusalUnresolved flag set. Submit or finalize on the new draft is then blocked with a claim that refused text is still present, while there is no panel or discard action capable of clearing the flag.

Useful? React with 👍 / 👎.

Comment thread web/src/routes/drafts/[id]/+page.svelte Outdated
Comment on lines +303 to +308
const buffer = divergentBuffer(
refused,
replaced ?? editor.snapshot(),
reloaded ? view : null
);
editor.keepRefused(buffer);

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 Discard late recovery results after leaving the draft

If the route changes to another draft while the refusal-triggered load() is pending, that load correctly returns null, but this continuation then uses the newly installed editor and calls keepRefused on it. Consequently the old draft's narrative can appear in the new draft's recovery panel and set its workflow guard; capture the originating controller/draft and stop the recovery continuation when either changes.

Useful? React with 👍 / 👎.

@TusanHomichi TusanHomichi left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Merge review at 0c955ce: hold this PR. I inspected the controller/route lifecycle and agree with the four existing, unresolved Codex findings; this summary does not create duplicate findings. pr-gate run 34661983530 is green, but it does not cover these boundaries.

Required repairs and regression evidence:

  1. DraftEditorController::destroy() cancels the only pending autosave inside the 600 ms debounce window. Preserve/flush legitimate working-copy edits against their original draft identity before allowing navigation, or explicitly warn/block navigation. Do not mistake the requirement to discard refused recovery buffers on navigation for permission to silently discard unsaved ordinary edits. Add a real navigation-before-debounce regression.
  2. received() ignores successful saves, so following the offered copy-back-and-save recovery path leaves refusalUnresolved set. Make successful resolution clear the matching guard without requiring an additional discard action, and without allowing unrelated/failed saves or a first attempted workflow action to clear other unresolved text. Test copy-back -> successful save -> submit/finalize without discard.
  3. The identity-change cleanup replaces the controller but leaves the page-level refusal guard set. Keep all refusal/guard state in the same draft lifetime, and prove that client-side navigation to another [id] does not leave it blocked with no recovery panel capable of resolving it.
  4. recoverFromRefusal() resumes after await load() using the current editor, even when load() returned null because the route changed. Capture the originating controller and generation/draft identity and invalidate the complete continuation before it updates buffers, errors, or guards. Distinguish obsolete navigation results from actual reload failures. Hold the refusal-triggered GET, navigate to another draft using SPA navigation (not a full page reload), then release it; assert no old content, error, or guard appears in the destination. A different-form destination and an A -> B -> A navigation are useful checks against field-ID coincidences and identity-only guards.

Keep the existing recovery text memory-only and read-only; no automatic merging/resubmission. Retain the existing failed-reload, late-typing, and repeated-refusal coverage. Revalidate against current main, which now includes #68, and rerun the full repository/browser gates. #59 remains open.

Verification limitation: this review used source inspection and GitHub CI/review evidence; no independent local Rust/browser run was performed.

A refused save reloaded the winning copy and took the writer's own text
with it, turning an honest concurrency refusal into lost work (#34).

The draft working copy, its autosave chain, and the recovery buffers now
have one owner: `web/src/lib/drafts/`, which the route page reads instead
of holding a second copy of every editable value (#59, partial). The
refusal keeps every save the contract refused, newest first, with
anything typed while the request was in flight, shown read-only,
copyable, and field-labelled. Nothing is merged, resubmitted, persisted,
or sent anywhere, and the buffers die with the draft identity.

Recovery state and its guard share the controller's lifetime, which is
what the review's four lifecycle findings needed:

- a queued ordinary edit is saved when the route goes away instead of
  being cancelled with its debounce timer; refused recovery text is not
  resubmitted, and a refusal the page has not answered yet is skipped;
- the workflow-act guard is per refusal, and a save the writer makes
  after seeing a refusal resolves exactly the buffers that save carries —
  copying the text back and saving unlocks submission and finalization
  without an extra discard, while another refusal's uncopied text keeps
  holding the act;
- the guard lives with the buffers in the controller, so a draft identity
  change takes it away instead of leaving a page blocked with nothing to
  dismiss;
- the recovery continuation captures the controller and the route it
  started on, so a refusal whose reload lands after the page moved on
  touches no buffer, no error, and no guard of the draft now on screen.

Browser proof in `web/e2e/draft-recovery.spec.ts` now runs eleven
scenarios over two real writer contexts, synchronized on held requests:
the loser copies their sentence; text typed during a pending request and
text typed while the winner was reloading both stay recoverable and never
reach the server; a failed reload keeps the buffer; a second refusal adds
to the first; a workflow act makes no request until the writer
acknowledges, and then does; copy-back and save lifts the guard without a
discard; an edit made inside the debounce window survives navigation; a
refusal dies with its draft while another draft's acts work; and a
refusal that lands after the page moved on changes nothing. Each fix was
falsified against the behaviour it replaces before it was kept.
@TusanHomichi
TusanHomichi force-pushed the codex/34-draft-refused-text branch from 0c955ce to afed873 Compare September 12, 2026 01:28

@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: afed87374b

ℹ️ 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 on lines +585 to +588
if (this.#stale(run)) {
return;
}
this.revision = saved.revision;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the in-flight revision before the teardown retry

When navigation occurs after edit B is made while save A is in flight, destroy() sees the dirty edit and starts #saveOnTeardown, but first marks the controller destroyed. This check then discards A's successful response before this.revision is updated, so the teardown save sends B using A's now-stale revision; the server refuses it and the teardown catch suppresses that result, silently losing B. The new teardown path needs to retain A's returned revision, or otherwise serialize the final save without treating A's response as obsolete.

Useful? React with 👍 / 👎.

Comment on lines +735 to +736
if (text === '' || text === (winning_narratives.get(id) ?? '')) {
continue;

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 Preserve refused narrative deletions

When the winning copy still contains a narrative that the losing writer intentionally cleared, current is empty and this condition drops the edit before comparing it with the winner. If that was the only divergence, no recovery buffer is retained and unresolved becomes false, so the reload restores the old narrative and submission or finalization can proceed without the writer acknowledging that their deletion was refused. Represent an empty-but-divergent narrative explicitly rather than filtering every empty value.

Useful? React with 👍 / 👎.

@TusanHomichi

Copy link
Copy Markdown
Member Author

Repairs at afed873, rebased onto main (7936a4e, which contains #68)

All four lifecycle findings are fixed, and each fix has a regression that fails against the behaviour it replaces.

1. A queued ordinary edit survives navigation. destroy() now sends the edit the debounce had not yet sent, instead of cancelling it with the timer. It waits for an in-flight save first so it carries the revision the server reached, it never resubmits refused recovery text, and a refusal the page has not answered yet is skipped. Regression: type, navigate to Home inside the debounce window, then assert the server holds the text and exactly one PUT was made. Falsified by removing the teardown save: the test fails.

2. Copy-back plus a successful save resolves the matching refusal. The guard is per buffer now: a save the writer makes after seeing a refusal resolves exactly the buffers whose text that save carries (field by field, ratings included), and nothing else. Regressions: copy the refused text back, wait for Saved, then finalize without any discard (passes); and with two refusals, putting back only the newest one keeps the act held and shows the message, while putting back the older one as well lets it through. Falsified by (a) never resolving on success and (b) resolving every buffer on any success — both fail.

3. Refusal state lives with the draft. The buffers and the guard moved into DraftEditorController: unresolved is derived from that controller's own buffers, so it dies with the draft identity instead of surviving as page state. Regression: refuse on one draft, navigate to another draft (a different form, different field identities) and back, and assert no panel, no refused text, no error, and that each draft's own submit actually goes through. Falsified with guard state declared outside the controller's lifetime: the test fails.

One honesty note on this one: the component-reusing [id] → [id] transition the finding describes has no shipped link — every route into a draft passes through Home, My records, or an enrollment page — so the test drives the reachable client-side navigation and asserts the acts work on both drafts. The state is per controller, so the unreachable transition cannot leak it either; the helper for a direct hop is documented as unused until such a link exists.

4. A late recovery continuation changes nothing. recoverFromRefusal captures the controller, the draft, and the requested version before its await, and returns without touching buffers, error, or guard when any of them changed; an obsolete answer is distinguished from a failed reload, which still reports itself. Regression: hold the refusal-triggered GET, navigate to another draft, release the GET, and assert no old content, error, or guard appears there (and none when returning to the first draft). Falsified by removing the identity check: the test fails.

The existing coverage is retained (failed reload, late typing, repeated refusals, memory-only/read-only buffer, no automatic merge or resubmission), and the browser suite is 21 scenarios green locally. pr-gate run 34664913384 failed in retention::authority_and_policy_failures_leave_no_partial_state — a test this branch does not touch (inject: Database(... "trigger fail_retention_audit already exists")). It passes 12/12 locally, and the same head passed this gate before the rebase; the job has been re-run.

@TusanHomichi

Copy link
Copy Markdown
Member Author

pr-gate on this head: the failure is pre-existing, and filed as #70

The rerun failed at exactly the same place as the first attempt:

thread 'authority_and_policy_failures_leave_no_partial_state' panicked at crates/consolebook-server/tests/retention.rs:607:212:
inject: Database(SqliteError { code: 1, message: "trigger fail_retention_audit already exists" })

That is a test this branch does not touch: git diff --stat 7936a4e..afed873 is docs/development.md plus four web/ files — no Rust at all, so the retention test and the code it exercises are byte-identical to main. Locally on this head, cargo test --workspace is green twice over, the retention binary is 10/10 across six consecutive runs, and the test alone is 12/12. The same branch passed this gate before the rebase.

The failing statement is the test's own second CREATE TRIGGER, after a DROP TRIGGER that reported no error — a fixture-level ordering problem with pooled connections, not an assertion about product behaviour. I filed it as #70 with the exact evidence and three concrete fixes rather than folding a retention-test change into a web-only PR.

Everything this PR changes is verified locally at afed873: npm run check and npm run build clean, cargo fmt --check clean, cargo clippy --workspace --all-targets -- -D warnings clean, cargo test --workspace 27 binaries with 0 failures, and the browser suite 21/21 — including the four new lifecycle regressions described above.

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.

Draft workspace: keep the losing writer's text visible after a stale-save reload

1 participant