Skip to content

feat: serve projects over HTTP on Postgres, on the TUI's own rules - #17

Merged
datapointchris merged 12 commits into
mainfrom
projects-go-api
Nov 7, 2026
Merged

datapointchris merged 12 commits into
mainfrom
projects-go-api

Conversation

@datapointchris

@datapointchris datapointchris commented Nov 7, 2026 •

Copy link
Copy Markdown
Owner

The web page, the agents and the scripts reach projects through a separate
API in another language, with its own copy of every rule. A rule added there
never reaches the TUI, and the copies already disagree on what a project holds.
Nothing here could answer over HTTP, so the two could not be brought together.

This serves the same routes and the same answers from this repo, on Postgres,
through the service the TUI uses. Rules the other API held and service
lacked moved into service. Each release now publishes a signed image and
manifests beside the binaries. Nothing is pointed at them by this change.

Session b6774e69-f156-4d1a-9713-9886fbbc39f7.

What to look at

  1. service/service.go, service/references.go, service/listing.go in
    de84367 and 7c95543. Each refusal and each derived field is a rule both
    stores now share. Check that none depends on a store's accidental behavior.
  2. store/postgres/store.go and store/postgres/migrations/00001_projects.sql
    in de84367 and 87fae1a. InTx takes pg_advisory_xact_lock first, and
    binds the writer with set_config(..., true). Check that a rule's reads hold
    at commit, and that the trigger covers a cascade.
  3. api/routes/request_input.go and api/routes/answers.go in e7bb8ff and
    d32399e. A PATCH tells a left-out field from a null one here, and every
    refusal body is built here. Compare with api/wire/wire.go and api/schemas/.
  4. api/authentication/authentication.go and routes.ServiceScopes. Check that
    a service client reaches only the listed read patterns, and that / is in no
    scope.
  5. api-publish in .github/workflows/release.yml and deploy/ in 589c212.
    That job has never run. Check what a re-run does at each step.
  6. reorderItemCmd and positionBelow in tui/app.go in 0960325 and
    cb5219d, and taskPayload in sync/backend.go in cb5219d.

How it was verified

  • go test ./... passes in 10 packages. PROJECTS_TEST_STORE=postgres go test ./service/ runs every rule on a real Postgres and passes in 15 s.
  • TestEveryAnswerMatchesItsSchema validates a real answer against each file in
    api/schemas.
  • govulncheck ./... reads 0, after raising the modules testcontainers pulled
    in at flagged versions.
  • A throwaway program sent 1663 requests to each API and compared status and
    body, at this head. Each side ran on Postgres, restored from one copy of a
    production database. The program and the data are not in this repo.
    • The new API answered 200 ×1410, 201 ×38, 204 ×12, 400 ×2, 404 ×63, 405 ×8,
      409 ×12, 410 ×4, 422 ×114. Neither side answered a 5xx.
    • Differences: 135, each classed below, 0 unexplained. Twelve of them differ in
      status. Instants were compared as instants, and no time differed.
    • After undoing all 128 writes on the new side, all 13 projects and 55 items
      read identical to before, every field.
  • Peak memory on the new API was 22 MiB under that load. TERM exited clean in
    2 ms.
  • With Postgres on a host dropping packets, /ready answered 503 in 2 s and
    GET /api/v1/projects answered 503 in 5 s.
  • Sent directly, because the replay sends none of them: a position of 2^31 on
    POST, 2^32+1 on PATCH and a NUL in a title each answer 422. A project named
    twice in project_ids answers 201 with one membership.
  • The image builds at 33 MB and runs --read-only as uid 10500. API image now
    builds it on every pull request. deploy/app and deploy/migrate pass
    kubeconform -strict and kube-linter.
  • The same key script on 132335a and on this head, each on a fresh database,
    measured the reorder, refusal and reader changes listed under What changes.
  • A database written by this head opens and takes writes under 132335a, and
    one written by 132335a opens under this head.
Replay differences Count Why no client notices
Order left undefined: dependency_ids and /blocked, read with no ORDER BY 36 The other API promises no order
Position ties, near names on one position, a limit cutting across a tie 13 Same reason
Wording of a body that is not JSON 24 Status and shape match
Wording of a malformed UUID or instant, or a string where an integer belongs 30 Field and status match
Wording of a wrong JSON type on a reference 10 The other API reports one per union member
Bad reference and bad input together 7 Each refuses the other first: 404 there, 422 here
repo sent as an empty string 5 Stored as no repo here, so the untagged filter finds it
"yes" or 1 for a boolean, and the read straight after 8 200 there, 422 here. Every client sends a boolean
GET /project-items/ with a trailing slash 1 307 there, 404 here. No client adds one
HEAD /projects 1 405 there, 200 here. No client sends one

What changes

In the TUI, for anyone upgrading:

  • A moved item lands where it is dropped. A move down one row used to do nothing,
    because the single-row write tied with the neighbor, and an archived item above
    the drop threw it one row high. A drop below a row tied on position lands under
    that row.
  • A task checked off before the next pull stays checked. With sync on, a push now
    sends the task's own id, so the check names an id the server holds.
  • A refused write shows a whole sentence. Error: duplicate name now reads Error: An active project named 'alpha' already exists (<id>).
  • The reader's Projects: line lists an item's projects by position rather than
    by name.
  • Opening an existing database adds kind, completed_at and two lookup tables.
    Completing an item stamps completed_at, and no screen shows it yet.
  • Rules the TUI never reached moved too. service now refuses to complete an item
    with open tasks, and the TUI's own check already refused first. Deleting a
    project refuses incomplete orphans, and the TUI has no delete. No screen changes.

For anyone running the release:

  • go install of the root still builds only the CLI, and go list -deps . holds
    no pgx, goose or goapiauth.
  • projects-api is a second binary, at ghcr.io/datapointchris/projects, with
    manifests at ghcr.io/datapointchris/projects/manifests. One version names both
    it and the CLI.
  • projects-api migrate runs from a Job in deploy/migrate, applied before
    deploy/app. The README lists its settings and the cosign verify command.
  • Each migrate also adds any kind or status it finds missing from model's lists.
    Each one it adds writes an audit row.
  • A request past requestDeadline, or one a stalled database never answers, gets a
    503. Any other 500 names its request id.
  • An audit row carries the request's traceparent and baggage. A request log line
    carries its traceparent.

New routes the other API lacks: POST and GET /api/v1/undo, /health,
/ready, and /.well-known/oauth-protected-resource when PUBLIC_URL is set.

Decisions, and what they rejected

  • A rule the other API holds moves into service, and a handler holds none. A
    rule in a handler would have to land again for SQLite. Rejected: a thin layer
    of API-only rules, which is the divergence this removes.
  • pg_advisory_xact_lock at the top of InTx. It is the analog of SQLite's
    immediate transaction, and costs one writer at a time, which one person's write
    rate never feels. Rejected: serializable, which needs a retry the port does
    not state.
  • api/wire holds the answers, apart from model. A model change cannot alter
    an answer by accident. model's tags would also drop fields clients index by
    name.
  • undo_log has no audit trigger. It writes on every write, and would copy each
    whole row the real tables already audit.
  • repo: "" stores no repo. The other API keeps the empty string. Null is what
    the untagged filter expects, and the only sender is one flag on an existing CLI.
  • No lax boolean coercion, and wording left unmatched. Each only reaches a
    request no client sends, or a sentence no client parses.
  • /ready also checks the identity provider, through NewConnecting. A provider
    down at start holds the pod unready rather than crash-looping it.
  • /api/v2 is this API's version signal, and no answer carries schema_version.
    A list answers a bare array, which has no place for the field. Each schema states
    that a field added is not a new version. Rejected: a field on objects alone, which
    would leave list answers with no signal.
  • The API sets no repo-name ban. A container holds no registry, and the other API
    has no ban either. Rejected: shipping a registry into the image, which would make
    one deployment's repo list part of the release.
  • A position past int32 and a NUL are refused at decode, rather than in service.
    SQLite holds both, so a refusal in service would narrow the TUI's store for a
    limit only Postgres has.
  • Lookup rows come from model, on every migrate. A new kind then costs one
    list edit. Rejected: a seed in each store's migrations, where the next kind needs
    a migration file and an edit in step.
  • sync's pull leaves kind and completed_at alone. CLAUDE.md states the
    omission once, in sync's carve-out. Rejected: a test holding sync to the
    columns, because that engine is due to leave.
  • The TUI counts archived rows when it drops an item, rather than ReorderItem
    skipping them.
    The web page sends an index among every member, so the wire's
    position has to keep meaning that.

Risk and rollback

  • Merging cuts a release, and api-publish runs for the first time. If it fails,
    the tag and the binaries already exist, and a re-run builds only what is missing.
  • Nothing applies the manifests yet, so no service changes hands.
  • A revert leaves the added columns and lookup tables in each SQLite database, and
    the previous binary works with them.
  • api-publish, release/install-tools and release/pin-manifests are a fourth copy
    of what three other repos carry, with no shared owner. A cosign, flux or kustomize
    bump is a hand edit in each.

What this does not do

  • The CLI and TUI do not reach this API yet, and sync still reaches the old one.
  • No browser login and no CORS, so the web page cannot call it yet.
  • Production data does not move here. That move restores rows into a new database,
    because migrate refuses a database already holding these tables. It also sets
    item_numbers from the old sequence's own counter, because numbers already handed
    out can outrun the rows left.
  • No caller moves here. Moving one takes a new projects-cli- or projects-svc-
    client holding projects.items.read, because a client with neither prefix answers
    401.
  • A project name holding / works through {id} directly. Behind a proxy it depends
    on the proxy keeping %2F, which is not established.
  • No read route for the audit trail.
  • A project moved down one row in the TUI still stays where it was, because
    PATCH /projects/{id} keeps the other API's single-row write.

The review

#17 (comment)

  • G1.1 fixed — cb5219d.
  • G1.2 fixed — cb5219d.
  • G1.3 fixed — 3e8c3a7. The API sets no ban, so README and CLAUDE.md now say so. The statusOfRefusal row stays, because service still refuses where a caller sets the ban.
  • G1.4 fixed — d32399e, 7c95543. Position and NUL are refused at decode, a repeated project collapses in CreateItem, and a 500 names its request id. Reach is unproven, so these were run directly and have no test.
  • G1.5 deferred — moving production is a later change, now stated under What this does not do. A sequence behind its rows is a server fault, so duplicateError keeps answering it as a 500 naming the request.
  • code-architecture.1 fixed — 7c95543.
  • code-architecture.2 fixed — 3e8c3a7. The omission is stated once in CLAUDE.md's carve-out, with no coverage test, because the sync engine is due to leave.
  • code-architecture.3 fixed — 87fae1a.
  • code-architecture.4 fixed — d32399e. A refusal with no row answers 422 and logs the missing row.
  • code-architecture.5 fixed — 4ac7478. API image builds on every pull request.
  • useless-test.1 fixed — d5a9474.
  • useless-test.2 fixed — d5a9474.
  • useless-test.3 fixed — 87fae1a.
  • fleet-patterns.1 fixed — 87fae1a.
  • fleet-patterns.2 deferred — the data catalog reads this repo's main branch, so its rows land right after the merge. The schema_version part is fixed under contracts.4.
  • fleet-patterns.3 deferred — the shared owner is a change to a shared workflow and four repos, which this branch cannot carry. Risk and rollback names this as a fourth copy.
  • fleet-patterns.4 fixed — 4ac7478. The repo now declares a docker stack, so hadolint runs in pre-commit and CI.
  • stale-prose.1 fixed — 3e8c3a7.
  • stale-prose.2 fixed — 3e8c3a7.
  • stale-prose.3 fixed — 3e8c3a7.
  • stale-prose.4 fixed — 87fae1a, 3e8c3a7.
  • stale-prose.5 fixed — d32399e, 87fae1a, 3e8c3a7. The scope block went in favor of routes.ServiceScopes, keeping the scope's name, which a client author needs.
  • contracts.1 deferred — the data catalog reads this repo's main branch, so its rows land right after the merge.
  • contracts.2 fixed — d32399e. Every pattern is a literal, and the catalog's route reader now finds 28.
  • contracts.3 fixed — 3e8c3a7.
  • contracts.4 fixed — 3e8c3a7. Written as an exception in CLAUDE.md, with the sentence in each schema.
  • failure-paths.1 fixed — d32399e. Run against a host dropping packets, as listed under How it was verified, with no test.
  • synthesis.1 deferred — same as contracts.1.
  • synthesis.2 fixed — same as G1.3, stale-prose.1, stale-prose.2, stale-prose.3.
  • synthesis.3 fixed — the move and the new clients are now stated under What this does not do, as in G1.5 and contracts.3.
  • synthesis.4 fixed — same as code-architecture.1, code-architecture.3, code-architecture.4, code-architecture.5.
  • synthesis.5 fixed — same as fleet-patterns.1, fleet-patterns.4.

A second store needs the rules the HTTP contract carries, and every rule
belongs in service so it lands once for both stores.

service now resolves a UUID or a name for a project, and a UUID or a number
for an item, and it filters by status, repo, limit and a completion window.
Projects carry a kind and items and tasks carry a completion time. Refusals
are typed sentences, so an API can answer the right status and a client can
print the words.

store/postgres implements store.Store over pgx with goose migrations. InTx
takes an advisory lock first, the analog of SQLite's immediate transaction,
so a rule's reads still hold at commit. A row trigger writes every change to
audit_trail, reading the actor and request id from transaction-local settings
bound from the context.

service's suite runs on either store, chosen by PROJECTS_TEST_STORE.

SQLite mode changes where the rules moved: completing an item with open tasks
is refused, deleting a project refuses open orphans and removes completed
ones, and reordering dense-ranks a project's positions.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809
Worklog: 2401.1
projects-api answers the routes existing clients already call under /api/v1,
in the answer shapes they already read, so moving a client is a change of
address. api/wire holds those shapes apart from model, because a client depends
on the contract and never on the server's types. api/schemas holds a JSON Schema
per shape, and a test checks a real answer against each.

A handler decodes, calls service and encodes. A partial update is read field by
field, because only that tells a field left out from one sent as null.

POST and GET /api/v1/undo are new, so every operation the TUI performs has a
route. A person's client reaches every route and a service client reaches only
what projects.items.read lists, by registered pattern.

/health answers for the process and /ready for Postgres and the identity
provider. TERM drains requests in flight and exits zero, and the logs are JSON
on stdout.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809
Worklog: 2401.1
A cluster runs the API from what a release publishes, so a release now holds an
image and a kustomize artifact at the version go-semantic-release cut. One
version names the TUI and the API, because a rule change in service moves both.
Both artifacts are signed keylessly, and a deployment verifies them against this
workflow's identity.

deploy/migrate runs the schema migration as a Job before deploy/app rolls the
pods. Migrating at startup would let two pods race, and a bad migration would
crash-loop what is serving. CI renders and checks each manifest path as the
release pins it, so a base that fails is never pushed.

The README and the repo's instructions describe the API, and stop naming a
sibling product where its name says nothing a reader needs.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809
Worklog: 2401.1
A position counts every member of a project, and the TUI's list hides archived
members. Sending the row's index in that list landed a drop one row too high for
each archived member above it, and a move down one row did nothing. The drop now
counts every member at or above the row it lands under.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809
Worklog: 2401.1
testcontainers brings in klauspost/compress, moby/go-archive, x/crypto and x/text
at versions govulncheck flags, and the Vulnerabilities job fails on them.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809
Worklog: 2401.1

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — system-design

Used: Cloned PR 17 at head. Read the Dockerfile, release.yml, validate.yml, deploy/app, deploy/migrate, release/pin-manifests, api/main.go, api/authentication, the routes' scope table, and goapiauth v0.2.0's RequireBearer. Built or ran nothing.
Standards: No rules cited. I ran no fleet standards search and found no new rule to propose.
Findings: 0 correctness · 0 design · 0 prose

No findings.

Findings as JSON
{
  "used": "Cloned PR 17 at head. Read the Dockerfile, release.yml, validate.yml, deploy/app, deploy/migrate, release/pin-manifests, api/main.go, api/authentication, the routes' scope table, and goapiauth v0.2.0's RequireBearer. Built or ran nothing.",
  "standards": "No rules cited. I ran no `fleet standards search` and found no new rule to propose.",
  "findings": []
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — failure-paths

Used: Read the PR diff and the head's api/main.go, store/postgres/open.go and store.go, api/routes, release.yml, release/pin-manifests, release/install-tools and the deploy manifests. I did not run the failure; I traced it from the code.
Standards: Searched fleet standards search "request to unreachable dependency has no timeout hang". It returned go.md "Every HTTP request carries a deadline" and code-quality.md "A deadline over a polling loop bounds every call inside it". I did not read either in full.
Findings: 1 correctness · 0 design · 0 prose

Correctness

failure-paths.1 store/postgres/open.go:46 · api/settings/settings.go:28 · api/routes/routes.go:107 · store/postgres/store.go:59

Reach: unproven — a run with POSTGRES_HOST pointed at a blackholed address would settle it. The trigger is a Postgres that drops packets, such as a network partition, a firewall drop or a failover. Not a refused connection, which fails fast. The /ready probe and every request that opens a transaction reach it.

A Postgres that drops packets hangs /ready and every request, with no deadline on any of them.

postgres.Connect builds the URL with no connect_timeout. pgxpool.New is lazy, so no dial happens at startup.

readiness calls Ping with r.Context(). That context has no deadline. http.Server.WriteTimeout closes the connection but does not cancel the request context. The handler goroutine stays in the dial until the OS TCP timeout, roughly two minutes. Each kubelet probe adds another stuck goroutine.

InTx has the same gap. It calls pool.Begin(ctx) and then pg_advisory_xact_lock under the request context. A request waits on a dead or slow database, or on the single global write lock, until the client gives up. One slow writer holds the lock and every other write queues behind it. The pool can fill with waiting writers. Reads then wait on Acquire too.

The client sees a hung request, not a 503. The probe only reads 'not ready' because the kubelet's own 1s timeout fires.

This is the code-quality.md rule that a failure allowed to degrade costs only the unit that failed. Here it costs the whole pool.

Fix.

  • Add connect_timeout to Postgres.URL().
  • Wrap each readiness check in context.WithTimeout, for example 2s, in routes.readiness.
  • Bound InTx with a statement or lock timeout. Either set lock_timeout in BindWriter, or wrap the request context in context.WithTimeout in scoped.
  • Map context.DeadlineExceeded to a 503 in table.fail.
Findings as JSON
{
  "used": "Read the PR diff and the head's api/main.go, store/postgres/open.go and store.go, api/routes, release.yml, release/pin-manifests, release/install-tools and the deploy manifests. I did not run the failure; I traced it from the code.",
  "standards": "Searched `fleet standards search \"request to unreachable dependency has no timeout hang\"`. It returned go.md \"Every HTTP request carries a deadline\" and code-quality.md \"A deadline over a polling loop bounds every call inside it\". I did not read either in full.",
  "findings": [
    {
      "number": "failure-paths.1",
      "section": "correctness",
      "sites": [
        "store/postgres/open.go:46",
        "api/settings/settings.go:28",
        "api/routes/routes.go:107",
        "store/postgres/store.go:59"
      ],
      "reach": "unproven — a run with `POSTGRES_HOST` pointed at a blackholed address would settle it. The trigger is a Postgres that drops packets, such as a network partition, a firewall drop or a failover. Not a refused connection, which fails fast. The `/ready` probe and every request that opens a transaction reach it.",
      "body": "**A Postgres that drops packets hangs `/ready` and every request, with no deadline on any of them.**\n\n`postgres.Connect` builds the URL with no `connect_timeout`. `pgxpool.New` is lazy, so no dial happens at startup.\n\n`readiness` calls `Ping` with `r.Context()`. That context has no deadline. `http.Server.WriteTimeout` closes the connection but does not cancel the request context. The handler goroutine stays in the dial until the OS TCP timeout, roughly two minutes. Each kubelet probe adds another stuck goroutine.\n\n`InTx` has the same gap. It calls `pool.Begin(ctx)` and then `pg_advisory_xact_lock` under the request context. A request waits on a dead or slow database, or on the single global write lock, until the client gives up. One slow writer holds the lock and every other write queues behind it. The pool can fill with waiting writers. Reads then wait on `Acquire` too.\n\nThe client sees a hung request, not a 503. The probe only reads 'not ready' because the kubelet's own 1s timeout fires.\n\nThis is the code-quality.md rule that a failure allowed to degrade costs only the unit that failed. Here it costs the whole pool.\n\n**Fix.**\n- Add `connect_timeout` to `Postgres.URL()`.\n- Wrap each readiness check in `context.WithTimeout`, for example 2s, in `routes.readiness`.\n- Bound `InTx` with a statement or lock timeout. Either set `lock_timeout` in `BindWriter`, or wrap the request context in `context.WithTimeout` in `scoped`.\n- Map `context.DeadlineExceeded` to a 503 in `table.fail`."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — stale-prose

Used: Read every added comment, doc, help and config sentence at head 9b69e86. Marshalled the wire structs in a temporary test. Ran listenUntilDone past its drain timeout in a temporary test. Applied the migration's Up on a throwaway postgres:16-alpine and timed clones and audit rows. The temporary tests are removed and the clone is clean.
Standards: Applied pull-requests.md § "Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite"; I ran no other standards search and propose no rule.
Findings: 0 correctness · 0 design · 5 prose

Prose

stale-prose.1 README.md:351 · CLAUDE.md:312 · api/wire/wire.go:2

The client contract says an empty field is null, and lists and optional refusal fields answer otherwise. Kind: False.

The sentences:

  • README.md:351: "Every field is always sent, an empty one as null."
  • CLAUDE.md:312: "Every field is sent, an empty one as null, and times are UTC with a Z."
  • api/wire/wire.go:2-3: "every field is always sent, an empty one as null"

I marshalled the wire structs at the head. They returned:

  • wire.ProjectWithItemCount: ..."completed_count":0,"repos":[]}
  • wire.Error: {"error":"Not Found","message":"m"}
  • wire.Probe: {"status":"ok"}

An empty list is [], so the sentence is false for lists. Map says so in the same file: "answers an empty list rather than null". model/project_item.go also tells the API to keep "sending []", because nil and empty mean different things to a pull. A maintainer following these three sentences would break that. errors, ctx and reason carry omitempty, so they are not always sent.

Rewrite for README.md:351 and CLAUDE.md:312: "A row's every field is always sent: an empty value as null, and an empty list as []." CLAUDE.md:312 keeps its clause about times.

Rewrite for wire.go: "every field of a row is always sent, an empty value as null and an empty list as [], so a reader indexing by name finds it."

stale-prose.2 README.md:9

The README says scripts reach the API that projects-api serves, and nothing points at projects-api yet. Kind: False.

The sentence, README.md:9-10: "Scripts and agents reach the same projects and items through the API. projects-api, in this repo, serves it on Postgres."

The diff removed the name of the separate server here. The two sentences now read as one API. The PR body says otherwise: "Nothing is pointed at them by this change" and "sync still reaches the old one". A reader debugging sync or a script would look in this repo for a server that is not answering them.

Rewrite: "Scripts and agents reach the same projects and items through the API the database is bound to. projects-api, in this repo, is one server of that API, on Postgres." This names no client and no migration state, so it holds when clients move.

stale-prose.3 CLAUDE.md:130

CLAUDE.md still places the by-status project order in the stores, and this branch moved it out. Kind: False, a sentence this diff rewrote and its own code contradicts.

The sentence, CLAUDE.md:129-131: "store/store.go states each on its method: a new row's position, an item's updated_at, a restore's handling of a row already there, and the order projects list in by status. Each store states them in its own queries.sql."

At the base, ListProjectsWithItemCount held that order, in a CASE p.status ORDER BY. That method is gone at the head. rg -i status store/store.go returns only ListProjectKinds and ListProjectStatuses. store/store.go:58 reads "ListProjects orders by position and then name", and both queries.sql files do the same. The status order now lives in model.SortProjectsForBoard, called from service/service.go:176.

Rewrite: "store/store.go states each on its method: a new row's position, an item's updated_at, and a restore's handling of a row already there. Each store states them in its own queries.sql." Delete the clause about the order.

stale-prose.4 store/postgres/migrations/00001_projects.sql:110 · README.md:330

Two absolute sentences state one outcome, and a branch of the code gives another. Kind: False.

store/postgres/migrations/00001_projects.sql:110: "Every insert, update and delete to a row of an audited table leaves one entry here". Line 133 of the same file contradicts it: "An update that changes nothing leaves no entry." On Postgres 16, I inserted a row and ran UPDATE projects SET name='a' WHERE name='a'. count(*) on audit_trail stayed at 1. A real rename took it to 2.

Rewrite: "Every insert, delete and changing update to a row of an audited table leaves one entry here, whatever wrote it: a statement, a cascade, or a person at a SQL prompt."

README.md:330: "TERM stops new requests, lets the ones in flight finish for up to 20 seconds, and exits zero." I ran listenUntilDone with a handler that outlasted the drain. It returned after 20 s with draining: context deadline exceeded. main turns any error from run into os.Exit(1) at api/main.go:41.

Rewrite: "TERM stops new requests and lets the ones in flight finish. It exits zero if they finish inside drainTimeout in api/main.go, and non-zero if one outlasts it."

stale-prose.5 store/postgres/postgrestest/postgrestest.go:3 · deploy/app/api.yaml:109 · deploy/app/api.yaml:75 · README.md:336 · CLAUDE.md:305

Measured numbers and code-owned values are written out where nothing keeps them level. Kind: Will go stale for the measurements, and A value the code holds for the rest.

Measured, with no command beside them:

  • postgrestest.go:3-4: "clones a migrated template, which costs a few milliseconds where migrating costs a hundred." This is already off. CREATE DATABASE ... TEMPLATE took 26.7, 31.7 and 23.7 ms on a tmpfs Postgres 16. Rewrite: "and each test clones a migrated template, which is cheaper than migrating."
  • deploy/app/api.yaml:109-110: "A resident set of about 22 MiB after a couple of thousand requests over a few hundred rows." Rewrite: delete this sentence, and open the next with "The room above the resident set is for a list answered to several clients at once."

Held by code:

  • deploy/app/api.yaml:75: "Covers the preStop sleep plus the API's 20 second drain, with room to spare." drainTimeout owns the value. Rewrite: "Covers the preStop sleep plus drainTimeout in api/main.go, with room to spare."
  • README.md:336-346: "the one scope is projects.items.read", followed by seven route patterns. routes.ServiceScopes owns both, and no test compares the README with it. Rewrite: "It reaches only what its scopes list, in routes.ServiceScopes." Delete the block.
  • CLAUDE.md:305: "postgrestest starts one postgres:16-alpine per test binary". postgrestest.go:75 owns the tag. Rewrite: "postgrestest starts one Postgres container per test binary".
Findings as JSON
{
  "used": "Read every added comment, doc, help and config sentence at head 9b69e86. Marshalled the wire structs in a temporary test. Ran listenUntilDone past its drain timeout in a temporary test. Applied the migration's Up on a throwaway postgres:16-alpine and timed clones and audit rows. The temporary tests are removed and the clone is clean.",
  "standards": "Applied `pull-requests.md` § \"Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite\"; I ran no other standards search and propose no rule.",
  "findings": [
    {
      "number": "stale-prose.1",
      "section": "prose",
      "sites": [
        "README.md:351",
        "CLAUDE.md:312",
        "api/wire/wire.go:2"
      ],
      "reach": "",
      "body": "**The client contract says an empty field is `null`, and lists and optional refusal fields answer otherwise.** Kind: False.\n\nThe sentences:\n- `README.md:351`: \"Every field is always sent, an empty one as `null`.\"\n- `CLAUDE.md:312`: \"Every field is sent, an empty one as `null`, and times are UTC with a `Z`.\"\n- `api/wire/wire.go:2-3`: \"every field is always sent, an empty one as null\"\n\nI marshalled the wire structs at the head. They returned:\n- `wire.ProjectWithItemCount`: `...\"completed_count\":0,\"repos\":[]}`\n- `wire.Error`: `{\"error\":\"Not Found\",\"message\":\"m\"}`\n- `wire.Probe`: `{\"status\":\"ok\"}`\n\nAn empty list is `[]`, so the sentence is false for lists. `Map` says so in the same file: \"answers an empty list rather than null\". `model/project_item.go` also tells the API to keep \"sending `[]`\", because nil and empty mean different things to a pull. A maintainer following these three sentences would break that. `errors`, `ctx` and `reason` carry `omitempty`, so they are not always sent.\n\nRewrite for `README.md:351` and `CLAUDE.md:312`: \"A row's every field is always sent: an empty value as `null`, and an empty list as `[]`.\" `CLAUDE.md:312` keeps its clause about times.\n\nRewrite for `wire.go`: \"every field of a row is always sent, an empty value as null and an empty list as [], so a reader indexing by name finds it.\""
    },
    {
      "number": "stale-prose.2",
      "section": "prose",
      "sites": [
        "README.md:9"
      ],
      "reach": "",
      "body": "**The README says scripts reach the API that `projects-api` serves, and nothing points at `projects-api` yet.** Kind: False.\n\nThe sentence, `README.md:9-10`: \"Scripts and agents reach the same projects and items through the API. `projects-api`, in this repo, serves it on Postgres.\"\n\nThe diff removed the name of the separate server here. The two sentences now read as one API. The PR body says otherwise: \"Nothing is pointed at them by this change\" and \"`sync` still reaches the old one\". A reader debugging sync or a script would look in this repo for a server that is not answering them.\n\nRewrite: \"Scripts and agents reach the same projects and items through the API the database is bound to. `projects-api`, in this repo, is one server of that API, on Postgres.\" This names no client and no migration state, so it holds when clients move."
    },
    {
      "number": "stale-prose.3",
      "section": "prose",
      "sites": [
        "CLAUDE.md:130"
      ],
      "reach": "",
      "body": "**`CLAUDE.md` still places the by-status project order in the stores, and this branch moved it out.** Kind: False, a sentence this diff rewrote and its own code contradicts.\n\nThe sentence, `CLAUDE.md:129-131`: \"`store/store.go` states each on its method: a new row's position, an item's `updated_at`, a restore's handling of a row already there, and the order projects list in by status. Each store states them in its own `queries.sql`.\"\n\nAt the base, `ListProjectsWithItemCount` held that order, in a `CASE p.status` `ORDER BY`. That method is gone at the head. `rg -i status store/store.go` returns only `ListProjectKinds` and `ListProjectStatuses`. `store/store.go:58` reads \"ListProjects orders by position and then name\", and both `queries.sql` files do the same. The status order now lives in `model.SortProjectsForBoard`, called from `service/service.go:176`.\n\nRewrite: \"`store/store.go` states each on its method: a new row's position, an item's `updated_at`, and a restore's handling of a row already there. Each store states them in its own `queries.sql`.\" Delete the clause about the order."
    },
    {
      "number": "stale-prose.4",
      "section": "prose",
      "sites": [
        "store/postgres/migrations/00001_projects.sql:110",
        "README.md:330"
      ],
      "reach": "",
      "body": "**Two absolute sentences state one outcome, and a branch of the code gives another.** Kind: False.\n\n`store/postgres/migrations/00001_projects.sql:110`: \"Every insert, update and delete to a row of an audited table leaves one entry here\". Line 133 of the same file contradicts it: \"An update that changes nothing leaves no entry.\" On Postgres 16, I inserted a row and ran `UPDATE projects SET name='a' WHERE name='a'`. `count(*)` on `audit_trail` stayed at 1. A real rename took it to 2.\n\nRewrite: \"Every insert, delete and changing update to a row of an audited table leaves one entry here, whatever wrote it: a statement, a cascade, or a person at a SQL prompt.\"\n\n`README.md:330`: \"TERM stops new requests, lets the ones in flight finish for up to 20 seconds, and exits zero.\" I ran `listenUntilDone` with a handler that outlasted the drain. It returned after 20 s with `draining: context deadline exceeded`. `main` turns any error from `run` into `os.Exit(1)` at `api/main.go:41`.\n\nRewrite: \"TERM stops new requests and lets the ones in flight finish. It exits zero if they finish inside `drainTimeout` in `api/main.go`, and non-zero if one outlasts it.\""
    },
    {
      "number": "stale-prose.5",
      "section": "prose",
      "sites": [
        "store/postgres/postgrestest/postgrestest.go:3",
        "deploy/app/api.yaml:109",
        "deploy/app/api.yaml:75",
        "README.md:336",
        "CLAUDE.md:305"
      ],
      "reach": "",
      "body": "**Measured numbers and code-owned values are written out where nothing keeps them level.** Kind: Will go stale for the measurements, and A value the code holds for the rest.\n\nMeasured, with no command beside them:\n- `postgrestest.go:3-4`: \"clones a migrated template, which costs a few milliseconds where migrating costs a hundred.\" This is already off. `CREATE DATABASE ... TEMPLATE` took 26.7, 31.7 and 23.7 ms on a tmpfs Postgres 16. Rewrite: \"and each test clones a migrated template, which is cheaper than migrating.\"\n- `deploy/app/api.yaml:109-110`: \"A resident set of about 22 MiB after a couple of thousand requests over a few hundred rows.\" Rewrite: delete this sentence, and open the next with \"The room above the resident set is for a list answered to several clients at once.\"\n\nHeld by code:\n- `deploy/app/api.yaml:75`: \"Covers the preStop sleep plus the API's 20 second drain, with room to spare.\" `drainTimeout` owns the value. Rewrite: \"Covers the preStop sleep plus `drainTimeout` in api/main.go, with room to spare.\"\n- `README.md:336-346`: \"the one scope is `projects.items.read`\", followed by seven route patterns. `routes.ServiceScopes` owns both, and no test compares the README with it. Rewrite: \"It reaches only what its scopes list, in `routes.ServiceScopes`.\" Delete the block.\n- `CLAUDE.md:305`: \"`postgrestest` starts one `postgres:16-alpine` per test binary\". `postgrestest.go:75` owns the tag. Rewrite: \"`postgrestest` starts one Postgres container per test binary\"."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — code-architecture

Used: Cloned the repo and checked out PR 17 at 9b69e86. Read service, store, api, sync, deploy, release and CI. Ran go list -deps ./api and rg searches for each fact's other sites. Ran neither the API nor the tests, so each behavior stated is read from the code.
Standards: Cited code-quality.md § "Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site" and data.md § "Seed data is only the enum lookup rows, upserted on every deploy; content is imported through the CLI". Searches on refusal-to-status tables and on image gates before a tag found no rule that applies.
Findings: 0 correctness · 5 design · 0 prose

Design and architecture

code-architecture.1 service/service.go:165 · service/listing.go:66 · service/service.go:466 · service/listing.go:189 · service/service.go:1079 · service/listing.go:224 · service/service.go:1089 · service/listing.go:213 · model/project.go:91 · service/listing.go:98

Reach: The next change is the one the PR names under What this does not do: the CLI and TUI reaching this API. backend.Backend holds the TUI's half of each pair, and sync/backend.go:28 wraps it, so that client has to re-derive each TUI answer from an API answer. Any new read rule costs two edits until then. Four pairs already exist, which is the second instance.

Service answers each read twice, through two method families with different rules. The TUI's family serves backend.Backend. The API's family was added beside it.

Read TUI method API method Difference
Projects ListProjectsByStatus ListProjects Only the API refuses an unknown status. Only the TUI ranks by status first, so all orders differently
Items in a project ListItemsByProject ListProjectItems The TUI drops archived items. The API applies a filter
Search Search SearchItems The TUI drops archived items. The API keeps them
Blocked ListBlocked ListBlockedItems Each reads the store itself

Each method reads the store and applies its own filter. CLAUDE.md states the goal as a new rule landing once. That holds across stores, and fails across these two families. It is a second owner under code-quality.md § "Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site".

ListBlockerItems at service/listing.go:234 already has the right shape: it calls GetBlockers and adds the joins.

Fix: give each read one internal method that owns its filter and order. Make the other family a named call over it. ListProjectsByStatus becomes ListProjects plus the board sort. Search becomes the shared search minus archived rows.

code-architecture.2 sync/pull.go:170 · sync/pull.go:223 · store/sqlite/queries.sql:286 · store/sqlite/queries.sql:297 · sync/backend.go:91 · CLAUDE.md:126

Reach: The next change is any screen showing kind or completed_at. The PR says no screen shows completed_at yet. On a synced database, kind reads as the local value or the default life, and completed_at stays null for work finished on the server. This PR is the instance: it added both columns and neither reached the pull.

A pull writes project and item rows through a column list nothing ties to model. CLAUDE.md says a new column joins its undo record and its store's restore, and that TestEveryModelFieldSurvivesAnUndo enforces this. The sync upserts are a third writer of the same rows, and no test reaches them.

fetchJSON[[]model.Project] at sync/pull.go:93 and []model.ProjectItemDetail at sync/pull.go:101 decode into types holding Kind and CompletedAt. UpsertProject and UpsertItem then have no parameter for either field. createProjectPayload also sends no kind. rg 'Kind|CompletedAt' sync returns nothing in that package.

store/sqlite/schema.sql:48 declares the null completed_at on a mirrored item deliberately. Nothing declares the same for kind.

Fix: add both columns to the two upserts and to the create payload, or state the omission in one place. Add a field-coverage test over the pull, in the shape of TestEveryModelFieldSurvivesAnUndo, so the next column fails it.

code-architecture.3 store/sqlite/lookup_rows.sql:3 · store/sqlite/lookup_rows.sql:5 · store/postgres/migrations/00001_projects.sql:6 · store/postgres/migrations/00001_projects.sql:12 · store/sqlite/schema.sql:3 · service/listing.go:327 · model/project.go:15 · model/project.go:57 · model/project.go:92

Reach: The next kind costs two edits: lookup_rows.sql and a new goose migration. The next status costs those two plus ProjectIsTerminal and statusRank, whose defaults decide its behavior without a compile error. This PR is the instance for kinds, since it introduces the table with three values and promises more by insert.

Two files seed the kinds and statuses, and no test compares them. store/sqlite/schema.sql:3 and service/listing.go:327 both say that adding a kind or status is an insert. It is an insert per store, each in its own form. SQLite re-runs lookup_rows.sql on every open. Postgres seeds once inside migration 00001.

rg for ListProjectKinds, "chore", "build" and Known kinds across the test files returns nothing. The suite that runs on both stores therefore never compares these sets.

For statuses, the insert claim is also incomplete. SortProjectsForBoard ranks an unnamed status as closed. ProjectIsTerminal calls it not terminal. A new status would sort among closed projects and carry no closed_at.

The Postgres seed also departs from data.md § "Seed data is only the enum lookup rows, upserted on every deploy; content is imported through the CLI", because a migration runs once.

Fix: declare model.ProjectKinds and model.ProjectStatuses. Add one service test, which runs on both stores, asserting each store answers exactly those sets. Have projects-api migrate upsert the rows from those lists, as SQLite does on open.

code-architecture.4 api/routes/answers.go:71 · api/routes/answers.go:121 · model/errors.go:5

Reach: The next rule that moves into service with a new sentinel has to edit model/errors.go and statusOfRefusal in step. The PR's stated decision is that a rule the other API holds moves into service, and this PR adds many sentinels that way, so more are expected.

A refusal's HTTP answer lives in a table no build or test ties to the sentinel list. model/errors.go declares 21 sentinels and statusOfRefusal lists all 21 today. Their agreement rests on the author remembering.

fail walks the table, then falls through to the fault branch. A new sentinel left out of the table is logged as an error and answers 500 with Internal server error: *model.Refusal, so a client's mistake reads as a server fault. This comes from reading fail, not from a run.

routes_test.go never names statusOfRefusal. TestEveryAnswerMatchesItsSchema drives only a few refusals, and none of them is a new sentinel's.

Fix: export the sentinels as one list in model, and add a test in package routes that fails on an entry with no row. Alternatively, have fail treat any *model.Refusal missing from the table as a client refusal and log the missing row.

code-architecture.5 .dockerignore:2 · Dockerfile:12 · .github/workflows/validate.yml:324 · .github/workflows/release.yml:36 · .github/workflows/release.yml:111 · CLAUDE.md:335

Reach: The next package service or api imports, directly or transitively, costs a .dockerignore line. config shows this already: it is in the image only because repos imports it. If the line is missed, api-publish fails after the tag, and a re-run rebuilds the same broken context. The fix needs a new commit and a new version, so the failed version has binaries and no image.

.dockerignore holds a second copy of the API's import graph, and the first check of that copy runs after the tag exists. The allowlist names api, backend, config, graph, model, repos, service and store. go list -deps ./api gives the same module directories today, so the two agree.

CLAUDE.md:335 states the rule by hand: a new directory joins the allowlist, or the image build fails. rg 'docker|buildx' across validate.yml, .pre-commit-config.yaml and Taskfile.yml returns nothing, so no pull request builds the image.

The only build is in api-publish, which needs: release. The manifests get a gate before the tag, in the deploy-manifests job. The image does not.

Fix: add a docker buildx build with no push to validate.yml, so the release's existing validate gate covers it. A deny-list .dockerignore would also remove the copy, at the cost of a larger context.

Findings as JSON
{
  "used": "Cloned the repo and checked out PR 17 at 9b69e86. Read service, store, api, sync, deploy, release and CI. Ran `go list -deps ./api` and `rg` searches for each fact's other sites. Ran neither the API nor the tests, so each behavior stated is read from the code.",
  "standards": "Cited code-quality.md § \"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\" and data.md § \"Seed data is only the enum lookup rows, upserted on every deploy; content is imported through the CLI\". Searches on refusal-to-status tables and on image gates before a tag found no rule that applies.",
  "findings": [
    {
      "number": "code-architecture.1",
      "section": "design",
      "sites": [
        "service/service.go:165",
        "service/listing.go:66",
        "service/service.go:466",
        "service/listing.go:189",
        "service/service.go:1079",
        "service/listing.go:224",
        "service/service.go:1089",
        "service/listing.go:213",
        "model/project.go:91",
        "service/listing.go:98"
      ],
      "reach": "The next change is the one the PR names under What this does not do: the CLI and TUI reaching this API. `backend.Backend` holds the TUI's half of each pair, and `sync/backend.go:28` wraps it, so that client has to re-derive each TUI answer from an API answer. Any new read rule costs two edits until then. Four pairs already exist, which is the second instance.",
      "body": "**`Service` answers each read twice, through two method families with different rules.** The TUI's family serves `backend.Backend`. The API's family was added beside it.\n\n| Read | TUI method | API method | Difference |\n| --- | --- | --- | --- |\n| Projects | `ListProjectsByStatus` | `ListProjects` | Only the API refuses an unknown status. Only the TUI ranks by status first, so `all` orders differently |\n| Items in a project | `ListItemsByProject` | `ListProjectItems` | The TUI drops archived items. The API applies a filter |\n| Search | `Search` | `SearchItems` | The TUI drops archived items. The API keeps them |\n| Blocked | `ListBlocked` | `ListBlockedItems` | Each reads the store itself |\n\nEach method reads the store and applies its own filter. `CLAUDE.md` states the goal as a new rule landing once. That holds across stores, and fails across these two families. It is a second owner under code-quality.md § \"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\".\n\n`ListBlockerItems` at `service/listing.go:234` already has the right shape: it calls `GetBlockers` and adds the joins.\n\n**Fix:** give each read one internal method that owns its filter and order. Make the other family a named call over it. `ListProjectsByStatus` becomes `ListProjects` plus the board sort. `Search` becomes the shared search minus archived rows."
    },
    {
      "number": "code-architecture.2",
      "section": "design",
      "sites": [
        "sync/pull.go:170",
        "sync/pull.go:223",
        "store/sqlite/queries.sql:286",
        "store/sqlite/queries.sql:297",
        "sync/backend.go:91",
        "CLAUDE.md:126"
      ],
      "reach": "The next change is any screen showing `kind` or `completed_at`. The PR says no screen shows `completed_at` yet. On a synced database, `kind` reads as the local value or the default `life`, and `completed_at` stays null for work finished on the server. This PR is the instance: it added both columns and neither reached the pull.",
      "body": "**A pull writes project and item rows through a column list nothing ties to `model`.** `CLAUDE.md` says a new column joins its undo record and its store's restore, and that `TestEveryModelFieldSurvivesAnUndo` enforces this. The sync upserts are a third writer of the same rows, and no test reaches them.\n\n`fetchJSON[[]model.Project]` at `sync/pull.go:93` and `[]model.ProjectItemDetail` at `sync/pull.go:101` decode into types holding `Kind` and `CompletedAt`. `UpsertProject` and `UpsertItem` then have no parameter for either field. `createProjectPayload` also sends no kind. `rg 'Kind|CompletedAt' sync` returns nothing in that package.\n\n`store/sqlite/schema.sql:48` declares the null `completed_at` on a mirrored item deliberately. Nothing declares the same for `kind`.\n\n**Fix:** add both columns to the two upserts and to the create payload, or state the omission in one place. Add a field-coverage test over the pull, in the shape of `TestEveryModelFieldSurvivesAnUndo`, so the next column fails it."
    },
    {
      "number": "code-architecture.3",
      "section": "design",
      "sites": [
        "store/sqlite/lookup_rows.sql:3",
        "store/sqlite/lookup_rows.sql:5",
        "store/postgres/migrations/00001_projects.sql:6",
        "store/postgres/migrations/00001_projects.sql:12",
        "store/sqlite/schema.sql:3",
        "service/listing.go:327",
        "model/project.go:15",
        "model/project.go:57",
        "model/project.go:92"
      ],
      "reach": "The next kind costs two edits: `lookup_rows.sql` and a new goose migration. The next status costs those two plus `ProjectIsTerminal` and `statusRank`, whose defaults decide its behavior without a compile error. This PR is the instance for kinds, since it introduces the table with three values and promises more by insert.",
      "body": "**Two files seed the kinds and statuses, and no test compares them.** `store/sqlite/schema.sql:3` and `service/listing.go:327` both say that adding a kind or status is an insert. It is an insert per store, each in its own form. SQLite re-runs `lookup_rows.sql` on every open. Postgres seeds once inside migration `00001`.\n\n`rg` for `ListProjectKinds`, `\"chore\"`, `\"build\"` and `Known kinds` across the test files returns nothing. The suite that runs on both stores therefore never compares these sets.\n\nFor statuses, the insert claim is also incomplete. `SortProjectsForBoard` ranks an unnamed status as closed. `ProjectIsTerminal` calls it not terminal. A new status would sort among closed projects and carry no `closed_at`.\n\nThe Postgres seed also departs from data.md § \"Seed data is only the enum lookup rows, upserted on every deploy; content is imported through the CLI\", because a migration runs once.\n\n**Fix:** declare `model.ProjectKinds` and `model.ProjectStatuses`. Add one service test, which runs on both stores, asserting each store answers exactly those sets. Have `projects-api migrate` upsert the rows from those lists, as SQLite does on open."
    },
    {
      "number": "code-architecture.4",
      "section": "design",
      "sites": [
        "api/routes/answers.go:71",
        "api/routes/answers.go:121",
        "model/errors.go:5"
      ],
      "reach": "The next rule that moves into `service` with a new sentinel has to edit `model/errors.go` and `statusOfRefusal` in step. The PR's stated decision is that a rule the other API holds moves into `service`, and this PR adds many sentinels that way, so more are expected.",
      "body": "**A refusal's HTTP answer lives in a table no build or test ties to the sentinel list.** `model/errors.go` declares 21 sentinels and `statusOfRefusal` lists all 21 today. Their agreement rests on the author remembering.\n\n`fail` walks the table, then falls through to the fault branch. A new sentinel left out of the table is logged as an error and answers 500 with `Internal server error: *model.Refusal`, so a client's mistake reads as a server fault. This comes from reading `fail`, not from a run.\n\n`routes_test.go` never names `statusOfRefusal`. `TestEveryAnswerMatchesItsSchema` drives only a few refusals, and none of them is a new sentinel's.\n\n**Fix:** export the sentinels as one list in `model`, and add a test in package `routes` that fails on an entry with no row. Alternatively, have `fail` treat any `*model.Refusal` missing from the table as a client refusal and log the missing row."
    },
    {
      "number": "code-architecture.5",
      "section": "design",
      "sites": [
        ".dockerignore:2",
        "Dockerfile:12",
        ".github/workflows/validate.yml:324",
        ".github/workflows/release.yml:36",
        ".github/workflows/release.yml:111",
        "CLAUDE.md:335"
      ],
      "reach": "The next package `service` or `api` imports, directly or transitively, costs a `.dockerignore` line. `config` shows this already: it is in the image only because `repos` imports it. If the line is missed, `api-publish` fails after the tag, and a re-run rebuilds the same broken context. The fix needs a new commit and a new version, so the failed version has binaries and no image.",
      "body": "**`.dockerignore` holds a second copy of the API's import graph, and the first check of that copy runs after the tag exists.** The allowlist names `api`, `backend`, `config`, `graph`, `model`, `repos`, `service` and `store`. `go list -deps ./api` gives the same module directories today, so the two agree.\n\n`CLAUDE.md:335` states the rule by hand: a new directory joins the allowlist, or the image build fails. `rg 'docker|buildx'` across `validate.yml`, `.pre-commit-config.yaml` and `Taskfile.yml` returns nothing, so no pull request builds the image.\n\nThe only build is in `api-publish`, which `needs: release`. The manifests get a gate before the tag, in the `deploy-manifests` job. The image does not.\n\n**Fix:** add a `docker buildx build` with no push to `validate.yml`, so the release's existing `validate` gate covers it. A deny-list `.dockerignore` would also remove the copy, at the cost of a larger context."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — useless-test

Used: Built PR 17 in the clone and ran go test -short on ./api/..., ./service/, ./store/... and ./tui/. Ran ./store/postgres/ and PROJECTS_TEST_STORE=postgres ./service/ on a private Postgres. Applied and reverted five mutations: a reworded null refusal, the name index dropped, the number index dropped, the number index collapsing nulls, and an audited undo_log. Read every added and edited test beside its code, and the existing tests over the changed code. Kept because each is the only pin: TestEveryAnswerMatchesItsSchema pins the answer shapes API clients read, and TestEveryModelFieldSurvivesAnUndo pins silent field loss across delete and undo.
Standards: Cited testing.md § "Test behavior the user cares about, not what this commit happened to change", testing.md § "Never assert on rendered output — assert the value it was built from", and pull-requests.md § "Tests the diff adds are checked for whether they test the program or the change".
Findings: 0 correctness · 3 design · 0 prose

Design and architecture

useless-test.1 store/sqlite/open_test.go:331 · store/sqlite/open_test.go:435

Reach: The diff changes Open and migrate, which both tests call, though not the two indexes they exercise. The next change to idx_items_number or idx_projects_name_active pays to read these tests. The name clause holds its own copy of the index DDL, so it keeps passing when indexes.sql changes.

Two tests check SQLite's documented index behavior rather than this program's rules.

Delete these:

  • store/sqlite/open_test.go, TestOpen_NumberIndexAllowsManyUnnumberedItems: delete the whole test. Its null half checks that a SQLite unique index allows many NULLs, and its own comment says "SQLite does". Its duplicate half repeats TestOpen_ExistingDatabaseGainsTheUniquenessRules.
  • store/sqlite/open_test.go, TestMigrate_ReplacesTheGlobalNameConstraint: delete the comment and CREATE UNIQUE INDEX at lines 435-443, and the p3 insert at lines 449-453. The test types the partial index itself, then checks that SQLite enforces it. Keep the p2 insert, because it alone shows the migration removed the global UNIQUE.

What the mutations returned:

  • Removing idx_projects_name_active from indexes.sql failed TestOpen_ExistingDatabaseGainsTheUniquenessRules. TestMigrate_ReplacesTheGlobalNameConstraint still passed, so the p3 clause cannot see the real index.
  • Removing idx_items_number failed TestOpen_ExistingDatabaseGainsTheUniquenessRules, so the duplicate number rule stays pinned.
  • Changing the index to IFNULL(number,0) failed many service tests, such as TestDependencyCycleDetection, which creates three unnumbered items. So the behavior of many items waiting for a number stays pinned.

No replacement test is needed.

useless-test.2 api/routes/routes_test.go:286

Reach: PATCH /api/v1/project-items/{id} with title: null reaches this clause. The next rewording of messageCannotBeNull in api/routes/request_input.go fails it with nothing wrong.

A refusal test compares a typed sentence byte for byte.

In api/routes/routes_test.go, TestAPartialUpdateTellsNullFromLeftOut, delete the want constant at line 286 and the refused.Message != want clause at line 287. Delete the Message field from the refused struct too. Keep the rest of the test, because it is the only pin of null clearing a field and left-out keeping it.

The sentence is the literal messageCannotBeNull, with title: joined on the front. Line 290 already asserts the field name through loc. The 422 and the single-error count stay on line 287.

I reworded messageCannotBeNull, and this was the only test in ./api/... to fail. The refusal behavior was unchanged.

The contract exception does not apply. The PR body says wording was left unmatched because it is "a sentence no client parses". This falls under testing.md § "Never assert on rendered output — assert the value it was built from".

If the test should name the kind of refusal, assert errors[0].type == "value_error". That is a stable key the handler sets.

useless-test.3 store/postgres/store_test.go:69

Reach: The next change that adds an undo_log trigger fails this clause, and it is a deliberate change. Nothing a user or an operator reads changes when it fails.

An audit test asserts that this diff left one trigger out.

In store/postgres/store_test.go, TestEveryWriteIsAuditedWithItsWriter, delete the seen["undo_log insert"] clause at lines 69-71. Keep the rest of the test, because it pins the writer on each audit row and the cascade being recorded.

The clause restates a decision the PR body lists: "undo_log has no audit trigger." It has the same shape as the testing.md example where a class no longer renders. The migration comment already records the reason.

I added an undo_log audit trigger to the migration and ran both suites on a private Postgres. This clause was the only failure in ./store/postgres/. PROJECTS_TEST_STORE=postgres go test ./service/ still passed every rule.

No replacement test is needed. Audit growth is a storage decision, and it is reviewed in the migration.

Findings as JSON
{
  "used": "Built PR 17 in the clone and ran `go test -short` on `./api/...`, `./service/`, `./store/...` and `./tui/`. Ran `./store/postgres/` and `PROJECTS_TEST_STORE=postgres ./service/` on a private Postgres. Applied and reverted five mutations: a reworded null refusal, the name index dropped, the number index dropped, the number index collapsing nulls, and an audited `undo_log`. Read every added and edited test beside its code, and the existing tests over the changed code. Kept because each is the only pin: `TestEveryAnswerMatchesItsSchema` pins the answer shapes API clients read, and `TestEveryModelFieldSurvivesAnUndo` pins silent field loss across delete and undo.",
  "standards": "Cited `testing.md` § \"Test behavior the user cares about, not what this commit happened to change\", `testing.md` § \"Never assert on rendered output — assert the value it was built from\", and `pull-requests.md` § \"Tests the diff adds are checked for whether they test the program or the change\".",
  "findings": [
    {
      "number": "useless-test.1",
      "section": "design",
      "sites": [
        "store/sqlite/open_test.go:331",
        "store/sqlite/open_test.go:435"
      ],
      "reach": "The diff changes `Open` and `migrate`, which both tests call, though not the two indexes they exercise. The next change to `idx_items_number` or `idx_projects_name_active` pays to read these tests. The name clause holds its own copy of the index DDL, so it keeps passing when `indexes.sql` changes.",
      "body": "**Two tests check SQLite's documented index behavior rather than this program's rules.**\n\nDelete these:\n\n- `store/sqlite/open_test.go`, `TestOpen_NumberIndexAllowsManyUnnumberedItems`: delete the whole test. Its null half checks that a SQLite unique index allows many NULLs, and its own comment says \"SQLite does\". Its duplicate half repeats `TestOpen_ExistingDatabaseGainsTheUniquenessRules`.\n- `store/sqlite/open_test.go`, `TestMigrate_ReplacesTheGlobalNameConstraint`: delete the comment and `CREATE UNIQUE INDEX` at lines 435-443, and the `p3` insert at lines 449-453. The test types the partial index itself, then checks that SQLite enforces it. Keep the `p2` insert, because it alone shows the migration removed the global `UNIQUE`.\n\nWhat the mutations returned:\n\n- Removing `idx_projects_name_active` from `indexes.sql` failed `TestOpen_ExistingDatabaseGainsTheUniquenessRules`. `TestMigrate_ReplacesTheGlobalNameConstraint` still passed, so the `p3` clause cannot see the real index.\n- Removing `idx_items_number` failed `TestOpen_ExistingDatabaseGainsTheUniquenessRules`, so the duplicate number rule stays pinned.\n- Changing the index to `IFNULL(number,0)` failed many service tests, such as `TestDependencyCycleDetection`, which creates three unnumbered items. So the behavior of many items waiting for a number stays pinned.\n\nNo replacement test is needed."
    },
    {
      "number": "useless-test.2",
      "section": "design",
      "sites": [
        "api/routes/routes_test.go:286"
      ],
      "reach": "`PATCH /api/v1/project-items/{id}` with `title: null` reaches this clause. The next rewording of `messageCannotBeNull` in `api/routes/request_input.go` fails it with nothing wrong.",
      "body": "**A refusal test compares a typed sentence byte for byte.**\n\nIn `api/routes/routes_test.go`, `TestAPartialUpdateTellsNullFromLeftOut`, delete the `want` constant at line 286 and the `refused.Message != want` clause at line 287. Delete the `Message` field from the `refused` struct too. Keep the rest of the test, because it is the only pin of null clearing a field and left-out keeping it.\n\nThe sentence is the literal `messageCannotBeNull`, with `title: ` joined on the front. Line 290 already asserts the field name through `loc`. The 422 and the single-error count stay on line 287.\n\nI reworded `messageCannotBeNull`, and this was the only test in `./api/...` to fail. The refusal behavior was unchanged.\n\nThe contract exception does not apply. The PR body says wording was left unmatched because it is \"a sentence no client parses\". This falls under `testing.md` § \"Never assert on rendered output — assert the value it was built from\".\n\nIf the test should name the kind of refusal, assert `errors[0].type == \"value_error\"`. That is a stable key the handler sets."
    },
    {
      "number": "useless-test.3",
      "section": "design",
      "sites": [
        "store/postgres/store_test.go:69"
      ],
      "reach": "The next change that adds an `undo_log` trigger fails this clause, and it is a deliberate change. Nothing a user or an operator reads changes when it fails.",
      "body": "**An audit test asserts that this diff left one trigger out.**\n\nIn `store/postgres/store_test.go`, `TestEveryWriteIsAuditedWithItsWriter`, delete the `seen[\"undo_log insert\"]` clause at lines 69-71. Keep the rest of the test, because it pins the writer on each audit row and the cascade being recorded.\n\nThe clause restates a decision the PR body lists: \"`undo_log` has no audit trigger.\" It has the same shape as the `testing.md` example where a class no longer renders. The migration comment already records the reason.\n\nI added an `undo_log` audit trigger to the migration and ran both suites on a private Postgres. This clause was the only failure in `./store/postgres/`. `PROJECTS_TEST_STORE=postgres go test ./service/` still passed every rule.\n\nNo replacement test is needed. Audit growth is a storage decision, and it is reviewed in the migration."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — contracts

Used: Built projects-api at head and ran --version, --help and version. Probed routes through a temporary test on the production auth wrapper, with real signed tokens. Ran the catalog's coverage and go_routes from a clone against head. Searched every registered repo for route, scope and client-id callers, and compared the old API's routes, scopes and query parameters.
Standards: Cited data.md § "A new source of data gets its row in the data catalog in the change that creates it", cli-design.md § "The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes", infrastructure.md § "One OAuth client per (machine × app), isolated by the client_id prefix", and api-design.md § "A client depends on the wire contract, never on the server's types". Also checked api-design.md § "An error answers {error, message}" and § "A product API owns /api/v1 on its app's host", and the head conforms to both.
Findings: 1 correctness · 3 design · 0 prose

Correctness

contracts.1 api/routes/routes.go:58 · api/schemas/project.schema.json:3 · store/postgres/migrations/00001_projects.sql:1 · fleet-data-catalog:producers.yaml:75 · fleet-data-catalog:producers.yaml:98 · fleet-data-catalog:producers.yaml:113 · fleet-data-catalog:producers.yaml:186

Reach: catalog-contracts coverage, which reads every active repo. I ran it from a catalog clone with --checkouts-dir holding only this head. It exits 1 once this merges and the checkout it reads reaches main.

Merging makes the catalog's coverage check fail. The PR adds an HTTP service, ten output schemas and a Postgres store. The catalog lists none of them.

The run returned:

http-api       projects  1 files, first api/routes/routes.go
output-schema  projects  10 files, first api/schemas/error.schema.json
2 sources the catalog does not account for

It exited 1. data.md § "A new source of data gets its row in the data catalog in the change that creates it" says a new store or HTTP service gets a row in producers.yaml. It also says the check fails a run and files an inbox row.

Coverage does not name the new store, but the same rule requires its row and its retention. The existing projects store row also goes stale. It has engine sqlite and says location: ichrisbirch's Postgres.

Fix: land a catalog change with or before this merge. It takes three rows:

  • {name: projects, repo: projects, framework: go-net-http, source: api} under http_apis
  • - repo: projects under output_schema_repos
  • a sql_stores row for store/postgres/migrations, with its retention and the audit_trail table in view

Design and architecture

contracts.2 api/routes/routes.go:66 · api/routes/routes.go:94 · fleet-data-catalog:src/fleet_data_catalog/http_routes.py:22

Reach: The catalog's go-net-http route reader, as soon as the http_apis row exists. I ran go_routes on this head's api directory. The next change it costs is that row, which publishes a wrong route list. Each cross-fleet route question then reads that list.

The route spelling hides 26 of the API's 28 routes from the catalog. Each route is written "GET "+prefix+"/projects". The reader's GO_MUX_CONSTANT pattern captures only the constant, so the path after it is lost.

go_routes on this head returned 6 rows:

GET /health
GET /ready
GET /api/v1
POST /api/v1
PATCH /api/v1
DELETE /api/v1

The catalog would record those six. Listing the service clears the coverage check and still publishes a wrong surface, with no failure.

The spelling also defeats a plain search. rg 'GET /api/v1/project-items' finds nothing in routes.go.

Fix: write each pattern as one literal, such as "GET /api/v1/projects", in New and in ServiceScopes. The alternative is teaching GO_MUX_CONSTANT the trailing literal in the catalog.

contracts.3 api/authentication/authentication.go:18 · api/routes/routes.go:25 · README.md:9 · README.md:334 · doit:api/src/doit_api/settings.py:154 · doit:api/src/doit_api/settings.py:193 · doit:api/src/doit_api/readers/icb.py:140 · doit:api/src/doit_api/readers/icb.py:238 · homelab:pyinfra/fleet_service_clients.py:34 · homelab:pyinfra/doit_service.py:38 · ichrisbirch:cli/internal/config/config.go:38

Reach: No caller is pointed here yet, so nothing breaks today. The next change is the cutover. It reaches icb-svc-doit, icb-svc-fleet-runs and the icb CLI, and each needs a new client and scope. I ran the first token shape below through the production auth wrapper. A projects-svc- client holding the old scope is the second.

The PR says the routes and answers match, but no existing credential is admitted, and nothing says so. The new prefix and scope follow infrastructure.md § "One OAuth client per (machine × app), isolated by the client_id prefix". The gap is disclosure. "What this does not do" never lists it, and README.md:9 tells scripts and agents they reach items through this API.

A signed icb-svc-fleet-runs token holding icb.project-items.read returned 401:

{"code":"invalid_token","error":"Unauthorized","message":"the bearer token is not valid"}

That message reads as a bad token rather than a wrong product. A projects-svc-x token holding the old scope returned 403 with its scopes reach no route.

The cost is largest in doit. It reads /project-items on the same icb client and token as tasks, articles and books, so the project reads need a second app block and a second reader client.

Fix: add a line to "What this does not do" and to the README section at line 334. It should say that moving a caller takes a new projects-cli- or projects-svc- client, registered in homelab, holding projects.items.read. It should also name the three callers above.

contracts.4 api/schemas/project.schema.json:3 · api/schemas/project-item.schema.json:3 · api/wire/wire.go:36

Reach: The next reader of the catalog contract, and the next reshaped answer. A client has no field saying which shape it holds. No existing caller breaks.

The ten schemas carry no schema_version, against the rule for endpoint shapes. rg schema_version api/ README.md CLAUDE.md returns nothing. cli-design.md § "The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes" says schema_version appears in the document. ~/webapps/nomad/api/schemas/overview.schema.json does this.

The PR's reason is that "existing clients read these shapes as they are". That reason does not rule out a new field, because api-design.md § "A client depends on the wire contract, never on the server's types" has clients ignore unknown fields.

A list route answers a bare array, so the field has nowhere to go on those routes. The :v1 in each $id and the /api/v1 prefix are the only version signals the head has.

Fix: choose one and write it down.

  • Add schema_version to the object answers.
  • Or state in CLAUDE.md that /api/v2 is this API's version signal, and cite the rule as the exception. Also put nomad's sentence in the schemas: a field added is not a new version, and a field reshaped or removed is.
Findings as JSON
{
  "used": "Built `projects-api` at head and ran `--version`, `--help` and `version`. Probed routes through a temporary test on the production auth wrapper, with real signed tokens. Ran the catalog's `coverage` and `go_routes` from a clone against head. Searched every registered repo for route, scope and client-id callers, and compared the old API's routes, scopes and query parameters.",
  "standards": "Cited `data.md` § \"A new source of data gets its row in the data catalog in the change that creates it\", `cli-design.md` § \"The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes\", `infrastructure.md` § \"One OAuth client per (machine × app), isolated by the client_id prefix\", and `api-design.md` § \"A client depends on the wire contract, never on the server's types\". Also checked `api-design.md` § \"An error answers {error, message}\" and § \"A product API owns /api/v1 on its app's host\", and the head conforms to both.",
  "findings": [
    {
      "number": "contracts.1",
      "section": "correctness",
      "sites": [
        "api/routes/routes.go:58",
        "api/schemas/project.schema.json:3",
        "store/postgres/migrations/00001_projects.sql:1",
        "fleet-data-catalog:producers.yaml:75",
        "fleet-data-catalog:producers.yaml:98",
        "fleet-data-catalog:producers.yaml:113",
        "fleet-data-catalog:producers.yaml:186"
      ],
      "reach": "`catalog-contracts coverage`, which reads every active repo. I ran it from a catalog clone with `--checkouts-dir` holding only this head. It exits 1 once this merges and the checkout it reads reaches main.",
      "body": "**Merging makes the catalog's coverage check fail.** The PR adds an HTTP service, ten output schemas and a Postgres store. The catalog lists none of them.\n\nThe run returned:\n\n```text\nhttp-api       projects  1 files, first api/routes/routes.go\noutput-schema  projects  10 files, first api/schemas/error.schema.json\n2 sources the catalog does not account for\n```\n\nIt exited 1. `data.md` § \"A new source of data gets its row in the data catalog in the change that creates it\" says a new store or HTTP service gets a row in `producers.yaml`. It also says the check fails a run and files an inbox row.\n\nCoverage does not name the new store, but the same rule requires its row and its retention. The existing `projects` store row also goes stale. It has engine sqlite and says `location: ichrisbirch's Postgres`.\n\n**Fix:** land a catalog change with or before this merge. It takes three rows:\n- `{name: projects, repo: projects, framework: go-net-http, source: api}` under `http_apis`\n- `- repo: projects` under `output_schema_repos`\n- a `sql_stores` row for `store/postgres/migrations`, with its retention and the `audit_trail` table in view"
    },
    {
      "number": "contracts.2",
      "section": "design",
      "sites": [
        "api/routes/routes.go:66",
        "api/routes/routes.go:94",
        "fleet-data-catalog:src/fleet_data_catalog/http_routes.py:22"
      ],
      "reach": "The catalog's `go-net-http` route reader, as soon as the `http_apis` row exists. I ran `go_routes` on this head's `api` directory. The next change it costs is that row, which publishes a wrong route list. Each cross-fleet route question then reads that list.",
      "body": "**The route spelling hides 26 of the API's 28 routes from the catalog.** Each route is written `\"GET \"+prefix+\"/projects\"`. The reader's `GO_MUX_CONSTANT` pattern captures only the constant, so the path after it is lost.\n\n`go_routes` on this head returned 6 rows:\n\n```text\nGET /health\nGET /ready\nGET /api/v1\nPOST /api/v1\nPATCH /api/v1\nDELETE /api/v1\n```\n\nThe catalog would record those six. Listing the service clears the coverage check and still publishes a wrong surface, with no failure.\n\nThe spelling also defeats a plain search. `rg 'GET /api/v1/project-items'` finds nothing in `routes.go`.\n\n**Fix:** write each pattern as one literal, such as `\"GET /api/v1/projects\"`, in `New` and in `ServiceScopes`. The alternative is teaching `GO_MUX_CONSTANT` the trailing literal in the catalog."
    },
    {
      "number": "contracts.3",
      "section": "design",
      "sites": [
        "api/authentication/authentication.go:18",
        "api/routes/routes.go:25",
        "README.md:9",
        "README.md:334",
        "doit:api/src/doit_api/settings.py:154",
        "doit:api/src/doit_api/settings.py:193",
        "doit:api/src/doit_api/readers/icb.py:140",
        "doit:api/src/doit_api/readers/icb.py:238",
        "homelab:pyinfra/fleet_service_clients.py:34",
        "homelab:pyinfra/doit_service.py:38",
        "ichrisbirch:cli/internal/config/config.go:38"
      ],
      "reach": "No caller is pointed here yet, so nothing breaks today. The next change is the cutover. It reaches `icb-svc-doit`, `icb-svc-fleet-runs` and the `icb` CLI, and each needs a new client and scope. I ran the first token shape below through the production auth wrapper. A `projects-svc-` client holding the old scope is the second.",
      "body": "**The PR says the routes and answers match, but no existing credential is admitted, and nothing says so.** The new prefix and scope follow `infrastructure.md` § \"One OAuth client per (machine × app), isolated by the client_id prefix\". The gap is disclosure. \"What this does not do\" never lists it, and README.md:9 tells scripts and agents they reach items through this API.\n\nA signed `icb-svc-fleet-runs` token holding `icb.project-items.read` returned 401:\n\n```json\n{\"code\":\"invalid_token\",\"error\":\"Unauthorized\",\"message\":\"the bearer token is not valid\"}\n```\n\nThat message reads as a bad token rather than a wrong product. A `projects-svc-x` token holding the old scope returned 403 with `its scopes reach no route`.\n\nThe cost is largest in doit. It reads `/project-items` on the same `icb` client and token as tasks, articles and books, so the project reads need a second app block and a second reader client.\n\n**Fix:** add a line to \"What this does not do\" and to the README section at line 334. It should say that moving a caller takes a new `projects-cli-` or `projects-svc-` client, registered in homelab, holding `projects.items.read`. It should also name the three callers above."
    },
    {
      "number": "contracts.4",
      "section": "design",
      "sites": [
        "api/schemas/project.schema.json:3",
        "api/schemas/project-item.schema.json:3",
        "api/wire/wire.go:36"
      ],
      "reach": "The next reader of the catalog contract, and the next reshaped answer. A client has no field saying which shape it holds. No existing caller breaks.",
      "body": "**The ten schemas carry no `schema_version`, against the rule for endpoint shapes.** `rg schema_version api/ README.md CLAUDE.md` returns nothing. `cli-design.md` § \"The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes\" says `schema_version` appears in the document. `~/webapps/nomad/api/schemas/overview.schema.json` does this.\n\nThe PR's reason is that \"existing clients read these shapes as they are\". That reason does not rule out a new field, because `api-design.md` § \"A client depends on the wire contract, never on the server's types\" has clients ignore unknown fields.\n\nA list route answers a bare array, so the field has nowhere to go on those routes. The `:v1` in each `$id` and the `/api/v1` prefix are the only version signals the head has.\n\n**Fix:** choose one and write it down.\n- Add `schema_version` to the object answers.\n- Or state in `CLAUDE.md` that `/api/v2` is this API's version signal, and cite the rule as the exception. Also put nomad's sentence in the schemas: a field added is not a new version, and a field reshaped or removed is."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — fleet-patterns

Used: Read PR 17 at 9b69e86 and ran hadolint on its Dockerfile, which passed. Compared it with psyche, slate, doit, fleet, ypl, meso, nomad, learning, ichrisbirch, goapiauth, forge, reusable-workflows and fleet-data-catalog. No API was started. These matched and gave no finding: the metadata path, because goapiauth exempts it; the migration Job layout; the root-module API, which fleet also has; and the probes.
Standards: Cited data.md § "A new source of data gets its row in the data catalog in the change that creates it", cli-design.md § "The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes", telemetry.md § "A record read later carries its writer's traceparent and baggage, and its reader links back", release.md § "A release.yml calls a shared workflow and pins nothing itself", and repo-structure.md § "The per-repo build surface is declared in a registry, not detected".
Findings: 0 correctness · 4 design · 0 prose

Design and architecture

fleet-patterns.1 store/postgres/migrations/00001_projects.sql:112 · store/postgres/queries.sql:9 · store/postgres/store_test.go:58 · slate:src/slate/alembic/versions/1a7f84e9a913_keep_the_trace_and_baggage_of_each_.py:56 · slate:src/slate/models/record_change.py:97 · slate:tests/models/test_audit_trail.py:46 · ichrisbirch:ichrisbirch/models/record_change.py:82 · ichrisbirch:tests/ichrisbirch/models/test_icb_audit_trail.py:45

Reach: Every write through the new API lands in audit_trail through InTx. Without trace fields, a write cannot be joined to the trace that made it. The next table added to store/postgres/migrations gets no audit trigger and no test fails. The next fix to the trigger function must be made by hand in slate, ichrisbirch and projects.

The audit trail copies slate's trigger from before slate added trace context, and drops the coverage test both siblings hold.

audit_trail_record() here matches slate's function body line for line. Slate added traceparent and baggage columns on 2026-11-02, in revision 1a7f84e9a913, and its after_begin listener binds them. Projects has neither: rg 'traceparent|baggage' api store README.md returns nothing. telemetry.md § "A record read later carries its writer's traceparent and baggage, and its reader links back" asks for those fields. The pull request body gives no reason for leaving them out.

Slate and ichrisbirch both run test_every_table_is_audited_but_what_opts_out, which compares every table with its trigger. Projects asserts five named rows from one flow, so a new table without a trigger passes.

Slate is the better solution, because it holds both the trace fields and the coverage test.

Fix: add both trace columns and their settings to 00001_projects.sql now, since no deployment has applied it. Bind them in BindWriter. Add a test that lists pg_trigger against every table except undo_log, audit_trail and goose's version table.

fleet-patterns.2 api/main.go:1 · store/postgres/migrations/00001_projects.sql:1 · api/schemas/project.schema.json:3 · fleet-data-catalog:producers.yaml:75 · fleet-data-catalog:producers.yaml:95 · fleet-data-catalog:producers.yaml:98 · fleet-data-catalog:producers.yaml:186 · nomad:api/schemas/overview.schema.json:8

Reach: Merging leaves a new HTTP service, a new Postgres store and ten schema files out of the catalog. producers.yaml still gives this store as sqlite, located in ichrisbirch's Postgres. It would still say that when the service changes hands. Any reader of these shapes has no contract to point at, so it copies the fields.

The new API, its Postgres store and its schemas have no row in the data catalog.

fleet-data-catalog/producers.yaml lists psyche, ypl, learning, meso and nomad as go-net-http APIs, and projects is absent. sql_queries names only store/sqlite/queries.sql. output_schema_repos does not list projects, so api/schemas produces no contract, while nomad's API schemas do. The local checkout holds no row, and a search of that repo's open pull requests for projects returned none.

data.md § "A new source of data gets its row in the data catalog in the change that creates it" requires a row for a new HTTP service or store. It says catalog-contracts coverage exits 1 on an unlisted source. I did not run that command against this head.

The pull request also drops schema_version, because existing clients read these shapes as they are. nomad carries it, and cli-design.md § "The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes" asks for it. List answers here are bare arrays, which have no place for the field. That exception belongs in the repo's CLAUDE.md, stated as an exception to the rule.

Fix: open a companion change to producers.yaml. It adds an http_apis row with source: api, a sql_queries row for store/postgres/queries.sql, a sql_stores row using migrations with its retention, and a repo: projects line under output_schema_repos.

fleet-patterns.3 release/install-tools:31 · release/pin-manifests:67 · .github/workflows/release.yml:36 · .github/workflows/validate.yml:336 · slate:release/install-tools:31 · slate:release/pin-manifests:67 · slate:.github/workflows/release.yml:238 · psyche:release/install-tools:31 · psyche:release/pin-manifests:67 · doit:release/install-tools:31 · doit:release/pin-manifests:59

Reach: The next cosign, flux or kustomize bump is a hand edit of version and SHA-256 in four repos: doit, psyche, slate and projects. Forge's pins hold no entry for these tools, so nothing moves or checks them together. The next fix to api-publish lands in slate and has to be re-found here, as its refs/tags/cli/ note already differs.

A fourth copy of the release scripts and publish job lands here, with no shared owner.

diff against psyche and slate shows release/pin-manifests differs only in the image name. release/install-tools differs only in its pin list and usage text. The api-publish job up to signing differs from slate's only in needs, env, permissions, comments and the build-arg name. The deploy-manifests block in validate.yml is the same as slate's. doit carries install-tools and an older single-path pin-manifests.

release.md § "A release.yml calls a shared workflow and pins nothing itself" exists so that a pin moves in one commit. It leaves a publishing job with the caller, so the copy breaks no rule as written. See the proposed rule.

Following slate was right for this change. The cost is one more copy to edit.

Fix: move install-tools, pin-manifests and the publish steps into reusable-workflows, with the image name as an input, and have the four repos call them. A follow-up can do this. Until then, the pull request body should say this is a fourth copy.

fleet-patterns.4 Dockerfile:1 · .pre-commit-config.yaml:309 · terminal-library-fleet:repos.json · slate:.pre-commit-config.yaml:304 · psyche:.pre-commit-config.yaml:350

Reach: The next edit to Dockerfile commits with no hadolint run in pre-commit or CI. The image first builds in api-publish, after the tag exists. So a bad base or instruction surfaces after the release is cut.

The repo now ships an image, but its registry entry declares no docker component.

repos-registry lists projects with go and actions components only. psyche and slate each declare {stack: docker}, and forge generates a generated:docker hadolint block for them. rg docker .pre-commit-config.yaml on this head returns nothing.

I ran hadolint by hand with the generated block's arguments, and the Dockerfile passes today. The gap is the next edit, not this one. repo-structure.md § "The per-repo build surface is declared in a registry, not detected" makes the registry the declaration.

sql_dialect stays sqlite, and this pull request hand-writes a custom Postgres sqlfluff block with a .sqlfluffignore to match. That workaround has a stated reason, and I do not report it.

Fix: add {"stack": "docker", "dir": "."} to projects in the registry, then regenerate with forge in this pull request.

Findings as JSON
{
  "used": "Read PR 17 at 9b69e86 and ran hadolint on its Dockerfile, which passed. Compared it with psyche, slate, doit, fleet, ypl, meso, nomad, learning, ichrisbirch, goapiauth, forge, reusable-workflows and fleet-data-catalog. No API was started. These matched and gave no finding: the metadata path, because goapiauth exempts it; the migration Job layout; the root-module API, which fleet also has; and the probes.",
  "standards": "Cited `data.md` § \"A new source of data gets its row in the data catalog in the change that creates it\", `cli-design.md` § \"The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes\", `telemetry.md` § \"A record read later carries its writer's traceparent and baggage, and its reader links back\", `release.md` § \"A release.yml calls a shared workflow and pins nothing itself\", and `repo-structure.md` § \"The per-repo build surface is declared in a registry, not detected\".",
  "findings": [
    {
      "number": "fleet-patterns.1",
      "section": "design",
      "sites": [
        "store/postgres/migrations/00001_projects.sql:112",
        "store/postgres/queries.sql:9",
        "store/postgres/store_test.go:58",
        "slate:src/slate/alembic/versions/1a7f84e9a913_keep_the_trace_and_baggage_of_each_.py:56",
        "slate:src/slate/models/record_change.py:97",
        "slate:tests/models/test_audit_trail.py:46",
        "ichrisbirch:ichrisbirch/models/record_change.py:82",
        "ichrisbirch:tests/ichrisbirch/models/test_icb_audit_trail.py:45"
      ],
      "reach": "Every write through the new API lands in `audit_trail` through `InTx`. Without trace fields, a write cannot be joined to the trace that made it. The next table added to `store/postgres/migrations` gets no audit trigger and no test fails. The next fix to the trigger function must be made by hand in slate, ichrisbirch and projects.",
      "body": "**The audit trail copies slate's trigger from before slate added trace context, and drops the coverage test both siblings hold.**\n\n`audit_trail_record()` here matches slate's function body line for line. Slate added `traceparent` and `baggage` columns on 2026-11-02, in revision `1a7f84e9a913`, and its `after_begin` listener binds them. Projects has neither: `rg 'traceparent|baggage' api store README.md` returns nothing. `telemetry.md` § \"A record read later carries its writer's traceparent and baggage, and its reader links back\" asks for those fields. The pull request body gives no reason for leaving them out.\n\nSlate and ichrisbirch both run `test_every_table_is_audited_but_what_opts_out`, which compares every table with its trigger. Projects asserts five named rows from one flow, so a new table without a trigger passes.\n\nSlate is the better solution, because it holds both the trace fields and the coverage test.\n\n**Fix**: add both trace columns and their settings to `00001_projects.sql` now, since no deployment has applied it. Bind them in `BindWriter`. Add a test that lists `pg_trigger` against every table except `undo_log`, `audit_trail` and goose's version table."
    },
    {
      "number": "fleet-patterns.2",
      "section": "design",
      "sites": [
        "api/main.go:1",
        "store/postgres/migrations/00001_projects.sql:1",
        "api/schemas/project.schema.json:3",
        "fleet-data-catalog:producers.yaml:75",
        "fleet-data-catalog:producers.yaml:95",
        "fleet-data-catalog:producers.yaml:98",
        "fleet-data-catalog:producers.yaml:186",
        "nomad:api/schemas/overview.schema.json:8"
      ],
      "reach": "Merging leaves a new HTTP service, a new Postgres store and ten schema files out of the catalog. `producers.yaml` still gives this store as sqlite, located in ichrisbirch's Postgres. It would still say that when the service changes hands. Any reader of these shapes has no contract to point at, so it copies the fields.",
      "body": "**The new API, its Postgres store and its schemas have no row in the data catalog.**\n\n`fleet-data-catalog/producers.yaml` lists psyche, ypl, learning, meso and nomad as `go-net-http` APIs, and projects is absent. `sql_queries` names only `store/sqlite/queries.sql`. `output_schema_repos` does not list projects, so `api/schemas` produces no contract, while nomad's API schemas do. The local checkout holds no row, and a search of that repo's open pull requests for `projects` returned none.\n\n`data.md` § \"A new source of data gets its row in the data catalog in the change that creates it\" requires a row for a new HTTP service or store. It says `catalog-contracts coverage` exits 1 on an unlisted source. I did not run that command against this head.\n\nThe pull request also drops `schema_version`, because existing clients read these shapes as they are. nomad carries it, and `cli-design.md` § \"The shape a --json read or an endpoint returns is a JSON Schema file that the data catalog publishes\" asks for it. List answers here are bare arrays, which have no place for the field. That exception belongs in the repo's `CLAUDE.md`, stated as an exception to the rule.\n\n**Fix**: open a companion change to `producers.yaml`. It adds an `http_apis` row with `source: api`, a `sql_queries` row for `store/postgres/queries.sql`, a `sql_stores` row using `migrations` with its retention, and a `repo: projects` line under `output_schema_repos`."
    },
    {
      "number": "fleet-patterns.3",
      "section": "design",
      "sites": [
        "release/install-tools:31",
        "release/pin-manifests:67",
        ".github/workflows/release.yml:36",
        ".github/workflows/validate.yml:336",
        "slate:release/install-tools:31",
        "slate:release/pin-manifests:67",
        "slate:.github/workflows/release.yml:238",
        "psyche:release/install-tools:31",
        "psyche:release/pin-manifests:67",
        "doit:release/install-tools:31",
        "doit:release/pin-manifests:59"
      ],
      "reach": "The next cosign, flux or kustomize bump is a hand edit of version and SHA-256 in four repos: doit, psyche, slate and projects. Forge's pins hold no entry for these tools, so nothing moves or checks them together. The next fix to `api-publish` lands in slate and has to be re-found here, as its `refs/tags/cli/` note already differs.",
      "body": "**A fourth copy of the release scripts and publish job lands here, with no shared owner.**\n\n`diff` against psyche and slate shows `release/pin-manifests` differs only in the image name. `release/install-tools` differs only in its pin list and usage text. The `api-publish` job up to signing differs from slate's only in `needs`, env, permissions, comments and the build-arg name. The `deploy-manifests` block in `validate.yml` is the same as slate's. doit carries `install-tools` and an older single-path `pin-manifests`.\n\n`release.md` § \"A release.yml calls a shared workflow and pins nothing itself\" exists so that a pin moves in one commit. It leaves a publishing job with the caller, so the copy breaks no rule as written. See the proposed rule.\n\nFollowing slate was right for this change. The cost is one more copy to edit.\n\n**Fix**: move `install-tools`, `pin-manifests` and the publish steps into `reusable-workflows`, with the image name as an input, and have the four repos call them. A follow-up can do this. Until then, the pull request body should say this is a fourth copy."
    },
    {
      "number": "fleet-patterns.4",
      "section": "design",
      "sites": [
        "Dockerfile:1",
        ".pre-commit-config.yaml:309",
        "terminal-library-fleet:repos.json",
        "slate:.pre-commit-config.yaml:304",
        "psyche:.pre-commit-config.yaml:350"
      ],
      "reach": "The next edit to `Dockerfile` commits with no hadolint run in pre-commit or CI. The image first builds in `api-publish`, after the tag exists. So a bad base or instruction surfaces after the release is cut.",
      "body": "**The repo now ships an image, but its registry entry declares no `docker` component.**\n\n`repos-registry` lists projects with `go` and `actions` components only. psyche and slate each declare `{stack: docker}`, and forge generates a `generated:docker` hadolint block for them. `rg docker .pre-commit-config.yaml` on this head returns nothing.\n\nI ran hadolint by hand with the generated block's arguments, and the Dockerfile passes today. The gap is the next edit, not this one. `repo-structure.md` § \"The per-repo build surface is declared in a registry, not detected\" makes the registry the declaration.\n\n`sql_dialect` stays `sqlite`, and this pull request hand-writes a custom Postgres sqlfluff block with a `.sqlfluffignore` to match. That workaround has a stated reason, and I do not report it.\n\n**Fix**: add `{\"stack\": \"docker\", \"dir\": \".\"}` to projects in the registry, then regenerate with forge in this pull request."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — G1

Used: Ran go test ./..., built projects-api, ran migrate and serve on a private Postgres, sent real tokens through the API on Postgres and SQLite, drove TUI moves, ran the real sync engine against the API, ran pin-manifests, and did not build the image or run api-publish.
Standards: Cited data.md § "A migration that replaces an identity seeds its successor from the identitys own counter, never from MAX of the column"; searches on API input validation, stores disagreeing, position ties and rules wired per surface found no rule to cite.
Findings: 4 correctness · 1 design · 0 prose

Correctness

G1.1 tui/app.go:824

Reach: Any TUI database holding two items on one position. The single-row ReorderItem at 132335a wrote those ties, and the PR body's replay table counts position ties in production data. A sync pull mirrors the server's positions.

A drop below a tied row does not land where it was dropped. The body says a moved item lands where it is dropped. reorderItemCmd counts every member with member.Position <= above.Position. That count includes any row tied with above, and a tied row can sit below the drop.

I drove the keys through App.Update over SQLite. The seed was alpha 0, bravo 1, charlie 1, delta 2. I pressed G, m, k, then Enter on delta.

while moving: [alpha bravo delta charlie]
after:        [alpha bravo charlie delta]

The item snaps back to where it started. TestAMovedItemLandsWhereItIsDroppedBelowAnArchivedOne misses this because its seed comes from CreateItem, which never ties.

Fix: merge the unarchived and archived lists and sort them by position, created_at and id, the store's own order. Remove the moved item, then use the index of above.ID plus one. Add a test with a tied seed.

G1.2 sync/backend.go:237 · service/service.go:969

Reach: The TUI with sync on: a person adds a task and checks it off before the next pull. sync/backend.go is unchanged on this branch, so the old API receives the same payload with no id.

A task checked off before the next pull comes back unchecked. SyncBackend.CreateTask generates a local id, then pushes only item_id and title. The server therefore assigns a different id. The later PATCH names the local id and gets a 404, and the push code drops the op on a 404. The next pull replaces the local row with the server's unchecked one.

I ran the real sync.Engine and SyncBackend against this branch's API on Postgres.

POST .../tasks {"item_id":"…","title":"t"} -> 201 {"id":"…1d5f-7844-8b2d-e051b53db4a1"…}
PATCH .../tasks/…1d5e-7979-a9ea-f8f66575a20f {"completed":true} -> 404
local tasks before pull: id …a20f, completed true
local tasks after pull:  id …db4a1, completed false

The user's check is lost with no error. I did not run this against the old API.

This branch makes the fix small, because CreateProjectItemTask.ID and POST …/tasks now take an id. Fix: add ID to taskPayload and send result.ID, as CreateItem and CreateProject already do. The file is outside this diff, so this can be its own one-commit change.

G1.3 api/main.go:86 · api/settings/settings.go:71 · api/routes/answers.go:96 · README.md:292 · CLAUDE.md:191

Reach: POST /api/v1/projects or a rename through PATCH /api/v1/projects/{id}, from any script or agent, once the manifests are applied. The README names scripts and agents as this API's clients.

The API drops the repo-name ban the TUI enforces. api/main.go:86 builds the service with AssigningItemNumbers alone. RefusingRepoNames is set only in the root main.go, at lines 48 and 119. api/settings has no registry setting. A project named projects is refused in the TUI and has no refusal path through this API. I read this from the wiring and did not send that request.

statusOfRefusal maps ErrRepoNamedProject to 422, but no deployed path can raise it. routes_test.go builds its service the same way, so no test notices.

Two sentences are now false:

  • README: "It runs the rules the TUI runs, over a Postgres store instead of SQLite, so a rule behaves the same in both."
  • CLAUDE.md: "Enforcement is on the service (RefusingRepoNames), the one path every create and rename takes."

Fix: add a REPOS_REGISTRY setting to settings.Server, deliver the file through the ConfigMap, and pass the option when it is set. If the gap is deliberate, delete the mapping and rewrite the README sentence: "It runs the rules the TUI runs, except the repo-name ban, which reads a registry file this API has no copy of."

G1.4 store/postgres/store.go:181 · store/postgres/store.go:164 · store/postgres/store.go:459 · api/routes/request_input.go:183 · service/service.go:571 · api/routes/answers.go:127

Reach: unproven — a survey of what the web page, CLI and agents send would settle it. The triggers are a note holding a NUL byte, one project twice in project_ids (for instance by id and by name), and a position above int32.

Three inputs get past service, and two of them behave differently per store. Review point 1 in the body asks whether any rule depends on a store's accidental behavior. These do. I ran each through the routes with a real token.

Input Postgres SQLite
PATCH /projects/{id} with position 4294967297 200, stored as 1 200, stored as 4294967297
POST /projects with position 2147483648 201, stored as -2147483648 not run
POST /project-items with a title holding \u0000 500 201
project_ids naming one project twice 500 500

The wrapped position is the worst case, because a wrong value is written and answered as success. partialInt decodes a 64-bit int, and the store casts it with int32().

Every 500 answers Internal server error: *fmt.wrapError, which tells a client nothing.

A 500 also matters to sync, because sync/push.go retries a 5xx. I wrote a NUL note through SyncBackend against this API. The queue held at PendingCount:2, with the next write stuck behind the note on every retry.

Fix: have service refuse a NUL in text and a position outside int32 with a 422. Collapse repeated projects in CreateItem after resolving them. Make the 500 body name the request id, which the log already holds.

Design and architecture

G1.5 store/postgres/migrations/00001_projects.sql:2 · store/postgres/migrations/00001_projects.sql:33 · store/postgres/store.go:111 · deploy/migrate/migration.yaml:1

Reach: The next change: moving production onto this API. The body's opening states that aim, and its Risk section says no service changes hands yet. The README tells an operator to apply migrate/ and then app/.

Neither route onto production data works from what this PR ships. The body's replay restored a production copy, but that program is not in this repo. "What this does not do" never mentions the move.

Route 1 is migrating the existing database. ichrisbirch/models/project.py already declares project_kinds, projects and item_numbers, so 00001 collides with them. I ran projects-api migrate against a stub database holding only project_kinds. It returned partial migration error … relation "project_kinds" already exists and exited 1. The Job would retry up to backoffLimit and then fail.

Route 2 is a new database with the rows restored. item_numbers then still starts at 100000, whatever numbers the rows hold. With 100000 and 100001 restored, nextval returned 100000. With one row at 100000, POST /project-items answered 500 on uq_project_items_number, and the retry succeeded with 100001. That costs one failed create per number already held, and sync retries each 5xx.

Reading MAX(number) is not enough either. In the other API, item_numbers is shared with other tables, so its counter can pass every surviving item. data.md § "A migration that replaces an identity seeds its successor from the identitys own counter, never from MAX of the column" says a handle must never repeat.

The cutover will have to invent an adoption step and a sequence seed after this schema has shipped. Fix: ship the move in this repo, with the restore steps and a setval from the old sequence's own counter. Name the unique constraint in duplicateError so a collision is not an anonymous 500. If that belongs to a later PR, say so under "What this does not do".

Findings as JSON
{
  "used": "Ran `go test ./...`, built `projects-api`, ran `migrate` and serve on a private Postgres, sent real tokens through the API on Postgres and SQLite, drove TUI moves, ran the real sync engine against the API, ran `pin-manifests`, and did not build the image or run `api-publish`.",
  "standards": "Cited `data.md` § \"A migration that replaces an identity seeds its successor from the identitys own counter, never from MAX of the column\"; searches on API input validation, stores disagreeing, position ties and rules wired per surface found no rule to cite.",
  "findings": [
    {
      "number": "G1.1",
      "section": "correctness",
      "sites": [
        "tui/app.go:824"
      ],
      "reach": "Any TUI database holding two items on one position. The single-row `ReorderItem` at `132335a` wrote those ties, and the PR body's replay table counts position ties in production data. A sync pull mirrors the server's positions.",
      "body": "**A drop below a tied row does not land where it was dropped.** The body says a moved item lands where it is dropped. `reorderItemCmd` counts every member with `member.Position <= above.Position`. That count includes any row tied with `above`, and a tied row can sit below the drop.\n\nI drove the keys through `App.Update` over SQLite. The seed was alpha 0, bravo 1, charlie 1, delta 2. I pressed `G`, `m`, `k`, then Enter on delta.\n\n```text\nwhile moving: [alpha bravo delta charlie]\nafter:        [alpha bravo charlie delta]\n```\n\nThe item snaps back to where it started. `TestAMovedItemLandsWhereItIsDroppedBelowAnArchivedOne` misses this because its seed comes from `CreateItem`, which never ties.\n\nFix: merge the unarchived and archived lists and sort them by position, created_at and id, the store's own order. Remove the moved item, then use the index of `above.ID` plus one. Add a test with a tied seed."
    },
    {
      "number": "G1.2",
      "section": "correctness",
      "sites": [
        "sync/backend.go:237",
        "service/service.go:969"
      ],
      "reach": "The TUI with sync on: a person adds a task and checks it off before the next pull. `sync/backend.go` is unchanged on this branch, so the old API receives the same payload with no id.",
      "body": "**A task checked off before the next pull comes back unchecked.** `SyncBackend.CreateTask` generates a local id, then pushes only `item_id` and `title`. The server therefore assigns a different id. The later `PATCH` names the local id and gets a 404, and the push code drops the op on a 404. The next pull replaces the local row with the server's unchecked one.\n\nI ran the real `sync.Engine` and `SyncBackend` against this branch's API on Postgres.\n\n```text\nPOST .../tasks {\"item_id\":\"…\",\"title\":\"t\"} -> 201 {\"id\":\"…1d5f-7844-8b2d-e051b53db4a1\"…}\nPATCH .../tasks/…1d5e-7979-a9ea-f8f66575a20f {\"completed\":true} -> 404\nlocal tasks before pull: id …a20f, completed true\nlocal tasks after pull:  id …db4a1, completed false\n```\n\nThe user's check is lost with no error. I did not run this against the old API.\n\nThis branch makes the fix small, because `CreateProjectItemTask.ID` and `POST …/tasks` now take an id. Fix: add `ID` to `taskPayload` and send `result.ID`, as `CreateItem` and `CreateProject` already do. The file is outside this diff, so this can be its own one-commit change."
    },
    {
      "number": "G1.3",
      "section": "correctness",
      "sites": [
        "api/main.go:86",
        "api/settings/settings.go:71",
        "api/routes/answers.go:96",
        "README.md:292",
        "CLAUDE.md:191"
      ],
      "reach": "`POST /api/v1/projects` or a rename through `PATCH /api/v1/projects/{id}`, from any script or agent, once the manifests are applied. The README names scripts and agents as this API's clients.",
      "body": "**The API drops the repo-name ban the TUI enforces.** `api/main.go:86` builds the service with `AssigningItemNumbers` alone. `RefusingRepoNames` is set only in the root `main.go`, at lines 48 and 119. `api/settings` has no registry setting. A project named `projects` is refused in the TUI and has no refusal path through this API. I read this from the wiring and did not send that request.\n\n`statusOfRefusal` maps `ErrRepoNamedProject` to 422, but no deployed path can raise it. `routes_test.go` builds its service the same way, so no test notices.\n\nTwo sentences are now false:\n\n- README: \"It runs the rules the TUI runs, over a Postgres store instead of SQLite, so a rule behaves the same in both.\"\n- CLAUDE.md: \"Enforcement is on the service (`RefusingRepoNames`), the one path every create and rename takes.\"\n\nFix: add a `REPOS_REGISTRY` setting to `settings.Server`, deliver the file through the ConfigMap, and pass the option when it is set. If the gap is deliberate, delete the mapping and rewrite the README sentence: \"It runs the rules the TUI runs, except the repo-name ban, which reads a registry file this API has no copy of.\""
    },
    {
      "number": "G1.4",
      "section": "correctness",
      "sites": [
        "store/postgres/store.go:181",
        "store/postgres/store.go:164",
        "store/postgres/store.go:459",
        "api/routes/request_input.go:183",
        "service/service.go:571",
        "api/routes/answers.go:127"
      ],
      "reach": "unproven — a survey of what the web page, CLI and agents send would settle it. The triggers are a note holding a NUL byte, one project twice in `project_ids` (for instance by id and by name), and a position above int32.",
      "body": "**Three inputs get past `service`, and two of them behave differently per store.** Review point 1 in the body asks whether any rule depends on a store's accidental behavior. These do. I ran each through the routes with a real token.\n\n| Input | Postgres | SQLite |\n| --- | --- | --- |\n| `PATCH /projects/{id}` with `position` 4294967297 | 200, stored as 1 | 200, stored as 4294967297 |\n| `POST /projects` with `position` 2147483648 | 201, stored as -2147483648 | not run |\n| `POST /project-items` with a title holding `\\u0000` | 500 | 201 |\n| `project_ids` naming one project twice | 500 | 500 |\n\nThe wrapped position is the worst case, because a wrong value is written and answered as success. `partialInt` decodes a 64-bit `int`, and the store casts it with `int32()`.\n\nEvery 500 answers `Internal server error: *fmt.wrapError`, which tells a client nothing.\n\nA 500 also matters to sync, because `sync/push.go` retries a 5xx. I wrote a NUL note through `SyncBackend` against this API. The queue held at `PendingCount:2`, with the next write stuck behind the note on every retry.\n\nFix: have `service` refuse a NUL in text and a position outside int32 with a 422. Collapse repeated projects in `CreateItem` after resolving them. Make the 500 body name the request id, which the log already holds."
    },
    {
      "number": "G1.5",
      "section": "design",
      "sites": [
        "store/postgres/migrations/00001_projects.sql:2",
        "store/postgres/migrations/00001_projects.sql:33",
        "store/postgres/store.go:111",
        "deploy/migrate/migration.yaml:1"
      ],
      "reach": "The next change: moving production onto this API. The body's opening states that aim, and its Risk section says no service changes hands yet. The README tells an operator to apply `migrate/` and then `app/`.",
      "body": "**Neither route onto production data works from what this PR ships.** The body's replay restored a production copy, but that program is not in this repo. \"What this does not do\" never mentions the move.\n\n**Route 1 is migrating the existing database.** `ichrisbirch/models/project.py` already declares `project_kinds`, `projects` and `item_numbers`, so `00001` collides with them. I ran `projects-api migrate` against a stub database holding only `project_kinds`. It returned `partial migration error … relation \"project_kinds\" already exists` and exited 1. The Job would retry up to `backoffLimit` and then fail.\n\n**Route 2 is a new database with the rows restored.** `item_numbers` then still starts at 100000, whatever numbers the rows hold. With 100000 and 100001 restored, `nextval` returned 100000. With one row at 100000, `POST /project-items` answered 500 on `uq_project_items_number`, and the retry succeeded with 100001. That costs one failed create per number already held, and sync retries each 5xx.\n\nReading `MAX(number)` is not enough either. In the other API, `item_numbers` is shared with other tables, so its counter can pass every surviving item. `data.md` § \"A migration that replaces an identity seeds its successor from the identitys own counter, never from MAX of the column\" says a handle must never repeat.\n\nThe cutover will have to invent an adoption step and a sequence seed after this schema has shipped. Fix: ship the move in this repo, with the restore steps and a `setval` from the old sequence's own counter. Name the unique constraint in `duplicateError` so a collision is not an anonymous 500. If that belongs to a later PR, say so under \"What this does not do\"."
    }
  ]
}

This review does not gate merge.

@datapoints-fleet-automation datapoints-fleet-automation Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review — synthesis

Used: the 27 findings of round 1, from 8 review(s), and nothing else
Standards: none, because this step reads only the findings
Findings: 0 correctness · 5 design · 0 prose

Design and architecture

synthesis.1 explains fleet-patterns.2 · contracts.1

The new API, its Postgres store and its schemas have no row in the data catalog's producers.yaml. Both findings name the same missing catalog rows and the same data.md rule.

synthesis.2 explains G1.3 · stale-prose.1 · stale-prose.2 · stale-prose.3

Prose in README and CLAUDE.md states a guarantee that the code does not hold. The API lacks the repo-name ban, empty lists are not null, and the API scope and status order claims are stale.

synthesis.3 explains G1.5 · contracts.3

The PR does not disclose that moving production onto this API needs more work. The data migration, the sequence seed and the new client credentials and scopes are all left unstated under 'What this does not do'.

synthesis.4 explains code-architecture.1 · code-architecture.3 · code-architecture.4 · code-architecture.5

A fact is held at two sites with nothing tying them together, so a new member means a hand edit in step. The sites are the TUI and API read methods, the lookup seeds in the two stores, the sentinel list and HTTP status table, and the Docker allowlist and import graph.

synthesis.5 explains fleet-patterns.1 · fleet-patterns.4

The Postgres audit trail and Dockerfile tooling lack the coverage that sibling repos hold. The trace fields and the every-table-audited test are missing, and the registry declares no docker component.

One defect, raised more than once

fleet-patterns.2 · contracts.1: The new API, its Postgres store and its schemas have no row in the data catalog's producers.yaml.

Shapes as JSON
{
  "shapes": [
    {
      "number": "synthesis.1",
      "section": "design",
      "explains": [
        "fleet-patterns.2",
        "contracts.1"
      ],
      "body": "The new API, its Postgres store and its schemas have no row in the data catalog's `producers.yaml`. Both findings name the same missing catalog rows and the same data.md rule."
    },
    {
      "number": "synthesis.2",
      "section": "design",
      "explains": [
        "G1.3",
        "stale-prose.1",
        "stale-prose.2",
        "stale-prose.3"
      ],
      "body": "Prose in README and CLAUDE.md states a guarantee that the code does not hold. The API lacks the repo-name ban, empty lists are not null, and the API scope and status order claims are stale."
    },
    {
      "number": "synthesis.3",
      "section": "design",
      "explains": [
        "G1.5",
        "contracts.3"
      ],
      "body": "The PR does not disclose that moving production onto this API needs more work. The data migration, the sequence seed and the new client credentials and scopes are all left unstated under 'What this does not do'."
    },
    {
      "number": "synthesis.4",
      "section": "design",
      "explains": [
        "code-architecture.1",
        "code-architecture.3",
        "code-architecture.4",
        "code-architecture.5"
      ],
      "body": "A fact is held at two sites with nothing tying them together, so a new member means a hand edit in step. The sites are the TUI and API read methods, the lookup seeds in the two stores, the sentinel list and HTTP status table, and the Docker allowlist and import graph."
    },
    {
      "number": "synthesis.5",
      "section": "design",
      "explains": [
        "fleet-patterns.1",
        "fleet-patterns.4"
      ],
      "body": "The Postgres audit trail and Dockerfile tooling lack the coverage that sibling repos hold. The trace fields and the every-table-audited test are missing, and the registry declares no docker component."
    }
  ],
  "duplicates": [
    {
      "defect": "The new API, its Postgres store and its schemas have no row in the data catalog's producers.yaml.",
      "findings": [
        "fleet-patterns.2",
        "contracts.1"
      ]
    }
  ]
}

This review does not gate merge.

@datapoints-fleet-automation

Copy link
Copy Markdown
Contributor

PR review round 1

Ran: G1, useless-test, code-architecture, fleet-patterns, system-design, stale-prose, failure-paths, contracts, synthesis
Did not run: none
Rules: 3 proposed, filed into the fleet inbox as proposals.

reviewer model effort duration uncached input cache write cache read output findings review
G1 opus high 11m 18s 42 234,560 3,094,439 61,613 5 review
useless-test opus high 5m 08s 20 126,741 772,287 20,218 3 review
code-architecture opus medium 3m 59s 14 157,574 502,176 23,497 5 review
fleet-patterns opus medium 9m 57s 24 124,197 777,039 26,780 4 review
system-design sonnet medium 0m 30s 14 27,561 142,937 2,325 0 review
stale-prose opus medium 3m 31s 16 84,111 328,204 19,581 5 review
failure-paths sonnet medium 0m 42s 14 30,343 167,706 3,744 1 review
contracts opus medium 6m 18s 36 143,677 1,710,735 34,343 4 review
synthesis sonnet medium 0m 12s 6 19,221 21,245 1,569 5 review
total 186 947,985 7,516,768 193,670 32
Round record
{
  "grammar": 6,
  "round": 1,
  "repo": "datapointchris/projects",
  "pr": 17,
  "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
  "experiment": "none",
  "dispatch": "20261107T195837Z-pr-review",
  "roster": "7cfd389c050fe0ef4d6a4f766e70754eb2804889",
  "harness": "6a7722b7cb06ebfd9ed1c1f63f514ce24240fb70",
  "machine": "queue",
  "started": "2026-11-07T19:58:42Z",
  "finished": "2026-11-07T20:10:34Z",
  "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": 678,
      "tokens": {
        "input": 42,
        "cache_write": 234560,
        "cache_read": 3094439,
        "output": 61613
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 42,
          "cache_write": 234560,
          "cache_read": 3094439,
          "output": 61613
        }
      },
      "session": "01a1b797-77b3-76ad-90c1-c0c077153357",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814707407",
      "review_id": 5814707407,
      "findings": {
        "correctness": 4,
        "design": 1,
        "prose": 0
      },
      "numbers": [
        "G1.1",
        "G1.2",
        "G1.3",
        "G1.4",
        "G1.5"
      ],
      "rules": {
        "proposed": 1,
        "keys": [
          "rule/code-architecture/a-rule-a-service-takes-as-a-constructor-option-is-set-at-every-entry-point-and-each-entry-points-test-proves-it-fires"
        ],
        "filed": true,
        "why": "",
        "path": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records/G1.rules.json"
      },
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:42Z",
      "finished": "2026-11-07T20:10:03Z",
      "account": "fleetizen",
      "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": 308,
      "tokens": {
        "input": 20,
        "cache_write": 126741,
        "cache_read": 772287,
        "output": 20218
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 20,
          "cache_write": 126741,
          "cache_read": 772287,
          "output": 20218
        }
      },
      "session": "01a1b797-7b6d-7ac3-b317-eb4cc58ca2a9",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814688552",
      "review_id": 5814688552,
      "findings": {
        "correctness": 0,
        "design": 3,
        "prose": 0
      },
      "numbers": [
        "useless-test.1",
        "useless-test.2",
        "useless-test.3"
      ],
      "rules": {},
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:45Z",
      "finished": "2026-11-07T20:03:54Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "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": 239,
      "tokens": {
        "input": 14,
        "cache_write": 157574,
        "cache_read": 502176,
        "output": 23497
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 14,
          "cache_write": 157574,
          "cache_read": 502176,
          "output": 23497
        }
      },
      "session": "01a1b797-7ae2-7dd8-a177-228246ebe9ec",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814684054",
      "review_id": 5814684054,
      "findings": {
        "correctness": 0,
        "design": 5,
        "prose": 0
      },
      "numbers": [
        "code-architecture.1",
        "code-architecture.2",
        "code-architecture.3",
        "code-architecture.4",
        "code-architecture.5"
      ],
      "rules": {},
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:44Z",
      "finished": "2026-11-07T20:02:46Z",
      "account": "fleetizen",
      "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": 597,
      "tokens": {
        "input": 24,
        "cache_write": 124197,
        "cache_read": 777039,
        "output": 26780
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 24,
          "cache_write": 124197,
          "cache_read": 777039,
          "output": 26780
        }
      },
      "session": "01a1b797-7bec-74c7-994a-79f213159b88",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814703674",
      "review_id": 5814703674,
      "findings": {
        "correctness": 0,
        "design": 4,
        "prose": 0
      },
      "numbers": [
        "fleet-patterns.1",
        "fleet-patterns.2",
        "fleet-patterns.3",
        "fleet-patterns.4"
      ],
      "rules": {
        "proposed": 2,
        "keys": [
          "rule/data/an-audit-trail-is-a-postgres-row-trigger-reading-transaction-local-settings-and-a-test-lists-every-table-against-its-trigger",
          "rule/release/an-apps-image-and-manifest-publish-steps-live-in-the-shared-workflows-because-the-callers-copy-pins-tools-by-hand"
        ],
        "filed": true,
        "why": "",
        "path": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records/fleet-patterns.rules.json"
      },
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:51Z",
      "finished": "2026-11-07T20:08:51Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "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": 30,
      "tokens": {
        "input": 14,
        "cache_write": 27561,
        "cache_read": 142937,
        "output": 2325
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-sonnet-5-5": {
          "input": 14,
          "cache_write": 27561,
          "cache_read": 142937,
          "output": 2325
        }
      },
      "session": "01a1b797-7a2d-7b8e-a036-6f1258f7b029",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814672710",
      "review_id": 5814672710,
      "findings": {
        "correctness": 0,
        "design": 0,
        "prose": 0
      },
      "numbers": [],
      "rules": {},
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:43Z",
      "finished": "2026-11-07T19:59:16Z",
      "account": "fleetizen",
      "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": 84111,
        "cache_read": 328204,
        "output": 19581
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 16,
          "cache_write": 84111,
          "cache_read": 328204,
          "output": 19581
        }
      },
      "session": "01a1b797-7d2d-7b25-be7d-4d4843ca2163",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814682654",
      "review_id": 5814682654,
      "findings": {
        "correctness": 0,
        "design": 0,
        "prose": 5
      },
      "numbers": [
        "stale-prose.1",
        "stale-prose.2",
        "stale-prose.3",
        "stale-prose.4",
        "stale-prose.5"
      ],
      "rules": {},
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:51Z",
      "finished": "2026-11-07T20:02:25Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "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": 42,
      "tokens": {
        "input": 14,
        "cache_write": 30343,
        "cache_read": 167706,
        "output": 3744
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-sonnet-5-5": {
          "input": 14,
          "cache_write": 30343,
          "cache_read": 167706,
          "output": 3744
        }
      },
      "session": "01a1b797-7f5e-782c-ae4e-e8ed84c8807e",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814673688",
      "review_id": 5814673688,
      "findings": {
        "correctness": 1,
        "design": 0,
        "prose": 0
      },
      "numbers": [
        "failure-paths.1"
      ],
      "rules": {},
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:52Z",
      "finished": "2026-11-07T19:59:36Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "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": 378,
      "tokens": {
        "input": 36,
        "cache_write": 143677,
        "cache_read": 1710735,
        "output": 34343
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 36,
          "cache_write": 143677,
          "cache_read": 1710735,
          "output": 34343
        }
      },
      "session": "01a1b797-7e0c-7e90-8c9a-65da444013c1",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814692634",
      "review_id": 5814692634,
      "findings": {
        "correctness": 1,
        "design": 3,
        "prose": 0
      },
      "numbers": [
        "contracts.1",
        "contracts.2",
        "contracts.3",
        "contracts.4"
      ],
      "rules": {},
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T19:58:52Z",
      "finished": "2026-11-07T20:05:12Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "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": 2,
      "seconds": 12,
      "tokens": {
        "input": 6,
        "cache_write": 19221,
        "cache_read": 21245,
        "output": 1569
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-sonnet-5-5": {
          "input": 6,
          "cache_write": 19221,
          "cache_read": 21245,
          "output": 1569
        }
      },
      "session": "01a1b7a1-f76a-7e55-ad82-8a82ec05e336",
      "worklog": "2401.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/projects/pull/17#pullrequestreview-5814708850",
      "review_id": 5814708850,
      "findings": {
        "correctness": 0,
        "design": 5,
        "prose": 0
      },
      "numbers": [
        "synthesis.1",
        "synthesis.2",
        "synthesis.3",
        "synthesis.4",
        "synthesis.5"
      ],
      "rules": {},
      "head": "9b69e86f838da95f3ddac16d3fcba8705a8cd8c0",
      "kept": false,
      "records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr17-round1-20261107T195837Z/records",
      "started": "2026-11-07T20:10:18Z",
      "finished": "2026-11-07T20:10:34Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "queue"
    }
  ],
  "rules": {
    "proposed": 3,
    "keys": [
      "rule/code-architecture/a-rule-a-service-takes-as-a-constructor-option-is-set-at-every-entry-point-and-each-entry-points-test-proves-it-fires",
      "rule/data/an-audit-trail-is-a-postgres-row-trigger-reading-transaction-local-settings-and-a-test-lists-every-table-against-its-trigger",
      "rule/release/an-apps-image-and-manifest-publish-steps-live-in-the-shared-workflows-because-the-callers-copy-pins-tools-by-hand"
    ],
    "unfiled": []
  },
  "kept": []
}

The image first built in the release job, after the tag exists, so a package left
out of the .dockerignore allowlist cost a version with binaries and no image. The
repo now declares a docker stack, which brings hadolint. The Postgres SQL lint has
a hook id of its own, because a second hook named sqlfluff-lint replaced the
generated SQLite one on regeneration.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56
Worklog: 2401.1
A database the single-row reorder wrote holds ties, and a row tied with the one
above a drop can sit on either side of it, so the drop now counts members in the
order a store lists them. A task pushed with no id got a new one on the server, so
a check made before the next pull named an id the server never held. The push
dropped that 404, and the pull brought the task back unchecked.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56
Worklog: 2401.1
…nnot hold

A database dropping packets held a request until the client gave up, and pgx waits
as long as its context allows. Each request now has a deadline, a connection
attempt has its own timeout, and a readiness check answers inside the probe's
timeout. A request past its deadline answers 503 rather than 500.

A position past int32 and a NUL in a string are both refused at decode, because
each store fails on them differently and neither failure is a client's to read. A
refusal with no row in statusOfRefusal answers 422 and logs the missing row, so a
new refusal kind cannot arrive as a 500. Any other 500 names its request id.

Each route pattern is a literal string, so a reader of the source sees the whole
route where it is registered.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56
Worklog: 2401.1
… named twice

Two reads of the same rows could disagree, because the TUI's reads and the API's
each built their own answer. ListProjectsByStatus, ListItemsByProject, ListArchived,
ListBlockedItems and Search now sit over the read the API answers from. CreateItem
treats a project named twice as one membership, as a store's primary key would
otherwise refuse it as a server fault.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56
Worklog: 2401.1
…ws from model

An audit row names its actor and request, and now also the traceparent and baggage
the request arrived with, so a row joins to the trace that wrote it. The request log
line carries the traceparent too.

Project kinds and statuses each have one list, in model. postgres.Migrate upserts
them on every run, so a kind added to the list reaches a database with no new
migration file. TestEachStoreHoldsTheKnownKindsAndStatuses holds both stores to it.
TestEveryTableIsAuditedButWhatOptsOut fails when a new table brings no trigger.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56
Worklog: 2401.1
A partial index allowing many unnumbered items and the name constraint's other
clauses pin a migration nothing else depends on. Comparing a refusal's message byte
for byte fails on wording no client reads.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56
Worklog: 2401.1
…ler takes

The API sets no repo-name ban, because a container holds no registry. /api/v2 is its
version signal, and each schema says a field added is not a new version. A client
holding neither prefix answers 401, so moving a caller takes a client of its own.

Agent-Name: claude-code-unattended
Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7
Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56
Worklog: 2401.1
@datapointchris
datapointchris merged commit a2869d8 into main Nov 7, 2026
4 checks passed
@datapointchris
datapointchris deleted the projects-go-api branch November 7, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant