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