# Row 62 (#1555): the adapter reads a project tree, candidate packet Author: Darkwing. Reviewers: Dewey and Filbert. Brief: `docs/plans/2026-10-11_multi-project-sync.md`, row A. Base: `edc5de71` (`base.txt`). It applies unchanged to `6ae8b844`, where the gate ran: the commits between the two touch no file in this candidate. The feature is uncommitted; only this packet is committed. I pushed nothing. ## Files `files.txt` lists the 13 repository paths. `candidate-manifest.sha256` has their sha256. `build.patch` is `git diff --cached --binary edc5de71` over them: 13 files, +608/−73. | File | Change | |---|---| | `packages/business/src/vars.mjs` | `tracker.descendants`: project layer, boolean, default false | | `packages/business/tests/vars.test.mjs` | accepts true, refuses `"true"`, `1` and the business layer; default false with provenance `default` | | `packages/cli/src/config.mjs` | one line, `descendants: v["tracker.descendants"]` in `trackerFor()` (decision 87) | | `packages/cli/tests/config.test.mjs` | the two tracker tests expect `descendants: false`; one more assertion that `true` reaches the boot entry | | `packages/cli/tests/trackers-boot.test.mjs` | line 44 expects `descendants: false`; see "Scope" | | `packages/tasks/src/adapter.mjs` | `settings()` takes `descendants` (absent means false, a non-boolean refuses `tracker-config`); `ctx.tree` | | `packages/tasks/src/startup.mjs` | `inspect(ctx, project)` is the install check for any project; new `discover(ctx)` | | `packages/tasks/src/sync.mjs` | tick and reconcile read each project in `ctx.tree`; watch-only reads; the leave-tree rule in `missing()` | | `packages/tasks/src/fake.mjs` | project trees: parents, the project list, archive, inherited access | | `packages/tasks/tests/tree.test.mjs` | new: 11 tests, listed below | | `packages/tasks/tests/shapes.test.mjs` | new test: the fake's project list against recorded shapes | | `packages/tasks/README.md` | new section "Project trees"; the host contract and startup notes | | `packages/tasks/live/README.md` | new section "Project trees" | `verbs.mjs`, `vikunja.mjs`, `index.mjs` and `packages/bus/` are unchanged. ## Scope `packages/cli/src/config.mjs` and `config.test.mjs` are outside the brief's files. Sage added them as lead decision 87 (commit `9277de33`), a decided scope addition: without the line, a host config with `tracker.descendants` set never reaches the adapter, and the gate read would need a packet script. `packages/cli/tests/trackers-boot.test.mjs` is not named in decision 87. It follows from it. Line 44 compares `boot.trackers` with an exact object, so the config line breaks it: with the original line 44 and the candidate `config.mjs` the test fails, 0 passed and 1 failed. The change adds `descendants: false` to that object and nothing else. Reviewers and Sage should treat it as part of decision 87's addition, or tell me to take it out. The extra `config.test.mjs` assertion, `true` reaching the boot entry, is mine. Decision 87 asked for the two `false` updates. Without the new assertion no test sent `true` through `config.mjs`, and the gate's live run is the first place that value is used. ## How it works With `descendants` off, `ctx.tree` holds the root and its view from startup and nothing else. Every read is the v0 read with the root's project id. The v0 tests pass unchanged, and tree test 1 checks that no `GET /projects` and no read under a child happens. With it on: 1. Startup is unchanged. The root's install check still refuses the business with `tracker-install`. 2. Each reconcile, including the first one at start, calls `discover()`. It reads `GET /projects`, every page, and walks `parent_project_id` breadth-first from the root. It skips ids at or below 0 and archived projects. Each child gets `inspect()`. A pass gives its view, a `tracker-install` refusal gives `null` (watch-only), and any other refusal propagates. `ctx.tree` is replaced only after the walk finishes, so a failed reconcile keeps the previous tree. 3. Tick and reconcile read each project in `ctx.tree`. An installed project is read through its board, as v0 reads the root. A watch-only one is read through `walk(ctx, 'done = false', {}, project)`, and each task gets bucket `WATCH_ONLY` (0). 4. Gone detection in a tick uses a set of refs placed by this tick's reads, not task ids. A task moved from A to B shows on B's board under the same id; keying by id would hide the old ref's tombstone. The `placed-by-id` mutant below fails the move test. 5. `missing()` takes the project from the ref (`parseRef`). If the task still reads 200 in the same project but that project is no longer in `ctx.tree`, the task gets `no-access` with its last `updated`. `fieldsFor()` builds the fields of a watch-only task with `taskFields(task, 1)` and then sets `bucket: 0`. `taskFields` checks the bucket is a positive integer, so the 1 is a placeholder that never reaches the ledger. The bus schema accepts 0 (an integer). One line in reconcile changed meaning: `if (!bucket) continue` became `if (bucket === undefined) continue`. The old form would skip every watch-only task, since 0 is falsy. `undefined` is still the case it was written for, an open task the board didn't show. ## The brief, line by line | Brief | Where | |---|---| | `tracker.descendants`, project-layer boolean, default false | `vars.mjs`; `vars.test.mjs` "types" and "merge" | | The tree from `GET /projects` by `parent_project_id` | `discover()`; tree tests 2 and 5 | | Discovery at startup and each reconcile, not each tick | tree test 2 (tick has no `GET /projects`, reconcile starts with it); test 8 (a late child joins at reconcile, not tick) | | Archived projects are not in the tree | tree test 5 (with the list forced to include archived ones); test 9 | | Leave-tree rule, recorded and tested | README "Project trees"; tree test 9, three ways | | The ledger is never rewritten | tree test 9: snapshots read `open`, then `no-access` with the same `updated` | | Root install fails: refuse | tree test 4 | | Child passes: installed. Fails: watch-only, business ready | tree tests 2 and 3 | | Watch-only records title, done, assignees, comments; no bucket state | tree test 11, decision 87's bucket 0 | | Moves root to child, child to child, child to outside | tree test 7 | | Poll cost stated | README; tree test 2 counts 6 GETs per tick and 11 per reconcile; probes.md P4 | | Cross-project read probed | probes.md P4 and "Gate read" | | The fake models the tree | `fake.mjs`; probes.md "Fake"; shapes test 2 | | Descendants off matches v0 | tree test 1; every pre-existing tasks test unchanged | | verbs read-only; a child task refuses `task-project` | `verbs.mjs` unchanged; tree test 11 | | No change to `packages/bus/`; no Vikunja writes | `files.txt`; tree test 11 checks `sync.mjs` has no write method string | ## Tests `packages/tasks/tests/tree.test.mjs`: 1. With descendants off the adapter reads no project list and no child, as in v0. 2. A three-level tree is read per project, and a project outside it is not. Root, an installed child, a watch-only grandchild and an outside project shared with the sync bot. The tick's six requests are checked in order; the reconcile is `GET /projects` plus 10. 3. A watch-only child keeps the business ready, and a failed child read refuses as transient (a 503 on its views). 4. A root without the installed buckets refuses with descendants on. 5. The project list refuses without `read_all`, and skips archived projects and saved filters. A fetch wrapper adds `is_archived=true`, so the fake returns archived projects and the adapter's own check has to drop them. 6. `descendants` must be true or false. 7. A move inside the tree is a moved tombstone plus a snapshot under the new ref: root to A, A to B (watch-only), A to outside. 8. A child made after startup joins the tree at the next reconcile. 9. A child that leaves the tree has its tasks recorded missing, and the ledger keeps their history: archived, reparented to the top with a direct share, and reparented under a project the bot can't see. The hidden parent's title never reaches the bus. 10. A task in a bucket outside the five keeps its raw bucket id. 11. Bucket 0 on a watch-only task never becomes a bucket or a state. A move between open buckets records nothing; done then reopen records `done` twice; every snapshot has bucket 0; no `task.state` event; a verb refuses `task-project`; the source of `sync.mjs` has no `.titles` or `.ids[` lookup and no write method string. This is the test decision 87 asked for. `shapes.test.mjs` adds "the project list holds recorded project objects in the recorded list envelope". No recording holds a `GET /projects` list, so it checks the envelope against the recorded views list and each item against the recorded `GET /projects/{p}` body, keys and types. ### Mutants `out/mutants.sh` edits one source file in the build worktree, runs the tests, and restores the file from the candidate. Each mutant fails at least one test (`out/mutants.txt`): | Mutant | Fails | |---|---| | `no-leave-tree-check`: drop the `ctx.tree.has(project)` branch in `missing()` | tree 9 | | `no-watch-only-bucket`: `fieldsFor` keeps the bucket it is given | tree 2, 3, 7, 8, 11 | | `placed-by-id`: gone detection keyed by task id | tree 7 | | `keep-archived`: `discover()` keeps archived projects | tree 5 | | `config-drops-descendants`: `config.mjs` passes `false` | cli config "trackers come from..." | ## Things a reviewer should know - The `p.id <= 0` skip guards shape only. The walk starts at the root and follows positive parent ids, so it can't reach a negative id. The skip stops a saved filter from failing the `parent_project_id` check. - The fake's `GET /projects/{p}` body now has `parent_project_id`, `position`, `created` and `updated`, from a shared `#projectJson`. Shapes test 1 still matches it to the recorded body. - `commentsAt` and `commentCount` stay keyed by task id. Vikunja task ids are global, so a moved task keeps its comment state. - A watch-only child's open tasks come from its task list, so a tick costs the same two GETs as an installed project. - I noticed in the v2.7.0 source that creating a project under v2 also creates a default kanban with a backlog bucket. A child made in the UI is then watch-only until someone installs the five buckets by hand. Nothing in this row depends on it. - No probe in this row touched a live Vikunja. probes.md has the source evidence and the exact gate read. ## Gate The gate ran in a detached worktree of `6ae8b844` with `build.patch` applied and `node_modules` linked to the main checkout's (`out/gate/apply.txt`, `out/gate/gate.sh`). Every suite ran in order and each log is in `out/gate/`. test-task ran with `DOCKER_HOST=unix:///nonexistent`. Every suite passed on this candidate (`out/gate/summary.txt`): | Suite | rc | Result | |---|---|---| | `packages/tasks` | 0 | 63 passed, 0 failed | | `packages/business` | 0 | 60 passed, 0 failed | | `packages/bus` | 0 | 74 passed, 0 failed | | `packages/cli` | 0 | 83 passed, 0 failed | | test-auth | 0 | 15 passed, 0 failed | | test-conductor | 0 | 17 passed, 0 failed | | test-config | 0 | 24 passed, 0 failed | | test-discord | 0 | 66 passed, 0 failed | | test-extension-package | 0 | 18 passed, 0 failed | | test-foundation | 0 | 44 passed, 0 failed | | test-queue | 0 | 27 passed, 0 failed | | test-release | 0 | 14 passed, 0 failed | | test-task | 0 | 26 passed, 0 failed | After the run I removed the gate worktree's `gate_default` Docker network; it had no containers. Two earlier gate runs were stopped. The first had no `node_modules` link, so test-discord failed its "pi binary present" check, 57 passed and 1 failed. The second I stopped to add the `true` assertion to `config.test.mjs`, which changed the candidate. Neither run's logs are in this packet; they were on the earlier candidate. ## Gate read for Sage probes.md "Gate read" has the exact requests: `GET /api/v2/projects` and `GET /api/v2/tasks` with a `project in 19, 18, 20, 21` filter, sync key 9, GETs only. The adapter reads per project whatever read 2 answers; a 200 there opens a follow-up row.