Repository navigation
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdPlan:
docs/plans/2026-09-03-statusboard-api.md, left as written — a plan records what wasintended, including where it turned out to be wrong.
What changes
Every trackable thing is a
ServiceComponent. One component collection replaces the servicelist 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
ServiceEventof its own, and aprovider's later post claims it rather than making a second card.
detected_byrecords whofound the outage first, because a claim fills in the provider's id and would otherwise destroy
that fact.
Ancestry is a closure table.
ComponentAncestorholds one row per pair of components, oneabove 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.ServiceRequestgives the Add-by-URL dead end somewhere to send a URL, featuringmoved 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 neverinstantiated its serializer. Dotted paths now reach into
enums.?fields=naming an unknown field returned200 {}while the contract promised400.409nothing returned and a502that was hidden. A test now compares the two.bin/check_prose.pycounted a multi-word code span as two words, and reported success whenrun from a directory where it scanned nothing.
Verification
just checkgreen 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, andmakemigrations --check.api/tests/test_contract.pyholds the code anddocs/api/openapi.yamltogether in bothdirections, 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_SECONDSneeds adding to the deployment repository's.env.example,default
3600. That repository is not in this checkout.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: archivingwrites the closing "no longer published" update, so hiding the event would hide an outage's
ending.
its own annotation does not declare.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YTrc91QNqcM31iyvkeijEi