# Row 62 probes: reading a Vikunja project tree Row 62 (#1555) has the tasks adapter read a project and its descendants. No probe in this row touched a live Vikunja. Sage told me not to touch any board or project, live or scratch, during the build. The evidence below is the v2.7.0 source, Sage's live records, and the fake. Sage runs the reads in "Gate read" with the sync key at the gate. ## Source Vikunja tag `v2.7.0`, commit `a16be96aa454671fdf213b0fbe411dd38a098418`, read from a local checkout. Paths are relative to the repository root. ### P1. The project list `pkg/routes/api/v2/projects.go:39-46` registers `GET /projects` (`projects-list`). Its description: "Returns the projects the authenticated user has access to (owned plus shared, with child projects of accessible parents), paginated. Archived projects are excluded unless is_archived=true." The adapter reads it with the standard envelope (`items`, `total`, `page`, `per_page`, `total_pages`), every page. `pkg/models/project.go:620-651`: `getArchived` false adds `is_archived = false` to the query. `pkg/models/project.go:154-158`: pseudo-project ids are negative. -1 is Favorites, -2 and below are saved filters. The adapter skips any id at or below 0. The recorded `GET /projects/{p}` body in `packages/tasks/tests/fixtures/v2-shapes.json` has `parent_project_id` and `is_archived`, so the field names and types come from a v2.7.0 answer, not only from source. No recording holds a `GET /projects` list. `shapes.test.mjs` checks the fake's list items against the recorded project object and its envelope against the recorded views list. ### P2. Archive state reaches every descendant `pkg/models/project.go:884-888`: "is_archived is materialized down the tree". `pkg/models/project.go:1216-1217` calls `SetArchiveStateForProjectDescendants` when the flag changes, and `:1634` is the update that writes it. An archived parent's children come back with `is_archived: true` too, so skipping archived items drops the whole subtree. The adapter also checks the flag itself, in case a list ever includes archived projects. ### P3. Access inherits down the tree `pkg/models/project_access.go:31-75`. `projectAccess` holds every project a user can reach. The query joins each grant to `project_ancestors`, so a share on a parent reaches every descendant. An inherited admin wins anywhere in the chain. Below admin, the nearest grant wins. The sync bot needs one share on the root. The fake's `#access` follows the same rule. ### P4. One task read across projects `pkg/routes/api/v2/task_collection.go:134-141` registers `GET /tasks` (`tasks-list`): "Returns the tasks the authenticated user can see across every project they have access to, paginated and flat." `pkg/models/api_routes.go:495-501`, in `tokenAuthorizesRoute`: a token with `tasks` `read_all` may call `GET /api/v2/tasks` as well as `/api/v2/projects/:project/tasks`. The sync key already has that scope. `pkg/models/task_collection.go:220-230`: with no project id, the project set is `getRawProjectsForUser` with the default options, so archived projects are left out (P1). `pkg/models/task_collection_filter.go:157-158` maps a filter field `project` to `project_id`, so `project in 19, 18, 20, 21` filters by project. The adapter doesn't use this read. It reads per project, as the brief says, until Sage's live read shows the cross-project read works for the sync key. If it does, a tick is two GETs whatever the tree's size, and a switch is a follow-up row. ### P5. Writes on buckets and views The adapter writes no project, view or bucket. Sage's first install attempt on #19 (`agents/sage/work/vikunja-setup/2026-10-11_install-19-attempt1.json`) found that on v2.7.0 bucket and view writes answer 405 under `/api/v2` and work only under `/api/v1`. Nothing in this row depends on that. It matters for the runbook, which installs boards by hand. ## Live facts used From Sage, with the records under `agents/sage/work/vikunja-setup/`: - The bus is on project #19. Its kanban view is 76. The five buckets are todo 159, in-progress 161, in-review 248, blocked 162, done 57. - View 76 has four more buckets: 55, 56, 158 (`Backlog`) and 160 (`Pending`). The install check ignores extra buckets. A task in one is recorded with that raw bucket id (`tree.test.mjs`, "a task in a bucket outside the five keeps its raw bucket id"). - #19's children are 18, 20 and 21. None has the five buckets, so all three are watch-only. - Sync key 9 has `projects` `read_all` and lists 29 projects (`2026-10-11_shares-read.json`). ## Fake `packages/tasks/src/fake.mjs` models: - `parent_project_id` on every project, set by `project(..., {parent})` and changed by `reparent()` - `GET /projects` with `projects` `read_all`, archived projects left out unless `is_archived=true`, saved filters and Favorites as negative ids (`pseudoProject()`) - `archive()`, which marks the project and every descendant - access through an ancestor's share, as in P3, and `unshare()` `packages/tasks/tests/tree.test.mjs` runs the adapter against it. The three-level test counts requests. A quiet tick on a root, one installed child and one watch-only grandchild is 6 GETs, two per project. A reconcile is 11: the project list, views and buckets for each of the two children, and two per project. For #19 (root plus three watch-only children) a quiet tick is 8 GETs, plus one per extra page of 50 tasks. ## Gate read Two GETs, sync key 9, against the live instance. Record the status, the item count on the page and `total`. Record no titles. Spaces in a filter go as `%20`. 1. The tree. ``` GET /api/v2/projects query: per_page=50&page=1 key: sync key 9 ``` Expect 200 and `total` 29 (Sage's earlier read). Also record, for ids 19, 18, 20 and 21 only, `parent_project_id` and `is_archived`. Expect 18, 20 and 21 to have parent 19, and all four `is_archived: false`. This is the read `discover()` makes at startup and at each reconcile. 2. One task read across the tree. ``` GET /api/v2/tasks query: filter=project in 19, 18, 20, 21&sort_by=id&order_by=asc&per_page=50&page=1 key: sync key 9 ``` A 200 means the cross-project read works for the sync key, and a follow-up row can move a tick to it. 401 or 403 means the scope check in P4 doesn't hold for this key, and the adapter keeps reading per project. For comparison, the per-project open counts are what the adapter reads today, one per project: ``` GET /api/v2/projects/{19,18,20,21}/tasks query: filter=done = false&sort_by=id&order_by=asc&per_page=50&page=1 key: sync key 9 ``` These four are optional. They let the gate compare `total` from read 2 with the sum the adapter would see. Read 2 has no `done` filter, so its `total` counts closed tasks too. The host config with `tracker.descendants` set reaches the adapter through `packages/cli/src/config.mjs` (lead decision 87), so the gate needs no packet script.