Tasks write actions for the UI port: move, reorder, rename, version, bulk move, delete, duplicate (on the executor) #894

Open
kayg wants to merge 4 commits from feat/tasks-actions into dev
Owner

Rework after review 76844 (recommendation c)

This rework follows the independent review 76844 (2 P1, 6 P2, 4 P3) and the plan in #808: every task note write goes through the one exact-byte executor that is already on dev (crates/notes/src/write_executor/). Work is in two steps.

Step 1a: shipped (gates and perf in the comments)

action what it writes undo
version on tasks.list / tasks.get rows nothing –
tasks.move ONE executor transaction: status, completed, scheduled and the owning checkbox lines on exact bytes, then the rank tasks.undo {job} (recorded inverse bytes), then tasks.reorder {restore}
tasks.move-many ONE executor transaction for every task that plans; a stale or unplannable task is skipped before anything is written tasks.undo {job}
tasks.undo (new) submits the recorded inverse splices of your own committed move as a new transaction; refuses whole if any note it touched or read changed since – (a repeat replays its receipt)
tasks.reorder one row in order.db, a whole group the first time tasks.reorder {restore}
tasks.update { title } notes.rename_task job, title sent alone notes.undo {job}
tasks.delete (1b) the task note to the Trash as ONE exact executor transaction, lines stay notes.restore {id}
tasks.duplicate (1b) a new note only: todo, fresh block ids; refused with subtasks tasks.delete of the copy
finding fix test
P1-1, P1-2, P2-4 delete.rs, duplicate.rs, lines.rs deleted, actions removed from the contract – (rebuilt in 1b)
P2-1 status+When not atomic, replay can't finish status::plan_targets plans both as one splice set; status_action::execute submits one plan; a retry replays the recorded plan review_76844_p2_1_status_and_when_are_one_transaction
P2-2 undo re-renders, not exact tasks.undo = recorded inverse bytes; moves accept only someday/todo review_76844_p2_2_undo_restores_exact_bytes, undo_refuses_after_a_later_edit_and_writes_nothing
P2-3 When-only edit unguarded goes through the same executor path (version + full pre-image) review_76844_p2_3_date_only_move_refuses_a_changed_note
P2-5 rank/group unchecked, between can loop ranks: 1–256 of the 62 digits, never ending in 0; between bounded and returns None; forward drags check the task, its neighbours and order against the task index group (new optional today/group_by/group_tag) review_76844_p2_5_bad_ranks_are_refused_and_between_always_ends, review_76844_p2_5_reorder_refuses_bad_ranks_and_other_groups, order_and_restore_name_each_task_once
P2-6 mixed line endings a note mixing LF and CRLF that would change is refused (its room keeps one ending, so the executor can't place it byte-exact); nothing is written review_76844_p2_6_mixed_line_endings_refuse_and_keep_bytes
P3-1 rename by status: key Notes::rename_task only for ids the tasks index lists; other notes keep the H1 rename review_76844_p3_rename_is_for_tasks_only_and_alone
P3-2 title + keys as two steps title must be sent alone same test
P3-3, P3-4 went away with delete/duplicate –

Decisions

  • Hand-typed unquoted dates (scheduled: 2026-10-20) are opaque leaves for the strict writer, which won't replace them. That move refuses with "edit in source mode" and keeps every byte (a_hand_typed_unquoted_date_refuses_and_keeps_bytes). Dates Calternal writes are quoted and move fine. Widening the strict grammar is a Calendar/Tasks decision.
  • The strict writer JSON-quotes the keys it changes ("status": "todo"). This is existing B1 behavior on dev; undo restores the old spelling exactly.
  • Moves no longer stamp last edited. tz is still accepted. This matches tasks.set-status.
  • Rank and file stay two steps. The executor still refuses rank participants (#808), so a move's rank is written after its file transaction, and its undo is a second step.

Step 1b: shipped (interim delete + duplicate; gates 76937, perf 76971)

Delete = Trash the task note as an executor participant; leave its lines. The offline executor already places a removal as a journaled, recoverable rename that keeps the displaced inode (recovery.rs::finish_inner). The Home layer refuses post: None today, so 1b adds a Trash participant there:

  • room/executor.rs: imaging a post: None note checks the pre-image and binding as today. The image is marked trash (serde default, so old records still load).
  • Payload::validate: a Trash image has no update, and its state equals the pre-state.
  • Publication (after COMMIT, replayed by recovery until published):
    • Trash/<id>.md is a hard link to the retained inode (exact bytes, no copy), plus <id>.json. Both steps are idempotent.
    • links::remember runs for the departed path.
    • The room closes, as after Cmd::Trash.
    • The index row is forgotten, and deleted goes into the same record_once batch.
    • History is kept.
  • Undo is notes.restore {id}, the existing Trash restore job: it brings back the same id and the exact bytes. A strict executor inverse needs Home creation, which is step 2.
  • Owning lines stay and read as "in Trash" (ADR 0066).

Sizes: one participant, O(note bytes) to image; placement is O(1) renames.

failure effect detection recovery test
task changed after the client read refused before PREPARE, nothing written version + ExpectedRoom + full pre-image refetch stale-edit delete test
typing during placement can't land room lease – lease test
outside edit races placement rolled back, displaced bytes kept executor displaced-bytes check recovery rollback crash/race test
crash before COMMIT file back from evidence journal Prepared rollback crash-point matrix
crash after COMMIT, before publication Trash entry missing for a while; note stays blocked published = false recovery publishes (idempotent) crash-point matrix
Trash slot occupied by other bytes publication pending, note blocked, reported existing occupied check manual occupied-slot test

Duplicate = new note only. Nothing existing is written, so it needs no executor participant:

  • read the source note (version-checked);
  • refuse if the shared parsed view finds an owning task link in the body;
  • re-key every block id across the whole body;
  • set status: todo and drop completed;
  • create it through tasks.create's path, with an id derived from the key.

Undo is notes.undo of that create job.

Step 2: full version (after 1b)

  • Delete also removes clean owning lines, with #799's parsed extent (the blank-line + indented-paragraph case) and #798's owner classification, as exact splices in the same executor transaction.
  • Duplicate gets #798's block-ID re-keying, so no subtask ever has two owners.
  • Tests: every reviewer probe from #798 and #799 carried over, plus a test per P1/P2 scenario of 76844. Then #798 and #799 get a "superseded" comment.

Gates

Exact commands and results are posted as comments, never committed:

  • cargo fmt --all -- --check
  • cargo clippy -p calternal-tasks -p calternal-notes -p calternal-engine -p calternal --all-targets -- -D warnings
  • cargo test -p calternal-tasks, -p calternal-engine, -p calternal-notes
  • cargo test -p calternal contract and node apps/web/scripts/action-types.mjs --check
  • perf: tasks-write-bench on a perf VM under perf-lock (gate: tasks.reorder_ack p95 ≤ 15 ms)

Never merge.

🤖 Generated with Claude Code

## Rework after review 76844 (recommendation c) This rework follows the independent review [76844](https://git.kayg.org/kayg/calternal/pulls/894#issuecomment-76844) (2 P1, 6 P2, 4 P3) and the plan in #808: every task note write goes through the one exact-byte executor that is already on dev (`crates/notes/src/write_executor/`). Work is in two steps. ### Step 1a: shipped (gates and perf in the comments) | action | what it writes | undo | |---|---|---| | `version` on `tasks.list` / `tasks.get` rows | nothing | – | | `tasks.move` | ONE executor transaction: `status`, `completed`, `scheduled` and the owning checkbox lines on exact bytes, then the rank | `tasks.undo {job}` (recorded inverse bytes), then `tasks.reorder {restore}` | | `tasks.move-many` | ONE executor transaction for every task that plans; a stale or unplannable task is `skipped` before anything is written | `tasks.undo {job}` | | `tasks.undo` (new) | submits the recorded inverse splices of your own committed move as a new transaction; refuses whole if any note it touched or read changed since | – (a repeat replays its receipt) | | `tasks.reorder` | one row in `order.db`, a whole group the first time | `tasks.reorder {restore}` | | `tasks.update { title }` | `notes.rename_task` job, title sent alone | `notes.undo {job}` | | `tasks.delete` (1b) | the task note to the Trash as ONE exact executor transaction, lines stay | `notes.restore {id}` | | `tasks.duplicate` (1b) | a new note only: todo, fresh block ids; refused with subtasks | `tasks.delete` of the copy | | finding | fix | test | |---|---|---| | P1-1, P1-2, P2-4 | `delete.rs`, `duplicate.rs`, `lines.rs` deleted, actions removed from the contract | – (rebuilt in 1b) | | P2-1 status+When not atomic, replay can't finish | `status::plan_targets` plans both as one splice set; `status_action::execute` submits one plan; a retry replays the recorded plan | `review_76844_p2_1_status_and_when_are_one_transaction` | | P2-2 undo re-renders, not exact | `tasks.undo` = recorded inverse bytes; moves accept only `someday`/`todo` | `review_76844_p2_2_undo_restores_exact_bytes`, `undo_refuses_after_a_later_edit_and_writes_nothing` | | P2-3 When-only edit unguarded | goes through the same executor path (version + full pre-image) | `review_76844_p2_3_date_only_move_refuses_a_changed_note` | | P2-5 rank/group unchecked, `between` can loop | ranks: 1–256 of the 62 digits, never ending in `0`; `between` bounded and returns `None`; forward drags check the task, its neighbours and `order` against the task index group (new optional `today`/`group_by`/`group_tag`) | `review_76844_p2_5_bad_ranks_are_refused_and_between_always_ends`, `review_76844_p2_5_reorder_refuses_bad_ranks_and_other_groups`, `order_and_restore_name_each_task_once` | | P2-6 mixed line endings | a note mixing LF and CRLF that would change is refused (its room keeps one ending, so the executor can't place it byte-exact); nothing is written | `review_76844_p2_6_mixed_line_endings_refuse_and_keep_bytes` | | P3-1 rename by `status:` key | `Notes::rename_task` only for ids the tasks index lists; other notes keep the H1 rename | `review_76844_p3_rename_is_for_tasks_only_and_alone` | | P3-2 title + keys as two steps | `title` must be sent alone | same test | | P3-3, P3-4 | went away with delete/duplicate | – | **Decisions** - **Hand-typed unquoted dates** (`scheduled: 2026-10-20`) are opaque leaves for the strict writer, which won't replace them. That move refuses with "edit in source mode" and keeps every byte (`a_hand_typed_unquoted_date_refuses_and_keeps_bytes`). Dates Calternal writes are quoted and move fine. Widening the strict grammar is a Calendar/Tasks decision. - **The strict writer JSON-quotes the keys it changes** (`"status": "todo"`). This is existing B1 behavior on dev; undo restores the old spelling exactly. - **Moves no longer stamp `last edited`.** `tz` is still accepted. This matches `tasks.set-status`. - **Rank and file stay two steps.** The executor still refuses rank participants (#808), so a move's rank is written after its file transaction, and its undo is a second step. ### Step 1b: shipped (interim delete + duplicate; gates 76937, perf 76971) **Delete = Trash the task note as an executor participant; leave its lines.** The offline executor already places a removal as a journaled, recoverable rename that keeps the displaced inode (`recovery.rs::finish_inner`). The Home layer refuses `post: None` today, so 1b adds a Trash participant there: - `room/executor.rs`: imaging a `post: None` note checks the pre-image and binding as today. The image is marked `trash` (serde default, so old records still load). - `Payload::validate`: a Trash image has no update, and its state equals the pre-state. - Publication (after COMMIT, replayed by recovery until `published`): - `Trash/<id>.md` is a hard link to the retained inode (exact bytes, no copy), plus `<id>.json`. Both steps are idempotent. - `links::remember` runs for the departed path. - The room closes, as after `Cmd::Trash`. - The index row is forgotten, and `deleted` goes into the same `record_once` batch. - History is kept. - Undo is `notes.restore {id}`, the existing Trash restore job: it brings back the same id and the exact bytes. A strict executor inverse needs Home creation, which is step 2. - Owning lines stay and read as "in Trash" (ADR 0066). Sizes: one participant, O(note bytes) to image; placement is O(1) renames. | failure | effect | detection | recovery | test | |---|---|---|---|---| | task changed after the client read | refused before PREPARE, nothing written | version + `ExpectedRoom` + full pre-image | refetch | stale-edit delete test | | typing during placement | can't land | room lease | – | lease test | | outside edit races placement | rolled back, displaced bytes kept | executor displaced-bytes check | recovery rollback | crash/race test | | crash before COMMIT | file back from evidence | journal `Prepared` | rollback | crash-point matrix | | crash after COMMIT, before publication | Trash entry missing for a while; note stays blocked | `published = false` | recovery publishes (idempotent) | crash-point matrix | | Trash slot occupied by other bytes | publication pending, note blocked, reported | existing occupied check | manual | occupied-slot test | **Duplicate = new note only.** Nothing existing is written, so it needs no executor participant: - read the source note (version-checked); - refuse if the shared parsed view finds an owning task link in the body; - re-key every block id across the whole body; - set `status: todo` and drop `completed`; - create it through `tasks.create`'s path, with an id derived from the key. Undo is `notes.undo` of that create job. ### Step 2: full version (after 1b) - **Delete** also removes clean owning lines, with #799's parsed extent (the blank-line + indented-paragraph case) and #798's owner classification, as exact splices in the same executor transaction. - **Duplicate** gets #798's block-ID re-keying, so no subtask ever has two owners. - **Tests:** every reviewer probe from #798 and #799 carried over, plus a test per P1/P2 scenario of 76844. Then #798 and #799 get a "superseded" comment. ### Gates Exact commands and results are posted as comments, never committed: - `cargo fmt --all -- --check` - `cargo clippy -p calternal-tasks -p calternal-notes -p calternal-engine -p calternal --all-targets -- -D warnings` - `cargo test -p calternal-tasks`, `-p calternal-engine`, `-p calternal-notes` - `cargo test -p calternal contract` and `node apps/web/scripts/action-types.mjs --check` - perf: `tasks-write-bench` on a perf VM under `perf-lock` (gate: `tasks.reorder_ack` p95 ≤ 15 ms) Never merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tasks: action tests
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled
fcd89a7f02
Author
Owner

Gates at 3928a30e (dev-01, Nix shell, through ~/bin/slot)

gate command result
fmt cargo fmt --all --check pass
clippy cargo clippy --workspace --all-targets -j4 -- -D warnings pass (0 errors; first run found 5, fixed)
tasks tests cargo test -j4 -p calternal-tasks 89 passed, 0 failed (14 new in actions_tests + reorder/tests)
notes / calternal / engine cargo test -j4 -p calternal-tasks -p calternal-notes -p calternal -p calternal-engine (run at the previous head 54a92182, only clippy-only edits since) notes 547 passed, tasks 89, rest ok; 0 failed
contract cargo test -j4 -p calternal contract_matches_the_server pass (contracts/actions.json regenerated: +6 actions, tasks.update gains title)
web types node apps/web/scripts/action-types.mjs --check pass (actions.gen.ts regenerated, committed)

New tests cover: row version equals notes.get's and a tick with it works with no notes.get; rename (title, heading, file, links, undo, no H1 added, bad titles); reorder (one row, no file written, replay, group without ranks, stale undo refused); move (stale refused with bytes unchanged, only the date line changes, clear, undo, Someday ↔ day, placement + undo); move-many (per-item results, a stale item skipped, undo); delete (exact bytes, Trash, replay, undo byte for byte, odd lines left, last line without newline); duplicate (copy bytes, history cleared, line inserted, replay, undo, task with no line).

Findings while testing (fixed in the PR): the status executor refuses when the room has unflushed bytes, so tasks.move saves the room first; last edited stamps on flush change the version, so the version handed back for Undo is read after the flush.

## Gates at `3928a30e` (dev-01, Nix shell, through `~/bin/slot`) | gate | command | result | |---|---|---| | fmt | `cargo fmt --all --check` | pass | | clippy | `cargo clippy --workspace --all-targets -j4 -- -D warnings` | pass (0 errors; first run found 5, fixed) | | tasks tests | `cargo test -j4 -p calternal-tasks` | 89 passed, 0 failed (14 new in `actions_tests` + `reorder/tests`) | | notes / calternal / engine | `cargo test -j4 -p calternal-tasks -p calternal-notes -p calternal -p calternal-engine` (run at the previous head `54a92182`, only clippy-only edits since) | notes 547 passed, tasks 89, rest ok; 0 failed | | contract | `cargo test -j4 -p calternal contract_matches_the_server` | pass (`contracts/actions.json` regenerated: +6 actions, `tasks.update` gains `title`) | | web types | `node apps/web/scripts/action-types.mjs --check` | pass (`actions.gen.ts` regenerated, committed) | New tests cover: row `version` equals `notes.get`'s and a tick with it works with no `notes.get`; rename (title, heading, file, links, undo, no H1 added, bad titles); reorder (one row, no file written, replay, group without ranks, stale undo refused); move (stale refused with bytes unchanged, only the date line changes, clear, undo, Someday ↔ day, placement + undo); move-many (per-item results, a stale item skipped, undo); delete (exact bytes, Trash, replay, undo byte for byte, odd lines left, last line without newline); duplicate (copy bytes, history cleared, line inserted, replay, undo, task with no line). Findings while testing (fixed in the PR): the status executor refuses when the room has unflushed bytes, so `tasks.move` saves the room first; `last edited` stamps on flush change the version, so the version handed back for Undo is read after the flush.
Author
Owner

Perf: tasks.list with version, 10k notes + 10k tasks (perf-01, under perf-lock)

tasks-bench --notes 10k --runs 3, release binaries built on dev-01 (cargo build --release --locked -p calternal-perf --bin tasks-bench), relayed through the control host, sha256 checked on both ends. base = origin/dev 41f2efa3 (51eb4a22…), branch = 3928a30e (ce183533…). Alternated base, branch, base, branch in one session, same fixture Home. 200 tasks.list calls per list per run; the lists go through the real engine, so they include the version lookup.

list (n=200 rows, p50 / p95 ms) base run 1 base run 2 branch run 1 branch run 2
anytime 1.80 / 2.05 1.82 / 2.02 2.10 / 2.39 2.10 / 2.69
today 2.38 / 2.68 2.23 / 2.48 2.55 / 2.88 2.57 / 2.90
upcoming 2.23 / 2.49 2.27 / 2.57 2.52 / 2.78 2.49 / 2.83
someday 1.75 / 1.92 1.86 / 2.29 2.11 / 2.62 2.07 / 2.47
tag 2.21 / 2.66 2.26 / 3.56 2.52 / 3.01 2.44 / 2.83
today_by_priority 2.17 / 2.83 2.26 / 2.80 2.39 / 2.63 2.37 / 2.69

Cost of version: about +0.2 to +0.35 ms at p50 (≈ +10–18 %) for a 200-row page (one updates_by_note lookup per row in one blocking call). Every list stays under 3.1 ms p95 here. GATE tasks.list, tasks.counts and tasks.describe: PASS in all four runs. These are the bench's own gates, so the task's "no regression beyond the gate" holds.

Not caused by this PR, same on base: GATE cold.index growth <= 10% FAILS in all four runs (base +19.8 % / +17.7 %, branch +18.9 % / +20.2 %: the tasks pass costs ~19 % of cold indexing, unchanged). GATE tasks.update FAILED once on base (run 2) and once on branch (run 2) and passed the other two; it is flaky on this VM, with no code path of this PR in it.

Not measured here: the new write actions (move, reorder, delete, duplicate) have no bench yet; ADR 0066 lists tasks.reorder_ack ≤ 15 ms p95 with no file written, which reorder_moves_one_row… asserts functionally (no file written) but does not time. Timing belongs on the perf VM in a follow-up.

Raw outputs are kept on perf-01 at ~/perf/ta-keep/; the seeded Home and the binaries were removed.

## Perf: `tasks.list` with `version`, 10k notes + 10k tasks (perf-01, under `perf-lock`) `tasks-bench --notes 10k --runs 3`, release binaries built on dev-01 (`cargo build --release --locked -p calternal-perf --bin tasks-bench`), relayed through the control host, sha256 checked on both ends. **base** = `origin/dev` `41f2efa3` (`51eb4a22…`), **branch** = `3928a30e` (`ce183533…`). Alternated base, branch, base, branch in one session, same fixture Home. 200 `tasks.list` calls per list per run; the lists go through the real engine, so they include the version lookup. | list (n=200 rows, p50 / p95 ms) | base run 1 | base run 2 | branch run 1 | branch run 2 | |---|---|---|---|---| | anytime | 1.80 / 2.05 | 1.82 / 2.02 | 2.10 / 2.39 | 2.10 / 2.69 | | today | 2.38 / 2.68 | 2.23 / 2.48 | 2.55 / 2.88 | 2.57 / 2.90 | | upcoming | 2.23 / 2.49 | 2.27 / 2.57 | 2.52 / 2.78 | 2.49 / 2.83 | | someday | 1.75 / 1.92 | 1.86 / 2.29 | 2.11 / 2.62 | 2.07 / 2.47 | | tag | 2.21 / 2.66 | 2.26 / 3.56 | 2.52 / 3.01 | 2.44 / 2.83 | | today_by_priority | 2.17 / 2.83 | 2.26 / 2.80 | 2.39 / 2.63 | 2.37 / 2.69 | Cost of `version`: about **+0.2 to +0.35 ms at p50** (≈ +10–18 %) for a 200-row page (one `updates_by_note` lookup per row in one blocking call). Every list stays under 3.1 ms p95 here. `GATE tasks.list`, `tasks.counts` and `tasks.describe`: PASS in all four runs. These are the bench's own gates, so the task's "no regression beyond the gate" holds. Not caused by this PR, same on base: `GATE cold.index growth <= 10%` FAILS in all four runs (base +19.8 % / +17.7 %, branch +18.9 % / +20.2 %: the tasks pass costs ~19 % of cold indexing, unchanged). `GATE tasks.update` FAILED once on base (run 2) and once on branch (run 2) and passed the other two; it is flaky on this VM, with no code path of this PR in it. Not measured here: the new write actions (`move`, `reorder`, `delete`, `duplicate`) have no bench yet; ADR 0066 lists `tasks.reorder_ack` ≤ 15 ms p95 with no file written, which `reorder_moves_one_row…` asserts functionally (no file written) but does not time. Timing belongs on the perf VM in a follow-up. Raw outputs are kept on perf-01 at `~/perf/ta-keep/`; the seeded Home and the binaries were removed.
Author
Owner

Independent review: #894 against the B1–B3 failure history (#798, #799, #801)

Verdict: (c) merge the best of both. Keep #894's API, version, reorder and status-only move. Drop its delete, duplicate and line-scanner code (delete.rs, duplicate.rs, lines.rs), and rebuild those on the shared Home executor, using the planner rules and probes from #798 and #799. Then close #798 and #799. 2 P1, 6 P2, 4 P3.

I reviewed head 3928a30e against origin/dev 41f2efa3, including every review comment on #798, #799 and #801. This is a code-reading review only. Nothing was built or run, because the control host is read-only and no VM probes were run, so the scenarios below are not reproduced. Items marked plausible depend on paths I read on dev but did not trace end to end.

Key context. #798, #799 and #801 were put on hold for the same reason: #808 (0066-tasks-writes-v2) says every task write must be a pure exact-splice planner run by ONE journaled executor. That executor and B1 v2 are already on dev (BM5: #809, #814, #812; crates/notes/src/write_executor/, docs/plans/tasks-v2-set-status.md). #894's status step uses that executor. Everything else in #894 (When edits, delete, duplicate, rename) goes through the old room and bulk path. That is the path the v2 decision replaced, and where most of the earlier failures came from.

1. The earlier failure cases, one by one

earlier failure (where it was found) #894 where / why
Exact-byte guard ends before placement: save ingests outside bytes and retries (#801 r1 P1, #799 r1 P1, #798 r3 P2) same delete and duplicate source edits use rename::edit_step → bulk Step::Edit → EditKind::Patch{base}. room/edit.rs:129–160 forks the note at base and merges; it does not refuse. The When edit in move is a room Props edit. Only the status part goes through the executor.
Insertion-only edit on a changed source still reports success (#798 r1 P1-3) same duplicate.rs:119–139 inserts at b.end with base = the snapshot version. If the source changed, the insert is merged in, with no check. See P1-1.
Trash step has no task-snapshot guard (#798 r2 P1-1) same delete.rs:54–60 checks the version, then reads up to 64 source notes and waits for the job lock. delete.rs:87 Step::Trash{note} carries no expected bytes.
Delete removes ordinary reference bullets (#798 r1 P1-1) not present lines::block removes only the exact checkbox + single link-child shape. Anything else is left in place and counted.
Block IDs on continuation lines copied (#798 r1 P1-2) worse the duplicate copies the whole body unchanged (duplicate.rs:112). Every block ID is copied, not just ones on continuation lines. See P1-2.
A child-line marker hides the owner, so a second owner appears (#798 r2 P1-2) same class, different route the copied body keeps every owning subtask line (- [ ] sub ^id + - [Sub](<…>)), so each subtask gets a second owner. See P1-2.
Undo restores a removal that never happened (#798 r3 P1) not present bulk/undo.rs::plan plans only steps that have a result. Move and reorder undo is built only from changes that were actually made.
Undo drops the expected bytes; no refusal on intervening change (#801 r1 P1-2, #798 r3 P2) same for delete, duplicate and rename notes.undo on a bulk job merges with later typing, and only the deleted-span guard runs. Move undo does refuse (it is version-checked) but is not exact. See P2-2.
Delete undo rewrites link spelling (#798 r1 P1-4) not present the inverse restores the captured text exactly as it was
Mixed LF/CRLF normalized, so undo isn't byte-exact (#799 r1 P2) same path (plausible) delete and duplicate source edits are saved by the room's save path, which re-encodes the whole file in the majority newline style
YAML guard bypass: interior quotes, comments, flow values (#798 r1 P2, #801 r1) same class When (move), title (rename.rs:203) and the duplicate's properties use the lenient notes::props writer. Only the index's broken flag refuses. B1 v2 uses the strict writer. To be fair, tasks.update on dev already writes scheduled this way.
Custom checkbox marks overwritten (#801 r1 P2) not present status goes through B1 v2, which refuses custom marks
Missed continuation IDs in move collision/link retargeting; last copy lost between files (#799 r1 P1-1/2) not applicable #894 moves only properties and never moves lines between notes. That is a real simplification.
Rank writes: stale revision, mismatched group (#799 design) partly restore has an expect check, but a forward reorder never checks that the task belongs to group, and rank values from the client are not checked. See P2-5.

2. Findings

P1-1: delete and duplicate write to changed notes instead of refusing

The PR's failure table says "a note changed under a delete / copy plan → job fails, nothing deleted". The code doesn't do that.

  • Source notes. delete.rs:68–86 and duplicate.rs:119–139 build Step::Edit{base: snapshot.version}. room/edit.rs:129–160 applies the Patch to a fork at base and merges it into the current text.
    • Scenario: Daily.md is at v10 with - [ ] Call Bob ^aaaa… and its link child. A delete is planned. Before its step runs, a phone appends before 5pm to that checkbox line (v11). The merged removal deletes the v10 characters and leaves before 5pm behind as a stray fragment on the next line. The job reports done.
    • The source notes carry no client version and no expected bytes at all.
  • Task note. Step::Trash has no snapshot guard. If the task's status or description is edited after the delete.rs:54 check, the note is still trashed and the action reports done. Same as #798 r2 P1-1.
  • Fix: don't patch the old path. Each source removal or insertion should become an exact splice with expected bytes, and the task note's Trash should be a guarded participant, all in one executor transaction (#808). That gives refusal, crash recovery and recorded-inverse undo in one place.

P1-2: duplicate copies every block identity and every owning subtask line

duplicate.rs:99–112 changes only six properties and keeps the body "exactly".

  • Scenario: a task body with - [ ] Draft outline ^aaaaaaaaaaaaaaaa, followed by the link child - [Outline](<Tasks/Outline.md>).
    • After duplicating, two notes carry ^aaaa…, so a #^aaaa… link has two targets.
    • Outline now has a second owning line, which breaks the one-owner rule (ADR 0068).
  • This is #798 r1 P1-2 and r2 P1-2 again, and broader.
  • Fix: re-key every block ID across the whole parsed body. Strip owning task links from the copy or convert them to plain checkboxes. Otherwise refuse before creating anything. #798's continuation-ID and relationship code, plus its probes review_r1_duplicate_multiline_block_id_is_fresh and review_r2_relationship_marker_retains_owner, are the starting point.

P2-1: a move with both a When change and a status change is not atomic, and a replay after a crash can't finish it

  • moves.rs:186–200 commits the status through the executor (key {key}:status). Then moves.rs:210–220 does a separate room Props edit.
  • If the process crashes between the two, a Someday task dropped on 11-05 ends up todo with no date, so it shows in Anytime.
  • The engine retries with the same key. one() now sees the status already matches, so change_status = None. It falls to moves.rs:202–208 with the old version and returns block_changed. The half-done move is reported as a failure, and no undo is returned.
  • move-many (moves.rs:313) has the same problem: items moved before the crash come back skipped, and their undo steps are lost.
  • A refused or failed Props edit after the status commit gives the same result without any crash.
  • Fix: plan status, scheduled and completed as one exact-splice set for the task note, and submit it as one executor transaction. tasks::status::plan already composes property splices with the strict writer; add the When leaf. A replay then loads the recorded plan.

P2-2: move's undo is not exact

  • moves.rs:231–237 undoes by moving forward again ("move back").
  • Move.status accepts all five statuses (moves.rs:54), even though the docs say someday/todo. Moving done → todo clears completed, and moving back to done stamps a new completed time.
  • scheduled is restored from the index value (now(), moves.rs:110–125), not from the raw bytes:
    • quoting or a date-time spelling is re-rendered;
    • a removed key comes back at the end of the frontmatter;
    • a stale index row would restore the wrong date (plausible).
  • The move-back step and the tasks.reorder{restore} step are separate actions with no all-or-nothing rule.
  • Fix: use a recorded inverse from the executor. Until Home undo is enabled, at least restrict status to someday/todo and store the raw scheduled and completed lines in the undo input.

P2-3: a When-only move checks the version, then edits without a guard

moves.rs:202–220 compares notes.get().version, then sends a Props edit with no base. An edit that lands between the two is overwritten at the key level, on the lenient writer. It needs the same fix as P2-1.

P2-4: lines::block gets the block's extent wrong

  • lines.rs:104–110 looks only at line+2.
  • Scenario: the link child is followed by a blank line, then a paragraph indented 4 spaces. That paragraph belongs to the link bullet in CommonMark. Delete removes the two lines and leaves the paragraph, which then becomes an indented code block at the top level.
  • This is the same "who owns this extent" class as #799 r1 P1-2. It is also a third, hand-written line scanner next to the shared parsed view (#805) that B1 v2 uses.
  • Fix: take the extent from the shared parsed view and refuse if anything is left over.

P2-5: tasks.reorder accepts any rank and group from the client

  • restore (reorder.rs:192–215) writes client-supplied rank strings with no charset or length check. Unknown bytes read as digit 0.
  • Once a rank ends in 0, between() (reorder.rs:246–268) never terminates.
    • Scenario: restore task X to rank "0" (with expect = its current rank), then reorder Y with before: X. between("", Some("0")) pushes '0' forever.
    • Memory grows without limit inside a BEGIN IMMEDIATE transaction on a blocking thread, and every other reorder for that Home waits.
  • The forward path never checks that the task is in group. order ids are not checked to be tasks in that group, so ranks are stored for arbitrary strings.
  • Fix:
    • accept ranks only from the 62-digit set, at most 256 bytes (as #799 did), not ending in 0;
    • bound the loop in between;
    • check the group with the task index (#799's projected-membership check);
    • require order to be exactly the group's current members.

P2-6: mixed line endings (plausible, same as #799 r1 P2)

Delete and duplicate source edits are saved through the room. A note with both LF and CRLF line endings comes back normalized, so untouched bytes change and undo can't restore them. Refuse such notes before writing, or let the executor's byte-exact placement handle it.

P3

  1. rename.rs:52 now switches to the task-title path for any note with a status: key (for example a project note with status: active). Such a note gains a title: key it never had. Use the task index instead (is this id an indexed task?).
  2. tasks.update with a title does the property edit and the rename as two steps (lib.rs update handler). If the rename fails, the first edit stays applied and no undo is returned.
  3. After a delete or duplicate whose job failed and was undone, a replay with the same key finds the job row (delete.rs:41–44) and returns Ok{state:"failed", undo:[…]} instead of the original block_changed.
  4. Duplicate undo runs the line removal first, then Delete{if_version}. If the copy was edited since, the delete refuses and the undo is left half done. That is safe, but it is reported as done.

3. Access, idempotency, big-O

  • Access is fine. Every path resolves through the caller's own index (update::target) and notes.enter(caller.user). A foreign id is not_found. Shared-note permissions are correctly listed as out of scope.
  • Idempotency. It is fine for complete replays (the engine stores results, and job and copy ids derive from the key). Partial replays fail as described in P2-1 and P3-3.
  • Big-O. The PR's numbers check out. version costs one MAX(seq) index lookup per row, and the perf comment shows about +0.3 ms at p50 for a 200-row page. Reorder is O(1) rows (O(G ≤ 2000) the first time). Delete and duplicate plan in O(source bytes), with at most 64 owners. The only unbounded cost is the between loop in P2-5.

4. Recommendation: (c), with the exact pieces

Keep from #894 (land once fixed):

  • version on rows: versions.rs, History::versions, and its wiring into tasks.list and tasks.get;
  • tasks.reorder and OrderDb::edit, with P2-5 fixed;
  • the tasks.move and tasks.move-many API and answer shapes;
  • status-only move through the executor;
  • the contract and type entries the UI port needs;
  • the title rename, with P3-1 and P3-2 fixed.

Rework in #894:

  • the When+status move becomes one executor transaction, which fixes P2-1 to P2-3;
  • move-many builds on it.

Drop from #894:

  • delete.rs, duplicate.rs and lines.rs, i.e. everything built on bulk Step::Edit/Trash.

Rebuild delete and duplicate as v2 planners on the executor (#808's B3), using:

  • from #798: owner and relationship classification (strip structural markers; remove only owning checkbox lines and relationship bullets), the full-extent block-ID re-key for duplicate, the YAML refusal corpus, and all review_r1_*, review_r2_* and review_r3_* probes as permanent regressions;
  • from #799: the pulldown-cmark subtree extent, full-extent marker enumeration, the rank length cap and the group-membership refusal, and its six review799_* probes.
    • #799's physical cross-note line move isn't needed by the UI port. Leave it until it can be built as a v2 planner.

Interim, if the UI port can't wait for B3 v2:

  • Delete = Trash the task note as a guarded executor participant and leave the owning lines. ADR 0066 already makes them read as "in Trash", and this removes all the line-editing risk.
  • Duplicate = new note only. Re-key every block ID and refuse if the body holds owning task links.

Then close #798 and #799. Keep their branches until the probes are ported. #801 is already superseded by #812.

No code pushed, nothing merged.

## Independent review: #894 against the B1–B3 failure history (#798, #799, #801) **Verdict: (c) merge the best of both.** Keep #894's API, `version`, reorder and status-only move. Drop its delete, duplicate and line-scanner code (`delete.rs`, `duplicate.rs`, `lines.rs`), and rebuild those on the shared Home executor, using the planner rules and probes from #798 and #799. Then close #798 and #799. **2 P1, 6 P2, 4 P3.** I reviewed head `3928a30e` against `origin/dev` `41f2efa3`, including every review comment on #798, #799 and #801. This is a code-reading review only. Nothing was built or run, because the control host is read-only and no VM probes were run, so the scenarios below are not reproduced. Items marked *plausible* depend on paths I read on dev but did not trace end to end. **Key context.** #798, #799 and #801 were put on hold for the same reason: [#808](https://git.kayg.org/kayg/calternal/pulls/808) (`0066-tasks-writes-v2`) says every task write must be a pure exact-splice planner run by ONE journaled executor. That executor and B1 v2 are already on dev (BM5: #809, #814, #812; `crates/notes/src/write_executor/`, `docs/plans/tasks-v2-set-status.md`). #894's status step uses that executor. Everything else in #894 (When edits, delete, duplicate, rename) goes through the old room and `bulk` path. That is the path the v2 decision replaced, and where most of the earlier failures came from. ### 1. The earlier failure cases, one by one | earlier failure (where it was found) | #894 | where / why | |---|---|---| | Exact-byte guard ends before placement: save ingests outside bytes and retries (#801 r1 P1, #799 r1 P1, #798 r3 P2) | **same** | delete and duplicate source edits use `rename::edit_step` → `bulk` `Step::Edit` → `EditKind::Patch{base}`. `room/edit.rs:129–160` forks the note at `base` and **merges**; it does not refuse. The When edit in move is a room `Props` edit. Only the status part goes through the executor. | | Insertion-only edit on a changed source still reports success (#798 r1 P1-3) | **same** | `duplicate.rs:119–139` inserts at `b.end` with base = the snapshot version. If the source changed, the insert is merged in, with no check. See P1-1. | | Trash step has no task-snapshot guard (#798 r2 P1-1) | **same** | `delete.rs:54–60` checks the version, then reads up to 64 source notes and waits for the job lock. `delete.rs:87` `Step::Trash{note}` carries no expected bytes. | | Delete removes ordinary reference bullets (#798 r1 P1-1) | not present | `lines::block` removes only the exact checkbox + single link-child shape. Anything else is left in place and counted. | | Block IDs on continuation lines copied (#798 r1 P1-2) | **worse** | the duplicate copies the whole body unchanged (`duplicate.rs:112`). **Every** block ID is copied, not just ones on continuation lines. See P1-2. | | A child-line marker hides the owner, so a second owner appears (#798 r2 P1-2) | **same class, different route** | the copied body keeps every owning subtask line (`- [ ] sub ^id` + `- [Sub](<…>)`), so each subtask gets a second owner. See P1-2. | | Undo restores a removal that never happened (#798 r3 P1) | not present | `bulk/undo.rs::plan` plans only steps that have a result. Move and reorder undo is built only from changes that were actually made. | | Undo drops the expected bytes; no refusal on intervening change (#801 r1 P1-2, #798 r3 P2) | **same** for delete, duplicate and rename | `notes.undo` on a bulk job merges with later typing, and only the deleted-span guard runs. Move undo does refuse (it is version-checked) but is not exact. See P2-2. | | Delete undo rewrites link spelling (#798 r1 P1-4) | not present | the inverse restores the captured text exactly as it was | | Mixed LF/CRLF normalized, so undo isn't byte-exact (#799 r1 P2) | **same path** (*plausible*) | delete and duplicate source edits are saved by the room's save path, which re-encodes the whole file in the majority newline style | | YAML guard bypass: interior quotes, comments, flow values (#798 r1 P2, #801 r1) | **same class** | When (move), title (`rename.rs:203`) and the duplicate's properties use the lenient `notes::props` writer. Only the index's `broken` flag refuses. B1 v2 uses the strict writer. To be fair, `tasks.update` on dev already writes `scheduled` this way. | | Custom checkbox marks overwritten (#801 r1 P2) | not present | status goes through B1 v2, which refuses custom marks | | Missed continuation IDs in move collision/link retargeting; last copy lost between files (#799 r1 P1-1/2) | not applicable | #894 moves only properties and never moves lines between notes. That is a real simplification. | | Rank writes: stale revision, mismatched group (#799 design) | **partly** | restore has an `expect` check, but a forward reorder never checks that the task belongs to `group`, and rank values from the client are not checked. See P2-5. | ### 2. Findings #### P1-1: delete and duplicate write to changed notes instead of refusing The PR's failure table says "a note changed under a delete / copy plan → job fails, nothing deleted". The code doesn't do that. - **Source notes.** `delete.rs:68–86` and `duplicate.rs:119–139` build `Step::Edit{base: snapshot.version}`. `room/edit.rs:129–160` applies the Patch to a fork at `base` and merges it into the current text. - Scenario: Daily.md is at v10 with `- [ ] Call Bob ^aaaa…` and its link child. A delete is planned. Before its step runs, a phone appends ` before 5pm` to that checkbox line (v11). The merged removal deletes the v10 characters and leaves ` before 5pm` behind as a stray fragment on the next line. The job reports `done`. - The source notes carry no client version and no expected bytes at all. - **Task note.** `Step::Trash` has no snapshot guard. If the task's status or description is edited after the `delete.rs:54` check, the note is still trashed and the action reports `done`. Same as #798 r2 P1-1. - **Fix:** don't patch the old path. Each source removal or insertion should become an exact splice with expected bytes, and the task note's Trash should be a guarded participant, all in one executor transaction (#808). That gives refusal, crash recovery and recorded-inverse undo in one place. #### P1-2: duplicate copies every block identity and every owning subtask line `duplicate.rs:99–112` changes only six properties and keeps the body "exactly". - Scenario: a task body with `- [ ] Draft outline ^aaaaaaaaaaaaaaaa`, followed by the link child `- [Outline](<Tasks/Outline.md>)`. - After duplicating, two notes carry `^aaaa…`, so a `#^aaaa…` link has two targets. - Outline now has a second owning line, which breaks the one-owner rule (ADR 0068). - This is #798 r1 P1-2 and r2 P1-2 again, and broader. - **Fix:** re-key every block ID across the whole parsed body. Strip owning task links from the copy or convert them to plain checkboxes. Otherwise refuse before creating anything. #798's continuation-ID and relationship code, plus its probes `review_r1_duplicate_multiline_block_id_is_fresh` and `review_r2_relationship_marker_retains_owner`, are the starting point. #### P2-1: a move with both a When change and a status change is not atomic, and a replay after a crash can't finish it - `moves.rs:186–200` commits the status through the executor (key `{key}:status`). Then `moves.rs:210–220` does a separate room `Props` edit. - If the process crashes between the two, a Someday task dropped on 11-05 ends up `todo` with no date, so it shows in Anytime. - The engine retries with the same key. `one()` now sees the status already matches, so `change_status = None`. It falls to `moves.rs:202–208` with the old version and returns `block_changed`. The half-done move is reported as a failure, and no undo is returned. - `move-many` (`moves.rs:313`) has the same problem: items moved before the crash come back `skipped`, and their undo steps are lost. - A refused or failed Props edit after the status commit gives the same result without any crash. - **Fix:** plan status, `scheduled` and `completed` as one exact-splice set for the task note, and submit it as one executor transaction. `tasks::status::plan` already composes property splices with the strict writer; add the When leaf. A replay then loads the recorded plan. #### P2-2: move's undo is not exact - `moves.rs:231–237` undoes by moving forward again ("move back"). - `Move.status` accepts all five statuses (`moves.rs:54`), even though the docs say someday/todo. Moving done → todo clears `completed`, and moving back to done stamps a **new** `completed` time. - `scheduled` is restored from the index value (`now()`, `moves.rs:110–125`), not from the raw bytes: - quoting or a date-time spelling is re-rendered; - a removed key comes back at the end of the frontmatter; - a stale index row would restore the wrong date (*plausible*). - The move-back step and the `tasks.reorder{restore}` step are separate actions with no all-or-nothing rule. - **Fix:** use a recorded inverse from the executor. Until Home undo is enabled, at least restrict `status` to someday/todo and store the raw `scheduled` and `completed` lines in the undo input. #### P2-3: a When-only move checks the version, then edits without a guard `moves.rs:202–220` compares `notes.get().version`, then sends a `Props` edit with no base. An edit that lands between the two is overwritten at the key level, on the lenient writer. It needs the same fix as P2-1. #### P2-4: `lines::block` gets the block's extent wrong - `lines.rs:104–110` looks only at line+2. - Scenario: the link child is followed by a blank line, then a paragraph indented 4 spaces. That paragraph belongs to the link bullet in CommonMark. Delete removes the two lines and leaves the paragraph, which then becomes an indented code block at the top level. - This is the same "who owns this extent" class as #799 r1 P1-2. It is also a third, hand-written line scanner next to the shared parsed view (#805) that B1 v2 uses. - **Fix:** take the extent from the shared parsed view and refuse if anything is left over. #### P2-5: `tasks.reorder` accepts any rank and group from the client - `restore` (`reorder.rs:192–215`) writes client-supplied `rank` strings with no charset or length check. Unknown bytes read as digit 0. - Once a rank ends in `0`, `between()` (`reorder.rs:246–268`) never terminates. - Scenario: restore task X to rank `"0"` (with `expect` = its current rank), then reorder Y with `before: X`. `between("", Some("0"))` pushes `'0'` forever. - Memory grows without limit inside a `BEGIN IMMEDIATE` transaction on a blocking thread, and every other reorder for that Home waits. - The forward path never checks that the task is in `group`. `order` ids are not checked to be tasks in that group, so ranks are stored for arbitrary strings. - **Fix:** - accept ranks only from the 62-digit set, at most 256 bytes (as #799 did), not ending in `0`; - bound the loop in `between`; - check the group with the task index (#799's projected-membership check); - require `order` to be exactly the group's current members. #### P2-6: mixed line endings (*plausible*, same as #799 r1 P2) Delete and duplicate source edits are saved through the room. A note with both LF and CRLF line endings comes back normalized, so untouched bytes change and undo can't restore them. Refuse such notes before writing, or let the executor's byte-exact placement handle it. #### P3 1. `rename.rs:52` now switches to the task-title path for **any** note with a `status:` key (for example a project note with `status: active`). Such a note gains a `title:` key it never had. Use the task index instead (is this id an indexed task?). 2. `tasks.update` with a title does the property edit and the rename as two steps (`lib.rs` update handler). If the rename fails, the first edit stays applied and no undo is returned. 3. After a delete or duplicate whose job failed and was undone, a replay with the same key finds the job row (`delete.rs:41–44`) and returns `Ok{state:"failed", undo:[…]}` instead of the original `block_changed`. 4. Duplicate undo runs the line removal first, then `Delete{if_version}`. If the copy was edited since, the delete refuses and the undo is left half done. That is safe, but it is reported as done. ### 3. Access, idempotency, big-O - **Access is fine.** Every path resolves through the caller's own index (`update::target`) and `notes.enter(caller.user)`. A foreign id is `not_found`. Shared-note permissions are correctly listed as out of scope. - **Idempotency.** It is fine for complete replays (the engine stores results, and job and copy ids derive from the key). Partial replays fail as described in P2-1 and P3-3. - **Big-O.** The PR's numbers check out. `version` costs one `MAX(seq)` index lookup per row, and the perf comment shows about +0.3 ms at p50 for a 200-row page. Reorder is O(1) rows (O(G ≤ 2000) the first time). Delete and duplicate plan in O(source bytes), with at most 64 owners. The only unbounded cost is the `between` loop in P2-5. ### 4. Recommendation: (c), with the exact pieces **Keep from #894** (land once fixed): - `version` on rows: `versions.rs`, `History::versions`, and its wiring into `tasks.list` and `tasks.get`; - `tasks.reorder` and `OrderDb::edit`, with P2-5 fixed; - the `tasks.move` and `tasks.move-many` API and answer shapes; - status-only move through the executor; - the contract and type entries the UI port needs; - the title rename, with P3-1 and P3-2 fixed. **Rework in #894:** - the When+status move becomes one executor transaction, which fixes P2-1 to P2-3; - move-many builds on it. **Drop from #894:** - `delete.rs`, `duplicate.rs` and `lines.rs`, i.e. everything built on `bulk` `Step::Edit`/`Trash`. **Rebuild delete and duplicate as v2 planners on the executor** (#808's B3), using: - **from #798:** owner and relationship classification (strip structural markers; remove only owning checkbox lines and relationship bullets), the full-extent block-ID re-key for duplicate, the YAML refusal corpus, and **all** `review_r1_*`, `review_r2_*` and `review_r3_*` probes as permanent regressions; - **from #799:** the pulldown-cmark subtree extent, full-extent marker enumeration, the rank length cap and the group-membership refusal, and its six `review799_*` probes. - #799's physical cross-note line move isn't needed by the UI port. Leave it until it can be built as a v2 planner. **Interim, if the UI port can't wait for B3 v2:** - Delete = Trash the task note as a guarded executor participant and leave the owning lines. ADR 0066 already makes them read as "in Trash", and this removes all the line-editing risk. - Duplicate = new note only. Re-key every block ID and refuse if the body holds owning task links. **Then close #798 and #799.** Keep their branches until the probes are ported. #801 is already superseded by #812. No code pushed, nothing merged.
kayg changed title from Tasks write actions for the UI port: move, reorder, delete, duplicate, rename, version, bulk move to Tasks write actions for the UI port: move, reorder, rename, version, bulk move. Delete and duplicate are being rebuilt on the executor. 2026-11-03 12:24:55 +00:00
Author
Owner

Gates: steps 1a + 1b on head 5f158246

Run on calternal-dev-01-compute02, in a Nix devshell, through ~/bin/slot.

gate command result
fmt cargo fmt --all -- --check OK
clippy cargo clippy -j4 -p calternal-tasks -p calternal-notes -p calternal-engine -p calternal -p calternal-perf --all-targets -- -D warnings exit 0, no warnings
contract UPDATE_CONTRACTS=1 cargo test -j4 -p calternal contract (regenerated and committed in 5f158246), then cargo test -j4 -p calternal contract_matches_the_server 1 passed
web types node apps/web/scripts/action-types.mjs --check OK
tasks cargo test -j4 -p calternal-tasks 98 passed, 0 failed
engine cargo test -j4 -p calternal-engine 20 passed, 1 ignored
notes cargo test -j4 -p calternal-notes 547 passed, 6 ignored

New probes for review 76844, all passing:

  • P1-1 review_76844_p1_1_delete_refuses_a_changed_task, delete_trashes_through_the_executor_and_restores_exact_bytes
  • P1-2 review_76844_p1_2_duplicate_rekeys_ids_and_refuses_subtasks, duplicate::tests::rekey_replaces_every_block_id_the_same_way_and_nothing_else
  • P2-1 review_76844_p2_1_status_and_when_are_one_transaction
  • P2-2 review_76844_p2_2_undo_restores_exact_bytes, undo_refuses_after_a_later_edit_and_writes_nothing
  • P2-3 review_76844_p2_3_date_only_move_refuses_a_changed_note
  • P2-5 review_76844_p2_5_bad_ranks_are_refused_and_between_always_ends, review_76844_p2_5_reorder_refuses_bad_ranks_and_other_groups, order_and_restore_name_each_task_once
  • P2-6 review_76844_p2_6_mixed_line_endings_refuse_and_keep_bytes
  • P3 review_76844_p3_rename_is_for_tasks_only_and_alone
  • strict writer limit a_hand_typed_unquoted_date_refuses_and_keeps_bytes

Not yet covered: a crash-point matrix for the new Trash participant (crash at Placed / Committed / HistoryInstalled / BatchPublished / PublicationRecorded, then recovery). The executor's own placement and recovery tests cover the removal itself. The Trash publication is idempotent by construction (link-or-verify plus details-if-missing), but no crash test drives it yet. It's the first item of step 2.

## Gates: steps 1a + 1b on head `5f158246` Run on `calternal-dev-01-compute02`, in a Nix devshell, through `~/bin/slot`. | gate | command | result | |---|---|---| | fmt | `cargo fmt --all -- --check` | OK | | clippy | `cargo clippy -j4 -p calternal-tasks -p calternal-notes -p calternal-engine -p calternal -p calternal-perf --all-targets -- -D warnings` | exit 0, no warnings | | contract | `UPDATE_CONTRACTS=1 cargo test -j4 -p calternal contract` (regenerated and committed in `5f158246`), then `cargo test -j4 -p calternal contract_matches_the_server` | 1 passed | | web types | `node apps/web/scripts/action-types.mjs --check` | OK | | tasks | `cargo test -j4 -p calternal-tasks` | **98 passed**, 0 failed | | engine | `cargo test -j4 -p calternal-engine` | 20 passed, 1 ignored | | notes | `cargo test -j4 -p calternal-notes` | **547 passed**, 6 ignored | New probes for review 76844, all passing: - **P1-1** `review_76844_p1_1_delete_refuses_a_changed_task`, `delete_trashes_through_the_executor_and_restores_exact_bytes` - **P1-2** `review_76844_p1_2_duplicate_rekeys_ids_and_refuses_subtasks`, `duplicate::tests::rekey_replaces_every_block_id_the_same_way_and_nothing_else` - **P2-1** `review_76844_p2_1_status_and_when_are_one_transaction` - **P2-2** `review_76844_p2_2_undo_restores_exact_bytes`, `undo_refuses_after_a_later_edit_and_writes_nothing` - **P2-3** `review_76844_p2_3_date_only_move_refuses_a_changed_note` - **P2-5** `review_76844_p2_5_bad_ranks_are_refused_and_between_always_ends`, `review_76844_p2_5_reorder_refuses_bad_ranks_and_other_groups`, `order_and_restore_name_each_task_once` - **P2-6** `review_76844_p2_6_mixed_line_endings_refuse_and_keep_bytes` - **P3** `review_76844_p3_rename_is_for_tasks_only_and_alone` - **strict writer limit** `a_hand_typed_unquoted_date_refuses_and_keeps_bytes` **Not yet covered:** a crash-point matrix for the new Trash participant (crash at Placed / Committed / HistoryInstalled / BatchPublished / PublicationRecorded, then recovery). The executor's own placement and recovery tests cover the removal itself. The Trash publication is idempotent by construction (link-or-verify plus details-if-missing), but no crash test drives it yet. It's the first item of step 2.
Author
Owner

Perf: write actions on perf-01, under perf-lock

Setup: release binaries built on dev-01 at cac156dd (cargo build --release --locked -p calternal-perf --bin tasks-write-bench), relayed through the control host. sha256 matched on both ends: 82a5ce20…. The fixture is a tasks-bench --notes 10k Home (10k notes + 10k task notes). Command: tasks-write-bench --base data/base-10000 --state state-w --n 200.

Everything goes through the real action pipeline and is timed until the answer. Every move is undone, every delete restored, and every copy deleted.

measure n p50 ms p95 ms max ms
tasks.reorder (one drag, 809-task group) 200 11.31 12.72 32.63
tasks.move When only (1 note) 200 281 481 535
tasks.move Someday → day (status + When + owner line, 2 notes) 200 824 1107 1145
tasks.undo (exact inverse) 400 569 1219 1296
tasks.move-many 50 tasks 4 2466 2468 2513
tasks.move-many 50 undo 4 2450 2530 2581
baseline tasks.set-status (B1, already on dev) 200 1695 1948 2004
tasks.delete (Trash through the executor) 100 2005 2099 2110
tasks.duplicate (new note only) 100 26.8 44.0 47.9

Gates

  • tasks.reorder_ack p95 12.72 ms ≤ 15 ms (ADR 0066): PASS. First ranking of the group (order sent): 34 ms.
  • The executor-based writes are far over the 40 ms write budget: move, undo, move-many and delete. So is tasks.set-status on dev, which uses the same shared executor and is slower than a move here. The cost is in the shared Home write executor, not in these adapters. Getting the executor near budget is its own perf task; I haven't changed the executor's speed in this PR.
  • duplicate doesn't touch the executor: p95 44 ms.

Other findings

  • 400 first moves were refused, then retried: list rows carry version: 0 for a note never opened, and notes.get opens it at a higher version. The client has to read the row again once. This was already the case before this PR.
  • 100 set-status/delete calls were refused once with changed note inventory, while the index caught up after the 50-task move-many, and went through on retry. This is B1's existing ExpectedInventory guard. Under load, a client sees a spurious block_changed and must retry.

The raw outputs (write4.out, plus earlier write2.out) stay on perf-01 at ~/perf/t894/. The seeded Home and state have been removed.

## Perf: write actions on perf-01, under `perf-lock` **Setup:** release binaries built on dev-01 at `cac156dd` (`cargo build --release --locked -p calternal-perf --bin tasks-write-bench`), relayed through the control host. sha256 matched on both ends: `82a5ce20…`. The fixture is a `tasks-bench --notes 10k` Home (10k notes + 10k task notes). Command: `tasks-write-bench --base data/base-10000 --state state-w --n 200`. Everything goes through the real action pipeline and is timed until the answer. Every move is undone, every delete restored, and every copy deleted. | measure | n | p50 ms | p95 ms | max ms | |---|---|---|---|---| | `tasks.reorder` (one drag, 809-task group) | 200 | 11.31 | **12.72** | 32.63 | | `tasks.move` When only (1 note) | 200 | 281 | 481 | 535 | | `tasks.move` Someday → day (status + When + owner line, 2 notes) | 200 | 824 | 1107 | 1145 | | `tasks.undo` (exact inverse) | 400 | 569 | 1219 | 1296 | | `tasks.move-many` 50 tasks | 4 | 2466 | 2468 | 2513 | | `tasks.move-many` 50 undo | 4 | 2450 | 2530 | 2581 | | **baseline** `tasks.set-status` (B1, already on dev) | 200 | 1695 | 1948 | 2004 | | `tasks.delete` (Trash through the executor) | 100 | 2005 | 2099 | 2110 | | `tasks.duplicate` (new note only) | 100 | 26.8 | 44.0 | 47.9 | **Gates** - **`tasks.reorder_ack` p95 12.72 ms ≤ 15 ms (ADR 0066): PASS.** First ranking of the group (`order` sent): 34 ms. - **The executor-based writes are far over the 40 ms write budget**: move, undo, move-many and delete. So is `tasks.set-status` on dev, which uses the same shared executor and is slower than a move here. The cost is in the shared Home write executor, not in these adapters. Getting the executor near budget is its own perf task; I haven't changed the executor's speed in this PR. - `duplicate` doesn't touch the executor: p95 44 ms. **Other findings** - 400 first moves were refused, then retried: list rows carry `version: 0` for a note never opened, and `notes.get` opens it at a higher version. The client has to read the row again once. This was already the case before this PR. - 100 set-status/delete calls were refused once with `changed note inventory`, while the index caught up after the 50-task move-many, and went through on retry. This is B1's existing `ExpectedInventory` guard. Under load, a client sees a spurious `block_changed` and must retry. The raw outputs (`write4.out`, plus earlier `write2.out`) stay on perf-01 at `~/perf/t894/`. The seeded Home and state have been removed.
kayg changed title from Tasks write actions for the UI port: move, reorder, rename, version, bulk move. Delete and duplicate are being rebuilt on the executor. to Tasks write actions for the UI port: move, reorder, rename, version, bulk move, delete, duplicate (on the executor) 2026-11-03 13:31:21 +00:00
Author
Owner

Rebasing onto the executor hot-path fix (#898)

#898 (perf/executor-hot-path, into dev) fixes the executor cost and the two spurious refusals this PR's bench found. Writes no longer reread the whole journal, the recovery sweep is skipped when nothing is in flight, and placement syncs are batched. No guard is weakened; the plan and failure table are in #898. Rebased onto #898, this branch passes the notes, tasks and engine tests. The bench numbers are in #898.

A worked rebase of this PR's head cac156dd is pushed as scratch/executor-on-894 (never to be merged; it shows the resolution). To rebase:

  1. git rebase origin/perf/executor-hot-path (or onto dev once #898 lands). The conflicts are in crates/tasks/src/lib.rs (keep both pub mod owners; and pub mod reorder;) and crates/tasks/src/status_action.rs (keep your execute/plan_one shape and apply the steps below).
  2. Inventory guard: in plan_one, replace the global epoch (home.note_write_inventory(), Requirement::ExpectedInventory { epoch }) with crate::owners::read(&db.meta(), &folded) for each target that has owners. Plan from those rows. Push Requirement::ExpectedOwners { digest: crate::owners::digest(&owners) }, return crate::owners::probe(owners.into_keys().collect()) with the plan, and submit with home.execute_note_write_probed(plan, Some(probe)). A move without owners gets the empty digest and always matches.
  3. Versions: versions::of becomes home.row_versions(ids). Unopened notes get a negative file token instead of 0. Replace every i.version != snap.version or snap.version != m.version with !home.version_matches(&id, sent, snap.version).await? (plan_one, moves.rs, and trash_exact in delete).
  4. Journal reads: NoteWriteExecutor::read_records is gone. In committed_note_write, use NoteWriteExecutor::read_record(&notes, &journal, &home, &key), which reads one record file, not all of them.
  5. undo::inverse still takes the old epoch. The inverse's bytes are exact, so the probe has no owner rows to re-read; either drop the epoch or scope it to the inverse's notes. That's your call; #898 leaves it alone.

🤖 Generated with Claude Code

## Rebasing onto the executor hot-path fix (#898) #898 (`perf/executor-hot-path`, into dev) fixes the executor cost and the two spurious refusals this PR's bench found. Writes no longer reread the whole journal, the recovery sweep is skipped when nothing is in flight, and placement syncs are batched. No guard is weakened; the plan and failure table are in #898. Rebased onto #898, this branch passes the notes, tasks and engine tests. The bench numbers are in #898. A worked rebase of this PR's head cac156dd is pushed as `scratch/executor-on-894` (never to be merged; it shows the resolution). To rebase: 1. `git rebase origin/perf/executor-hot-path` (or onto dev once #898 lands). The conflicts are in `crates/tasks/src/lib.rs` (keep both `pub mod owners;` and `pub mod reorder;`) and `crates/tasks/src/status_action.rs` (keep your `execute`/`plan_one` shape and apply the steps below). 2. **Inventory guard:** in `plan_one`, replace the global epoch (`home.note_write_inventory()`, `Requirement::ExpectedInventory { epoch }`) with `crate::owners::read(&db.meta(), &folded)` for each target that has owners. Plan from those rows. Push `Requirement::ExpectedOwners { digest: crate::owners::digest(&owners) }`, return `crate::owners::probe(owners.into_keys().collect())` with the plan, and submit with `home.execute_note_write_probed(plan, Some(probe))`. A move without owners gets the empty digest and always matches. 3. **Versions:** `versions::of` becomes `home.row_versions(ids)`. Unopened notes get a negative file token instead of 0. Replace every `i.version != snap.version` or `snap.version != m.version` with `!home.version_matches(&id, sent, snap.version).await?` (`plan_one`, `moves.rs`, and `trash_exact` in delete). 4. **Journal reads:** `NoteWriteExecutor::read_records` is gone. In `committed_note_write`, use `NoteWriteExecutor::read_record(&notes, &journal, &home, &key)`, which reads one record file, not all of them. 5. `undo::inverse` still takes the old epoch. The inverse's bytes are exact, so the probe has no owner rows to re-read; either drop the epoch or scope it to the inverse's notes. That's your call; #898 leaves it alone. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Keeper proposal after reading #798, #799, #894 and all their review comments: gate #894 (cac156dd68c222da099343628a75abfa97a75504), the replacement following review 76844, rather than revive the held bulk/room implementations. #798 round 3 explicitly requires stop-and-simplify; #799 is held for the same executor design. Propose closing #798/#799 as superseded only after recording the missing promotion/physical-line-move scope and preserving/porting their review probes. Do not close #894 as a duplicate: it carries the reviewed replacement API and the executor rework. Neither earlier PR is approved for merge. No PR closed or merged. I will merge current dev into #894, run fresh gates, add crash/retry regressions and report remaining scope explicitly.

Keeper proposal after reading #798, #799, #894 and all their review comments: gate #894 (`cac156dd68c222da099343628a75abfa97a75504`), the replacement following review 76844, rather than revive the held bulk/room implementations. #798 round 3 explicitly requires stop-and-simplify; #799 is held for the same executor design. Propose closing #798/#799 as superseded only after recording the missing promotion/physical-line-move scope and preserving/porting their review probes. Do not close #894 as a duplicate: it carries the reviewed replacement API and the executor rework. Neither earlier PR is approved for merge. No PR closed or merged. I will merge current dev into #894, run fresh gates, add crash/retry regressions and report remaining scope explicitly.
Author
Owner

Crash/retry gate plan for current-dev qualification: keep one executor, no new write framework. Reproduced on 7cd0854aa: SIGKILL at Placed(0) followed by the original Someday-to-day move returns operation key reused with different request or author. Cause: the fingerprint includes a derived target based on the current status, which recovery already changed. Fix 72ef8de87 fingerprints requested fields and enters replay even when the effective status is already satisfied. Cost remains O(request bytes), no new durable writes. New tests cover Placed/Committed/HistoryInstalled/BatchPublished/PublicationRecorded, exact inverse bytes, and one indexed task; delete covers the same five publication boundaries, plus a duplicate crash before the engine receipt.

failure effect detection recovery gate
move crashes after placement changed derived request original-key retry regression (RED observed) load original recorded plan from stable request five crash phases
delete crashes during Trash publication live/Trash views can differ until recovery raw Trash bytes + index lookup existing executor recovery, original-key retry five crash phases
duplicate crashes after file creation rekeyed text may differ on retry real process kill before action receipt preserve one copy and its identities duplicate crash regression

Per-field stale checks (#919) are an unresolved gate dependency: #928 is open and explicitly provides only an opt-in shared helper; Tasks does not yet opt in. No claim that whole-note versions satisfy this gate. The pending Tasks UI stack also still calls the old update/set-status actions, so its specs alone cannot cover move/reorder/delete. Added a browser-protocol regression spec on #894 for those actions, alongside the scratch UI integration matrix. No merge or close.

Crash/retry gate plan for current-dev qualification: keep one executor, no new write framework. Reproduced on `7cd0854aa`: SIGKILL at `Placed(0)` followed by the original Someday-to-day move returns `operation key reused with different request or author`. Cause: the fingerprint includes a derived target based on the current status, which recovery already changed. Fix `72ef8de87` fingerprints requested fields and enters replay even when the effective status is already satisfied. Cost remains O(request bytes), no new durable writes. New tests cover Placed/Committed/HistoryInstalled/BatchPublished/PublicationRecorded, exact inverse bytes, and one indexed task; delete covers the same five publication boundaries, plus a duplicate crash before the engine receipt. | failure | effect | detection | recovery | gate | |---|---|---|---|---| | move crashes after placement | changed derived request | original-key retry regression (RED observed) | load original recorded plan from stable request | five crash phases | | delete crashes during Trash publication | live/Trash views can differ until recovery | raw Trash bytes + index lookup | existing executor recovery, original-key retry | five crash phases | | duplicate crashes after file creation | rekeyed text may differ on retry | real process kill before action receipt | preserve one copy and its identities | duplicate crash regression | Per-field stale checks (#919) are an unresolved gate dependency: #928 is open and explicitly provides only an opt-in shared helper; Tasks does not yet opt in. No claim that whole-note versions satisfy this gate. The pending Tasks UI stack also still calls the old update/set-status actions, so its specs alone cannot cover move/reorder/delete. Added a browser-protocol regression spec on #894 for those actions, alongside the scratch UI integration matrix. No merge or close.
Author
Owner

qualification update at head 2255752cc against dev 02cdf87897c18486cd7856a487896019b3eb470d (still current):

gate result
dev merge clean merge f4031f5a9, pushed
crash/retry RED: original move retry and duplicate-marker retry failed; GREEN: 5 tests pass after 72ef8de87 and f0b98e5be; move/delete cover five executor boundaries
browser action protocol 4/4 Chromium on dev-01, 4/4 WebKit on dev-02, at 1440/390, no retries
pending Tasks UI stack initial Chromium 70 pass / 11 skip / 1 fail; WebKit 69 / 11 / 2; candidate repeat-10 passes; dev repeat qualification still finishing
web svelte-check 0 errors / 16 existing warnings; 109/109 unit tests pass
Rust fmt/clippy pass on fixed Rust source; full workspace still running using private TMPDIR after the shared /tmp inode exhaustion
first paint BLOCKED: JS dev raw/gzip/brotli 840859/280629/238200 bytes; candidate 840940/280660/238309, deltas +81/+31/+109. CSS identical. A compact lookup experiment was reverted because it did not pass the flat gate.
per-field stale BLOCKED: #919 helper is still open in #928; Tasks only has whole-note version checks, which do not satisfy base/after per-field sequencing
perf queued behind shared perf-01 lock since 05:29 IST; no measurement claimed. Both-lock ABAB and control script/binaries prepared, hashes checked

browser spec explicitly covers protocol, not pending UI wiring: the UI stack still calls update/set-status. Notes intentionally excludes task rows, so its route is not a task editor. promotion and physical line moves remain missing scope compared with the older PRs. #894 is not GREEN. no merge or close.

qualification update at head `2255752cc` against dev `02cdf87897c18486cd7856a487896019b3eb470d` (still current): | gate | result | |---|---| | dev merge | clean merge `f4031f5a9`, pushed | | crash/retry | RED: original move retry and duplicate-marker retry failed; GREEN: 5 tests pass after `72ef8de87` and `f0b98e5be`; move/delete cover five executor boundaries | | browser action protocol | 4/4 Chromium on dev-01, 4/4 WebKit on dev-02, at 1440/390, no retries | | pending Tasks UI stack | initial Chromium 70 pass / 11 skip / 1 fail; WebKit 69 / 11 / 2; candidate repeat-10 passes; dev repeat qualification still finishing | | web | svelte-check 0 errors / 16 existing warnings; 109/109 unit tests pass | | Rust | fmt/clippy pass on fixed Rust source; full workspace still running using private TMPDIR after the shared /tmp inode exhaustion | | first paint | BLOCKED: JS dev raw/gzip/brotli 840859/280629/238200 bytes; candidate 840940/280660/238309, deltas +81/+31/+109. CSS identical. A compact lookup experiment was reverted because it did not pass the flat gate. | | per-field stale | BLOCKED: #919 helper is still open in #928; Tasks only has whole-note version checks, which do not satisfy base/after per-field sequencing | | perf | queued behind shared perf-01 lock since 05:29 IST; no measurement claimed. Both-lock ABAB and control script/binaries prepared, hashes checked | browser spec explicitly covers protocol, not pending UI wiring: the UI stack still calls update/set-status. Notes intentionally excludes task rows, so its route is not a task editor. promotion and physical line moves remain missing scope compared with the older PRs. #894 is not GREEN. no merge or close.
Author
Owner

reorder perf follow-up plan after the first locked RED result (p95 20.90 ms > 15 ms, 809-task group): OrderDb::edit currently opens a new SQLite connection, sets WAL, runs schema setup and closes it for every drag. keep one lazy writer connection per user, separate from the existing read-only connection. bound: one connection per already-open user; one write transaction still owns the whole rank plan and commit, with SQLite IMMEDIATE preserving cross-process exclusion. no schema or wire change. if open/setup fails, leave the cached slot empty and retry on the next action; if commit fails, return the error as before. validate with existing rank/restore/group tests and the same locked reorder fixture, then requalify Rust. #898 does not modify order.rs, so this does not duplicate its executor redesign.

reorder perf follow-up plan after the first locked RED result (p95 20.90 ms > 15 ms, 809-task group): `OrderDb::edit` currently opens a new SQLite connection, sets WAL, runs schema setup and closes it for every drag. keep one lazy writer connection per user, separate from the existing read-only connection. bound: one connection per already-open user; one write transaction still owns the whole rank plan and commit, with SQLite IMMEDIATE preserving cross-process exclusion. no schema or wire change. if open/setup fails, leave the cached slot empty and retry on the next action; if commit fails, return the error as before. validate with existing rank/restore/group tests and the same locked reorder fixture, then requalify Rust. #898 does not modify order.rs, so this does not duplicate its executor redesign.
Author
Owner

qualification continues at 57516bcbf (dev remains pinned to 02cdf8789). fixed two original retry failures, then fixed the newly found legacy-record checksum regression in 7b56b2ab6: omitting false Image.trash preserves version-one checksums. the existing ExpectedOwners compatibility tests were RED (2 failures) and now GREEN (3/3 including corrupt-record refusal). move/delete/duplicate recovery regression runner is GREEN (3 parent regressions across 11 kill windows, plus 2 helpers).

fresh Chromium and WebKit action-protocol matrices on this head: 4/4 each at 1440/390. fmt, workspace/all-target clippy with warnings denied and targeted reorder tests pass. full workspace run is still active on dev-01 in tasks-894-workspace-v3.service, log ~/tasks-894-workspace-v3.log; the older workspace run was superseded and is not a pass.

both shared perf locks were acquired in order at 05:53 IST and remain held. corrected tick ABAB (identical benchmark source built against dev and the PR), 100 accepted transitions per round, 50 counted retries, zero final refusals:

p95 ms dev A1 PR B1 dev A2 PR B2 ABAB delta dev control delta
tick including retries 396.469 396.293 417.524 404.340 -1.64% +1.84%

the initial tick mode without retries was discarded: it refused the actual transitions and mostly timed no-op resets. b4e6a3f10 fixes that harness bias. first reorder batch was RED at 20.90 ms p95; 57516bcbf reuses the writer connection. the first fixed batch is GREEN at 5.41 ms for the same 809-task group; second batch pending.

the first notes ABAB (3 warm runs per round) is not flat: sync.save_ack p95 +63.41%, storm.typing +6.32%, storm.save_ack -5.25%; save control -32.97%, typing control -0.14%. a 5-run-per-round repeat is queued using the final server binary; both results will be reported. final candidate server sha256 909e1c1c02df5f8306e60afa81e55dd8a073750e8bb1dddabef9008c7115b625; corrected final action benchmark 9bce45e23d0ffe5f54b7c79d70cdd0bb5ebe9602b9023b344bc4be1dfbfac896.

active perf-01 units: tasks-894-perf-hold, tasks-894-reorder-fixed, tasks-894-notes-final; perf-02: tasks-894-perf-hold. tick comparison finished. reports are under ~/perf/tasks-894/reports/. both holders will be stopped as soon as measurements finish. still BLOCKED on first-paint bytes (+81 raw/+31 gzip/+109 brotli) and per-field stale opt-in (#919/#928), independently of pending perf/workspace results. no merge or close.

qualification continues at `57516bcbf` (dev remains pinned to `02cdf8789`). fixed two original retry failures, then fixed the newly found legacy-record checksum regression in `7b56b2ab6`: omitting false `Image.trash` preserves version-one checksums. the existing ExpectedOwners compatibility tests were RED (2 failures) and now GREEN (3/3 including corrupt-record refusal). move/delete/duplicate recovery regression runner is GREEN (3 parent regressions across 11 kill windows, plus 2 helpers). fresh Chromium and WebKit action-protocol matrices on this head: 4/4 each at 1440/390. fmt, workspace/all-target clippy with warnings denied and targeted reorder tests pass. full workspace run is still active on dev-01 in `tasks-894-workspace-v3.service`, log `~/tasks-894-workspace-v3.log`; the older workspace run was superseded and is not a pass. both shared perf locks were acquired in order at 05:53 IST and remain held. corrected tick ABAB (identical benchmark source built against dev and the PR), 100 accepted transitions per round, 50 counted retries, zero final refusals: | p95 ms | dev A1 | PR B1 | dev A2 | PR B2 | ABAB delta | dev control delta | |---|---:|---:|---:|---:|---:|---:| | tick including retries | 396.469 | 396.293 | 417.524 | 404.340 | -1.64% | +1.84% | the initial tick mode without retries was discarded: it refused the actual transitions and mostly timed no-op resets. `b4e6a3f10` fixes that harness bias. first reorder batch was RED at 20.90 ms p95; `57516bcbf` reuses the writer connection. the first fixed batch is GREEN at 5.41 ms for the same 809-task group; second batch pending. the first notes ABAB (3 warm runs per round) is not flat: sync.save_ack p95 +63.41%, storm.typing +6.32%, storm.save_ack -5.25%; save control -32.97%, typing control -0.14%. a 5-run-per-round repeat is queued using the final server binary; both results will be reported. final candidate server sha256 `909e1c1c02df5f8306e60afa81e55dd8a073750e8bb1dddabef9008c7115b625`; corrected final action benchmark `9bce45e23d0ffe5f54b7c79d70cdd0bb5ebe9602b9023b344bc4be1dfbfac896`. active perf-01 units: `tasks-894-perf-hold`, `tasks-894-reorder-fixed`, `tasks-894-notes-final`; perf-02: `tasks-894-perf-hold`. tick comparison finished. reports are under `~/perf/tasks-894/reports/`. both holders will be stopped as soon as measurements finish. still BLOCKED on first-paint bytes (+81 raw/+31 gzip/+109 brotli) and per-field stale opt-in (#919/#928), independently of pending perf/workspace results. no merge or close.
Author
Owner

final gate report: blocked

head bf08566987f9211d23920074d8f6694da1ee48d5, merged current dev 258f3eac8a8fc925eeb5c57a9df2a4b68b44e365. no pr was merged or closed.

gate result evidence / limit
dev merge and push pass clean merge; final head above
fmt / clippy -D warnings pass repeated at final head on dev-01
cargo test --workspace pass complete v3 run, including doctests; identical final rust tree
crash retry correctness pass red first, then 3 parent regressions across 11 kill windows; 5/5 runner tests including helpers
old executor records pass checksum compatibility failed first; 3/3 fixture tests pass after fix
chromium, 1440 + 390 pass tasks-actions + picker-lazy: 8/8 on dev-01
webkit, 1440 + 390 pass same specs: 8/8 on dev-02
web unit / svelte-check pass 109/109 tests; 0 errors, 16 existing warnings
first-paint brotli bytes pass 230948 → 230826, -122 b; raw +81 b, gzip +25 b; css identical
tick perf pass corrected common harness, abab -1.64%; dev control +1.84%
reorder perf pass 809-task group, 50 drags: p95 5.413 / 5.296 ms, both ≤15 ms
notes perf flatness blocked save / typing regressions below; under budget is insufficient
touched list paths flatness blocked mixed deltas below; baseline also fails first-touch / cold growth budgets
per-field stale check blocked #919 contract and shared helper #928 remain dependencies; whole-note versions do not satisfy this gate
full old b2/b3 scope held promotion and physical-line move scope still need disposition
review approval pending review discussion carried by comments; no formal approval returned by reviews api

the retry tests kill a real child process. move and delete cover placed(0), committed, history installed, batch published and publication recorded. delete checks exact trash bytes and restore. duplicate covers durable copy before receipt and checks one copy with stable marker identities. fixes include replay fingerprint stability, deterministic duplicate block ids, old journal checksum compatibility, and a lazy persistent reorder writer connection. each change was committed and pushed separately.

rust commands, through nix and ~/bin/slot on dev-01:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -j4 -- -D warnings
cargo test -p calternal-tasks -j4 reorder -- --nocapture
cargo test --workspace -j4 --no-fail-fast

workspace v3 ran at 57516bcbf4165307cc9cf60518e21eebe4ba413c; its crates tree and cargo/flake files are identical to final head. fmt/clippy were repeated at final head. dev's old 02cdf87897c18486cd7856a487896019b3eb470d and current 258f3eac8 also have identical rust/cargo/flake inputs. crates tree ids: dev 8d3da72182449fb35b5d9c68e190a36541cbd195, candidate 6734c40ad1e7acefe8e5c462a06922fd2d895ef7. the final merge adds frontend lazy autocomplete only. perf binaries were built before that merge and are backend-source-equivalent, not newly built at final head.

browser commands, in apps/web under nix and ~/bin/slot, with CALTERNAL_BIN pointing to the worktree debug server:

bunx playwright test e2e/tasks-actions.spec.ts e2e/picker-lazy.spec.ts --project=chromium-1440 --project=chromium-390 --workers=1 --retries=0
bunx playwright test e2e/tasks-actions.spec.ts e2e/picker-lazy.spec.ts --project=webkit-1440 --project=webkit-390 --workers=1 --retries=0

frozen bun install, web check/unit tests and bun run build && node scripts/bundle-size.mjs dist --json also ran under nix/slot. current repository first-paint gate measures brotli. final dev/pr raw js 812596/812677, gzip 271284/271309, brotli 230948/230826. css raw/gzip/brotli 102659/21043/18383 on both.

the pending ui stack was separately probed on scratch integration 9b54b7a8a, not the merge candidate: chromium 70 pass / 11 skip / 1 initial failure; webkit 69 / 11 / 2. composer and board failures did not reproduce in repeat-10 on either candidate or dev, so the brief's flake rule applies. tracked in #964 and #979. that stack still uses update/set-status; passing browser protocol cases does not claim move/reorder/delete ui wiring exists.

perf: both shared locks held together, perf-01 acquired first; measurement on perf-01; fresh 10k-note/task fixtures, abab and dev-vs-dev control in the same session. all binaries verified by sha256. all jobs stopped, locks released and seeded data removed. raw reports, exact shell scripts and binary fingerprints are attached.

corrected tick (--existing-only --n 50): each round has 100 accepted transitions, 50 retries and zero final refusals, including retry cost. p95 dev a1/a2 396.469/417.524 ms; candidate b1/b2 396.293/404.340 ms. means 406.996 → 400.317 ms, -1.64%; control 410.277 → 417.830 ms, +1.84%. old tick reports without v2 timed mostly no-ops and are excluded from the verdict.

new action endpoints are absent on dev, so these are absolute measurements, with delta n/a. tasks-write-bench --n 50, two fresh fixtures:

action p95 ms run 1 run 2 mean
reorder 5.413 5.296 5.354
move when 158.017 158.883 158.450
someday to day 314.136 344.840 329.488
delete 735.806 727.341 731.573
duplicate 33.316 31.274 32.295
undo 347.722 360.100 353.911
50-task batch 1401.510 1338.667 1370.088

action timing is answered-attempt latency; logs separately report 100 version refetches and 50 late retries, zero final refusals. common tick above includes retry cost. before the persistent writer fix, reorder was 20.90/12.44 ms; fixed results pass twice.

final notes abab uses identical common calternal-perf harness: --sizes 10k --runs 5 --cold-runs 1 --idle-secs 0 --storm-edits 10000 --storm-clients 4 --seed 1. p95 means of a1/a2 and b1/b2:

path dev ms pr ms delta dev control delta
storm.save_ack 10.0609 13.6584 +35.76% -2.02%
storm.typing 2.2192 2.6098 +17.60% +2.18%
sync.durable_write 23.6342 26.7960 +13.38% +11.54%
sync.round_trip .3457 .3518 +1.76% -2.04%
sync.save_ack 4.8470 5.1320 +5.88% +5.48%
uploads.save_ack 8.5694 11.3508 +32.46% -8.13%

earlier 3-warm-run results are retained separately, not pooled: sync.save_ack +63.41%, storm.typing +6.32%, storm.save_ack -5.25%. neither run establishes flat notes. #898 already owns executor hot-path redesign; this report does not merge or duplicate that work.

additional touched list/version path, tasks-bench --notes 10000 --runs 3, 200 list samples and 2340 warm writes per round:

path dev ms pr ms delta dev control delta
anytime 2.4562 2.4123 -1.79% -7.73%
anytime child-tag 3.0350 3.2677 +7.66% -30.14%
counts .3352 .3281 -2.12% -34.54%
someday 2.5628 2.4607 -3.98% -3.88%
tag 2.8994 3.1500 +8.64% -3.27%
today 3.0782 3.1047 +.86% -.06%
today priority 2.8763 2.7965 -2.77% -10.18%
untracked 1.7239 1.3958 -19.03% -40.68%
upcoming 2.9519 2.9988 +1.59% -5.11%
update first touch 56.5777 57.5108 +1.65% -.86%
update warm 16.1256 15.5086 -3.83% -8.66%
describe .0378 .0360 -4.78% +.26%
describe + rows .0791 .0724 -8.55% -1.62%

all six list harness rounds exit 1 because first-touch update exceeds 40 ms and cold-index growth exceeds 10%, also on dev. these are complete measurement reports, not harness crashes or candidate-only failures.

keeper proposal: keep #894, which carries the reviewed executor replacement from review comment 76844. #798's round-three discussion explicitly requested simplification and #799 was held for the same redesign. conditionally close #798 (f1600ed05109566241d1daa19c91be1d2b81ed1c) and #799 (7656894724bc5689f4ae3807665c703c455107bd) as superseded only after their review probes and missing promotion/physical-line-move scope have been preserved or explicitly resolved. do not close #894 as the duplicate. proposals were posted on all three; no closure or merge performed.

evidence archive

final gate report: **blocked** head `bf08566987f9211d23920074d8f6694da1ee48d5`, merged current dev `258f3eac8a8fc925eeb5c57a9df2a4b68b44e365`. no pr was merged or closed. | gate | result | evidence / limit | |---|---|---| | dev merge and push | pass | clean merge; final head above | | fmt / clippy -D warnings | pass | repeated at final head on dev-01 | | cargo test --workspace | pass | complete v3 run, including doctests; identical final rust tree | | crash retry correctness | pass | red first, then 3 parent regressions across 11 kill windows; 5/5 runner tests including helpers | | old executor records | pass | checksum compatibility failed first; 3/3 fixture tests pass after fix | | chromium, 1440 + 390 | pass | tasks-actions + picker-lazy: 8/8 on dev-01 | | webkit, 1440 + 390 | pass | same specs: 8/8 on dev-02 | | web unit / svelte-check | pass | 109/109 tests; 0 errors, 16 existing warnings | | first-paint brotli bytes | pass | 230948 → 230826, -122 b; raw +81 b, gzip +25 b; css identical | | tick perf | pass | corrected common harness, abab -1.64%; dev control +1.84% | | reorder perf | pass | 809-task group, 50 drags: p95 5.413 / 5.296 ms, both ≤15 ms | | notes perf flatness | **blocked** | save / typing regressions below; under budget is insufficient | | touched list paths flatness | **blocked** | mixed deltas below; baseline also fails first-touch / cold growth budgets | | per-field stale check | **blocked** | #919 contract and shared helper #928 remain dependencies; whole-note versions do not satisfy this gate | | full old b2/b3 scope | **held** | promotion and physical-line move scope still need disposition | | review approval | pending | review discussion carried by comments; no formal approval returned by reviews api | the retry tests kill a real child process. move and delete cover placed(0), committed, history installed, batch published and publication recorded. delete checks exact trash bytes and restore. duplicate covers durable copy before receipt and checks one copy with stable marker identities. fixes include replay fingerprint stability, deterministic duplicate block ids, old journal checksum compatibility, and a lazy persistent reorder writer connection. each change was committed and pushed separately. rust commands, through nix and ~/bin/slot on dev-01: ```sh cargo fmt --all -- --check cargo clippy --workspace --all-targets -j4 -- -D warnings cargo test -p calternal-tasks -j4 reorder -- --nocapture cargo test --workspace -j4 --no-fail-fast ``` workspace v3 ran at `57516bcbf4165307cc9cf60518e21eebe4ba413c`; its crates tree and cargo/flake files are identical to final head. fmt/clippy were repeated at final head. dev's old `02cdf87897c18486cd7856a487896019b3eb470d` and current `258f3eac8` also have identical rust/cargo/flake inputs. crates tree ids: dev `8d3da72182449fb35b5d9c68e190a36541cbd195`, candidate `6734c40ad1e7acefe8e5c462a06922fd2d895ef7`. the final merge adds frontend lazy autocomplete only. perf binaries were built before that merge and are backend-source-equivalent, not newly built at final head. browser commands, in apps/web under nix and ~/bin/slot, with CALTERNAL_BIN pointing to the worktree debug server: ```sh bunx playwright test e2e/tasks-actions.spec.ts e2e/picker-lazy.spec.ts --project=chromium-1440 --project=chromium-390 --workers=1 --retries=0 bunx playwright test e2e/tasks-actions.spec.ts e2e/picker-lazy.spec.ts --project=webkit-1440 --project=webkit-390 --workers=1 --retries=0 ``` frozen bun install, web check/unit tests and `bun run build && node scripts/bundle-size.mjs dist --json` also ran under nix/slot. current repository first-paint gate measures brotli. final dev/pr raw js 812596/812677, gzip 271284/271309, brotli 230948/230826. css raw/gzip/brotli 102659/21043/18383 on both. the pending ui stack was separately probed on scratch integration `9b54b7a8a`, not the merge candidate: chromium 70 pass / 11 skip / 1 initial failure; webkit 69 / 11 / 2. composer and board failures did not reproduce in repeat-10 on either candidate or dev, so the brief's flake rule applies. tracked in #964 and #979. that stack still uses update/set-status; passing browser protocol cases does not claim move/reorder/delete ui wiring exists. perf: both shared locks held together, perf-01 acquired first; measurement on perf-01; fresh 10k-note/task fixtures, abab and dev-vs-dev control in the same session. all binaries verified by sha256. all jobs stopped, locks released and seeded data removed. raw reports, exact shell scripts and binary fingerprints are attached. corrected tick (`--existing-only --n 50`): each round has 100 accepted transitions, 50 retries and zero final refusals, including retry cost. p95 dev a1/a2 396.469/417.524 ms; candidate b1/b2 396.293/404.340 ms. means 406.996 → 400.317 ms, **-1.64%**; control 410.277 → 417.830 ms, **+1.84%**. old tick reports without `v2` timed mostly no-ops and are excluded from the verdict. new action endpoints are absent on dev, so these are absolute measurements, with delta n/a. `tasks-write-bench --n 50`, two fresh fixtures: | action p95 ms | run 1 | run 2 | mean | |---|---:|---:|---:| | reorder | 5.413 | 5.296 | 5.354 | | move when | 158.017 | 158.883 | 158.450 | | someday to day | 314.136 | 344.840 | 329.488 | | delete | 735.806 | 727.341 | 731.573 | | duplicate | 33.316 | 31.274 | 32.295 | | undo | 347.722 | 360.100 | 353.911 | | 50-task batch | 1401.510 | 1338.667 | 1370.088 | action timing is answered-attempt latency; logs separately report 100 version refetches and 50 late retries, zero final refusals. common tick above includes retry cost. before the persistent writer fix, reorder was 20.90/12.44 ms; fixed results pass twice. final notes abab uses identical common `calternal-perf` harness: `--sizes 10k --runs 5 --cold-runs 1 --idle-secs 0 --storm-edits 10000 --storm-clients 4 --seed 1`. p95 means of a1/a2 and b1/b2: | path | dev ms | pr ms | delta | dev control delta | |---|---:|---:|---:|---:| | storm.save_ack | 10.0609 | 13.6584 | **+35.76%** | -2.02% | | storm.typing | 2.2192 | 2.6098 | **+17.60%** | +2.18% | | sync.durable_write | 23.6342 | 26.7960 | +13.38% | +11.54% | | sync.round_trip | .3457 | .3518 | +1.76% | -2.04% | | sync.save_ack | 4.8470 | 5.1320 | +5.88% | +5.48% | | uploads.save_ack | 8.5694 | 11.3508 | +32.46% | -8.13% | earlier 3-warm-run results are retained separately, not pooled: sync.save_ack +63.41%, storm.typing +6.32%, storm.save_ack -5.25%. neither run establishes flat notes. #898 already owns executor hot-path redesign; this report does not merge or duplicate that work. additional touched list/version path, `tasks-bench --notes 10000 --runs 3`, 200 list samples and 2340 warm writes per round: | path | dev ms | pr ms | delta | dev control delta | |---|---:|---:|---:|---:| | anytime | 2.4562 | 2.4123 | -1.79% | -7.73% | | anytime child-tag | 3.0350 | 3.2677 | +7.66% | -30.14% | | counts | .3352 | .3281 | -2.12% | -34.54% | | someday | 2.5628 | 2.4607 | -3.98% | -3.88% | | tag | 2.8994 | 3.1500 | +8.64% | -3.27% | | today | 3.0782 | 3.1047 | +.86% | -.06% | | today priority | 2.8763 | 2.7965 | -2.77% | -10.18% | | untracked | 1.7239 | 1.3958 | -19.03% | -40.68% | | upcoming | 2.9519 | 2.9988 | +1.59% | -5.11% | | update first touch | 56.5777 | 57.5108 | +1.65% | -.86% | | update warm | 16.1256 | 15.5086 | -3.83% | -8.66% | | describe | .0378 | .0360 | -4.78% | +.26% | | describe + rows | .0791 | .0724 | -8.55% | -1.62% | all six list harness rounds exit 1 because first-touch update exceeds 40 ms and cold-index growth exceeds 10%, also on dev. these are complete measurement reports, not harness crashes or candidate-only failures. keeper proposal: keep **#894**, which carries the reviewed executor replacement from review comment 76844. #798's round-three discussion explicitly requested simplification and #799 was held for the same redesign. conditionally close #798 (`f1600ed05109566241d1daa19c91be1d2b81ed1c`) and #799 (`7656894724bc5689f4ae3807665c703c455107bd`) as superseded only after their review probes and missing promotion/physical-line-move scope have been preserved or explicitly resolved. do not close #894 as the duplicate. proposals were posted on all three; no closure or merge performed. [evidence archive](https://git.kayg.org/attachments/48ee4b2d-0f18-45b2-a584-d4210f1adbd4)
Author
Owner

Orchestrator status: WebKit specs are pending: std VMs paused; no new jobs will start on dev-02 or the Mac while the pause is active. Chromium specs will run on dev-01. Perf work is waiting for the current perf-01 lock and the maintainer CPU pin; post-pin comparisons will use a fresh ABAB session and same-session dev-vs-dev control.

Orchestrator status: WebKit specs are `pending: std VMs paused`; no new jobs will start on dev-02 or the Mac while the pause is active. Chromium specs will run on dev-01. Perf work is waiting for the current perf-01 lock and the maintainer CPU pin; post-pin comparisons will use a fresh ABAB session and same-session dev-vs-dev control.
Author
Owner

Gate update: WebKit pending: std VMs paused. No dev-02 or Mac job has been started. Tasks and Notes Chromium is queued on dev-01 behind daily-train-1108; the fresh perf ABAB has not started while the train is active. I will post the post-pin comparison after it completes.

Gate update: WebKit pending: std VMs paused. No dev-02 or Mac job has been started. Tasks and Notes Chromium is queued on dev-01 behind daily-train-1108; the fresh perf ABAB has not started while the train is active. I will post the post-pin comparison after it completes.
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled
Merge conflict checking is in progress. Try again in few moments.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/tasks-actions:feat/tasks-actions
git switch feat/tasks-actions

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch dev
git merge --no-ff feat/tasks-actions
git switch feat/tasks-actions
git rebase dev
git switch dev
git merge --ff-only feat/tasks-actions
git switch feat/tasks-actions
git rebase dev
git switch dev
git merge --no-ff feat/tasks-actions
git switch dev
git merge --squash feat/tasks-actions
git switch dev
git merge --ff-only feat/tasks-actions
git switch dev
git merge feat/tasks-actions
git push origin dev
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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
kayg/calternal!894
No description provided.