# 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 refuses `task-project`, as a foreign ref always has. A project in the tree with no view, a watch-only one, refuses with the new code `project-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, and `read()`, `step()` and every `plan.check` take that. A bucket move, a close and the board read all use the task's own project and view. No `ctx.project` or `ctx.view` is left in `read()`, `task.update`, `task.close` or `create()`'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, so `located()` reads each related task once with the sync token before `broker.authorize`. Missing gives the tracker's refusal (`task-not-found`), a task in another project than its ref names gives `task-moved`. This covers relations on `task.create` and both the add and the remove lists of `task.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.create` takes an optional `project`, Mos's ruling as the brief states it. Left out, it's the root. It must be a positive safe integer (`invalid-request` otherwise, so `"1"`, `1.5`, `0` and `null` refuse before any read) and an installed project of the tree (`project-not-installed` for watch-only, unknown and outside alike). ## Choices to review 1. **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-installed` for both and for a project outside the tree. A ref outside the tree keeps `task-project`, because a ref names a task and that code already means "not this business's task". A create's `project` names where to write, and the brief puts unknown with watch-only. 2. **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. 3. **`tree.test.mjs` is outside the brief's owned files.** One assertion there expected `task-project` for 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 to `project-not-installed` and its comment says where the installed cases are tested. Nothing else in the file changed. 4. **The create decision still binds no target.** `task.create` authorizes with `target: 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. 5. **`fake.mjs` is untouched.** `moveToProject` already 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): 1. 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.update` with a state on the child's view, `task.scope.change` by the coder and `task.priority.change` by 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. 2. `task.create` refuses the watch-only grandchild, a project outside the tree and 999 with `project-not-installed`; `null`, `0`, `-1`, `1.5`, `"1"` and `[id]` with `invalid-request`. No writes, no allowed creates. 3. With descendants off, create takes only the root. 4. A verb on the watch-only task and a relation to it refuse `project-not-installed`; `vikunja:999/1` still gives `task-project`. 5. Relations are read before authority: a ref claiming the child for a root task gives `task-moved`, a missing child task gives `task-not-found`, for schedule add, schedule remove and create, with no writes; then a correct relation goes through. 6. A task moved to another project of the tree refuses its old ref with `task-moved` and 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 |