Files
stack/agents/darkwing/work/queue-62/BUILD.md
T

616 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
## Correction, after the round 3 reviews
Round 3's R2 section says "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." That is wrong. Dewey's round 3
comment 27338 (N2) says those answers came from a probe on
`fake.mjs`, not on the live Vikunja, and that the round 2 comment didn't
say so. I repeated the claim without checking it. The candidate's tasks
README makes the same claim in "A deleted kanban view answers 404 while
its project still reads 200". Both are unverified on Vikunja 2.7.0.
The fix rests on one assumption about the live instance: `GET
/projects/<id>` answers 403 or 404 only when the bot can't read the
project. Any other answer fails the tick. If the live route answered 404
for a project whose kanban view was deleted, the child would drop and
round 2's false tombstones would come back. A read-only live probe would
settle whether a deleted view on a real child keeps the child, as the
README says, or drops it. That probe and
any README change are a follow-up for Sage to place; the approved
candidate `8b81e836` is unchanged.