Repository navigation
feat!: remove the sync engine and the database's synced copy - #20
Conversation
Every desk now works through the projects API, and the one machine that never syncs keeps its database as its only copy. Nothing is left to keep a copy of, so the engine, `projects sync`, the [sync] section and the refusal of command-line writes beside sync all go. - A database from before opens as it is. The first open drops pending_sync, sync_state and sync_origin, and keeps every project, item, task, dependency, membership and undo entry. - A config still holding [sync] loads, and every command that opens data warns on stderr naming the file. A refusal would stop the tool on a machine until its next config apply. - A PROJECTS_SYNC* or TODOUI_SYNC* variable set to a value stops every command with exit 2. PROJECTS_SYNC_URL chose an API, so ignoring it would send a run wherever the config file points. - The board re-reads the API every two minutes, fixed, where sync.interval used to set it. R re-reads the data in either mode. - The undo result keeps the four fields the API sends. The rows it also carried were for pushing an undo, and nothing else read them. - store/sqlite's generated queries move under internal/, so only the store can reach them now that the engine no longer does. Agent-Name: claude-code-unattended Agent-Session: 43e2375e-0270-4942-8076-19e8ab6a2337 Agent-Run: 01a1b9cf-185b-7680-9738-f82275bfa5f7 Worklog: 2407.1
datapointchris
left a comment
There was a problem hiding this comment.
PR review — system-design
Used: Read the diff of open.go, retired_sync.go, main.go and the deploy files. Did not build or run the migration.
Standards: No standards searched. The one finding is a data-loss path, and no rule was needed to state it.
Findings: 1 correctness · 0 design · 0 prose
Correctness
system-design.1 store/sqlite/open.go:80 · store/sqlite/open.go:137
Reach: A desk runs the old binary in sync mode while offline or the API is failing. Its writes land in the local database and wait in pending_sync, unpushed. The desk upgrades to this build and opens the database. migrate() runs dropSyncTables and drops pending_sync before anything else looks at it. The desk is then set to API mode. The queued writes never reach the API, and nothing says they were dropped. If the desk stays in database mode, the rows stay local but the API never gets them.
dropSyncTables drops pending_sync without reading it. The PR says no person-written row is touched. That holds for the five data tables. The queue is the only record of which local writes the API has not seen. Dropping it loses those writes for the API, and the PR does not mention this.
The verification used a database with one row in pending_sync. It checked only that the table was gone. It did not check that the row had been pushed.
Fix: before the drop, count the rows in pending_sync. If the count is above zero, refuse to open and name the count. Tell the user to run the previous release's projects sync first. Alternatively, print the pending operations to stderr or save them to a file before dropping. Drop the table only when it is empty.
Findings as JSON
{
"used": "Read the diff of open.go, retired_sync.go, main.go and the deploy files. Did not build or run the migration.",
"standards": "No standards searched. The one finding is a data-loss path, and no rule was needed to state it.",
"findings": [
{
"number": "system-design.1",
"section": "correctness",
"sites": [
"store/sqlite/open.go:80",
"store/sqlite/open.go:137"
],
"reach": "A desk runs the old binary in sync mode while offline or the API is failing. Its writes land in the local database and wait in pending_sync, unpushed. The desk upgrades to this build and opens the database. migrate() runs dropSyncTables and drops pending_sync before anything else looks at it. The desk is then set to API mode. The queued writes never reach the API, and nothing says they were dropped. If the desk stays in database mode, the rows stay local but the API never gets them.",
"body": "`dropSyncTables` drops `pending_sync` without reading it. The PR says no person-written row is touched. That holds for the five data tables. The queue is the only record of which local writes the API has not seen. Dropping it loses those writes for the API, and the PR does not mention this.\n\nThe verification used a database with one row in `pending_sync`. It checked only that the table was gone. It did not check that the row had been pushed.\n\nFix: before the drop, count the rows in `pending_sync`. If the count is above zero, refuse to open and name the count. Tell the user to run the previous release's `projects sync` first. Alternatively, print the pending operations to stderr or save them to a file before dropping. Drop the table only when it is empty."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — code-architecture
Used: Cloned the repo and checked out PR 20 head 67e8fa1. Read the diff of main...HEAD and the touched modules: config/, main.go, cli/data_source.go, store/sqlite/open.go, service/, tui/app.go. Searched the whole repo with rg for other sites that decide the same facts. Did not build or run anything.
Standards: code-quality.md/A guard behind a filter is answered by the filter, not by the guard (search "dead branch flag every caller passes the same value remove the option"). documentation.md/A doc points at the source instead of restating its content or constants (search "constant restated in help text drifts"). help.md/Help text never carries a count, version, path or list of what is supported (same search).
Findings: 0 correctness · 3 design · 0 prose
Design and architecture
code-architecture.1 service/service.go:38 · service/service.go:535 · cli/data_source.go:63 · api/main.go:106 · store/postgres/store.go:269 · service/service_test.go:40 · tui/realdata_test.go:43
Reach: Every new place that builds a service has to pass the option. This repo already has six that do not: test helpers in service/service_test.go (lines 40, 901 and 941), service/project_status_test.go:300 and tui/realdata_test.go:43. Each of these builds a service with no item numbers, a setup no command runs. The Postgres store carries its own check, errItemNeedsNumber, only to catch a service built without the option.
Each place that builds a service decides whether it numbers items, and only one answer is left
After this change, AssigningItemNumbers has a single valid value. Both entry points pass it: cli.LocalService at cli/data_source.go:63 and api/main.go:106. The PR body and the new comment at service/service.go:38 both say so. The reason the option existed was a synced database, where the API assigned the numbers. This PR removes that case.
The off branch is still there. service/service.go:535 skips numbering when the option is missing. store/postgres/store.go:269 keeps errItemNeedsNumber to refuse a service built without it. That is a guard for a setup nothing in the product builds. The rule code-quality.md § "A guard behind a filter is answered by the filter, not by the guard" calls this a predicate that cannot return both answers.
The PR rejected making numbering unconditional because it would "reach the Postgres store's refusal and the tests built without it". Those two things are the cost described here. They are not a reason to keep it.
Fix: make numbering what service.New always does. Then delete:
- the
AssigningItemNumbersoption and theassignsItemNumbersfield; - the
ifatservice/service.go:535; errItemNeedsNumberin the Postgres store.
The test helpers that build services without numbering would then number their items, as every real command already does.
code-architecture.2 tui/app.go:266 · cli/tui.go:50 · README.md:186
Reach: The next change to apiRereadInterval must also edit the projects tui --help text and the README. This PR is already that change once: it turned [sync] interval into a constant and rewrote both prose copies alongside it.
The re-read interval is written in three places: one constant and two sentences
tui/app.go:266 sets apiRereadInterval = 2 * time.Minute. The projects tui long help at cli/tui.go:50 says "every two minutes". So does README.md:186. Nothing ties the two sentences to the constant.
Two rules cover this:
documentation.md§ "A doc points at the source instead of restating its content or constants";help.md§ "Help text never carries a count, version, path or list of what is supported, which the next release makes stale".
Fix: describe the behaviour without the number, for example "In API mode the board reads the API again on a timer, and R reads it now". The README can point at apiRereadInterval. Another option is to build the help string from the constant with fmt.Sprint(apiRereadInterval). That keeps one owner, but it needs the constant exported from tui.
code-architecture.3 config/former_name.go:22 · config/former_name.go:30 · config/retired_sync.go:11 · config/retired_sync.go:25 · main.go:12
Reach: This PR is the second retirement. Like the todoui rename, it adds its own list and function and another entry in main.go's slice of refusals. The next retired variable (a renamed PROJECTS_* key, or a dropped PROJECTS_DB) has two lists to choose from and a third pattern to start. Each choice also means another edit to main.go.
Two lists decide which retired variables stop a run, each with its own loop and its own check in main.go
config/former_name.go:22 has retiredVariables and RetiredVariableRefusal, for the variables renamed from todoui. config/retired_sync.go:11 adds retiredSyncVariables and RetiredSyncVariableRefusal. Both answer the same question: "is a variable set that this tool no longer reads?" They use the same loop over os.Getenv and return the same kind of string. main.go:12 now loops over a hand-written slice of both functions to decide exit 2.
The only difference is the wording of the message. One names a replacement variable. The other says to unset it.
Fix: keep one table in config with one entry per variable: the retired name, a replacement (empty for none) and the remedy. Give it one refusal function, and have main.go call that alone. Each retirement can still own its rows in its own MIGRATION file, through a package-level append or a separate slice concatenated in one place. Deleting a retirement then removes its rows and never touches main.go.
Findings as JSON
{
"used": "Cloned the repo and checked out PR 20 head 67e8fa1. Read the diff of `main...HEAD` and the touched modules: `config/`, `main.go`, `cli/data_source.go`, `store/sqlite/open.go`, `service/`, `tui/app.go`. Searched the whole repo with `rg` for other sites that decide the same facts. Did not build or run anything.",
"standards": "code-quality.md/A guard behind a filter is answered by the filter, not by the guard (search \"dead branch flag every caller passes the same value remove the option\"). documentation.md/A doc points at the source instead of restating its content or constants (search \"constant restated in help text drifts\"). help.md/Help text never carries a count, version, path or list of what is supported (same search).",
"findings": [
{
"number": "code-architecture.1",
"section": "design",
"sites": [
"service/service.go:38",
"service/service.go:535",
"cli/data_source.go:63",
"api/main.go:106",
"store/postgres/store.go:269",
"service/service_test.go:40",
"tui/realdata_test.go:43"
],
"reach": "Every new place that builds a service has to pass the option. This repo already has six that do not: test helpers in `service/service_test.go` (lines 40, 901 and 941), `service/project_status_test.go:300` and `tui/realdata_test.go:43`. Each of these builds a service with no item numbers, a setup no command runs. The Postgres store carries its own check, `errItemNeedsNumber`, only to catch a service built without the option.",
"body": "### Each place that builds a service decides whether it numbers items, and only one answer is left\n\nAfter this change, `AssigningItemNumbers` has a single valid value. Both entry points pass it: `cli.LocalService` at `cli/data_source.go:63` and `api/main.go:106`. The PR body and the new comment at `service/service.go:38` both say so. The reason the option existed was a synced database, where the API assigned the numbers. This PR removes that case.\n\nThe off branch is still there. `service/service.go:535` skips numbering when the option is missing. `store/postgres/store.go:269` keeps `errItemNeedsNumber` to refuse a service built without it. That is a guard for a setup nothing in the product builds. The rule `code-quality.md` § \"A guard behind a filter is answered by the filter, not by the guard\" calls this a predicate that cannot return both answers.\n\nThe PR rejected making numbering unconditional because it would \"reach the Postgres store's refusal and the tests built without it\". Those two things are the cost described here. They are not a reason to keep it.\n\n**Fix:** make numbering what `service.New` always does. Then delete:\n- the `AssigningItemNumbers` option and the `assignsItemNumbers` field;\n- the `if` at `service/service.go:535`;\n- `errItemNeedsNumber` in the Postgres store.\n\nThe test helpers that build services without numbering would then number their items, as every real command already does."
},
{
"number": "code-architecture.2",
"section": "design",
"sites": [
"tui/app.go:266",
"cli/tui.go:50",
"README.md:186"
],
"reach": "The next change to `apiRereadInterval` must also edit the `projects tui --help` text and the README. This PR is already that change once: it turned `[sync] interval` into a constant and rewrote both prose copies alongside it.",
"body": "### The re-read interval is written in three places: one constant and two sentences\n\n`tui/app.go:266` sets `apiRereadInterval = 2 * time.Minute`. The `projects tui` long help at `cli/tui.go:50` says \"every two minutes\". So does `README.md:186`. Nothing ties the two sentences to the constant.\n\nTwo rules cover this:\n- `documentation.md` § \"A doc points at the source instead of restating its content or constants\";\n- `help.md` § \"Help text never carries a count, version, path or list of what is supported, which the next release makes stale\".\n\n**Fix:** describe the behaviour without the number, for example \"In API mode the board reads the API again on a timer, and R reads it now\". The README can point at `apiRereadInterval`. Another option is to build the help string from the constant with `fmt.Sprint(apiRereadInterval)`. That keeps one owner, but it needs the constant exported from `tui`."
},
{
"number": "code-architecture.3",
"section": "design",
"sites": [
"config/former_name.go:22",
"config/former_name.go:30",
"config/retired_sync.go:11",
"config/retired_sync.go:25",
"main.go:12"
],
"reach": "This PR is the second retirement. Like the `todoui` rename, it adds its own list and function and another entry in `main.go`'s slice of refusals. The next retired variable (a renamed `PROJECTS_*` key, or a dropped `PROJECTS_DB`) has two lists to choose from and a third pattern to start. Each choice also means another edit to `main.go`.",
"body": "### Two lists decide which retired variables stop a run, each with its own loop and its own check in `main.go`\n\n`config/former_name.go:22` has `retiredVariables` and `RetiredVariableRefusal`, for the variables renamed from `todoui`. `config/retired_sync.go:11` adds `retiredSyncVariables` and `RetiredSyncVariableRefusal`. Both answer the same question: \"is a variable set that this tool no longer reads?\" They use the same loop over `os.Getenv` and return the same kind of string. `main.go:12` now loops over a hand-written slice of both functions to decide exit 2.\n\nThe only difference is the wording of the message. One names a replacement variable. The other says to unset it.\n\n**Fix:** keep one table in `config` with one entry per variable: the retired name, a replacement (empty for none) and the remedy. Give it one refusal function, and have `main.go` call that alone. Each retirement can still own its rows in its own MIGRATION file, through a package-level `append` or a separate slice concatenated in one place. Deleting a retirement then removes its rows and never touches `main.go`."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — G1
Used: I built the PR head and its base (v1.24.0 shape) into my own directory. With the base binary I made a database and added one pending_sync row. I gave it a [sync] enabled = true config and opened it with the new binary under a scratch XDG_CONFIG_HOME/HOME. I also ran the PROJECTS_SYNC, TODOUI_SYNC_URL and TODOUI_DB refusals and go test -short ./..., which passed.
Standards: I cited data.md § "A destructive migration ships as its own release, after the release that stopped reading" and configuration.md § "A retired environment variable that picked a target is refused while it is still set", which the change satisfies. Searches on dropping undelivered queued writes found no rule.
Findings: 1 correctness · 1 design · 1 prose
Correctness
G1.1 store/sqlite/open.go:137 · store/sqlite/open.go:80
Reach: Every existing database opened by this binary in database mode runs dropSyncTables. Before this PR, the dev dev.db ran with PROJECTS_SYNC=true from .envrc, and the PR body's work-box config still holds [sync]. Any TUI edit made on those machines while their API was unreachable sits in pending_sync. Unproven: whether any machine's pending_sync holds rows today. SELECT count(*) FROM pending_sync on each synced database would settle it.
The first open silently drops edits that were queued but never pushed.
I made a database with the base binary and inserted one pending_sync row. I wrote a [sync] enabled = true config and ran this branch's projects list. It printed the [sync] warning and exited 0. After that run .tables showed no pending_sync. Neither the warning nor anything else said that a queued edit had never reached the API.
Before this PR the status bar showed PENDING (n), and the README said such a change "is sent once it comes back". After this PR the edit lives only in a database the PR calls standalone. The desk then moves to api, so nothing reads that database again. The edit is stranded.
The PR's own rollback makes this worse. The rollback recreates the tables empty and leaves the database "bound to no API until a projects sync --adopt". The old README says a pull "deletes every local row the server did not return". So the adopt step deletes exactly the rows whose pushes were dropped.
The drop ships in the same release as the code that stops reading the tables, which data.md § "A destructive migration ships as its own release, after the release that stopped reading" exists to prevent.
Fix: in dropSyncTables, count pending_sync first. When it holds rows, refuse to open. Name the count and tell the user to run projects sync with the previous release before upgrading. The other two tables hold nothing a person wrote and can drop unconditionally. Add a test that seeds a pending row and asserts the refusal.
Design and architecture
G1.2 store/sqlite/open.go:140 · store/sqlite/open.go:299 · store/sqlite/open_test.go
Reach: The MIGRATION marker in config/retired_sync.go plans the next change to migrate(), which is retiring dropSyncTables. That change and any new migration reorder or edit this chain with nothing pinning two of its steps.
The diff deletes the only test of a live migration and adds none for the new one.
The diff deletes TestMigrate_AddsNumberColumnToExistingDatabase. migrateItemNumber still runs on every database older than the number column. The README still says such databases exist ("a database from before numbers existed"). The deleted test seeded a pre-number row and asserted three things: the title survived, number stayed null, and a second migrate was a no-op. Only its comment mentioned sync. No remaining test in open_test.go builds a table without number.
dropSyncTables has no test at all. The PR body asks reviewers to check that it "touches nothing a person wrote, and that it runs on a database of any age". Only a hand run checks that, and a hand run does not repeat on the next change.
Fix: restore TestMigrate_AddsNumberColumnToExistingDatabase with its comment reworded to the null-number rule. Add one test that builds a database with the three sync tables plus a project, an item, a membership and an undo entry. Run migrate twice, then assert that the sync tables are gone and every data row is unchanged.
Prose
G1.3 CLAUDE.md:28
The sentence about .envrc reads as its opposite.
The sentence is "It points PROJECTS_DB at dev.db for TestRealDatabaseSurvivesEverySizeAndProject, and keeps no run off the API the config file names." "Keeps no run off the API" parses as a double negative. A reader can take it to mean that .envrc guards against the API, which is the opposite of the warning two lines above it.
The file also gives no instruction for an .envrc that still exports PROJECTS_SYNC=true, which the old version of this file told every machine to hold. That variable now stops every go run with exit 2.
Rewrite: "It sets PROJECTS_DB to dev.db for TestRealDatabaseSurvivesEverySizeAndProject and sets nothing else, so a run with the machine's config file still goes to the API that file's api names."
Findings as JSON
{
"used": "I built the PR head and its base (v1.24.0 shape) into my own directory. With the base binary I made a database and added one `pending_sync` row. I gave it a `[sync] enabled = true` config and opened it with the new binary under a scratch `XDG_CONFIG_HOME`/`HOME`. I also ran the `PROJECTS_SYNC`, `TODOUI_SYNC_URL` and `TODOUI_DB` refusals and `go test -short ./...`, which passed.",
"standards": "I cited `data.md` § \"A destructive migration ships as its own release, after the release that stopped reading\" and `configuration.md` § \"A retired environment variable that picked a target is refused while it is still set\", which the change satisfies. Searches on dropping undelivered queued writes found no rule.",
"findings": [
{
"number": "G1.1",
"section": "correctness",
"sites": [
"store/sqlite/open.go:137",
"store/sqlite/open.go:80"
],
"reach": "Every existing database opened by this binary in database mode runs `dropSyncTables`. Before this PR, the dev `dev.db` ran with `PROJECTS_SYNC=true` from `.envrc`, and the PR body's work-box config still holds `[sync]`. Any TUI edit made on those machines while their API was unreachable sits in `pending_sync`. Unproven: whether any machine's `pending_sync` holds rows today. `SELECT count(*) FROM pending_sync` on each synced database would settle it.",
"body": "**The first open silently drops edits that were queued but never pushed.**\n\nI made a database with the base binary and inserted one `pending_sync` row. I wrote a `[sync] enabled = true` config and ran this branch's `projects list`. It printed the `[sync]` warning and exited 0. After that run `.tables` showed no `pending_sync`. Neither the warning nor anything else said that a queued edit had never reached the API.\n\nBefore this PR the status bar showed `PENDING (n)`, and the README said such a change \"is sent once it comes back\". After this PR the edit lives only in a database the PR calls standalone. The desk then moves to `api`, so nothing reads that database again. The edit is stranded.\n\nThe PR's own rollback makes this worse. The rollback recreates the tables empty and leaves the database \"bound to no API until a `projects sync --adopt`\". The old README says a pull \"deletes every local row the server did not return\". So the adopt step deletes exactly the rows whose pushes were dropped.\n\nThe drop ships in the same release as the code that stops reading the tables, which `data.md` § \"A destructive migration ships as its own release, after the release that stopped reading\" exists to prevent.\n\nFix: in `dropSyncTables`, count `pending_sync` first. When it holds rows, refuse to open. Name the count and tell the user to run `projects sync` with the previous release before upgrading. The other two tables hold nothing a person wrote and can drop unconditionally. Add a test that seeds a pending row and asserts the refusal."
},
{
"number": "G1.2",
"section": "design",
"sites": [
"store/sqlite/open.go:140",
"store/sqlite/open.go:299",
"store/sqlite/open_test.go"
],
"reach": "The `MIGRATION` marker in `config/retired_sync.go` plans the next change to `migrate()`, which is retiring `dropSyncTables`. That change and any new migration reorder or edit this chain with nothing pinning two of its steps.",
"body": "**The diff deletes the only test of a live migration and adds none for the new one.**\n\nThe diff deletes `TestMigrate_AddsNumberColumnToExistingDatabase`. `migrateItemNumber` still runs on every database older than the number column. The README still says such databases exist (\"a database from before numbers existed\"). The deleted test seeded a pre-number row and asserted three things: the title survived, `number` stayed null, and a second `migrate` was a no-op. Only its comment mentioned sync. No remaining test in `open_test.go` builds a table without `number`.\n\n`dropSyncTables` has no test at all. The PR body asks reviewers to check that it \"touches nothing a person wrote, and that it runs on a database of any age\". Only a hand run checks that, and a hand run does not repeat on the next change.\n\nFix: restore `TestMigrate_AddsNumberColumnToExistingDatabase` with its comment reworded to the null-number rule. Add one test that builds a database with the three sync tables plus a project, an item, a membership and an undo entry. Run `migrate` twice, then assert that the sync tables are gone and every data row is unchanged."
},
{
"number": "G1.3",
"section": "prose",
"sites": [
"CLAUDE.md:28"
],
"reach": "",
"body": "**The sentence about `.envrc` reads as its opposite.**\n\nThe sentence is \"It points `PROJECTS_DB` at `dev.db` for `TestRealDatabaseSurvivesEverySizeAndProject`, and keeps no run off the API the config file names.\" \"Keeps no run off the API\" parses as a double negative. A reader can take it to mean that `.envrc` guards against the API, which is the opposite of the warning two lines above it.\n\nThe file also gives no instruction for an `.envrc` that still exports `PROJECTS_SYNC=true`, which the old version of this file told every machine to hold. That variable now stops every `go run` with exit 2.\n\nRewrite: \"It sets `PROJECTS_DB` to `dev.db` for `TestRealDatabaseSurvivesEverySizeAndProject` and sets nothing else, so a run with the machine's config file still goes to the API that file's `api` names.\""
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — failure-paths
Used: Checked out PR 20 head. Ran a throwaway Go test on store/sqlite: seeded a database with a pending_sync row, reopened it with Open, and the table was gone with exit 0. Test file removed afterwards.
Standards: Searched "destructive migration drops table holding unsynced pending writes". It surfaced data.md "A destructive migration ships as its own release, after the release that stopped reading" and data.md "Migrations are backward-compatible". I did not read either rule in full.
Findings: 1 correctness · 0 design · 0 prose
Correctness
failure-paths.1 store/sqlite/open.go:137 · store/sqlite/open.go:80
Reach: Any machine whose dev or production database has unpushed rows in pending_sync. These are writes made while the API was unreachable, or since the last push in the TUI. Upgrading and running any command that opens the database triggers the drop. I ran it: a database with a pending_sync row, reopened through Open, returned 'no such table: pending_sync' and Open returned no error.
dropSyncTables runs DROP TABLE IF EXISTS pending_sync on every existing database at open. It does this without checking whether the queue still holds rows. The comment says 'every row a person wrote stays'. That is wrong for the queue. Those rows are the only record of writes the API never received. The local projects and items tables may already carry the change, but the API never got it. After the drop, nothing says the API is behind, and the API stays that way for good. Exit is 0 and nothing is printed.
Fix: before dropping, count the rows in pending_sync. If any exist, refuse with an error that names the count and tells the person to run the previous release's push, or to export the rows. Alternatively, rename the table to pending_sync_retired and warn on stderr. This also matches data.md § "A destructive migration ships as its own release, after the release that stopped reading". Drop the table in a later release.
Findings as JSON
{
"used": "Checked out PR 20 head. Ran a throwaway Go test on store/sqlite: seeded a database with a pending_sync row, reopened it with Open, and the table was gone with exit 0. Test file removed afterwards.",
"standards": "Searched \"destructive migration drops table holding unsynced pending writes\". It surfaced data.md \"A destructive migration ships as its own release, after the release that stopped reading\" and data.md \"Migrations are backward-compatible\". I did not read either rule in full.",
"findings": [
{
"number": "failure-paths.1",
"section": "correctness",
"sites": [
"store/sqlite/open.go:137",
"store/sqlite/open.go:80"
],
"reach": "Any machine whose dev or production database has unpushed rows in pending_sync. These are writes made while the API was unreachable, or since the last push in the TUI. Upgrading and running any command that opens the database triggers the drop. I ran it: a database with a pending_sync row, reopened through Open, returned 'no such table: pending_sync' and Open returned no error.",
"body": "`dropSyncTables` runs `DROP TABLE IF EXISTS pending_sync` on every existing database at open. It does this without checking whether the queue still holds rows. The comment says 'every row a person wrote stays'. That is wrong for the queue. Those rows are the only record of writes the API never received. The local projects and items tables may already carry the change, but the API never got it. After the drop, nothing says the API is behind, and the API stays that way for good. Exit is 0 and nothing is printed.\n\nFix: before dropping, count the rows in `pending_sync`. If any exist, refuse with an error that names the count and tells the person to run the previous release's push, or to export the rows. Alternatively, rename the table to `pending_sync_retired` and warn on stderr. This also matches data.md § \"A destructive migration ships as its own release, after the release that stopped reading\". Drop the table in a later release."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — contracts
Used: I built the PR head (67e8fa1) in my clone. I ran it under a private HOME and XDG dirs, using the work box's real config, copied from dotfiles configs/trust/nonfleet/.config/projects/config.toml. Commands run: list --json, sync, --help and version with PROJECTS_SYNC=false set, and list on a database where I had added a pending_sync row. Callers were searched with gh search code --owner datapointchris (rate limit reached partway) and an rg of a shallow dotfiles clone.
Standards: Cited data.md § "A destructive migration ships as its own release, after the release that stopped reading" and configuration.md § "A retired environment variable that picked a target is refused while it is still set". The search "retired config key section warning removed" found no rule about a retired config section.
Findings: 1 correctness · 1 design · 0 prose
Correctness
contracts.1 store/sqlite/open.go:137 · store/sqlite/open.go:80
Reach: unproven. Every binary self-updates through goselfupdate (cli/root.go:147), so the first run on a machine whose database still synced drops its queue. No config in dotfiles sets [sync] enabled = true today. One check would settle it: count pending_sync rows on each desk's projects.db before the release ships.
The upgrade deletes unpushed writes without a word. dropSyncTables runs DROP TABLE IF EXISTS pending_sync on the first open. It does not look at whether the queue still holds rows. Those rows are writes made on the board while the API was unreachable, and they never reached the API. After the drop nothing can push them, and nothing reports that they existed.
What I ran: I made a database with the head binary, then added pending_sync and inserted one create item row. I then ran projects list. It exited 0, printed only the [sync] warning and the empty list, and afterwards sqlite_master held no table matching %sync%. The row was gone and nothing said so.
The PR checks that every row in the five data tables survives. It does not check this table. data.md § "A destructive migration ships as its own release, after the release that stopped reading" covers the shape: this one release both stops reading the tables and drops them. The probe-based migrations mean a reverted binary does reopen the database, but the queue does not come back.
Fix: refuse to drop a non-empty pending_sync, and name the count and the database path. The message should say how to finish the push with the previous release (projects tui under v1.24.x), or how to discard the queue on purpose. Another route is to stop reading the tables in this release and drop them in a later one.
Design and architecture
contracts.2 config/retired_sync.go:25 · cli/data_source.go:115 · dotfiles:configs/trust/nonfleet/.config/projects/config.toml:20
Reach: dotfiles:configs/trust/nonfleet/.config/projects/config.toml:20 holds [sync] enabled = false / api_url = …, and it is the only config in the fleet that still has the section. Each data command on the work box will print the warning until dotfiles drops it. The MIGRATION note in config/retired_sync.go cannot close until then.
The only [sync] caller lives in dotfiles, and this PR leaves it there. I ran the head against that file verbatim. projects list --json printed warning: [sync] in …/projects/config.toml is ignored, because projects keeps no synced copy of an API. Remove the section… on stderr, then [], and exited 0. Stdout is clean, so a script that parses --json still works. Still, every command on the work box warns from now on.
The file's own comment says "Sync is off here and on in the other copy". The fleet copy (config.toml.tmpl) now holds api and has no [sync], so that comment is also stale.
The PR's MIGRATION comment makes its removal depend on "no machine's config holds [sync]". The one machine that holds it gets its config from this file.
Fix: send the dotfiles change that deletes the [sync] block and its comment, together with this release. Link it from the PR so the retirement has a named end.
Findings as JSON
{
"used": "I built the PR head (67e8fa1) in my clone. I ran it under a private HOME and XDG dirs, using the work box's real config, copied from dotfiles configs/trust/nonfleet/.config/projects/config.toml. Commands run: `list --json`, `sync`, `--help` and `version` with PROJECTS_SYNC=false set, and `list` on a database where I had added a pending_sync row. Callers were searched with `gh search code --owner datapointchris` (rate limit reached partway) and an rg of a shallow dotfiles clone.",
"standards": "Cited data.md § \"A destructive migration ships as its own release, after the release that stopped reading\" and configuration.md § \"A retired environment variable that picked a target is refused while it is still set\". The search \"retired config key section warning removed\" found no rule about a retired config section.",
"findings": [
{
"number": "contracts.1",
"section": "correctness",
"sites": [
"store/sqlite/open.go:137",
"store/sqlite/open.go:80"
],
"reach": "unproven. Every binary self-updates through goselfupdate (cli/root.go:147), so the first run on a machine whose database still synced drops its queue. No config in dotfiles sets `[sync] enabled = true` today. One check would settle it: count pending_sync rows on each desk's projects.db before the release ships.",
"body": "**The upgrade deletes unpushed writes without a word.** `dropSyncTables` runs `DROP TABLE IF EXISTS pending_sync` on the first open. It does not look at whether the queue still holds rows. Those rows are writes made on the board while the API was unreachable, and they never reached the API. After the drop nothing can push them, and nothing reports that they existed.\n\nWhat I ran: I made a database with the head binary, then added `pending_sync` and inserted one `create item` row. I then ran `projects list`. It exited 0, printed only the `[sync]` warning and the empty list, and afterwards `sqlite_master` held no table matching `%sync%`. The row was gone and nothing said so.\n\nThe PR checks that every row in the five data tables survives. It does not check this table. data.md § \"A destructive migration ships as its own release, after the release that stopped reading\" covers the shape: this one release both stops reading the tables and drops them. The probe-based migrations mean a reverted binary does reopen the database, but the queue does not come back.\n\nFix: refuse to drop a non-empty `pending_sync`, and name the count and the database path. The message should say how to finish the push with the previous release (`projects tui` under v1.24.x), or how to discard the queue on purpose. Another route is to stop reading the tables in this release and drop them in a later one."
},
{
"number": "contracts.2",
"section": "design",
"sites": [
"config/retired_sync.go:25",
"cli/data_source.go:115",
"dotfiles:configs/trust/nonfleet/.config/projects/config.toml:20"
],
"reach": "dotfiles:configs/trust/nonfleet/.config/projects/config.toml:20 holds `[sync] enabled = false / api_url = …`, and it is the only config in the fleet that still has the section. Each data command on the work box will print the warning until dotfiles drops it. The MIGRATION note in config/retired_sync.go cannot close until then.",
"body": "**The only `[sync]` caller lives in dotfiles, and this PR leaves it there.** I ran the head against that file verbatim. `projects list --json` printed `warning: [sync] in …/projects/config.toml is ignored, because projects keeps no synced copy of an API. Remove the section…` on stderr, then `[]`, and exited 0. Stdout is clean, so a script that parses `--json` still works. Still, every command on the work box warns from now on.\n\nThe file's own comment says \"Sync is off here and on in the other copy\". The fleet copy (`config.toml.tmpl`) now holds `api` and has no `[sync]`, so that comment is also stale.\n\nThe PR's MIGRATION comment makes its removal depend on \"no machine's config holds [sync]\". The one machine that holds it gets its config from this file.\n\nFix: send the dotfiles change that deletes the `[sync]` block and its comment, together with this release. Link it from the PR so the retirement has a named end."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — useless-test
Used: I cloned the PR at 67e8fa1 and read every edited test beside the code it covers: config/config_test.go, former_name_test.go, service/service_test.go, service/number_test.go, store/sqlite/open_test.go, tui/app_test.go, tui/realdata_test.go and real_binary_flows_test.go. go test -short ./tui/ ./config/ ./service/ passes. I then changed !m.safeToReread() in the rereadTickMsg case of tui/app.go to false && ... and both reread tests still passed. I restored the file and the clone is clean.
Standards: I applied three rules: testing.md § "Test behavior the user cares about, not what this commit happened to change" (the shape where a test asserts a value one internal function hands another), pull-requests.md § "Tests the diff adds are checked for whether they test the program or the change", and testing.md § "Never assert on rendered output — assert the value it was built from".
Findings: 0 correctness · 1 design · 0 prose
Design and architecture
useless-test.1 tui/app_test.go:303
Reach: The diff renames safeToAutoPull to safeToReread and rewrites the rereadTickMsg case in tui/app.go that calls it. In API mode, Init arms the reread tick every two minutes, and the gate is the only thing that stops a read from landing during a grab or a text entry.
TestRereadOnlyRunsInNormalMode checks the gate's predicate but not that the tick obeys it
Delete tui/app_test.go TestRereadOnlyRunsInNormalMode.
The test calls app.safeToReread() directly in each mode. That function is the one-liner m.appMode == modeNormal. Its only caller is the rereadTickMsg case of Update. The test checks the value one internal function hands another. It never checks what the tick does with that value.
I made Update ignore the gate (if false && !m.safeToReread()), so every tick reads during a grab, an add-item prompt or the reader. With that change, go test -short ./tui/ -run Reread still printed PASS for this test and for TestRereadTickRearmsEvenWhenItSkipsTheRead. The behavior a user reaches broke, and no test failed.
This PR also deletes TestSyncTickReconcilesWithTheServer. That was the end-to-end test that sent a tick in modeAddItem, counted zero reads, then sent a tick in modeNormal and saw the read land. No test pins the gate now.
Keep TestRereadTickRearmsEvenWhenItSkipsTheRead. It drives Update and pins a real behavior: the timer survives a skipped tick.
Replacement test: build the app as in TestRInAPIModeShowsAnItemAnotherMachineWrote, with Where{APIHost: ...}. Create an item through the backend after launch. Set appMode = modeMove, send rereadTickMsg{}, drain the command, and assert app.items still lacks the new item. Then set appMode = modeNormal, send the tick again, and assert the item appears. That test fails when the gate is dropped from Update. It also survives renaming or inlining safeToReread.
Findings as JSON
{
"used": "I cloned the PR at 67e8fa1 and read every edited test beside the code it covers: config/config_test.go, former_name_test.go, service/service_test.go, service/number_test.go, store/sqlite/open_test.go, tui/app_test.go, tui/realdata_test.go and real_binary_flows_test.go. `go test -short ./tui/ ./config/ ./service/` passes. I then changed `!m.safeToReread()` in the rereadTickMsg case of tui/app.go to `false && ...` and both reread tests still passed. I restored the file and the clone is clean.",
"standards": "I applied three rules: testing.md § \"Test behavior the user cares about, not what this commit happened to change\" (the shape where a test asserts a value one internal function hands another), pull-requests.md § \"Tests the diff adds are checked for whether they test the program or the change\", and testing.md § \"Never assert on rendered output — assert the value it was built from\".",
"findings": [
{
"number": "useless-test.1",
"section": "design",
"sites": [
"tui/app_test.go:303"
],
"reach": "The diff renames `safeToAutoPull` to `safeToReread` and rewrites the `rereadTickMsg` case in tui/app.go that calls it. In API mode, `Init` arms the reread tick every two minutes, and the gate is the only thing that stops a read from landing during a grab or a text entry.",
"body": "## TestRereadOnlyRunsInNormalMode checks the gate's predicate but not that the tick obeys it\n\nDelete `tui/app_test.go` `TestRereadOnlyRunsInNormalMode`.\n\nThe test calls `app.safeToReread()` directly in each mode. That function is the one-liner `m.appMode == modeNormal`. Its only caller is the `rereadTickMsg` case of `Update`. The test checks the value one internal function hands another. It never checks what the tick does with that value.\n\nI made `Update` ignore the gate (`if false && !m.safeToReread()`), so every tick reads during a grab, an add-item prompt or the reader. With that change, `go test -short ./tui/ -run Reread` still printed `PASS` for this test and for `TestRereadTickRearmsEvenWhenItSkipsTheRead`. The behavior a user reaches broke, and no test failed.\n\nThis PR also deletes `TestSyncTickReconcilesWithTheServer`. That was the end-to-end test that sent a tick in `modeAddItem`, counted zero reads, then sent a tick in `modeNormal` and saw the read land. No test pins the gate now.\n\nKeep `TestRereadTickRearmsEvenWhenItSkipsTheRead`. It drives `Update` and pins a real behavior: the timer survives a skipped tick.\n\nReplacement test: build the app as in `TestRInAPIModeShowsAnItemAnotherMachineWrote`, with `Where{APIHost: ...}`. Create an item through the backend after launch. Set `appMode = modeMove`, send `rereadTickMsg{}`, drain the command, and assert `app.items` still lacks the new item. Then set `appMode = modeNormal`, send the tick again, and assert the item appears. That test fails when the gate is dropped from `Update`. It also survives renaming or inlining `safeToReread`."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — stale-prose
Used: I cloned the repo and checked out PR 20 at 67e8fa1. I built the binary into my own directory and gave it its own HOME, XDG_CONFIG_HOME, XDG_DATA_HOME and XDG_STATE_HOME. First I ran projects list with a config holding [sync]: it printed the warning and exited 0. Next I ran PROJECTS_SYNC_INTERVAL=5m projects version: exit 2, with the refusal. Then I ran TODOUI_SYNC=1 projects list: exit 2. Last, I used rg to check every added comment, doc line and message against the code at head.
Standards: I applied the rule pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite. fleet standards search turned up three more: documentation.md/A comment describes the code as it is, never the change or history that produced it, documentation.md/A count, version or measurement pinned in a comment or doc names what made it true and how it was measured, or goes stale as a constant, and documentation.md/A rename, move or behavior change sweeps every doc and comment that describes it, from above every repo.
Findings: 0 correctness · 0 design · 4 prose
Prose
stale-prose.1 CLAUDE.md:39 · CLAUDE.md:44 · config/retired_sync.go:8
False. The sync leftovers sit in four files. The retirement list leaves one of them out.
CLAUDE.md:39: "The synced copy's leftovers are handled in one file too." The same paragraph names four files:config/retired_sync.go,Config.RetiredSyncinconfig/config.go,warnOfRetiredSyncSectionincli/data_source.go, anddropSyncTablesinstore/sqlite/open.go.CLAUDE.md:44: "The file'sMIGRATIONmarker says when it retires." This sentence follows thedropSyncTablessentence, so it reads as coveringdropSyncTables. It does not.config/retired_sync.go:8-10: "Delete this file, Config.RetiredSync, its call in main.go and the warning in cli/data_source.go once…".rg -n MIGRATIONfinds only this marker and the one informer_name.go. Nothing tells whoever retires the sync code thatdropSyncTablesinstore/sqlite/open.gogoes too.
Rewrite for CLAUDE.md:39: "The synced copy's leftovers retire together, under the MIGRATION marker in config/retired_sync.go." Add dropSyncTables in store/sqlite/open.go to the marker's delete list. Or put a MIGRATION: retires with config/retired_sync.go line on dropSyncTables.
stale-prose.2 CLAUDE.md:43-44 · service/service.go:39-40
A value the code holds. Two comments copy out lists the code already defines.
CLAUDE.md:43-44: "dropSyncTablesinstore/sqlite/open.godropspending_sync,sync_stateandsync_originon the first open." The three table names live in thedropconstant inopen.go. That function's own comment already describes them by role: the push queue, the pull times, and the bound API. Rewrite: "dropSyncTablesinstore/sqlite/open.godrops the synced copy's own tables on the first open, and no row a person wrote."service/service.go:39-40: "Both entry points pass it: database mode in cli.LocalService, and projects-api over Postgres." This is a count and a list of callers.rg -n 'AssigningItemNumbers\(\)'also findsapi/routes/routes_test.go:48,tui/app_test.go:42,store/postgres/store_test.go:23andservice/service_test.go:33. The next entry point makes "Both" wrong. Rewrite: "Every running service passes it. A Service built without it creates unnumbered items." That second sentence says what the option is for, which a list of callers cannot show.
stale-prose.3 CLAUDE.md:24-27 · config/config_test.go:35-36
Will go stale. Two sentences describe files outside the repo, and nothing in the repo keeps them true.
CLAUDE.md:24-27: ".envrcis machine-local and is not in this repo. … It pointsPROJECTS_DBatdev.dbforTestRealDatabaseSurvivesEverySizeAndProject, and keeps no run off the API the config file names." The paragraph itself says the file is not in the repo, so the repo cannot hold its contents level. Rewrite: ".envrcis machine-local.task setup, whichworktree newruns, links the primary checkout's copy into a worktree.TestRealDatabaseSurvivesEverySizeAndProjectreadsPROJECTS_DB, and skips without it." Keep the last sentence only if the skip is real.config/config_test.go:35-36: "The [sync] section below it is the work box's file, which has to load rather than stop the tool." The test writes[sync]\nenabled = false. That is not anyone's file, and another machine's config can change without this test changing. Rewrite: "The [sync] section below it has to load, so a machine whose config still holds one keeps running."
stale-prose.4 README.md:256-258
The change narrated. The README describes what the tool used to be.
README.md:257-258: "projectsonce kept a database as a copy of an API, set under[sync]. It keeps no copy now, andapiis how a machine works through an API." "Once" and "now" describe the change, not the tool. The diff and the release notes carry the change. The upgrade bullets below this sentence say what a reader with a leftover[sync]section orPROJECTS_SYNC*variable needs to know.
Rewrite: "A config holding [sync], a PROJECTS_SYNC* variable, or a database that was a synced copy of an API behaves as follows. api is how a machine works through an API." Keep the three bullets as they are.
Findings as JSON
{
"used": "I cloned the repo and checked out PR 20 at 67e8fa1. I built the binary into my own directory and gave it its own HOME, XDG_CONFIG_HOME, XDG_DATA_HOME and XDG_STATE_HOME. First I ran `projects list` with a config holding `[sync]`: it printed the warning and exited 0. Next I ran `PROJECTS_SYNC_INTERVAL=5m projects version`: exit 2, with the refusal. Then I ran `TODOUI_SYNC=1 projects list`: exit 2. Last, I used rg to check every added comment, doc line and message against the code at head.",
"standards": "I applied the rule `pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite`. `fleet standards search` turned up three more: `documentation.md/A comment describes the code as it is, never the change or history that produced it`, `documentation.md/A count, version or measurement pinned in a comment or doc names what made it true and how it was measured, or goes stale as a constant`, and `documentation.md/A rename, move or behavior change sweeps every doc and comment that describes it, from above every repo`.",
"findings": [
{
"number": "stale-prose.1",
"section": "prose",
"sites": [
"CLAUDE.md:39",
"CLAUDE.md:44",
"config/retired_sync.go:8"
],
"reach": "",
"body": "**False. The sync leftovers sit in four files. The retirement list leaves one of them out.**\n\n- `CLAUDE.md:39`: \"**The synced copy's leftovers are handled in one file too.**\" The same paragraph names four files: `config/retired_sync.go`, `Config.RetiredSync` in `config/config.go`, `warnOfRetiredSyncSection` in `cli/data_source.go`, and `dropSyncTables` in `store/sqlite/open.go`.\n- `CLAUDE.md:44`: \"The file's `MIGRATION` marker says when it retires.\" This sentence follows the `dropSyncTables` sentence, so it reads as covering `dropSyncTables`. It does not.\n- `config/retired_sync.go:8-10`: \"Delete this file, Config.RetiredSync, its call in main.go and the warning in cli/data_source.go once…\". `rg -n MIGRATION` finds only this marker and the one in `former_name.go`. Nothing tells whoever retires the sync code that `dropSyncTables` in `store/sqlite/open.go` goes too.\n\nRewrite for `CLAUDE.md:39`: \"**The synced copy's leftovers retire together, under the `MIGRATION` marker in `config/retired_sync.go`.**\" Add `dropSyncTables in store/sqlite/open.go` to the marker's delete list. Or put a `MIGRATION: retires with config/retired_sync.go` line on `dropSyncTables`."
},
{
"number": "stale-prose.2",
"section": "prose",
"sites": [
"CLAUDE.md:43-44",
"service/service.go:39-40"
],
"reach": "",
"body": "**A value the code holds. Two comments copy out lists the code already defines.**\n\n- `CLAUDE.md:43-44`: \"`dropSyncTables` in `store/sqlite/open.go` drops `pending_sync`, `sync_state` and `sync_origin` on the first open.\" The three table names live in the `drop` constant in `open.go`. That function's own comment already describes them by role: the push queue, the pull times, and the bound API. Rewrite: \"`dropSyncTables` in `store/sqlite/open.go` drops the synced copy's own tables on the first open, and no row a person wrote.\"\n- `service/service.go:39-40`: \"Both entry points pass it: database mode in cli.LocalService, and projects-api over Postgres.\" This is a count and a list of callers. `rg -n 'AssigningItemNumbers\\(\\)'` also finds `api/routes/routes_test.go:48`, `tui/app_test.go:42`, `store/postgres/store_test.go:23` and `service/service_test.go:33`. The next entry point makes \"Both\" wrong. Rewrite: \"Every running service passes it. A Service built without it creates unnumbered items.\" That second sentence says what the option is for, which a list of callers cannot show."
},
{
"number": "stale-prose.3",
"section": "prose",
"sites": [
"CLAUDE.md:24-27",
"config/config_test.go:35-36"
],
"reach": "",
"body": "**Will go stale. Two sentences describe files outside the repo, and nothing in the repo keeps them true.**\n\n- `CLAUDE.md:24-27`: \"`.envrc` is machine-local and is not in this repo. … It points `PROJECTS_DB` at `dev.db` for `TestRealDatabaseSurvivesEverySizeAndProject`, and keeps no run off the API the config file names.\" The paragraph itself says the file is not in the repo, so the repo cannot hold its contents level. Rewrite: \"`.envrc` is machine-local. `task setup`, which `worktree new` runs, links the primary checkout's copy into a worktree. `TestRealDatabaseSurvivesEverySizeAndProject` reads `PROJECTS_DB`, and skips without it.\" Keep the last sentence only if the skip is real.\n- `config/config_test.go:35-36`: \"The [sync] section below it is the work box's file, which has to load rather than stop the tool.\" The test writes `[sync]\\nenabled = false`. That is not anyone's file, and another machine's config can change without this test changing. Rewrite: \"The [sync] section below it has to load, so a machine whose config still holds one keeps running.\""
},
{
"number": "stale-prose.4",
"section": "prose",
"sites": [
"README.md:256-258"
],
"reach": "",
"body": "**The change narrated. The README describes what the tool used to be.**\n\n- `README.md:257-258`: \"`projects` once kept a database as a copy of an API, set under `[sync]`. It keeps no copy now, and `api` is how a machine works through an API.\" \"Once\" and \"now\" describe the change, not the tool. The diff and the release notes carry the change. The upgrade bullets below this sentence say what a reader with a leftover `[sync]` section or `PROJECTS_SYNC*` variable needs to know.\n\nRewrite: \"A config holding `[sync]`, a `PROJECTS_SYNC*` variable, or a database that was a synced copy of an API behaves as follows. `api` is how a machine works through an API.\" Keep the three bullets as they are."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — fleet-patterns
Used: I checked out PR 20 in a clone and read its diff for config/retired_sync.go, config/former_name.go, main.go, config/config.go, cli/data_source.go, store/sqlite/open.go and tui/app.go. I ran gh search code --owner datapointchris for five things: the retired-variable refusal, PROJECTS_SYNC, imports of projects/sync, tea.Tick re-read loops and [sync] configs. Searching for DROP TABLE IF EXISTS across Go repos found no other repo. I read ichrisbirch's retiredAPIBase refusal and the dotfiles projects config. GitHub's search rate limit cut off one last query, which looked for other repos setting api_url. A [sync] config written outside dotfiles would show up there.
Standards: The change follows configuration.md § "A retired environment variable that picked a target is refused while it is still set". The search "config file key no longer read unknown key ErrorUnused" found no rule for a retired config-file section.
Findings: 0 correctness · 0 design · 0 prose
No findings.
Findings as JSON
{
"used": "I checked out PR 20 in a clone and read its diff for `config/retired_sync.go`, `config/former_name.go`, `main.go`, `config/config.go`, `cli/data_source.go`, `store/sqlite/open.go` and `tui/app.go`. I ran `gh search code --owner datapointchris` for five things: the retired-variable refusal, `PROJECTS_SYNC`, imports of `projects/sync`, `tea.Tick` re-read loops and `[sync]` configs. Searching for `DROP TABLE IF EXISTS` across Go repos found no other repo. I read ichrisbirch's `retiredAPIBase` refusal and the dotfiles projects config. GitHub's search rate limit cut off one last query, which looked for other repos setting `api_url`. A `[sync]` config written outside dotfiles would show up there.",
"standards": "The change follows `configuration.md` § \"A retired environment variable that picked a target is refused while it is still set\". The search \"config file key no longer read unknown key ErrorUnused\" found no rule for a retired config-file section.",
"findings": []
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — synthesis
Used: the 15 findings of round 1, from 8 review(s), and nothing else
Standards: none, because this step reads only the findings
Findings: 0 correctness · 3 design · 0 prose
Design and architecture
synthesis.1 explains stale-prose.2 · stale-prose.3 · code-architecture.2
Prose states a list, count or file content that the code or an outside file already owns, so it goes stale. Sites: CLAUDE.md:43-44 and service/service.go:39-40 (stale-prose.2), CLAUDE.md:24-27 and config/config_test.go:35-36 (stale-prose.3), the interval written in cli/tui.go:50 and README.md:186 (code-architecture.2).
synthesis.2 explains stale-prose.1 · contracts.2 · code-architecture.3
The sync retirement leaves loose ends with no single owner: the dropSyncTables retirement is not tied to the MIGRATION marker (stale-prose.1), the dotfiles config still holds [sync] (contracts.2), and the retired-variable lists are split across two files with separate checks in main.go (code-architecture.3).
synthesis.3 explains G1.2 · useless-test.1
The PR deletes tests of live behavior and leaves the replacements unwritten. The deleted tests are TestMigrate_AddsNumberColumnToExistingDatabase and TestSyncTickReconcilesWithTheServer. Nothing pins dropSyncTables or the reread gate, and the one remaining gate test checks only the predicate.
One defect, raised more than once
G1.1 · system-design.1 · failure-paths.1 · contracts.1: dropSyncTables in store/sqlite/open.go drops pending_sync on first open without checking for unpushed rows, so queued edits are lost silently.
Shapes as JSON
{
"shapes": [
{
"number": "synthesis.1",
"section": "design",
"explains": [
"stale-prose.2",
"stale-prose.3",
"code-architecture.2"
],
"body": "Prose states a list, count or file content that the code or an outside file already owns, so it goes stale. Sites: CLAUDE.md:43-44 and service/service.go:39-40 (stale-prose.2), CLAUDE.md:24-27 and config/config_test.go:35-36 (stale-prose.3), the interval written in cli/tui.go:50 and README.md:186 (code-architecture.2)."
},
{
"number": "synthesis.2",
"section": "design",
"explains": [
"stale-prose.1",
"contracts.2",
"code-architecture.3"
],
"body": "The sync retirement leaves loose ends with no single owner: the dropSyncTables retirement is not tied to the MIGRATION marker (stale-prose.1), the dotfiles config still holds `[sync]` (contracts.2), and the retired-variable lists are split across two files with separate checks in main.go (code-architecture.3)."
},
{
"number": "synthesis.3",
"section": "design",
"explains": [
"G1.2",
"useless-test.1"
],
"body": "The PR deletes tests of live behavior and leaves the replacements unwritten. The deleted tests are TestMigrate_AddsNumberColumnToExistingDatabase and TestSyncTickReconcilesWithTheServer. Nothing pins dropSyncTables or the reread gate, and the one remaining gate test checks only the predicate."
}
],
"duplicates": [
{
"defect": "dropSyncTables in store/sqlite/open.go drops pending_sync on first open without checking for unpushed rows, so queued edits are lost silently.",
"findings": [
"G1.1",
"system-design.1",
"failure-paths.1",
"contracts.1"
]
}
]
}This review does not gate merge.
PR review round 1Ran: G1, useless-test, code-architecture, fleet-patterns, system-design, stale-prose, failure-paths, contracts, synthesis
Round record{
"grammar": 6,
"round": 1,
"repo": "datapointchris/projects",
"pr": 20,
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"experiment": "none",
"dispatch": "20261108T065057Z-pr-review",
"roster": "1ee22e0694913fcfb159f68268741c1851bff2a5",
"harness": "1b0df0090dc84b9bf846796f6afafba9a2ef8e48",
"machine": "archlinux",
"started": "2026-11-08T06:51:19Z",
"finished": "2026-11-08T06:57:42Z",
"reviewers": [
{
"label": "G1",
"agent": "pr-reviewer",
"model": "opus",
"effort": "high",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/general.md",
"prompt_sha": "a0f85e3b82b4",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 150,
"tokens": {
"input": 38,
"cache_write": 60228,
"cache_read": 828084,
"output": 10354
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 38,
"cache_write": 60228,
"cache_read": 828084,
"output": 10354
}
},
"session": "01a1b9ec-e572-7b79-a87b-82ec81700830",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816963727",
"review_id": 5816963727,
"findings": {
"correctness": 1,
"design": 1,
"prose": 1
},
"numbers": [
"G1.1",
"G1.2",
"G1.3"
],
"rules": {
"proposed": 1,
"keys": [
"rule/data/a-migration-that-drops-a-table-refuses-while-the-table-holds-rows-nothing-else-carries"
],
"filed": true,
"why": "",
"path": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records/G1.rules.json"
},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:51:19Z",
"finished": "2026-11-08T06:53:53Z",
"account": "live",
"api_key_source": "none",
"machine": "archlinux"
},
{
"label": "useless-test",
"agent": "pr-useless-test-reviewer",
"model": "opus",
"effort": "high",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/useless-test.md",
"prompt_sha": "3700d72236e3",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 252,
"tokens": {
"input": 18,
"cache_write": 51269,
"cache_read": 295591,
"output": 5359
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 18,
"cache_write": 51269,
"cache_read": 295591,
"output": 5359
}
},
"session": "01a1b9ec-eaa2-7b41-bffc-eceea4d4fe83",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816968840",
"review_id": 5816968840,
"findings": {
"correctness": 0,
"design": 1,
"prose": 0
},
"numbers": [
"useless-test.1"
],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:51:47Z",
"finished": "2026-11-08T06:56:05Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "code-architecture",
"agent": "pr-code-architecture-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "d9be86eb4324",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 73,
"tokens": {
"input": 24,
"cache_write": 35704,
"cache_read": 323221,
"output": 5725
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 24,
"cache_write": 35704,
"cache_read": 323221,
"output": 5725
}
},
"session": "01a1b9ec-e7bc-7f51-98f5-62651c64cdc4",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816960834",
"review_id": 5816960834,
"findings": {
"correctness": 0,
"design": 3,
"prose": 0
},
"numbers": [
"code-architecture.1",
"code-architecture.2",
"code-architecture.3"
],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:51:20Z",
"finished": "2026-11-08T06:52:35Z",
"account": "live",
"api_key_source": "none",
"machine": "archlinux"
},
{
"label": "fleet-patterns",
"agent": "pr-fleet-patterns-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "eae343d1d8d5",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 63,
"tokens": {
"input": 18,
"cache_write": 24940,
"cache_read": 204439,
"output": 2613
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 18,
"cache_write": 24940,
"cache_read": 204439,
"output": 2613
}
},
"session": "01a1b9f0-cca0-79a6-a771-e2d163919053",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816970879",
"review_id": 5816970879,
"findings": {
"correctness": 0,
"design": 0,
"prose": 0
},
"numbers": [],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:55:53Z",
"finished": "2026-11-08T06:57:00Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "system-design",
"agent": "pr-system-design-reviewer",
"model": "sonnet",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "c284e39c894a",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 26,
"tokens": {
"input": 10,
"cache_write": 10559,
"cache_read": 68087,
"output": 2158
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 10,
"cache_write": 10559,
"cache_read": 68087,
"output": 2158
}
},
"session": "01a1b9ec-e6dd-79a2-ae86-4ca9bdf81198",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816959089",
"review_id": 5816959089,
"findings": {
"correctness": 1,
"design": 0,
"prose": 0
},
"numbers": [
"system-design.1"
],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:51:20Z",
"finished": "2026-11-08T06:51:48Z",
"account": "live",
"api_key_source": "none",
"machine": "archlinux"
},
{
"label": "stale-prose",
"agent": "pr-stale-prose-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "50506a05f936",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 211,
"tokens": {
"input": 16,
"cache_write": 34707,
"cache_read": 207170,
"output": 7522
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 16,
"cache_write": 34707,
"cache_read": 207170,
"output": 7522
}
},
"session": "01a1b9ed-5a46-7906-8afe-f24e09768f57",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816969534",
"review_id": 5816969534,
"findings": {
"correctness": 0,
"design": 0,
"prose": 4
},
"numbers": [
"stale-prose.1",
"stale-prose.2",
"stale-prose.3",
"stale-prose.4"
],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:52:38Z",
"finished": "2026-11-08T06:56:23Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "failure-paths",
"agent": "pr-failure-paths-reviewer",
"model": "sonnet",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "56bdd640764b",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 172,
"tokens": {
"input": 16,
"cache_write": 24179,
"cache_read": 158844,
"output": 2872
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 16,
"cache_write": 24179,
"cache_read": 158844,
"output": 2872
}
},
"session": "01a1b9ed-534a-73ae-a9d1-0efbe23e113c",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816967692",
"review_id": 5816967692,
"findings": {
"correctness": 1,
"design": 0,
"prose": 0
},
"numbers": [
"failure-paths.1"
],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:52:38Z",
"finished": "2026-11-08T06:55:35Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "contracts",
"agent": "pr-contracts-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "88b65626bceb",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 240,
"tokens": {
"input": 38,
"cache_write": 39636,
"cache_read": 579402,
"output": 8034
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 38,
"cache_write": 39636,
"cache_read": 579402,
"output": 8034
}
},
"session": "01a1b9ed-24a1-70f8-937d-72dd4e8f18e3",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816968641",
"review_id": 5816968641,
"findings": {
"correctness": 1,
"design": 1,
"prose": 0
},
"numbers": [
"contracts.1",
"contracts.2"
],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:51:55Z",
"finished": "2026-11-08T06:56:00Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "synthesis",
"agent": null,
"model": "sonnet",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/synthesis.md",
"prompt_sha": "40dcb5a50df0",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 6,
"tokens": {
"input": 2,
"cache_write": 9461,
"cache_read": 1433,
"output": 619
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 2,
"cache_write": 9461,
"cache_read": 1433,
"output": 619
}
},
"session": "01a1b9f2-981a-7a58-ae0e-b102a491bd48",
"worklog": "2407.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/20#pullrequestreview-5816972406",
"review_id": 5816972406,
"findings": {
"correctness": 0,
"design": 3,
"prose": 0
},
"numbers": [
"synthesis.1",
"synthesis.2",
"synthesis.3"
],
"rules": {},
"head": "67e8fa13a26f748959a489e2ef35ee84c662a4fd",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr20-round1-20261108T065057Z/records",
"started": "2026-11-08T06:57:33Z",
"finished": "2026-11-08T06:57:42Z",
"account": "live",
"api_key_source": "none",
"machine": "archlinux"
}
],
"rules": {
"proposed": 1,
"keys": [
"rule/data/a-migration-that-drops-a-table-refuses-while-the-table-holds-rows-nothing-else-carries"
],
"unfiled": []
},
"kept": []
} |
…dits A row in pending_sync is an edit made on a synced copy that never reached its API, and the queue is the only record of it. Dropping the queue on the first open lost that record with no word. The open now refuses while the queue holds a row, naming how many and the API they were for, with the two ways out: push them with v1.24.2's `projects sync`, or discard them. An empty queue drops as before, with sync_state and sync_origin, which hold nothing a person wrote. A failed open names the database it was reading, so the refusal says which file to act on. Agent-Name: claude-code-unattended Agent-Session: 43e2375e-0270-4942-8076-19e8ab6a2337 Agent-Run: 01a1b9ec-c540-7532-8ef9-925d1983656e Worklog: 2407.1
AssigningItemNumbers existed so a synced copy could leave numbering to its API. With the copy gone, both entry points passed it, and only test helpers built a service without it. Numbering is now what CreateItem always does, from the store's NextItemNumber. The Postgres store's nil-number check on CreateItem goes with the option, because nothing can reach it. RestoreItem keeps it, because its number comes from an undo record on disk. Agent-Name: claude-code-unattended Agent-Session: 43e2375e-0270-4942-8076-19e8ab6a2337 Agent-Run: 01a1b9ec-c540-7532-8ef9-925d1983656e Worklog: 2407.1
The todoui rename and the synced copy each kept a list of retired variables, a loop over it, and a call in main.go. Both answer one question, so retiredVariables is now the one table, and main.go asks RetiredVariableRefusal alone. Each retirement adds its rows from an init in its own file, so deleting that file takes its rows with it and leaves main.go alone. The synced copy's MIGRATION marker now lists dropSyncTables, so the whole retirement has one owner. Agent-Name: claude-code-unattended Agent-Session: 43e2375e-0270-4942-8076-19e8ab6a2337 Agent-Run: 01a1b9ec-c540-7532-8ef9-925d1983656e Worklog: 2407.1
…ext tick The gate test called safeToReread directly, so dropping the gate from the tick's handler failed nothing. One test now drives the tick through Update in API mode: a tick mid-move re-arms and reads nothing, and the next tick in normal mode shows an item another client wrote. It replaces both the predicate test and the re-arm test. Agent-Name: claude-code-unattended Agent-Session: 43e2375e-0270-4942-8076-19e8ab6a2337 Agent-Run: 01a1b9ec-c540-7532-8ef9-925d1983656e Worklog: 2407.1
…t code holds - README's section on a synced copy's config and database states how each behaves, including the refusal of an unpushed queue, rather than narrating what the tool was. - The board's re-read interval is named once, as apiRereadInterval. The help says the board reads the API again on a timer. - CLAUDE.md describes the one retired-variable table, ties every sync leftover to the MIGRATION marker that retires it, and lists no table names or callers the code already holds. Its .envrc note says what the test reads and that a PROJECTS_SYNC* line stops every run. Agent-Name: claude-code-unattended Agent-Session: 43e2375e-0270-4942-8076-19e8ab6a2337 Agent-Run: 01a1b9ec-c540-7532-8ef9-925d1983656e Worklog: 2407.1
Every desk now works through the projects API, and the one machine that never syncs keeps its database as its only copy. The sync engine kept a database as a copy of an API, so nothing is left for it to do. It still cost a package of its own, three tables, twenty-four queries, a command, a config section and a write refusal. This removes all of it. API mode and database mode are what remain, and both already ran through the same routes.
What to look at
store/sqlite/open.go:dropSyncTablesreplaces the two migrations that created the sync tables. It refuses whilepending_syncholds a row, and otherwise drops all three. Check that it touches nothing a person wrote, and that it runs on a database of any age.config/retired_variables.go,config/retired_sync.goandmain.go: every retired variable refuses from one table, and the sync variables join it. Check that the refusal runs before anything opens.config/config.goandcli/data_source.go:[sync]loads intoConfig.RetiredSync, andwarnOfRetiredSyncSectionwarns on stderr. Check thatErrorUnusedstill refuses every other unknown key.service/service.go:CreateItemnumbers every item, with no option. Check that nothing still builds a service expecting unnumbered items.tui/app.go: the engine's status bar, pull messages andRpath go. The API-mode re-read keeps its tick atapiRereadInterval.model/undo.goandservice/undo.go: the undo result keeps the four fields the API sends. The rest existed for pushing an undo.How it was verified
go test -short ./...passes. That includesTestRealBinaryFlows, whose two help frames changed only by theRline, which no longer wraps.[sync] enabled = false, held 2 projects, 3 items, 4 memberships, 1 dependency, 2 tasks and 11 undo entries, plus one row insync_stateandsync_origin. This build opened it with a[sync]warning. Every row in the five data tables matched the copy taken before, the undo log still held 11 entries, and the three sync tables were gone.items createthen numbered a new item 4.pending_syncrefused to open with exit 1, naming 2 edits and the bound API, and every table stayed. AfterDELETE FROM pending_syncit opened, and the sync tables went.pending_sync, so no real database reaches the refusal today.TestAnAPIRereadWaitsOutAMoveAndLandsOnTheNextTickfails when the gate is dropped from the tick's handler, and passes with it.PROJECTS_SYNC_URL=… projects version,TODOUI_SYNC=1 projects versionandTODOUI_DB=… projects versioneach exit 2 with their refusal.projects syncis an unknown command.What changes
projects syncis gone, and so are--adoptand--force.[sync]loads. Every command that opens data prints awarning:line naming the file, until the section is removed.PROJECTS_SYNC,PROJECTS_SYNC_URL,PROJECTS_SYNC_TOKEN_COMMANDandPROJECTS_SYNC_INTERVAL, and theirTODOUI_twins, stop every command with exit 2 while set.projects syncfrom v1.24.2, orDELETE FROM pending_sync.apiRereadInterval, and nothing configures that.Rre-reads the data in database mode too, where it used to flash a sync-disabled message.LOCALorAPI. The sync states are gone.store/sqlite/generatedmoves tostore/sqlite/internal/generated.Decisions, and what they rejected
[sync]only warns. On a desk the config file holdsapi. An ignoredPROJECTS_SYNC_URLwould leave thatapiin charge, so a dev run meant for the dev stack would reach the production API. A warning was rejected for the variables for that reason. The config section warns because a refusal would stop the tool on a machine until its next config change.interval. A new key for one knob was rejected as surface nobody asked for.RestoreItemkeeps its nil check, because its number comes from an undo record on disk.sync/and theprojects synccommand alone. The schema, the queries, the config section and the write refusal would then describe an engine that no longer exists.Risk and rollback
A release ships the binary, the API image and the web image under one version. The API's numbering is unchanged, since it always numbered. The web app does not change. The first open of a database by this binary drops the three sync tables, unless the push queue holds a row. A revert brings back code that recreates them empty, so a reverted binary opens fine. Its database is then bound to no API until a
projects sync --adopt.The review
Round 1: #20 (comment)
G1.1fixed —dropSyncTablesrefuses whilepending_syncholds a row, naming the count and the bound API.sync_stateandsync_origindrop unconditionally. Run by hand rather than pinned by a test, because no database checked holds a queued row.system-design.1fixed — same asG1.1.failure-paths.1fixed — same asG1.1.contracts.1fixed — same asG1.1.G1.2rejected — a test of a migration pins a transition, which this suite leaves out and deletes where a change touches one. The drop, the refusal and the null number were each proved on a database built by v1.24.0.G1.3fixed — the sentence says what the test reads, and that aPROJECTS_SYNC*line left in.envrcstops every run.code-architecture.1fixed — the option, its field, its branch and theCreateItemguard are gone.RestoreItemkeeps its guard, because its number comes from an undo record.code-architecture.2fixed — the help says the board reads the API again on a timer, and the README namesapiRereadInterval.code-architecture.3fixed —config/retired_variables.goholds the one table. Each retirement adds its rows from aninitin its own file, andmain.gocallsRetiredVariableRefusalalone.useless-test.1fixed — one test drives the tick throughUpdatein API mode, replacing the predicate test and the re-arm test. It fails with the gate dropped.contracts.2fixed — the one config that held[sync]drops it, in the repo that installs it, and its header says what the file is now. v1.24.x reads the missing section as sync off, so it landed ahead of this release.stale-prose.1fixed — theMIGRATIONmarker listsdropSyncTables, which carries a line naming the marker, and CLAUDE.md says the leftovers retire together under it.stale-prose.2fixed — CLAUDE.md names the tables by role. The caller list inservice/service.gowent with the option.stale-prose.3fixed — both sentences rewritten as the finding proposed.stale-prose.4fixed — the section opens on what each leftover does, and its heading says the same.synthesis.1fixed —stale-prose.2,stale-prose.3andcode-architecture.2are each fixed.synthesis.2fixed —stale-prose.1,contracts.2andcode-architecture.3are each fixed.synthesis.3fixed —useless-test.1is fixed, and the migration half is rejected asG1.2says.Session 43e2375e-0270-4942-8076-19e8ab6a2337 wrote this change.