Candidate manifest sha256 ec3f7a42f3bf6436634a5a83a12bb5678ca1e58a10ff4f66a23dfca7ff3f375f, 13 files onedc5de71. Gate on6ae8b844: 13 suites green. The candidate itself is not committed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
6.8 KiB
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
projectsread_alland lists 29 projects (2026-10-11_shares-read.json).
Fake
packages/tasks/src/fake.mjs models:
parent_project_idon every project, set byproject(..., {parent})and changed byreparent()GET /projectswithprojectsread_all, archived projects left out unlessis_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.
-
The tree.
GET /api/v2/projects query: per_page=50&page=1 key: sync key 9Expect 200 and
total29 (Sage's earlier read). Also record, for ids 19, 18, 20 and 21 only,parent_project_idandis_archived. Expect 18, 20 and 21 to have parent 19, and all fouris_archived: false. This is the readdiscover()makes at startup and at each reconcile. -
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 9A 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 9These four are optional. They let the gate compare
totalfrom read 2 with the sum the adapter would see. Read 2 has nodonefilter, so itstotalcounts 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.