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]>
166 lines
6.8 KiB
Markdown
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.
|