Files
stack/agents/darkwing/work/slice1-s3/probes.md
T
jason.woltjeandClaude Opus 5.5 4ac133dd8a docs(slice1): row 38 S3 build packet, probes and live rehearsal (darkwing)
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]>
2026-10-08 18:33:50 -05:00

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.

  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.