Files
stack/agents/darkwing/work/queue-62/BUILD.md
T
jason.woltjeandClaude Opus 5.5 90e568624f row 62 (#1555) round 3 packet: confirm read before a child drops, candidate 8b81e836
R2/B1: after a forbidden or not-found refusal on a child, the tick reads
GET /projects/<id> and drops the child only on 403 or 404; a deleted
kanban view (project read 200) fails the tick as in round 1. Tests 18
and 19, 23/23 mutants killed, gate on ae428a54 green (14 suites).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:54:19 -05:00

30 KiB
Raw Blame History

Row 62 (#1555): the adapter reads a project tree, candidate packet, rounds 1 and 2

Author: Darkwing. Reviewers: Dewey and Filbert. Brief: docs/plans/2026-10-11_multi-project-sync.md, row A. Base: edc5de71 (base.txt). It applies unchanged to 6ae8b844, where the gate ran: the commits between the two touch no file in this candidate. The feature is uncommitted; only this packet is committed. I pushed nothing.

Files

files.txt lists the 13 repository paths. candidate-manifest.sha256 has their sha256. build.patch is git diff --cached --binary edc5de71 over them: 13 files, +608/−73.

File Change
packages/business/src/vars.mjs tracker.descendants: project layer, boolean, default false
packages/business/tests/vars.test.mjs accepts true, refuses "true", 1 and the business layer; default false with provenance default
packages/cli/src/config.mjs one line, descendants: v["tracker.descendants"] in trackerFor() (decision 87)
packages/cli/tests/config.test.mjs the two tracker tests expect descendants: false; one more assertion that true reaches the boot entry
packages/cli/tests/trackers-boot.test.mjs line 44 expects descendants: false; see "Scope"
packages/tasks/src/adapter.mjs settings() takes descendants (absent means false, a non-boolean refuses tracker-config); ctx.tree
packages/tasks/src/startup.mjs inspect(ctx, project) is the install check for any project; new discover(ctx)
packages/tasks/src/sync.mjs tick and reconcile read each project in ctx.tree; watch-only reads; the leave-tree rule in missing()
packages/tasks/src/fake.mjs project trees: parents, the project list, archive, inherited access
packages/tasks/tests/tree.test.mjs new: 11 tests, listed below
packages/tasks/tests/shapes.test.mjs new test: the fake's project list against recorded shapes
packages/tasks/README.md new section "Project trees"; the host contract and startup notes
packages/tasks/live/README.md new section "Project trees"

verbs.mjs, vikunja.mjs, index.mjs and packages/bus/ are unchanged.

Scope

packages/cli/src/config.mjs and config.test.mjs are outside the brief's files. Sage added them as lead decision 87 (commit 9277de33), a decided scope addition: without the line, a host config with tracker.descendants set never reaches the adapter, and the gate read would need a packet script.

packages/cli/tests/trackers-boot.test.mjs is not named in decision 87. It follows from it. Line 44 compares boot.trackers with an exact object, so the config line breaks it: with the original line 44 and the candidate config.mjs the test fails, 0 passed and 1 failed. The change adds descendants: false to that object and nothing else. Reviewers and Sage should treat it as part of decision 87's addition, or tell me to take it out.

The extra config.test.mjs assertion, true reaching the boot entry, is mine. Decision 87 asked for the two false updates. Without the new assertion no test sent true through config.mjs, and the gate's live run is the first place that value is used.

How it works

With descendants off, ctx.tree holds the root and its view from startup and nothing else. Every read is the v0 read with the root's project id. The v0 tests pass unchanged, and tree test 1 checks that no GET /projects and no read under a child happens.

With it on:

  1. Startup is unchanged. The root's install check still refuses the business with tracker-install.
  2. Each reconcile, including the first one at start, calls discover(). It reads GET /projects, every page, and walks parent_project_id breadth-first from the root. It skips ids at or below 0 and archived projects. Each child gets inspect(). A pass gives its view, a tracker-install refusal gives null (watch-only), and any other refusal propagates. ctx.tree is replaced only after the walk finishes, so a failed reconcile keeps the previous tree.
  3. Tick and reconcile read each project in ctx.tree. An installed project is read through its board, as v0 reads the root. A watch-only one is read through walk(ctx, 'done = false', {}, project), and each task gets bucket WATCH_ONLY (0).
  4. Gone detection in a tick uses a set of refs placed by this tick's reads, not task ids. A task moved from A to B shows on B's board under the same id; keying by id would hide the old ref's tombstone. The placed-by-id mutant below fails the move test.
  5. missing() takes the project from the ref (parseRef). If the task still reads 200 in the same project but that project is no longer in ctx.tree, the task gets no-access with its last updated.

fieldsFor() builds the fields of a watch-only task with taskFields(task, 1) and then sets bucket: 0. taskFields checks the bucket is a positive integer, so the 1 is a placeholder that never reaches the ledger. The bus schema accepts 0 (an integer).

One line in reconcile changed meaning: if (!bucket) continue became if (bucket === undefined) continue. The old form would skip every watch-only task, since 0 is falsy. undefined is still the case it was written for, an open task the board didn't show.

The brief, line by line

Brief Where
tracker.descendants, project-layer boolean, default false vars.mjs; vars.test.mjs "types" and "merge"
The tree from GET /projects by parent_project_id discover(); tree tests 2 and 5
Discovery at startup and each reconcile, not each tick tree test 2 (tick has no GET /projects, reconcile starts with it); test 8 (a late child joins at reconcile, not tick)
Archived projects are not in the tree tree test 5 (with the list forced to include archived ones); test 9
Leave-tree rule, recorded and tested README "Project trees"; tree test 9, three ways
The ledger is never rewritten tree test 9: snapshots read open, then no-access with the same updated
Root install fails: refuse tree test 4
Child passes: installed. Fails: watch-only, business ready tree tests 2 and 3
Watch-only records title, done, assignees, comments; no bucket state tree test 11, decision 87's bucket 0
Moves root to child, child to child, child to outside tree test 7
Poll cost stated README; tree test 2 counts 6 GETs per tick and 11 per reconcile; probes.md P4
Cross-project read probed probes.md P4 and "Gate read"
The fake models the tree fake.mjs; probes.md "Fake"; shapes test 2
Descendants off matches v0 tree test 1; every pre-existing tasks test unchanged
verbs read-only; a child task refuses task-project verbs.mjs unchanged; tree test 11
No change to packages/bus/; no Vikunja writes files.txt; tree test 11 checks sync.mjs has no write method string

Tests

packages/tasks/tests/tree.test.mjs:

  1. With descendants off the adapter reads no project list and no child, as in v0.
  2. A three-level tree is read per project, and a project outside it is not. Root, an installed child, a watch-only grandchild and an outside project shared with the sync bot. The tick's six requests are checked in order; the reconcile is GET /projects plus 10.
  3. A watch-only child keeps the business ready, and a failed child read refuses as transient (a 503 on its views).
  4. A root without the installed buckets refuses with descendants on.
  5. The project list refuses without read_all, and skips archived projects and saved filters. A fetch wrapper adds is_archived=true, so the fake returns archived projects and the adapter's own check has to drop them.
  6. descendants must be true or false.
  7. A move inside the tree is a moved tombstone plus a snapshot under the new ref: root to A, A to B (watch-only), A to outside.
  8. A child made after startup joins the tree at the next reconcile.
  9. A child that leaves the tree has its tasks recorded missing, and the ledger keeps their history: archived, reparented to the top with a direct share, and reparented under a project the bot can't see. The hidden parent's title never reaches the bus.
  10. A task in a bucket outside the five keeps its raw bucket id.
  11. Bucket 0 on a watch-only task never becomes a bucket or a state. A move between open buckets records nothing; done then reopen records done twice; every snapshot has bucket 0; no task.state event; a verb refuses task-project; the source of sync.mjs has no .titles or .ids[ lookup and no write method string. This is the test decision 87 asked for.

shapes.test.mjs adds "the project list holds recorded project objects in the recorded list envelope". No recording holds a GET /projects list, so it checks the envelope against the recorded views list and each item against the recorded GET /projects/{p} body, keys and types.

Mutants

out/mutants.sh edits one source file in the build worktree, runs the tests, and restores the file from the candidate. Each mutant fails at least one test (out/mutants.txt):

Mutant Fails
no-leave-tree-check: drop the ctx.tree.has(project) branch in missing() tree 9
no-watch-only-bucket: fieldsFor keeps the bucket it is given tree 2, 3, 7, 8, 11
placed-by-id: gone detection keyed by task id tree 7
keep-archived: discover() keeps archived projects tree 5
config-drops-descendants: config.mjs passes false cli config "trackers come from..."

Things a reviewer should know

  • The p.id <= 0 skip guards shape only. The walk starts at the root and follows positive parent ids, so it can't reach a negative id. The skip stops a saved filter from failing the parent_project_id check.
  • The fake's GET /projects/{p} body now has parent_project_id, position, created and updated, from a shared #projectJson. Shapes test 1 still matches it to the recorded body.
  • commentsAt and commentCount stay keyed by task id. Vikunja task ids are global, so a moved task keeps its comment state.
  • A watch-only child's open tasks come from its task list, so a tick costs the same two GETs as an installed project.
  • I noticed in the v2.7.0 source that creating a project under v2 also creates a default kanban with a backlog bucket. A child made in the UI is then watch-only until someone installs the five buckets by hand. Nothing in this row depends on it.
  • No probe in this row touched a live Vikunja. probes.md has the source evidence and the exact gate read.

Gate

The gate ran in a detached worktree of 6ae8b844 with build.patch applied and node_modules linked to the main checkout's (out/gate/apply.txt, out/gate/gate.sh). Every suite ran in order and each log is in out/gate/. test-task ran with DOCKER_HOST=unix:///nonexistent.

Every suite passed on this candidate (out/gate/summary.txt):

Suite rc Result
packages/tasks 0 63 passed, 0 failed
packages/business 0 60 passed, 0 failed
packages/bus 0 74 passed, 0 failed
packages/cli 0 83 passed, 0 failed
test-auth 0 15 passed, 0 failed
test-conductor 0 17 passed, 0 failed
test-config 0 24 passed, 0 failed
test-discord 0 66 passed, 0 failed
test-extension-package 0 18 passed, 0 failed
test-foundation 0 44 passed, 0 failed
test-queue 0 27 passed, 0 failed
test-release 0 14 passed, 0 failed
test-task 0 26 passed, 0 failed

After the run I removed the gate worktree's gate_default Docker network; it had no containers.

Two earlier gate runs were stopped. The first had no node_modules link, so test-discord failed its "pi binary present" check, 57 passed and 1 failed. The second I stopped to add the true assertion to config.test.mjs, which changed the candidate. Neither run's logs are in this packet; they were on the earlier candidate.

Gate read for Sage

probes.md "Gate read" has the exact requests: GET /api/v2/projects and GET /api/v2/tasks with a project in 19, 18, 20, 21 filter, sync key 9, GETs only. The adapter reads per project whatever read 2 answers; a 200 there opens a follow-up row.

Round 2

Round 1 drew changes requested from both reviewers on candidate ec3f7a42. Filbert's comment 27279 (queue rev 408) had one blocking item, R1, and three test gaps, N1 to N3. Dewey's comment 27280 (rev 409) found the same blocking defect on its own and named three more test gaps. This round fixes R1 and adds a test for each of the six gaps. build.patch and candidate-manifest.sha256 now hold the round 2 candidate; the round 1 versions are in this packet's history at 6b83edc3. files.txt is unchanged, the same 13 files: 13 files, +778/−73 against edc5de71. Round 2 changed four of them: sync.mjs, fake.mjs, tree.test.mjs and the tasks README.md. The gate applied the patch to 122b1223 (out/gate-round2/apply.txt); no candidate file changed between 6ae8b844 and 122b1223.

Blocking

R1 (Filbert, and Dewey's blocking item): a child that answered 403 or 404 failed every tick until the next reconcile. Dewey added that notify(ctx) runs after tick(ctx) in the adapter's timer, so the credential notices stopped too. A tick that drops the child no longer throws, so notify runs again. No test drives the timer path; the tests call tick directly. The tick now drops it. In tick(), a tracker-forbidden or tracker-not-found refusal on a project's board, task list or cursor walk removes that project from ctx.tree, and the tick goes on with the others. The rule is leftTree() in sync.mjs. The dropped project's tasks are then on no board this tick, so missing() reads each one, as it does after a reconcile: a 403 gives no-access, a 404 gives not-found, a 200 in the same project gives no-access. The root still fails the tick on a 403 or 404. Any other failure on a child, a 503 for one, still fails the tick, and the child stays in the tree. A dropped child that comes back is found by the next reconcile's discover().

I chose drop over skip. A skipped child stays in ctx.tree, so every tick reads it again and gets the same 403 until the reconcile; dropping it does what the reconcile would do, one reconcile early. The tradeoff: a child that answers 403 for a short time is out of the tree until the next reconcile, and its open tasks get no-access tombstones meanwhile. The reconcile reads them again if the access is back. No test covers that return.

fake.mjs gained deleteProject(). Like Vikunja it deletes the project, its descendants and their tasks. After it a project read answers 404 with code 3001 and a task read 404 with code 4002.

Filbert's list names three ways a child leaves: unshared, reparented under a project the bot can't read, and deleted. A child under the root inherits the root's share, and project_access.go has no grant that takes access away, so a child can't stop being shared on its own while the root stays shared. The fake models it the same way. The reparented case is the 403 a child can get; tests 9 and 10 use it.

Test 9 now ticks between the change and the reconcile. The hidden child answers 403 on its board, so the tick drops it and records its task no-access. The archived child and the one reparented to the top with a share of its own still read, so they wait for the reconcile.

New test 10, "a child deleted or hidden between reconciles drops out at the next tick, and the root still refuses":

  • An installed child is deleted (404 on its board), and a watch-only child is moved under a project the bot can't read (403 on its task list). The root and a sibling child have edits in the same tick.
  • The tick leaves the business ready, records not-found and no-access for the two tasks, and records both edits.
  • The next tick sends no request under either project, and the reconcile records nothing.
  • A 503 on the sibling's task list fails the tick with tracker-unavailable, and the tick after it still reads the sibling.
  • The root unshared fails the tick with tracker-forbidden; shared again, the next tick records a root edit. The root deleted fails the tick with tracker-not-found.

The README sentence "Ticks before that reconcile keep reading the project" is gone. The paragraph now says: "A tick doesn't read the project list, so it keeps reading an archived child, or one moved out with a share of its own, until that reconcile. A child that answers 403 or 404 in a tick (no longer shared, moved under a project the sync bot can't read, or deleted) leaves the tree at that tick, and its tasks get the same one-by-one read. A 403 or 404 on the root still fails the tick, and so does any other failure on a child. A new child joins at the next reconcile." The paragraph above it adds "or deleted" and "or a tick before that".

Non-blocking

N1: a failed reconcile keeps the previous tree. New test 11. Two children; at the reconcile the first one's install check gets a 503, so the walk stops before the second. The reconcile fails with tracker-unavailable, and the next tick still records an edit on the second child and no task.missing.

N2: a closed task found through missing() takes its own project's done bucket. New test 12. The fake runs an hour behind, so the cursor never returns the close. An installed child's task and a watch-only child's task are closed. The tick reads both through missing() (via: task): the installed one gets the child's done bucket, which differs from the root's, and the watch-only one gets bucket 0.

N3: the leave-tree tombstone keeps the last recorded updated. Test 9 now edits the reparented task after the tick and before the reconcile, with the fake's clock two seconds ahead, so the task's updated changes. The reconcile reads the edit through missing(), and the tombstone still carries the updated of the task's first snapshot.

Dewey's three gaps. Each passed on the round 1 candidate, as Dewey said; the mutants below show what each test now catches.

  • reconcile-done-root-bucket: new test 13. A task in an installed child and one in a watch-only child are closed before startup. The startup reconcile records the child's done bucket and bucket 0.
  • tick-done-root-bucket: new test 14, the same check on the tick path, where the cursor returns the close. The tick has two such lines: a closed task the open read left out, and one closed between the open read and the cursor walk. The test covers both. A fetch wrapper closes a second task in each project right after that project's first request in the tick, which is its open read.
  • tree-check-before-moved: new test 15. The child is archived and its task moved to the root at the same time. The reconcile records moved to the root, not no-access, and a snapshot under the new ref.

Tests, round 2

packages/tasks/tests/tree.test.mjs has 17 tests (11 in round 1). Test 9 changed; tests 10 to 15 are new. Round 1's tests 10 and 11 are now 16 and 17.

Mutants, round 2

out/mutants-round2.sh uses round 1's method: edit one source file, run the tests, restore the file from the worktree index. Its output is out/mutants-round2.txt. Each mutant ran only the test file meant to fail it (tree.test.mjs, or config.test.mjs for the config one), because the row 55 mutant run shared the machine. The gate below runs every suite on the candidate. All 18 fail at least one test:

Mutant Fails
round 1's five as in round 1, with round 1's test 11 now 17; no-watch-only-bucket also fails 10, 12, 13 and 14
r2-child-refuses: a child's 403 or 404 fails the tick 9, 10
r2-skip-keeps-child: skip the child, keep it in the tree 10
r2-root-drops: the root is dropped too 10
r2-any-error-drops: any refusal drops a child 10
r2-403-only: a 404 still fails the tick 10
r2-404-only: a 403 still fails the tick 9, 10
tree-early: discover() sets ctx.tree before the walk 11
missing-done-root-bucket: missing() uses the root's done bucket 12
leave-tree-no-keep-updated: the leave-tree tombstone takes the read's updated 9
reconcile-done-root-bucket: the reconcile uses the root's done bucket 13
tick-done-root-bucket: a closed task only the cursor returned takes the root's done bucket 14, 17
tick-done-root-bucket-board: a task the open read showed and the cursor returned closed takes the root's done bucket 14
tree-check-before-moved: the tree check runs before the moved check 15

tree-early, missing-done-root-bucket and leave-tree-no-keep-updated use Filbert's names, and the last four use Dewey's, with tick-done-root-bucket split in two for the tick's two lines. Their edits may differ from mine.

Gate, round 2

The gate ran in a detached worktree of 122b1223 with the round 2 build.patch applied and node_modules linked, using round 1's script with a new output directory (out/gate-round2/gate.sh). Each log is in out/gate-round2/. test-task ran with DOCKER_HOST=unix:///nonexistent, so its Docker groups printed skip, as in round 1.

Every suite passed (out/gate-round2/summary.txt):

Suite rc Result
packages/tasks 0 69 passed, 0 failed (63 in round 1)
packages/business 0 60 passed, 0 failed
packages/bus 0 74 passed, 0 failed
packages/cli 0 83 passed, 0 failed
test-auth 0 15 passed, 0 failed
test-conductor 0 17 passed, 0 failed
test-config 0 24 passed, 0 failed
test-discord 0 66 passed, 0 failed
test-extension-package 0 18 passed, 0 failed
test-foundation 0 44 passed, 0 failed
test-queue 0 27 passed, 0 failed
test-release 0 14 passed, 0 failed
test-task 0 26 passed, 0 failed

A gate_default Docker network appeared during the run with no containers, and I removed it.

Candidate manifest sha256: 55316d885d51e0d089173476dbccacfdcf254e51c37aecff0f556ae80ca99179.

Round 3

Round 2 drew changes requested from both reviewers on candidate 55316d88: Filbert's comment 27296 (queue rev 426) and Dewey's comment 27297 (rev 427). Both found the same blocking defect, Filbert's R2 and Dewey's B1. Both confirm R1 is fixed, Dewey on the poll timer path too. build.patch and candidate-manifest.sha256 now hold the round 3 candidate; the round 2 versions are in this packet's history at 620c0e89. files.txt is unchanged: 13 files, +864/−73 against edc5de71. Round 3 changed four of them: sync.mjs, fake.mjs, tree.test.mjs and the tasks README.md. The gate applied the patch to ae428a54; no candidate file changed between edc5de71 and ae428a54.

Blocking

R2 (Filbert) and B1 (Dewey): a deleted kanban view looked like a deleted project. outcome() in vikunja.mjs maps every 404 except code 4002 to missing, which is tracker-not-found. A board read on a child whose kanban view was deleted answers 404 with code 13001, so the round 2 tick dropped a child the sync bot still reads. missing() then wrote no-access tombstones for its open tasks, and the next reconcile read them back watch-only. Round 1 failed that tick. Round 2 wrote false evidence to the ledger, which is worse.

I took Filbert's first fix. leftTree() is now async. After a tracker-forbidden or tracker-not-found refusal on a child, the tick reads GET /projects/<id> with the sync token and drops the child only if that read answers 403 or 404. Any other answer rethrows the board or walk refusal, so the tick fails as in round 1. Dewey's comment gives the real instance's answers: 200 for a deleted view, 403 for a hidden project, 404 for a deleted one, 200 for an archived one. The sync token already has projects read_one, so nothing changes in the scopes.

I chose the confirm read over Filbert's second option, dropping only on 403 and on 404 code 3001. That option depends on which code each route returns, and I haven't probed which one the board route gives for a deleted project. The confirm read asks the question the drop depends on. It costs one GET, and only on a tick where a child refused.

The tradeoff: a child with a deleted kanban view fails every tick until the next reconcile, as in round 1, and the poll timer's notify skips while it does. The reconcile's inspect() finds no kanban view and reads the child watch-only, or reads the new view if someone installed one. I didn't make the tick read it watch-only on the spot. That would put the reconcile's install check into the tick, and a deleted view is an operator change the reconcile already handles.

fake.mjs gained deleteView(). It drops the view, its buckets and the tasks' places in them. The project still reads, and a read of the view answers 404 with code 13001, the code the fake's view routes already return.

New test 18, "a child whose kanban view is deleted still refuses the tick, and the reconcile reads it watch-only":

  • An installed child's kanban view is deleted, and the root has an edit. The tick reads GET /projects/<child>, fails with tracker-not-found, and writes no event or snapshot, the root's edit included. The business stays ready.
  • A 403 on the board of a child the bot still reads fails the tick with tracker-forbidden.
  • The reconcile records nothing missing, gives the child's task bucket 0 and records the root's edit.
  • Dewey's 401 case: a 401 on the child's task list after the root read succeeded fails the tick with tracker-unauthorized, and the tick makes no project read. A 404 queued on the project read stays unused, so a 401 can't drop a child whatever that read would say.
  • A 404 on the task list with a 503 on the project read fails the tick with tracker-not-found, and the child stays. The next tick records an edit on it.

Dewey asked that a root edit in the same tick be recorded. On the drop path, test 10 already does that: a deleted child and a hidden one drop, and the root's and a sibling's edits from that tick are recorded. On the deleted-view path the tick fails closed, so it records nothing, and test 18 shows the reconcile recording the root's edit instead. I didn't take the watch-only path, so Dewey's second ask doesn't apply.

The README paragraph now says a child leaves the tree "only if GET /projects/<id> then answers 403 or 404 too", that a deleted kanban view keeps the child and fails the tick until the next reconcile reads it again ("watch-only if it has no installed kanban view"), and that a dropped child the sync bot can read again comes back at the next reconcile.

Non-blocking

Filbert's N1: a dropped child comes back after a reconcile. New test 19. A child moved under a project the bot can't read drops at the tick with no-access. Moved back under the root, the next tick still sends no request under it, and the reconcile records the task again with the child's installed bucket. The task's snapshots read bucket, no-access, bucket. A tick after that records an edit. Filbert's probe used a one-time fault on the board; with the confirm read a fault alone no longer drops a child, so the test moves the project instead. The test passes on round 2's code as well. It pins the return, it doesn't fix anything.

Dewey's r2-unauthorized-drops. Killed now, by test 18's 401 case. With the confirm read the mutant only matters when the project read answers 403 or 404 after a 401, which is the case the test sets up.

Unchanged survivors. Filbert's round 1 keep-negative, no-parent-shape and no-sort, and the two equivalent mutants, still survive. They were non-blocking in round 1 and round 2, and I didn't take them this round.

Tests, round 3

packages/tasks/tests/tree.test.mjs has 19 tests (17 in round 2). Tests 18 and 19 are new and sit at the end, so round 2's numbers hold. Test 18 fails on round 2's sync.mjs; test 19 passes on it, as said above.

Mutants, round 3

out/mutants-round3.sh uses the same method: edit one source file, run the test file meant to fail it, restore the file from the worktree index, which holds the round 3 candidate. The round 2 lines that edited leftTree() are retargeted at the new function. Output is out/mutants-round3.txt. All 23 fail at least one test:

Mutant Fails
round 1's five and round 2's seven non-R1 mutants as in round 2; no-watch-only-bucket also fails 18
r2-child-refuses 9, 10, 18, 19
r2-skip-keeps-child 10, 19
r2-root-drops 10
r2-any-error-drops 18
r2-403-only 10, 18
r2-404-only 9, 10, 19
r3-no-confirm: drop on the refusal alone, no project read 18
r3-confirm-403-only: drop only when the project read answers 403 10
r3-confirm-404-only: drop only when it answers 404 9, 10, 19
r3-confirm-not-200: drop on any answer but 200 18
r2-unauthorized-drops (Dewey's name): a 401 drops a child too 18

r2-any-error-drops failed only test 10 in round 2; now test 18 fails it, because test 10's 503 sibling no longer reaches a drop: the project read answers 200.

Gate, round 3

The gate ran in a detached worktree of ae428a54 with the round 3 build.patch applied and node_modules linked (out/gate-round3/gate.sh). out/gate-round3/apply.txt has the manifest check. The script now runs the webui suite too; round 2's didn't. test-task ran with DOCKER_HOST=unix:///nonexistent, so its Docker groups printed skip, as before. Each log is in out/gate-round3/.

Every suite passed (out/gate-round3/summary.txt):

Suite rc Result
packages/tasks 0 71 passed, 0 failed (69 in round 2)
packages/business 0 60 passed, 0 failed
packages/bus 0 80 passed, 0 failed
packages/cli 0 83 passed, 0 failed
packages/webui 0 40 passed, 0 failed
test-auth 0 15 passed, 0 failed
test-conductor 0 17 passed, 0 failed
test-config 0 24 passed, 0 failed
test-discord 0 66 passed, 0 failed
test-extension-package 0 18 passed, 0 failed
test-foundation 0 44 passed, 0 failed
test-queue 0 27 passed, 0 failed
test-release 0 14 passed, 0 failed
test-task 0 26 passed, 0 failed

The bus count went from 74 to 80 because rows landed on the base between the two gates, not because of this candidate. A gate_default Docker network with no containers was left after the run, and I removed it.

Candidate manifest sha256: 8b81e836c9d1363383dac8900dd881056d27e663a9c4aa32d2d1ee7639cf18ca.