Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
df41f88
docs: the client design, and the API the approved decks need
thalida Sep 3, 2026
661139e
docs: the plan for the API the client needs
thalida Sep 3, 2026
695dc91
docs: one account of the API, not two
thalida Sep 3, 2026
0c62ffc
feat: a component is featured and counts its own watchers
thalida Sep 3, 2026
ff818ef
feat: a component stores the chain above it
thalida Sep 3, 2026
13b5238
docs: plans sit beside specs, not under a tool's name
thalida Sep 3, 2026
33dff5d
feat: a search matches a component's whole path
thalida Sep 3, 2026
61e17d7
feat: one component collection, four screens
thalida Sep 3, 2026
3fa9a61
feat: an event records who found it
thalida Sep 3, 2026
db86420
fix: /meta/ honours ?fields=, including dotted paths into enums
thalida Sep 3, 2026
98efdfe
fix: a code span counts as one word in the prose checker
thalida Sep 3, 2026
0626324
feat: a status change with no provider post becomes an event
thalida Sep 3, 2026
78c3bc9
feat: a provider's post joins the event we already opened
thalida Sep 3, 2026
68a481a
feat: one event feed for Home, a service and a component
thalida Sep 4, 2026
2051da2
feat: an event, and its log as its own list
thalida Sep 4, 2026
4c29d8b
feat: a dead end can hand us the address
thalida Sep 4, 2026
8c2870c
refactor: delete what nothing reads any more
thalida Sep 4, 2026
9dd2934
feat: admin features a service from the service page
thalida Sep 4, 2026
6842cc3
test: move the prose-checker test beside the script it tests
thalida Sep 4, 2026
ff00a44
feat: the event admin says who found an outage and who wrote an update
thalida Sep 4, 2026
94334cb
fix: a component saved in the admin rebuilds its derived columns
thalida Sep 4, 2026
c3797c1
fix: a ?fields= path that names no field answers 400
thalida Sep 4, 2026
2f9f74a
docs: the contract describes the API the branch left behind
thalida Sep 4, 2026
9b9d7c2
ci: the merge gate runs the bin/ tests
thalida Sep 4, 2026
0022eca
test: a ranked search pages without repeating a row
thalida Sep 4, 2026
ae9e90f
fix: an archived component is not served anywhere
thalida Sep 4, 2026
c5f5602
fix: archived components leave every count and refuse a track
thalida Sep 4, 2026
4b6abfd
fix: a dotted path is refused wherever it names nothing
thalida Sep 4, 2026
5befea9
fix: the derived columns are rebuilt by the model, not by the admin
thalida Sep 4, 2026
b1339ce
feat: any component can be featured, from its own change page
thalida Sep 4, 2026
447fece
docs: four corrections, and a test for the archived rollup carve-out
thalida Sep 4, 2026
c352304
fix: check_prose resolves its default scan target from __file__
thalida Sep 4, 2026
7ae0210
feat: ancestry is a table of links, not an array of ids
thalida Sep 4, 2026
9eee63e
docs: the specs describe the ancestry table, not the array
thalida Sep 4, 2026
7408696
fix: deleting a component rebuilds its service
thalida Sep 4, 2026
65d5d00
refactor: the display annotations carry their plain names
thalida Sep 4, 2026
f2d9f28
refactor: the descendant count expression lives with the queries
thalida Sep 4, 2026
63a9a1a
refactor: catalog serves its views from a package
thalida Sep 4, 2026
4dec33b
docs: AGENTS.md records where code lives and which docs move
thalida Sep 4, 2026
6be5a15
refactor: the phase filter's two states are a choices class
thalida Sep 4, 2026
490d339
fix: a multi-select delete in the admin rebuilds the ancestry
thalida Sep 4, 2026
3916a15
fix: the admin counts a service's components the way the API does
thalida Sep 4, 2026
fd33a35
test: the contract and the code agree on response codes
thalida Sep 4, 2026
c38047e
fix: a component's path names only ancestors that are served
thalida Sep 4, 2026
036bf9b
test: every reader that serves components excludes the archived ones
thalida Sep 4, 2026
a3bfc80
test: a column declaring two defaults declares one value
thalida Sep 4, 2026
0412aed
docs: featuring is on any component, and only on the component admin
thalida Sep 4, 2026
eb3d422
fix: the ancestry backfill recomputes from parent
thalida Sep 4, 2026
42521f6
fix: a component's path stops at its service
thalida Sep 4, 2026
9ce7c3a
fix: a related count counts each row once
thalida Sep 4, 2026
ad1953a
fix: the audit log drops the search vector
thalida Sep 4, 2026
d872be5
docs: the layout rules describe the code as it is
thalida Sep 4, 2026
67445ba
docs: the rollup's zero descendant count is counted, not promised
thalida Sep 4, 2026
d2a09e0
docs: two test comments describe the suite as it stands
thalida Sep 4, 2026
5d2f946
fix: a database trigger refuses a component parented under the rollup
thalida Sep 4, 2026
50c37fb
refactor: a query answers ancestry and search, no stored copy
thalida Sep 4, 2026
22939c9
docs: the specs argue for computed ancestry and substring search
thalida Sep 4, 2026
6c3fe1b
refactor: drop the is_featured fieldset description
thalida Sep 4, 2026
97222e5
refactor: replace the two feature actions with list_editable
thalida Sep 4, 2026
d91aa50
fix: link service requests from the admin sidebar
thalida Sep 4, 2026
072501b
fix: an import records the person who asked for it
thalida Sep 4, 2026
905db58
feat: a service records how it reached the catalog
thalida Sep 4, 2026
451dc8d
fix: service requests join the catalog tabs, not the sidebar
thalida Sep 4, 2026
4ff22a4
fix: give magic link tokens a tab beside their user
thalida Sep 4, 2026
990ca6c
fix: rename the catalog sidebar entry from Services to Catalog
thalida Sep 4, 2026
fbc9981
test: derive admin reachability from the registry, not a hand list
thalida Sep 4, 2026
9c0892e
admin: move featured flag and archive fields in component form
thalida Sep 4, 2026
ca3952a
fix: an ordering value the list does not declare answers 400
thalida Sep 4, 2026
426735b
refactor: the dashboard filter decides who owns the board
thalida Sep 4, 2026
f8bbeca
refactor: q is a declared filter beside service and ancestor
thalida Sep 4, 2026
8582b11
feat: search matches the parent's name as well
thalida Sep 4, 2026
2113b5f
fix: the phase filter reads as the two choices it has
thalida Sep 4, 2026
59b4aa9
docs: say that the search sort is a default, not an override
thalida Sep 4, 2026
2a4bfb2
chore: standardize the just recipes so dev starts what it needs
thalida Sep 4, 2026
f183522
docs: what a test has to earn to stay in the suite
thalida Sep 4, 2026
3d1e8d0
refactor: apply_fetch requires the run that produced the data
thalida Sep 4, 2026
aa4a69a
test: the poll schedule is checked against a written-out window
thalida Sep 4, 2026
1396a0a
test: the subject test asks about the header, not the copy
thalida Sep 4, 2026
7a851a6
test: one test for a reading outliving its run, not two
thalida Sep 4, 2026
90b785e
test: assertions pin behaviour, not the markup around it
thalida Sep 4, 2026
57beada
refactor: the event admin drops a column nothing shows
thalida Sep 4, 2026
deb549e
test: the watcher count is checked against a second count
thalida Sep 4, 2026
4d692ff
docs: the change view holds everything the list holds
thalida Sep 4, 2026
6b6880d
docs: a column header says the name of the field under it
thalida Sep 4, 2026
3b2cb3a
docs: a column that only redraws a field is the field
thalida Sep 4, 2026
b63f428
docs: four admin rules, not seven overlapping ones
thalida Sep 4, 2026
5675fb8
fix: the manual source reads Manual
thalida Sep 4, 2026
21b5cc9
fix: a service says on its own page how it arrived
thalida Sep 5, 2026
709e023
fix: a change view holds everything its list holds
thalida Sep 5, 2026
5a790be
test: every column answers on the change view
thalida Sep 5, 2026
76171d0
refactor: the poll result column is the field
thalida Sep 5, 2026
a2132ac
feat: pausing a poller is a checkbox in the list
thalida Sep 5, 2026
6f803ad
refactor: the board list drops the default column
thalida Sep 5, 2026
c0012ba
docs: a recipe description fits on one line
thalida Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ jobs:
- name: Prose
run: python3 bin/check_prose.py

# Stdlib only as well. These cover the scripts in bin/, which have
# no dependency manager and no Django to test them with.
- name: The bin/ scripts
run: python3 -m unittest discover -s bin/tests

- name: No migration is missing
run: |
docker compose -f docker-compose.test.yml run --rm --entrypoint sh pytest \
Expand Down
7 changes: 3 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ repos:
rev: v0.42.0
hooks:
- id: markdownlint
# Two approved documents have never passed this hook: the design
# spec and the plan it was built from. Reflowing either would be
# a huge diff on prose nobody asked to rewrite.
exclude: ^docs/(specs|superpowers)/
# Specs and plans have never passed this hook. Reflowing prose
# nobody asked to rewrite would be a huge diff.
exclude: ^docs/(specs|plans)/
- repo: https://github.com/astral-sh/ruff-pre-commit
# Must match the ruff in api/uv.lock. On different versions the hook
# and `just lint` format differently and undo each other, so a commit
Expand Down
90 changes: 87 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Comments, docstrings, commit messages and documentation follow ASD-STE100.

- One idea per sentence. Under 20 words.
- One idea per sentence. 20 words or fewer.
- Active voice. Present tense.
- No em dashes. A qualifier goes in brackets: `GitHub (2026-08-30 15:32)`.
- A standalone `—` is allowed as an empty-cell marker. It is a placeholder,
Expand All @@ -27,6 +27,9 @@ Delete a comment when the reason it recorded stops being true.
- No sample output. It goes stale and nobody re-runs it.
- No section nobody asked for.
- A README describes the repository as it is now, not as it changed.
- A spec describes the system, so it moves with the code. A plan
records what somebody intended, so it stays as written. That includes
the places where the plan turned out to be wrong.

## Engineering

Expand Down Expand Up @@ -54,6 +57,28 @@ edit that worked. Open the page. Read the value back.
template that overrides it. An override patches one instance of a
problem; the setting fixes the class.

## Layout

Which file a piece of code goes in, by what it is.

- An app keeps one `views.py` until it serves more than one resource.
Then it becomes `views/`, one module per resource or per long-lived
operation. `catalog` serves three resources, and `imports.py` is an
operation on `Service`. A sub-resource stays with its parent, so an
event's update log is not a second module.
- A **queryset method** is chainable vocabulary. It lives on the
queryset in `models.py`, because a caller chains it onto a query.
- A **bare expression** is handed to `annotate()`. It lives in
`queries.py`, because nothing chains it.
- `queries.py` is row-scoped and `aggregates.py` is collection-scoped.
One lands in a serialized row. The other lands in the `aggregates`
key beside `results`.
- `common` holds no expression that describes an app. `common/ordering.py`
records this as a scar. The domain subqueries lived there, and the base
layer imported the app it describes. Naming an app's choices to publish
them is another thing, and it is fine: `/meta/` lists every enum, and
one place has to.

## Settings

The split is by who named the setting.
Expand Down Expand Up @@ -91,11 +116,45 @@ Every behaviour change carries a test. The suite runs with no network:
adapters are tested against recorded fixtures.

A test's comment says what would break if the assertion failed.
If the answer is "nothing", the test does not earn its place.

**Assert on behaviour, not arrangement.** Field order, section titles and
`list_display` contents change for cosmetic reasons. A test that pins
them fails on a harmless edit and catches no defect. One pinned the four
component fieldsets in order. Moving two lines then cost a test edit, and
it had never caught a bug.

**A test fails only when something is broken.** A reasonable refactor
breaks a wrong test and nothing else.

**Never restate the implementation.** A test that computes its expected
value the way the code does passes while both are wrong. Write the
expected value out.

**No test-only production code.** No argument, method, flag or branch
exists so that a test can reach something. A path no caller reaches is a
path the test should not reach either.

**Enter through the front door.** Use the API client, the admin client or
the management command. Call an internal directly only when no pathway
reaches it.

**Every assertion can fail.** Watch it fail once, before the code exists.
`assert fieldsets[3] is not None` held on every possible input.

**Test what this project decided.** Django and DRF are already tested.
That a `CharField` stores a string is theirs. That a poller claims an
incident inside the window is ours.

**In the admin, test the logic, not the form.** Worth pinning: a
read-only field the model maintains, a queryset the page filters, what
`list_editable` writes, which user `created_by` records. Not which
fields appear, or in what order.

```sh
just test # the suite
just test-cov # with the coverage gate
just test # the suite, host and bin/ scripts together
just lint # ruff check and format
just check # every check CI runs, coverage gate included
```

## Database
Expand All @@ -117,6 +176,31 @@ does not help answer it does not belong there.
One theme, dark. `UNFOLD["THEME"]` forces it, so a class with no `dark:`
variant still lands on the right ground.

**The change view holds every field.** A model field missing from every
fieldset is invisible and unreachable, and a changelist column with no
place on the form makes opening a row answer less than the list did.
`Service.source` recorded how a service arrived and reached no form at
all. A value computed from related rows lands as a readonly field, not
nowhere. So does provenance: a field recording how a row arrived is
shown and never typed, because an editable one can be made to lie.

**A column header is the name of its field.** The list and the form show
the same data, so they use the same word. A column headed `State` over
`is_archived` sent a reader looking for a field of that name. When the
two disagree the header gives way: the field name is what the model, the
API, the filters and every query already say.

**A column that only redraws a field is the field.** Put the field name
in `list_display`. Django draws a boolean as a tick, takes the header
from the model and sorts the column, none of it asked for. Hand-rolling
those three is where the header `State` came from. Keep a method only
where it does what a field cannot: composing several, following a
relation, or building a link.

**A column the list shows, the list filters.** If it is worth a column,
it is worth narrowing by. The converse does not hold: a filter may
narrow by something no column shows.

## Commits

A lowercase conventional prefix, then a declarative clause.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ Each worktree gets its own ports and database.
| | |
| --- | --- |
| `just init` | services, dependencies, migrations, admin, hooks |
| `just dev` | server and poller together |
| `just dev` | postgres, redis, migrations, then the server and poller |
| `just test` | the suite, on the host |
| `just check` | everything CI runs, in the image CI runs it in |
| `just lint` | fix and format |
| `just info` | this worktree's ports and database |
| `just reset` | drop the database and build it again |
| `just check-pages` | probe every recorded status page |
| `just clean` | stop this checkout's containers |
| `just probe-pages` | probe every recorded status page |
| `just release v0.1.0` | tag, build, sign, deploy |
| `just deploy` | redeploy the current image |

Expand Down
23 changes: 17 additions & 6 deletions api/api/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,27 @@ def parse(cls, value):
) from error


# Which deployment this is. We named it, so it is resolved here, and
# `debug` and `secret_key` in `settings.py` read it rather than being
# handed it.
# Which deployment this is. We named it, so `debug` and `secret_key`
# in `settings.py` read it here rather than being handed it.
ENVIRONMENT = Environment.parse(os.environ.get("ENVIRONMENT"))


class Throttle(StrEnum):
"""A rate this deployment enforces.

A view names its scope and `settings.py` gives it a rate, so the
string was written twice and had to match. DRF names the first two;
we named the rest.
A view names its scope and `settings.py` gives it a rate. The two
spellings had to match. DRF names the first two; we named the
rest.
"""

ANONYMOUS = "anon"
SIGNED_IN = "user"
IMPORT = "import"
MAGIC_LINK = "magic-link"
# Its own bucket, not IMPORT's. Add-by-URL tries an import, and on
# a miss sends the same URL here. One scope would let the first
# call spend the second's budget.
SERVICE_REQUEST = "service-request"


# Reading the catalog is the point, so the plain rates are wide. Each of
Expand All @@ -97,6 +100,7 @@ class Throttle(StrEnum):
Throttle.SIGNED_IN: "600/min",
Throttle.IMPORT: "6/min",
Throttle.MAGIC_LINK: "5/hour",
Throttle.SERVICE_REQUEST: "6/min",
}


Expand All @@ -120,6 +124,13 @@ class Throttle(StrEnum):
# Long enough to read a week of failures, and no longer.
POLL_RUN_RETENTION_DAYS = 30

# How far back a provider's event may start and still claim one we
# opened. Providers back-date `starts_at` to when an incident really
# began, which is before our poll saw it.
EVENT_CLAIM_WINDOW = timedelta(
seconds=int(os.environ.get("EVENT_CLAIM_WINDOW_SECONDS", "3600"))
)

# Who the system writes as. RFC 2606 reserves the domain. No mail leaves
# for it, and nobody can hold the address.
SYSTEM_EMAIL = "system@statusboard.invalid"
Expand Down
45 changes: 39 additions & 6 deletions api/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
APP_URL,
DEFAULT_PAGE_SIZE,
ENVIRONMENT,
EVENT_CLAIM_WINDOW,
MAGIC_LINK_TTL,
MAX_PAGE_SIZE,
POLL_COOLDOWN_SECONDS,
Expand Down Expand Up @@ -146,9 +147,6 @@
"rest_framework.filters.OrderingFilter",
"common.filters.FieldsBackend",
],
# The contract calls it `q`, and DRF's SearchFilter calls it
# `search`. One name, and the schema documents it.
"SEARCH_PARAM": "q",
# The contract has always answered 429, and nothing raised one. See
# `Throttle` for what each rate is protecting.
"DEFAULT_THROTTLE_CLASSES": [
Expand Down Expand Up @@ -279,15 +277,18 @@
"COMMAND": {"search_models": True, "show_history": True},
# The sidebar is two levels deep. It cannot nest an item under an
# item, so tabs carry the relationship. A component belongs to a
# service, and a status history to a component. All four are one
# page. The sidebar entry stays active on any of its tabs.
# service, and a status history to a component. A service request
# names something the catalog lacks, so it lives beside the catalog
# too. All five are one page. The sidebar entry stays active on any
# of its tabs.
"TABS": [
{
"models": [
"catalog.service",
"catalog.servicecomponent",
"status.serviceevent",
"status.componentstatus",
"catalog.servicerequest",
],
"items": [
{
Expand All @@ -306,6 +307,10 @@
"title": _("Status history"),
"link": reverse_lazy("admin:status_componentstatus_changelist"),
},
{
"title": _("Service requests"),
"link": reverse_lazy("admin:catalog_servicerequest_changelist"),
},
],
},
{
Expand Down Expand Up @@ -333,6 +338,26 @@
},
],
},
{
# A magic link belongs to a user. It has no reason to be
# findable on its own.
"models": [
"authentication.user",
"authentication.magiclinktoken",
],
"items": [
{
"title": _("Users"),
"link": reverse_lazy("admin:authentication_user_changelist"),
},
{
"title": _("Magic links"),
"link": reverse_lazy(
"admin:authentication_magiclinktoken_changelist"
),
},
],
},
],
"SIDEBAR": {
"show_search": True,
Expand All @@ -350,7 +375,9 @@
"link": reverse_lazy("admin:index"),
},
{
"title": _("Services"),
# Five models across catalog and status answer
# here now, not only the services themselves.
"title": _("Catalog"),
"icon": "lan",
"link": reverse_lazy("admin:catalog_service_changelist"),
},
Expand Down Expand Up @@ -380,11 +407,17 @@
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
# EventUpdate.source and ServiceEvent.detected_by share one choice
# set. Without this, the generator names the same enum twice.
"ENUM_NAME_OVERRIDES": {
"EventSourceEnum": "status.choices.EventSource",
},
"TAGS": [
{"name": "meta"},
{"name": "auth"},
{"name": "me"},
{"name": "catalog"},
{"name": "status"},
{"name": "board"},
],
}
Expand Down
1 change: 1 addition & 0 deletions api/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@
path("", include("authentication.urls")),
path("", include("catalog.urls")),
path("", include("dashboards.urls")),
path("", include("status.urls")),
]
Loading