8.6 KiB
Row 63, task verbs across the tree (row B), build (Darkwing)
Issue #1556. Brief: docs/plans/2026-10-11_multi-project-sync.md, section
"Multi-project verbs: task verbs across the tree (row B)" (blob 9301de2e).
Base 08941d8b. No commit from the base to the packet's commit touches
the six paths.
build.patch is git diff --cached --binary 08941d8b of the six paths.
Apply it with git apply --index to a detached worktree of the base, and
sha256sum -c candidate-manifest.sha256 then gives 6/6 OK.
| File | What changed |
|---|---|
packages/tasks/src/verbs.mjs |
verbs act in any installed project of the tree |
packages/tasks/tests/verbs.test.mjs |
six tests across a root, an installed child and a watch-only grandchild |
packages/tasks/tests/tree.test.mjs |
one assertion: the watch-only verb refusal code |
packages/tasks/README.md |
the new step, project, the new refusal |
packages/harness/src/tools.mjs |
task_create gets an optional integer project |
packages/harness/tests/tools.test.mjs |
asserts its type, that it isn't required, that only task_create has it |
What the code does
installed(project)is the one gate. A project the tree doesn't hold refusestask-project, as a foreign ref always has. A project in the tree with no view, a watch-only one, refuses with the new codeproject-not-installed. With descendants off the tree holds the root alone, so nothing changes for a business that hasn't opted in.target()returns{project, view, id}in place of a bare id, andread(),step()and everyplan.checktake that. A bucket move, a close and the board read all use the task's own project and view. Noctx.projectorctx.viewis left inread(),task.update,task.closeorcreate()'s writes; create's default project is the one remaining use.relation()accepts a task in any installed project and keeps its project. A ref's project is only the caller's claim, solocated()reads each related task once with the sync token beforebroker.authorize. Missing gives the tracker's refusal (task-not-found), a task in another project than its ref names givestask-moved. This covers relations ontask.createand both the add and the remove lists oftask.schedule. Without it, a relation to a task the ref placed in the child but which sits in a watch-only project would have been written.task.createtakes an optionalproject, Mos's ruling as the brief states it. Left out, it's the root. It must be a positive safe integer (invalid-requestotherwise, so"1",1.5,0andnullrefuse before any read) and an installed project of the tree (project-not-installedfor watch-only, unknown and outside alike).
Choices to review
- One code for create's unknown project. The brief asks for "a new
code the README names" for a watch-only or unknown project on create.
I used
project-not-installedfor both and for a project outside the tree. A ref outside the tree keepstask-project, because a ref names a task and that code already means "not this business's task". A create'sprojectnames where to write, and the brief puts unknown with watch-only. - Relations are read before authority, which the brief doesn't ask
for. The brief says
relation()accepts any installed project's task. Taking the ref's project on trust would let a caller write a relation to a task in a watch-only or foreign project by naming an installed one. One sync read per related task closes that. It costs a read per relation (create caps relations at 50). The README's verb steps gain a step 5 for it. tree.test.mjsis outside the brief's owned files. One assertion there expectedtask-projectfor a verb on a watch-only child's task, which was row A's v0 behaviour. Row B changes that code by design, so the assertion moves toproject-not-installedand its comment says where the installed cases are tested. Nothing else in the file changed.- The create decision still binds no target.
task.createauthorizes withtarget: null, as before, so a decision approving a create doesn't name which project it approved. A decision raised for the root can authorize a create into an installed child. The tree is the business's own and installed projects are writable by design, so I left it. Binding the project would change the decision record's shape, which is the broker's and not this row's. It's a follow-up if a reviewer wants it. fake.mjsis untouched.moveToProjectalready existed from row A, which was all the moved-task test needed.
Tests
The brief asks for each verb across two installed projects, a watch-only
refusal and a relation between projects. The six new tests in
verbs.test.mjs run on a tree of root, installed child and watch-only
grandchild (the grandchild holds one task the UI made):
- Every verb in the installed child, on the child's own board: create
into the child with a relation to a root task, create with the root
named explicitly, assign (and a reassign that refuses
task-assigned),task.updatewith a state on the child's view,task.scope.changeby the coder andtask.priority.changeby the pm (each with a decision the cto resolves), reassign, schedule with a due date and a relation removal, a root-to-child relation add, and close into the child's done bucket. task.createrefuses the watch-only grandchild, a project outside the tree and 999 withproject-not-installed;null,0,-1,1.5,"1"and[id]withinvalid-request. No writes, no allowed creates.- With descendants off, create takes only the root.
- A verb on the watch-only task and a relation to it refuse
project-not-installed;vikunja:999/1still givestask-project. - Relations are read before authority: a ref claiming the child for a
root task gives
task-moved, a missing child task givestask-not-found, for schedule add, schedule remove and create, with no writes; then a correct relation goes through. - A task moved to another project of the tree refuses its old ref with
task-movedand takes its new one.
Gate
Row 63's gate (tasks, harness, every scripts/test-*.sh) plus row A's
business and bus, sequential in the candidate worktree, TMPDIR in my
scratch, test-task.sh with DOCKER_HOST=unix:///nonexistent. It ran
2026-10-12 19:39:19Z to 19:42:45Z, after Sage's row 81 gate and Rocko's
row 77 run had ended. Every suite exits 0 (gate-summary.txt).
| Suite | Result |
|---|---|
| tasks | 80 pass (74 at the base plus the six) |
| harness | 59 pass |
| business | 60 pass |
| bus | 95 pass |
| test-auth | 15 passed |
| test-conductor | 17 passed |
| test-config | 24 passed |
| test-discord | 66 passed |
| test-extension-package | 18 passed |
| test-foundation | 44 passed |
| test-queue | 27 passed |
| test-release | 14 passed |
| test-task | 26 passed (the live recall pair skips without Docker) |
Mutants
mutants.sh with agents/darkwing/work/queue-59-review/r1/mut.mjs. Each
mutant edits one site (exactly one match), runs verbs.test.mjs and
tree.test.mjs (or the harness tools.test.mjs), and restores the file.
18 of 18 killed (mutants.txt, run 2026-10-12 19:20:11Z to 19:21:24Z).
| Mutant | What it undoes | Killed by |
|---|---|---|
| watch-only-writable | the watch-only refusal in installed() |
assertion, 2 tests |
| create-root-only | create posts to the root whatever project says |
assertion |
| create-watch-only-ok | tree.has in place of tree.get on create |
assertion |
| create-project-any | the integer check on project |
assertion |
| move-root-view | a state move looks up the root's bucket ids | tracker-not-found |
| move-path-root-view | the bucket move path uses the root's view | tracker-not-found |
| close-root-done | close moves to the root's done bucket | tracker-not-found |
| read-root-board | read() reads the root's board |
task-placement, 2 tests |
| read-no-project | read() drops the project check |
assertion (test 6) |
| relation-any-project | relation() drops installed() |
assertion, 2 tests |
| handle-no-located | verbs skip the relation read | assertion |
| create-no-located | create skips the relation read | assertion |
| located-no-project | the relation read drops the project check | assertion |
| schedule-add-only | schedule reads only the add list | assertion |
| located-after-authorize | the relation read moves after authorize | assertion |
| tool-project-string | the tool's project becomes a string |
assertion |
| tool-project-zero | the tool's minimum becomes 0 | assertion |
| tool-project-required | the tool requires project |
assertion |