Skip to content

feat: the API the approved client designs need - #2

Merged
thalida merged 94 commits into
mainfrom
client
Sep 5, 2026
Merged

thalida merged 94 commits into
mainfrom
client

Conversation

@thalida

@thalida thalida commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

Serves the six endpoints the approved client designs need, and deletes the three they replaced.
15 operations become 18.

Spec: docs/specs/2026-09-03-statusboard-client-design.md
Plan: docs/plans/2026-09-03-statusboard-api.md, left as written — a plan records what was
intended, including where it turned out to be wrong.

What changes

Every trackable thing is a ServiceComponent. One component collection replaces the service
list and both nested service routes, so Discover, a service's parts, a component's descendants
and an event's affected list are one query with different parameters.

A severity change nobody explains becomes an event. A provider can move a component to
Degraded and never write an incident. Reconcile now opens a ServiceEvent of its own, and a
provider's later post claims it rather than making a second card. detected_by records who
found the outage first, because a claim fills in the provider's id and would otherwise destroy
that fact.

Ancestry is a closure table. ComponentAncestor holds one row per pair of components, one
above the other, with real foreign keys. An array of ids held no reference: deleting a component
left its id in every descendant, and only a poll of that service cleared it, which for an
untracked service never comes. The table cascades instead, and the model, the admin's bulk
delete and the cascade each close a path that left ancestry stale.

Archived components are not served anywhere — every list, every count, every badge, and the
detail. A guard test enumerates the readers, because three of them had already forgotten.

Also: catalog.ServiceRequest gives the Add-by-URL dead end somewhere to send a URL, featuring
moved onto components and is managed only in the component admin, and the phase filter's two
states are a choices class published through /meta/ rather than a bare list of tuples.

Fixes outside the plan

  • GET /meta/?fields= was a complete no-op. The view hand-built its response and never
    instantiated its serializer. Dotted paths now reach into enums.
  • ?fields= naming an unknown field returned 200 {} while the contract promised 400.
  • The contract disagreed with the code about response codes on eight operations, including a
    409 nothing returned and a 502 that was hidden. A test now compares the two.
  • bin/check_prose.py counted a multi-word code span as two words, and reported success when
    run from a directory where it scanned nothing.

Verification

just check green in the image CI uses: 630 tests, coverage 96.77% against an 85% floor, ruff,
the docs cross-check, the prose check, the bin/ tests, and makemigrations --check.

api/tests/test_contract.py holds the code and docs/api/openapi.yaml together in both
directions, now including response codes, and a probe map asserted to equal the contract's
documented GET paths means a new endpoint cannot ship without proving ?fields= prunes it.

Known open, deliberately

  • EVENT_CLAIM_WINDOW_SECONDS needs adding to the deployment repository's .env.example,
    default 3600. That repository is not in this checkout.
  • A component that somebody tracks and that is later archived disappears from their board with
    no notice, and an event card's link to a since-archived component 404s. Both follow from
    excluding archived everywhere.
  • GET /events/?dashboard= still reaches through archived components, deliberately: archiving
    writes the closing "no longer published" update, so hiding the event would hide an outage's
    ending.
  • The response-code check compares codes, not bodies, and cannot see a view that returns a code
    its own annotation does not declare.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi

thalida and others added 30 commits September 3, 2026 10:39
The v1 spec's frontend and design sections predate the decks. They
describe a cream palette, tabs that no longer exist and a status dot the
row no longer carries. Five deck decisions also need API that does not
exist: a cross-service feed, component search, a component screen, an
event screen, and somewhere to send a URL we cannot read.

This records what the client is built from. It settles the stack, the
routes, the endpoints added and removed, the model changes, and how a
status change with no provider post becomes an event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3FXXWHwTQ89ytonD2sZaT
Fourteen tasks: retire the v1 spec's client sections, add the columns
Discover and the component screen need, write the events a provider
never published, serve the six new operations, then delete the three
they replace.

The spec also claimed Service.watcher_count was a column to remove.
Migration catalog/0002 already dropped it, and an annotation replaced
it, so a component repeats neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3FXXWHwTQ89ytonD2sZaT
The v1 spec's API, frontend and design sections predate the approved
decks. They name tabs that no longer exist and a palette that was
replaced. A pointer to the client design replaces them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Discover searches components, so the suggested sort needs both keys on
a component. The watcher count is an annotation, not a column: the
service had one, and four write paths never reached the signal that
kept it true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
A component's Components tab lists every descendant. A self-FK lookup
returns one level, and walking the chain per row cannot use an index.
Reconcile writes the array, and a reparent rewrites the subtree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`docs/superpowers/plans/` named the tool that generated the files, which
tells a reader nothing and buries them a level deeper than the specs.
`docs/` now holds three peers: api, plans and specs.

The markdownlint exclude named the old path, so the move would have put
five thousand lines of unreflowed prose in front of the hook.

The backend plan ended with a handoff section describing how to execute
a plan that is already executed. It went with the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Searching a service's name has to reach its components. The vector is
weighted so an exact hit on a rollup outranks a leaf that matched only
through its service. A reparent rewrites the subtree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Discover, a service's parts, a component's descendants and an event's
affected list are one query with different parameters. Four nested
routes would have been four copies of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
A provider claims an event we opened, which fills in their id. That
destroys the fact that we saw the outage first, so the origin needs a
column. An update records its own author, so one card can hold both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
MetaView built its response dict by hand and returned it directly,
skipping MetaSerializer entirely. FieldsMixin prunes at construction,
so ?fields= was declared on /meta/ but did nothing, on every variant.
This let a client ask for one field and always get the whole payload.

MetaSerializer now carries FieldsMixin, and MetaView serializes
through it, matching the pattern MeView already uses. enums stays a
plain dict built once, in MetaView, but is now read through a
SerializerMethodField that filters it by the branch FieldsMixin
already parses under "enums." A nested FieldsMixin serializer was
ruled out: it would restate the enum list a second time, and one
definition drifts from the other when an enum is added.

The contract-versus-code suite asserted only that ?fields= was
declared on every GET, which is how this shipped unnoticed. It now
also asserts, driven from openapi.yaml's own path list, that ?fields=
actually prunes each documented GET's response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
check_prose.py's words() dropped only tokens starting with a
backtick. A multi-word span like `external_id IS NULL` leaked its
middle and closing tokens as prose, while a single-word span like
`field` was dropped entirely and undercounted. Neither matched "one
idea per sentence": a code span is one thing to read, not a handful
of words to weigh individually or discard.

words() now collapses a balanced code span to one token before
counting. An unmatched backtick falls back to the old per-token
filter rather than raising, since sentences() can in principle hand
it half a span. Fixing the count newly flagged eight sentences across
api/; each is reworded here to fit, without reflowing prose that was
already legal. AGENTS.md now says "20 words or fewer" rather than
"Under 20 words": that has always been the enforced rule, and 44
already-reviewed sentences sit at exactly 20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
A provider can move a component to Degraded and never write an
incident. An event-only feed hid those, and the closed status span was
exposed nowhere else. The client now reads one table.

Closing takes a severity that means recovery, not merely one above the
threshold that opened the event. Unknown is our own poll failing, so it
holds the card open rather than reporting a recovery nobody saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Twilio's dot goes red at 14:02 and they post at 14:14. Two rows would
be two cards for one outage. Theirs is folded into ours, so the card
gains updates rather than a duplicate, and detected_by survives.

A claim merges their components onto ours, so the close rule now reads
all of them. One recovery is no account of the others, and a card
closed early is minted again on the next poll.

Archiving also closes. A provider that stops publishing a component
leaves an event nothing can ever recover, so a red row would sit on a
board for good.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Three screens showed the same list at different scopes. The phase
filter draws its line from CLOSED_PHASES, so no client carries a copy
of which phases are terminal.

Moved the board fixture into conftest.py and added a shared
jwt_client helper, because DEFAULT_AUTHENTICATION_CLASSES is JWT
only and force_login cannot authenticate a DRF view here.
test_board_api.py's client fixture now delegates to it instead of
keeping a second copy.

Added a fields probe for /events/ to test_contract.py, so ?fields=
is proven to prune the feed the same way every other GET does.

get_queryset no longer parses ?dashboard= itself before EventFilter
does. A malformed value used to reach get_object_or_404 as a raw
string and raise an uncaught ValueError, a 500 that common/errors.py
exists to prevent. It now only decides ownership once EventFilter's
own UUIDFilter has confirmed the value parses, and lets that filter
reject a bad one in the same shape any other malformed filter value
gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The header draws both tab badges before either tab asks for anything,
so the detail carries both counts. The log is paged on its own: a
provider writes as many updates as they like.

ServiceEventSerializer drops the nested `updates` array and gains
`detected_by` and a nested `service` ref, so a row from `/events/` can
name which service it belongs to and Home's Updates tab can label its
rows. The service ref import is lazy: catalog.serializers already
imports status.serializers, so a module-level import back would cycle.

EventUpdate.source and ServiceEvent.detected_by share one choice set,
which the schema generator was naming twice. ENUM_NAME_OVERRIDES gives
it one name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The Add-by-URL not-found screen offers to send us a URL, and an import
stored nothing about an attempt that failed. One row per URL with a
count, so the admin list is ordered by what people actually want.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The service list, both nested routes, ServiceFilter, WATCHER_COUNT,
Service.description and Service.is_featured all served screens the
approved decks replaced. by_event_kind counted tabs that no longer
exist, and the board's event filter named the same two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
is_featured moved to the overall component, and an admin asks to
feature a service rather than a component. The component list shows
watchers and gains a bulk action to feature or unfeature the overall
components in a selection, and service requests are ordered by how
often they are asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
api/tests/test_prose_checker.py loaded bin/check_prose.py by path,
but the API image builds from api/ alone. `just check` proves the
image, and bin/ is absent there, so the test failed only in the
image: 10 errors, every one a FileNotFoundError.

AGENTS.md rules out copying bin/ into the API image: one image per
component, built from that component's own directory. It also names
the fix: a stdlib-only script is the exception to running gated
checks in a container, because a container costs more than it
saves. check_prose.py is stdlib-only, so its test moves to
bin/tests/test_check_prose.py and runs with `python3 -m unittest`,
needing no venv and no Django. `just check` now runs it on the host
as `test-bin`, beside the existing check_prose.py invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`detected_by` exists to answer "did we find this first". A claim fills
`external_id` in, so nothing else can answer it. The column was on no
changelist, no filter and no form, so the admin could not ask.

`source` has the same problem on the timeline. A claimed event holds
our updates and the provider's, interleaved. Read without the source
they look like one author.

Both are read only, because `PollerWrittenAdmin` already refuses the
three permissions on these tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`ancestor_ids` and `search_document` had one writer, reconcile. The
admin is the only other one: `parent` and `name` are editable on the
component form.

A component added or reparented there held no ancestry and no search
vector. It was invisible to `?ancestor=`, counted by no
`descendant_count`, and unfindable by `?q=`. A poll repairs a tracked
service. An untracked or paused one is never selected, so the gap
stayed open.

`save_model` and not `ServiceComponent.save`, because reconcile writes
these rows with `bulk_update` and would pay the pass per row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`_prune` popped every field a name in the tree did not match, and
checked no name against the serializer. `?fields=total_nonsense` then
returned `200 {}`.

The contract has always promised 400 here. `fields` is on all twelve
GET operations and is the parameter a generated client uses most. A
typo, or a field a later change renames, returned an empty payload with
nothing to say why.

Nested branches inherit the check through the recursive prune.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The "declared, not generated" block still named
`overall_component__is_tracked`, which belonged to the service list this
branch removed. It counted three declared parameters where there are
eleven. It told a client to read a field's path in the response body to
build a filter, which is now false for the flagship endpoints.

`service` takes a slug where the body nests a `ServiceRef`. `component`
and `ancestor` name nothing in any body. `phase` takes `open` where the
body carries `investigating`.

Four smaller disagreements go with it. The `catalog` tag still claimed
incidents, which moved to `status`. `StatusAggregates` still described a
service list. `upcoming_maintenance_count` pointed at a call returning
every window ever, missing `phase=open`. `POST /catalog/requests/`
denied the optional auth its own test asserts.

The `fields` parameter now says a dotted path reaches into `enums` on
`/meta/`. That behaviour exists because a user reported the parameter
doing nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`just check` runs them. The workflow enumerates its own steps and had no
equivalent, so the tests moved out of the API suite ran only on a
laptop. CI is what a merge waits on, so they gated nothing.

They are stdlib only, so they run on the runner beside the prose check
rather than paying for a container.

A new bin test holds the two lists together. It reads the `check` recipe
and the workflow, and fails when one gains a check the other does not
have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`?q=` is Discover's default path and the most used list in the app.
`rank` is a float annotation with heavy ties, because identical
documents rank identically. A cursor over a non-unique key repeats or
skips a row, and infinite scroll shows it to a reader who never files
it. Nothing covered that path.

Two settings go with it. `SEARCH_PARAM` named the parameter for DRF's
`SearchFilter`, which no view uses any more: `ComponentListView` reads
`q` by hand. The setting was a trap, because changing it moved nothing.

`track()` minted a watcher address from a counter that resets per test,
where `UserFactory` holds one that does not. Email is unique, so the
first test to call both would raise far from the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
A provider drops a component and the row is archived, not deleted.
Three readers then disagreed about it. `Service.component_count` left it
out. `descendant_count`, the component collection, the component detail
and the board all returned it.

So a service page showed a badge counting one set of components and a
list under it showing another. The spec says of those two screens that
they cannot count differently.

Archived components are now left out of every list, every count and the
detail, which answers 404. The rule is `ServiceComponentQuerySet.visible`
and every reader calls it. Restating the filter per reader is how two
answers to one question drift apart.

It is not part of `for_display`. That queryset also prefetches a
service's rollup, and a service whose rollup is archived still needs a
status in its header. That stays the one place an archived component
reaches a caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`Service.tracked_component_count` counted archived rows, in the
annotation and in the fallback. A user whose only tracked component was
archived saw a count of one, read that as "this service is on my board",
and then got no such row from the board list.

`POST /dashboards/{uuid}/components/` looked the component up unfiltered.
`Service.overall_component` is the one place an archived component still
reaches a client, so the call was reachable without a stale id. It made a
board row that no list renders and nothing can offer to untrack. It
answers 404 now, the same as the component detail answers for that id.

The contract also claimed nothing points at a row no list holds.
`Component.path` and `Component.parent` do. The sentence was the defect,
not the behaviour, so it now says what a client should draw instead. The
board POST documented a 409 the code has never returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The contract promises `400` for a path naming an unknown field, because
a silently empty payload is the failure a user reported on `/meta/`.
`FieldsMixin._prune` raises, and two readers went around it.

`enums` is a plain dict, so `/meta/` filters it by hand and had no
unknown-name check. `?fields=enums.nonsense` answered `200 {"enums":
{}}`, the exact endpoint and failure class the rule exists for.

`Component.path` is a method field, so the recursion could not reach it.
A dotted path into a step was neither pruned nor refused. The step
serializer now prunes like every other nested one.

The refusal itself moves into `FieldsMixin.reject_unknown`, so a reader
that prunes by hand asks the same rule rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`rebuild_ancestry` and `rebuild_search` ran from the admin and from a
poll. Every other writer left the columns stale: a shell session, a data
migration, a later API write. The symptom is silence. The component is
absent from `?ancestor=`, from `?q=` and from every `descendant_count`,
and an untracked service is never polled to repair it.

`save` now rebuilds when the row is created, or when its name, parent or
service changed. Reconcile holds that back through `held_rebuilds` and
keeps its single rebuild at the end. Without the hold, a poll of a
hundred components would rebuild a hundred rows a hundred times.

The admin also rebuilt only the component's new service, so moving one
between services left the old service naming it. The hook rebuilds both,
and the ancestry walk now stops at a parent outside the service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Featuring was restricted to a service's overall component. The bulk
actions filtered on `is_overall` and reported a skip, and the field was
absent from the change form. `suggested` reads the flag on every
component, because Discover lists every component. Featuring one part of
a service is an editorial choice, not an accident to guard against.

The change page now carries the flag, and neither bulk action refuses a
leaf. The service page no longer offers featuring at all: one flag with
three writers and one restriction between them was the reason the
restriction had to exist. Featuring a service means ticking its overall
component, which the component changelist already filters for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
thalida and others added 28 commits September 4, 2026 16:02
Featured is a thing an admin sets on a component. Archiving is a state
that polling reports. They should not sit together on the form; the
featured flag belongs with basic properties, and archive state belongs
in its own section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`?fields=` refused an unknown name and `?ordering=` dropped it, then
sorted by the default. A caller read a list in an order it did not ask
for, with nothing in the response to say so.

`-suggested` was worse. `suggested` is an editorial order that names its
own directions, so the prefix built `--is_featured` and the ORM raised
from there. A hand written caller reached a 500.

Both refusals live in the one ordering backend, and the contract carries
the 400 on every list that takes the parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`dashboard` was owned twice. The view read the raw parameter, parsed the
UUID by hand and checked the owner, while the filter did the narrowing.
Two readers of one parameter drift.

The check moves into `filter_dashboard`. django-filter validates the
value before calling it, so the hand parse and its try/except go. A
malformed id still answers 400, a stranger's board still answers 404,
and an anonymous caller still answers 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The view read `q` out of the request and applied the search outside the
filter set. `q` is a documented parameter sitting next to `service`,
`ancestor` and `event`, which are all declared.

A check belongs in the mechanism that already does that job. Declaring
it puts every narrowing of the component list in one place, and the
generated schema now carries the parameter the contract always named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
A person types the name of the group a part sits under. Searching
`messaging` found Programmable Messaging and not the SMS row inside it,
which is the row they were looking for.

`parent__name` is a join read at query time, against the live row. The
weighted vector this replaces stored ancestor names, so every rename
rewrote the subtree below it. That is the cost the change does not carry
back.

One level only, because two would need the ancestry stored again. The
docstring and the spec both say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`filter_phase` named `CLOSED` and fell through to a trailing exclude.
That read as a fallback for the no-value case, so a reader asked whether
an absent `phase` means open only. It does not: django-filter skips a
method for an empty value, and everything comes back.

Both branches now name the choice they serve, and the docstring says
what an absent parameter does. Nothing asserted the no-parameter case,
so a change that made open the default would have been silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`initial` sets `ordering` when `q` or `service` is present, and the
comments explained the mechanism without naming these as defaults. A
reader took it twice as forcing a caller's sort. It does not: DRF reads
`?ordering=` afterwards and it wins.

A test now holds that. Losing it would leave the sort control on the
screen doing nothing, and nothing would say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
just dev ran the server and poller but never started Postgres or Redis
and never migrated, so a fresh worktree failed unexplained. It now
depends on up and migrate. check-pages is renamed to probe-pages,
since sharing a prefix with the merge gate (check) was misleading.
down and teardown, which overlapped, merge into one clean recipe.
test-cov is gone: check already owns the coverage gate, and test now
takes *args, so the only reason test-cov existed (test couldn't be
filtered) no longer holds. test-bin folds into test, and env, sync,
seed and image (steps of init and check, never typed on their own)
are marked [private]: still runnable, hidden from --list. Recipes are
grouped (Daily, Lifecycle, Data, Release) so `just --list` reads as a
short, obvious set instead of an alphabetical wall of 23 names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The suite had one rule: every behaviour change carries a test. Nothing
said what a test should assert, so tests grew that pin arrangement. One
pinned four admin fieldsets in an exact order and ended on an assertion
that no input could fail. Moving two lines then cost a test edit, and it
had never caught a bug.

Eight principles now say what earns a place: behaviour over arrangement,
no restated implementation, no test-only production code, and every
assertion able to fail.

Also repoints two comments at recipes that still exist, after the just
recipes were renamed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Both production callers already passed one. The default reached only
tests, which then wrote status and event rows with no run. That is a
shape production never creates, so the run-stamping went untested.

It also left the cross-service guard half dead. Its skip path ran only
under tests, so a real run for the wrong service was checked by nothing
the scheduler runs.

The tests now build a real run through `tests.factories.poll_run`. One
test moved off the removed default: a reading still outlives the run
that stamped it, because the column is SET_NULL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The jitter bound was built from the constant the code uses. A wrong
JITTER moved both, and the test still passed. It is now a tenth either
side, written out.

The one-poller test made the poller itself, through a factory that gets
or creates. The count then held with the signal that makes it deleted.
It also promised two boards with one watcher. It now tracks the service
from two boards, and asks only that `due()` returns the poller once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
It pinned the exact subject line beside the newline check. A copy edit
then failed a test about header injection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The rewrite of the hand-seeded test landed beside a test in test_polling
that already proved the same SET_NULL behaviour. Two tests answering one
question drift apart.

The kept one prunes through `forget_old_poll_runs`, which is what deletes
a run in production. The removed one called `delete()` directly, which
nothing does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
A test that pins a section title, a card label or an Unfold DOM id
fails on a cosmetic edit and catches no defect. Ten such tests went.
Some restated a framework guarantee: Django's own `admin.E116` already
proves every `list_filter` path resolves, and the stdlib owns the sort
order of a version 7 key.

The rest kept the assertion that can fail and dropped the one that
cannot. A reading now asserts the poll run's own link, not a field
label. The table search proves a service's name reaches the rows under
it, which is what its comment always claimed.

This stops a rename or an Unfold upgrade from costing a test edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`display_open` sat in no `list_display`, no fieldset and no template.
Grep across the repository found one reference, its own definition.
A path no caller reaches is a path a reader still has to understand.

Removing it orphaned the `CLOSED_PHASES` import here, so that goes too.
The set keeps its real readers in `status/models.py` and
`status/filters.py`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The helper answered with the app's own expression, so nine assertions
across two files asserted that it equalled itself. Dropping `distinct`
from the count left every one of them green, while one person with two
boards outranked a crowd.

The helper now walks the tree in Python and compares. The expression
under test is no longer the source of the answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The same defect was found twice by hand: a column on a changelist with
no counterpart on the change view. Opening a record answered less than
the list did. Service.source recorded how a service arrived and reached
no form at all.

Four rules say what an admin owes a reader, and a fifth says what not to
do about it. Renaming a label so two views agree is churn that costs
everyone the name they had learned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The rule landed backwards. It froze every name, including a column
header that named nothing. `State` sat over `is_archived`, so a reader
who went looking for a field called state found none.

A field name is what the model, the API and every filter already say. A
header is read in one place. The header is the one that gives way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The State column was a method that rebuilt what Django already does for
a boolean: the tick, the header from the model, the sort. It got the
header wrong, and a reader went looking for a field named state.

This is the general rule the Engineering section already states, said
once more where the admin work happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Four commits in a row appended a rule each, and two pairs said the same
thing twice. They are now the three the human asked for, plus the filter
rule: every field reaches the change view, a header names its field, a
column that only redraws a field is the field, and a column is
filterable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The label is a column header and a value in a list of choices. "Added
by hand" is a sentence about a row, not the name of a source. The
value string does not move, so no reader of `source` changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`source` was a column and no field. Opening the record lost the answer
the list gave. It is written by the importer and by the add form, so it
is shown and never typed. The filter stays: a filter may narrow by
something no column shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Every column now has a place on the form, and a filter beside it. The
watcher count, the poller's health, the board's default flag and a
link's state were on a list and on no form. The service request kept
no audit trail at all.

A column that only redrew one field is gone. Django draws a boolean, a
choice label and the header from the model. The rebuilt ones invented a
header that named no field, and a reader went looking for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
The same defect was found by hand twice. A column on a list had no
counterpart on the record, so opening it answered less than the list.
The pairing is read off the registry, because a list of known gaps
goes stale.

It reports genuine absence only. A counterpart under another name
still counts, and a test that pinned the words would fail on a
harmless edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`display_ok` redrew one boolean as OK or Failed, under a header named
Result. Django draws the tick, takes the header from the model and
sorts the column. The hand-rolled version named no field, which is how
a reader ends up hunting for one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
A provider going down means pausing several pollers at once. That meant
opening each one, and the pause was already a column and a filter.

One Save now covers any mix of pausing and resuming, the same way the
component list flips the featured flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
Which board is the default is a pointer on the user, not a field on the
board, so it cannot be a checkbox in the list. Nobody but an admin makes
a board either, so the column narrowed nothing worth a column.

It stays on the change view, and the filter stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
`just` prints the comment above a recipe beside its name. Three of them
ran past the width of a terminal and wrapped, so the list was harder to
read than no list.

Each description is now a phrase. What a recipe explains beyond that
sits above it, past a blank line, where `just --list` does not reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi
@thalida
thalida merged commit 62e933e into main Sep 5, 2026
1 check passed
@thalida
thalida deleted the client branch September 5, 2026 01:17
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