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]>
11 KiB
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.
- 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 forlaunchpadreturned 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. - This is intended upstream behavior.
pkg/models/label_test.go(#3592) says a bot reads labels its owner created and labels sibling bots created. - Control: the same bot, owned by a separate account
svc-mosaic-stackthat owns no labels, was shared into the same project with the same scopes.GET /labelsshowed onlyowner-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 /tokensgave 404/1005. Token minting has to run as the svc account.
- 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_idas svc. A plainGET /tokensreturns 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} {}, pmDELETE, coderDELETEand coderPOST /tasks/{missing}/labels. - The controls (
GET /tasks/{missing}404/4002, syncGET /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_atis 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, whichpkg/models/listeners.goregisters 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. updatedis reported to the second. Comment and labelcreatedcarry 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_pageis 50, andper_page=200is 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
count121, and all 121 ids were distinct. - The list envelope is
{items, total, page, per_page, total_pages}. - Sorting by
updated,idascending 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 = Treturned 0, even with 13 tasks showingupdatedT.- Those 13 matched
updated < T, and tasks reported at T+1 s matchedupdated < 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+1sreturned all 60. - A window plus
id > X, sorted by id, pages correctly. - The
.000Z,+00:00and 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
updatedseen. The margin is 60 s, which covers listener lag and rounding. - Pages are walked with
id > lastunder a fixed window. - Snapshots dedupe overlap: a task whose fields match
task_currentproduces no new row. - Moves between open buckets don't bump
updated, so the board read stays in every cycle. The reconcile'scomment_countcovers 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_idalone returns 200 and echoes the newbucket_id, but the board bucket doesn't change. S3 refusesbucket_idin PATCH (decision 51), and the fake rejects it too.PATCHwith an unknown key gives 422.PUT …/buckets/{b}/tasks {task_id}into the done bucket setsdone: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, andIf-None-Matchgives 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-remaining7 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.gokeys byuser_<id>and falls back to the IP for unauthenticated requests.
- Bot A got 200 eight times (
- Install: a new kanban view starts with manual buckets. Renaming the three
defaults with PUT and adding
in-reviewandblockedwith POST keepsdone_bucket_idanddefault_bucket_idpointing 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_tasksis an object keyed by kind. Each value is an array of full task objects. Aftert2 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
assigneesas an array of user objects. With no assignee the field isnull, not[]. - A user object for a person leaves out
bot_owner_id. A bot's has it. - The project list with no
donefilter returns done tasks. So does the cursor filterupdated > … && id > 0. A close made in the UI is therefore seen by the cursor, not only by the board diff. PATCH due_date: nullreturns 200 and stores0001-01-01T00:00:00Z. S3 reads that value as no due date.GET /tasks/2147483647and/tasks/2147483648both 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.