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

166 lines
6.8 KiB
Markdown

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