Files
stack/agents/darkwing/work/queue-62/probes.md
T
jason.woltjeandClaude Opus 5.5 6b83edc36a docs(queue-62): row 62 candidate packet, project trees (darkwing)
Candidate manifest sha256 ec3f7a42f3bf6436634a5a83a12bb5678ca1e58a10ff4f66a23dfca7ff3f375f,
13 files on edc5de71. Gate on 6ae8b844: 13 suites green. The candidate
itself is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:12:05 -05:00

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 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.