Candidate for #1520 against 81339889: packages/tasks and the bus task
verbs. Live run rehearsed on a scratch v2.7.0 container; the estate run
waits for T236's base URL.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
221 lines
11 KiB
Markdown
221 lines
11 KiB
Markdown
# S3 probes, addendum B section 7 (Darkwing, row 38, #1520)
|
|
|
|
Run on 2026-10-08 against a scratch Vikunja: the runbook's Path B digest
|
|
`vikunja/vikunja@sha256:e2204a1c1c6a81e833c2b3a5442be182ca2335b54c2e7e37578cc3fe12a27cfc`
|
|
(v2.7.0), sqlite, bound to `127.0.0.1:34571`, data dir under
|
|
`~/darkwing-scratch/s3/vk/`, wiped before each run. No probe touched the estate
|
|
instance or tasks.mosaicstack.dev. Users and bots were created by the probe with
|
|
random in-memory passwords. Each script refuses to write its output if any
|
|
password or token appears in it.
|
|
|
|
Scripts and raw output live in `~/darkwing-scratch/s3/probes/`:
|
|
`probe-s7.mjs` (main), `probe-s7b.mjs` (label control, comments, cursor, shapes,
|
|
rate limit), `probe-s7c.mjs` (date-filter bounds), `probe-s7d.mjs` and
|
|
`probe-s7e.mjs` (bump timing), `probe-s7f.mjs` (token revoke, decision 68). Source reads are from the v2.7.0 tag.
|
|
|
|
## L. Labels: what a bot can see
|
|
|
|
This was the critical probe. Result: with the runbook as written, a bot sees
|
|
labels from projects that aren't shared with it. If the bots get a dedicated
|
|
owner account, it sees none of them.
|
|
|
|
1. The runbook creates the bots from the owner's account. A pm bot shared only
|
|
into mosaic-stack got the full list back from `GET /labels`:
|
|
`on-ms-task`, `on-launchpad-task`, `on-personal-task`, `owner-unattached`.
|
|
A search for `launchpad` returned the same list. The bot also attached the
|
|
owner's launchpad label and the owner's unattached label to a mosaic-stack
|
|
task (201). Another user's label gave 403.
|
|
2. This is intended upstream behavior. `pkg/models/label_test.go` (#3592) says
|
|
a bot reads labels its owner created and labels sibling bots created.
|
|
3. Control: the same bot, owned by a separate account `svc-mosaic-stack` that
|
|
owns no labels, was shared into the same project with the same scopes.
|
|
- `GET /labels` showed only `owner-label-on-ms-task`, the one label attached
|
|
to a task in mosaic-stack.
|
|
- Attaching the owner's launchpad label gave 403, and so did the owner's
|
|
unattached label. The label already on an ms task gave 201.
|
|
- After the owner detached that label from every ms task, the bot saw `[]`.
|
|
- After another member attached their own label to an ms task, the bot saw
|
|
that label.
|
|
- The owner's account can't mint a token for svc's bot: `POST /tokens` gave
|
|
404/1005. Token minting has to run as the svc account.
|
|
4. A regular shared member sees their own labels plus the labels on tasks they
|
|
can read. Bots are the only case that leaks.
|
|
|
|
What I recommend for T236 and the runbook:
|
|
- Create the five `bot-$BIZ-*` accounts from a dedicated per-business owner
|
|
account. It owns no labels and no other bots, and it isn't Jason's account.
|
|
Share the bots into the project as the runbook already does.
|
|
- Mint the tokens while logged in as that account.
|
|
- In S3, the broker also accepts only label ids listed in the business file,
|
|
so a label that becomes visible later can't be attached.
|
|
|
|
## R. Revoking a bot's token (decision 68)
|
|
|
|
`probe-s7f.mjs`. `svc-mosaic-stack` creates bot `bot-ms-pm` and mints two
|
|
tokens for it, a and b. The owner shares the bot into the project. Every
|
|
account call uses a `/login` JWT, the same kind `vklogin` writes.
|
|
|
|
| Request | Result |
|
|
|---|---|
|
|
| owner `DELETE /tokens/{a}` | 403 Forbidden, token a still 200 |
|
|
| bot `DELETE /tokens/{a}` with token a | 401/11, token a still 200 |
|
|
| svc `DELETE /tokens/{a}` | 204 |
|
|
| token a straight after, and 1.5 s later | 401/11 |
|
|
| token b after a's revoke | 200 |
|
|
| svc `DELETE /tokens/{a}` again | 403 Forbidden |
|
|
| svc `GET /tokens` | `[]`, the bot's tokens aren't listed |
|
|
| svc `GET /tokens?owner_id={bot}` | `items` envelope with a and b, then only b |
|
|
| owner `GET /tokens?owner_id={bot}` | 404/1005 |
|
|
| svc mints token c, then calls with c and a | c 200, a 401/11 |
|
|
|
|
- Only the account that owns the bot can revoke its tokens, and the revoke
|
|
takes effect on the next request. Revoking one token leaves the bot's
|
|
other tokens working.
|
|
- To find an old token's id, list with `owner_id` as svc. A plain
|
|
`GET /tokens` returns only svc's own tokens.
|
|
|
|
## O. Startup probe order
|
|
|
|
The order in addendum B section 3 holds. With the minimal scopes, every probe
|
|
against a missing task gives 401/11 before any 404:
|
|
- sync `PATCH /tasks/{missing} {}`, pm `DELETE`, coder `DELETE` and coder
|
|
`POST /tasks/{missing}/labels`.
|
|
- The controls (`GET /tasks/{missing}` 404/4002, sync `GET /projects/{p}` 200)
|
|
show that the token itself works.
|
|
- An over-broad token gets 404/4002 on the same probes. That 404 is the signal
|
|
that the token has more scope than it needs.
|
|
- An over-broad sync token's `PATCH {}` on an existing task returns 304 and
|
|
writes nothing.
|
|
- An unknown token and an expired token both give 401/11.
|
|
- A mint with a past `expires_at` is accepted (201), so S3 checks expiry itself.
|
|
|
|
## U. What bumps `updated`
|
|
|
|
| Change | Bumps `updated` | How |
|
|
|---|---|---|
|
|
| PATCH of an owned field | yes | in the request |
|
|
| PATCH with the same value | no, 304 | |
|
|
| label add or remove | yes | in the request |
|
|
| comment create, edit or delete | yes | async listener |
|
|
| assignee add or remove | yes | async listener |
|
|
| relation create or delete | yes, source task only | async listener |
|
|
| move between open buckets | **no** | |
|
|
| move into done | yes, sets `done` | |
|
|
|
|
- The comment row is a correction to my first run, which recorded "comment
|
|
create does not bump". The listener is `HandleTaskUpdateLastUpdated`, which
|
|
`pkg/models/listeners.go` registers for comment, assignee, attachment and
|
|
relation events and dispatches on commit.
|
|
- Measured lag (`probe-s7e.txt`): three comments on an idle server, two by
|
|
the bot token and one by the owner. Each bump showed on the read 0 ms
|
|
after the 201, and reads at 50 ms to 4 s showed the same value.
|
|
- In the first run (`probe-s7.txt`), with other writes in flight, the read
|
|
straight after the comment showed no bump. The next read, 5 s later,
|
|
showed it. That bounds the lag under load at 5 s. It isn't a measurement
|
|
of the lag itself. The 60 s poll window is twelve times that bound.
|
|
- `updated` is reported to the second. Comment and label `created` carry
|
|
nanoseconds in the POST answer only. Read back with GET, they're whole
|
|
seconds too, and the comment check's bound is floored to match.
|
|
|
|
## Paging and the poll cursor
|
|
|
|
- `max_items_per_page` is 50, and `per_page=200` is capped at 50 on the board
|
|
and the list.
|
|
- Board paging works past 50. With 121 tasks in one bucket, pages held 50, 50
|
|
and 21, every page reported `count` 121, and all 121 ids were distinct.
|
|
- The list envelope is `{items, total, page, per_page, total_pages}`.
|
|
- Sorting by `updated,id` ascending orders correctly.
|
|
- 120 creates took 565 ms, so many tasks share one reported second.
|
|
- On this sqlite instance, a date filter can't select one reported second:
|
|
- `updated = T` returned 0, even with 13 tasks showing `updated` T.
|
|
- Those 13 matched `updated < T`, and tasks reported at T+1 s matched
|
|
`updated < T+1s`. The stored value and the reported second disagree by up
|
|
to a second.
|
|
- Windows with a margin work: `updated > T-1s && updated < T+1s` returned
|
|
all 60.
|
|
- A window plus `id > X`, sorted by id, pages correctly.
|
|
- The `.000Z`, `+00:00` and quoted forms parse. A space-separated form
|
|
gives 400.
|
|
|
|
What S3 does with this:
|
|
- The poll lower bound is the previous poll's start time minus a margin, not
|
|
the largest `updated` seen. The margin is 60 s, which covers listener lag
|
|
and rounding.
|
|
- Pages are walked with `id > last` under a fixed window.
|
|
- Snapshots dedupe overlap: a task whose fields match `task_current` produces
|
|
no new row.
|
|
- Moves between open buckets don't bump `updated`, so the board read stays in
|
|
every cycle. The reconcile's `comment_count` covers comments.
|
|
|
|
## D. Removing what isn't there
|
|
|
|
| Request | Result |
|
|
|---|---|
|
|
| unassign a user who isn't assigned | 204 (silent) |
|
|
| remove a label that isn't on the task | 403 |
|
|
| remove a relation that doesn't exist | 404/4009 |
|
|
| assign a user who is already assigned | 400/4021 |
|
|
| add a label that is already on the task | 400/8001 |
|
|
|
|
Because unassign is silent, S3 compares before every unassign and records an
|
|
absent target as a no-op, not as a success.
|
|
|
|
## Writes and moves
|
|
|
|
- `PATCH bucket_id` alone returns 200 and echoes the new `bucket_id`, but the
|
|
board bucket doesn't change. S3 refuses `bucket_id` in PATCH (decision 51),
|
|
and the fake rejects it too.
|
|
- `PATCH` with an unknown key gives 422.
|
|
- `PUT …/buckets/{b}/tasks {task_id}` into the done bucket sets `done:true`.
|
|
- On sqlite, a write right after an assignee change got
|
|
`500 database is locked`: the listener held the write lock. S3 treats any 5xx
|
|
or network error on a write as an uncertain outcome. It re-reads and compares,
|
|
and it never retries under the same approval (bus README rule).
|
|
|
|
## M. Tasks that leave the project
|
|
|
|
| Case | sync bot `GET /tasks/{t}` | Snapshot |
|
|
|---|---|---|
|
|
| moved to a project shared with the sync bot | 200, new `project_id` | `moved` with `project` |
|
|
| moved to a project not shared with it | 403, no code | `no-access` |
|
|
| deleted | 404/4002 | `not-found` |
|
|
|
|
## Other
|
|
|
|
- `GET /tasks/{t}` returns an ETag, and `If-None-Match` gives 304.
|
|
- Rate limit: tested with it enabled at 8 per 60 s and the default
|
|
`kind: user`.
|
|
- Bot A got 200 eight times (`x-ratelimit-remaining` 7 down to 0), then 429.
|
|
- Bot B, from the same IP straight after, got 200 with 7 remaining.
|
|
- The limit is per account, so one busy role can't starve the others.
|
|
- The source agrees: `pkg/routes/rate_limit.go` keys by `user_<id>` and
|
|
falls back to the IP for unauthenticated requests.
|
|
- Install: a new kanban view starts with manual buckets. Renaming the three
|
|
defaults with PUT and adding `in-review` and `blocked` with POST keeps
|
|
`done_bucket_id` and `default_bucket_id` pointing at the right buckets.
|
|
|
|
## G. Reads S3 relies on (probe-s7g)
|
|
|
|
Same scratch container and digest, four tasks in one project. These were
|
|
assumptions in the code until this run; each one held.
|
|
|
|
- `related_tasks` is an object keyed by kind. Each value is an array of full
|
|
task objects. After `t2 blocked t1`, t2 shows `{"blocked":[t1]}` and Vikunja
|
|
adds `{"blocking":[t2]}` to t1 on its own. The task read, the project list
|
|
and the board all carry it.
|
|
- After an assign, the task read and the board both return `assignees` as an
|
|
array of user objects. With no assignee the field is `null`, not `[]`.
|
|
- A user object for a person leaves out `bot_owner_id`. A bot's has it.
|
|
- The project list with no `done` filter returns done tasks. So does the
|
|
cursor filter `updated > … && id > 0`. A close made in the UI is therefore
|
|
seen by the cursor, not only by the board diff.
|
|
- `PATCH due_date: null` returns 200 and stores `0001-01-01T00:00:00Z`. S3
|
|
reads that value as no due date.
|
|
- `GET /tasks/2147483647` and `/tasks/2147483648` both give 404/4002. The
|
|
startup probe uses the first as its missing id.
|
|
- The id-keyed walk (`id > last`, `sort_by id`, `per_page 2`) over four tasks
|
|
returned `[1,2,3,4]` in three pages, the last one empty.
|
|
|
|
The three reads with a relation are in `tests/fixtures/v2-shapes.json` as the
|
|
`related` entries, and the shapes test compares the fake against them.
|