Skip to content

Commit 3323b97

Browse files
committed
feat(ui): shutdown planning page (ADR 0019)
Turns a library runbook into a planned outage for a real project, system and window -- the office work that happens days before anyone stands in a trench. Execution is task 130 and is deliberately a separate view: the two happen at different times, by different people, and only one of them is done wearing gloves at 2 AM. This page requires a sign-in and NEVER writes a shutdown_step_events record; if it could, that split would already have collapsed. Instantiating copies the template's steps into shutdown_steps, which is what makes a plan job-specific and editable without touching the library. Order matters in that copy: shutdown_steps.responsible_role is a relation while the template names roles by text key, so the project's shutdown_roles are created from shutdown_template_roles FIRST and the steps bound to them. Copying steps first would silently lose which role each wanted -- the instance side has no key to fall back on. Titles come from the library; the planner fills in contact name and phone, which is the entire point of the registry, flagged by a banner until they do. The schedule is derived at render from planned_window_start + planned_offset_min and never stored: moving the window moves every window step with it and writes no step row at all (asserted, not assumed). Both offset forms stay separated -- minutes for window steps, labels for pre/post -- and neither is ever synthesized from the other. Reordering swaps seq through a parked value, because the unique (shutdown, seq) index makes a direct two-row swap collide. No status, progress or current_step column anywhere. Migration U adds the schema's first and only non-admin delete rule: shutdown_steps.deleteRule = @request.auth.id != "" && shutdown_step_events_via_step.id = "" Removing a step is required by the task ("a real tie-in is never exactly the generic runbook") but every collection shipped deleteRule null. The line this draws is draft vs. record. A shutdown step is the plan someone is still writing, already freely editable for that reason; the evidentiary weight lives in shutdown_step_events, which stays append-only and superuser-only-delete. The back-relation guard means a step stops being deletable the instant its first event lands, so the draft-to-record transition derives itself with no status column. `shutdowns` is excluded: deleting one would cascade its steps and orphan every event for that night at once. docs/API.md gains all eight shutdown collections and its count is corrected from twenty-four to thirty-two -- it was missed when the module landed because it spells the number out. smoke test 122 -> 124 assertions, green; browser suite 7/7.
1 parent 8e05573 commit 3323b97

8 files changed

Lines changed: 837 additions & 12 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,26 @@ re-imports are safe.
486486
A pure presentation join: no schema, every cell derived at render from
487487
records other pages already write. This is the free deliverable; the
488488
compiled, hyperlinked PDF binder is the paid sidecar (ADR 0003).
489+
- **Shutdown planning (`shutdown_plan.html`, ADR 0019)** turns a library
490+
runbook into a planned outage. Two modes off the query string: `?project=`
491+
lists a job's shutdowns and creates one; `?shutdown=` is the editable plan.
492+
Instantiating **copies** the template's steps into `shutdown_steps`, which is
493+
what makes the plan job-specific and editable without touching the library.
494+
**Order matters in that copy**: `shutdown_steps.responsible_role` is a
495+
relation while the template names roles by text key, so the project's
496+
`shutdown_roles` are created from `shutdown_template_roles` *first* and the
497+
steps bound to them — copying steps first would silently lose which role each
498+
wanted, since the instance side has no key to fall back on. The planner then
499+
fills in contact name and phone, which is the whole point of the registry.
500+
The **schedule is derived at render** from `planned_window_start` +
501+
`planned_offset_min` and never stored: move the window and every window step
502+
moves with it with no other write. Reordering swaps `seq` through a parked
503+
value, because the unique `(shutdown, seq)` index makes a direct two-row swap
504+
collide. Removal is refused server-side once a step has events (migration U).
505+
The whole page requires auth — the deliberate opposite of the accountless
506+
execution view — and it **never writes a `shutdown_step_events` record**: if
507+
the planning page could log a step, the planning/execution split would
508+
already have collapsed. Hold points are marked and gate nothing.
489509
- **Punch list (`punchlist.html`)** answers the second of the three questions
490510
as a listing rather than a counter. `system.html` and `project.html` show
491511
open A/B/C **counts**; this is the report behind them — every item grouped

‎docs/API.md‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ REST behavior is a breaking change to this contract.
3030
3131
## Contract collections
3232

33-
The committed schema has twenty-four application collections. The original
33+
The committed schema has thirty-two application collections. The original
3434
sixteen are the published v1 set; rows marked **delta** are implemented but
3535
await a contract decision. `public` means no user token, `auth` means an
3636
ordinary authenticated user, and `admin-only` means no ordinary record-rule
@@ -62,14 +62,33 @@ operation. Shape-specific create rules still apply in addition to auth.
6262
| `service_visits` | **delta; ADR 0017** | public | public, tag XOR system + same-project rule | **never** | **never** |
6363
| `turnover_packages` | **delta; ADR 0010** | public | build route only (createRule `null`) | **never** | **never** |
6464
| `signatures` | **delta; ADR 0010** | public | auth | **never** | **never** |
65+
| `shutdown_templates` | **delta; ADR 0019** | public | auth | auth | admin-only |
66+
| `shutdown_template_steps` | **delta; ADR 0019** | public | auth | auth | admin-only |
67+
| `shutdown_template_roles` | **delta; ADR 0019** | public | auth | auth | admin-only |
68+
| `shutdown_contingencies` | **delta; ADR 0019** | public | auth | auth | admin-only |
69+
| `shutdown_roles` | **delta; ADR 0019** | public | auth | auth | admin-only |
70+
| `shutdowns` | **delta; ADR 0019** | public | auth | auth | admin-only |
71+
| `shutdown_steps` | **delta; ADR 0019** | public | auth | auth | **auth, only while unexecuted** |
72+
| `shutdown_step_events` | **delta; ADR 0019** | public | public, event shape rule | **never** | **never** |
6573

6674
`segments` was created after the auth migration and had briefly kept public
6775
create/update rules; migration `1789000033` (ADR 0011) closed that gap, so its
6876
create/update now require auth like `tags`, with reads public. No collection
6977
carries a public write rule except the deliberate accountless field paths
7078
(`checks`/`check_items`/`punch_items` create, `test_equipment` create, the
71-
`loto_events` and `service_visits` shape-ruled creates, and the parent-scoped
72-
`attachments` create).
79+
`loto_events`, `service_visits` and `shutdown_step_events` shape-ruled creates,
80+
and the parent-scoped `attachments` create).
81+
82+
**`shutdown_steps` carries the schema's one non-admin delete** (migration
83+
`1789000037`, ADR 0019). Its rule is
84+
`@request.auth.id != "" && shutdown_step_events_via_step.id = ""` — a signed-in
85+
planner may remove a plan line that has no events against it, and may not touch
86+
one that has. A shutdown step is a DRAFT the planner is still writing; it stops
87+
being a draft the moment the first event lands, and the back-relation is what
88+
detects that with no status column. The evidence itself
89+
(`shutdown_step_events`) remains append-only and superuser-only-delete, as does
90+
`shutdowns` — deleting an outage would cascade its steps and orphan every event
91+
for that night at once.
7392

7493
Field-level shapes are defined by the migrations in `pb_migrations/` and
7594
explained in [ARCHITECTURE.md](../ARCHITECTURE.md). Load-bearing semantics

‎docs/adr/0019-shutdown-runbook-execution.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@
4343
> are named by any step; `standby_crew` and `emergency` exist to be phoned. This
4444
> takes the module to **eight** collections and the schema to **32**.
4545
46+
> **Amendment (2026-09-07, task `120`) — a planner may delete an UNEXECUTED
47+
> step.** Task 120 requires removing steps ("a real tie-in is never exactly the
48+
> generic runbook"), but every collection shipped `deleteRule: null` and
49+
> `docs/API.md` publishes admin-only deletes as a contract property. Maintainer
50+
> ruling: migration U sets
51+
> `shutdown_steps.deleteRule = '@request.auth.id != "" && shutdown_step_events_via_step.id = ""'`
52+
> — the schema's first and only non-admin delete.
53+
>
54+
> The line it draws is draft vs. record. A `shutdown_steps` row is the plan
55+
> someone is still writing, already freely editable for that reason; the
56+
> evidentiary weight lives in `shutdown_step_events`, which stays append-only
57+
> and superuser-only-delete. The back-relation guard means a step stops being
58+
> deletable the instant its first event lands — the draft/record transition
59+
> derives itself, with no status column (constraint #4). `shutdowns` itself is
60+
> NOT included: deleting one would cascade its steps and orphan every event for
61+
> that night at once.
62+
4663
**Design only — no migration, no UI in this ADR** (task
4764
[`080`](../tasks/080-phase4-shutdown-runbook-design-adr.md)). The build tasks are
4865
written after this is Accepted, per the roadmap-maintenance workflow in

‎docs/tasks/120-shutdown-planning-ui.md‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# 120 — Shutdown planning UI: instantiate a runbook into a real outage
22

3-
Status: TODO
3+
Status: DONE (2026-09-07 — `pb_public/shutdown_plan.html` + migration U for the
4+
delete gap; smoke test 122 → 124 assertions, green)
45
Milestone: M2 Shutdown Runbooks + Punch List (v0.10.0)
56
Depends on: 110
67

@@ -46,21 +47,44 @@ if a genuinely new component is needed.
4647

4748
## Acceptance criteria
4849

49-
- [ ] From a seeded database, a signed-in user can create a shutdown from the
50+
- [x] From a seeded database, a signed-in user can create a shutdown from the
5051
tie-in template against a project + system, and 22 `shutdown_steps` are
5152
created with their groups, offsets and hold points intact.
52-
- [ ] Missing `shutdown_roles` can be created inline; the shutdown's steps then
53+
- [x] Missing `shutdown_roles` can be created inline; the shutdown's steps then
5354
resolve to real role records with contact details.
54-
- [ ] A step can be bound to a project tag; an unmatched hint warns and does not
55+
- [x] A step can be bound to a project tag; an unmatched hint warns and does not
5556
block saving.
56-
- [ ] Steps can be added, removed, reordered and edited **before** execution,
57+
- [x] Steps can be added, removed, reordered and edited **before** execution,
5758
and the page never writes a `shutdown_step_events` record.
58-
- [ ] Wall-clock times are derived at render — confirmed by changing
59+
- [x] Wall-clock times are derived at render — confirmed by changing
5960
`planned_window_start` and seeing every window step's time move with no
6061
other write.
61-
- [ ] An unauthenticated visitor cannot create or edit; the page says so rather
62+
- [x] An unauthenticated visitor cannot create or edit; the page says so rather
6263
than failing silently.
63-
- [ ] Page JS stays within the ~50 KB budget; no new library; no build step.
64+
- [x] Page JS stays within the ~50 KB budget; no new library; no build step.
65+
66+
## Execution notes (2026-09-07)
67+
68+
- **Spec item 4 needed a schema change.** Every collection shipped
69+
`deleteRule: null`, so "remove a step" was impossible as written. Raised, and
70+
resolved by maintainer ruling: migration U, a guarded rule that permits the
71+
delete only while the step has no events. ADR 0019 carries the amendment.
72+
- **Roles are created before steps, not offered after.** `shutdown_steps` holds
73+
`responsible_role` as a relation and has no text-key fallback, so a step
74+
copied before its role record existed would lose which role it wanted. The
75+
page therefore creates the project's roles from the template's role table
76+
(titles prefilled, contacts blank) and then binds the steps; the planner
77+
fills in contacts on the plan page, flagged by a banner. This satisfies spec
78+
item 2's intent — the contact details are the point — without a schema change.
79+
- **Two bugs the render check caught that the API tests could not**: the page
80+
called a `lcAuth.authFetch()` helper that does not exist in this codebase
81+
(rendered a blank shell), and sorted systems by `sequence` when the field is
82+
`startup_sequence` (400, empty picker). Both fixed.
83+
84+
**Coverage gap worth a future task:** `scripts/browser_test.sh` covers only the
85+
accountless field path, so no office page has render coverage. This task shipped
86+
an empty shell and a 400 that only a throwaway headless check caught. A small
87+
office-path browser suite would have caught both automatically.
6488

6589
## Guardrails
6690

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/// <reference path="../pb_data/types.d.ts" />
2+
//
3+
// Migration U — let a planner delete an UNEXECUTED shutdown step (ADR 0019,
4+
// task 120). This is the first non-admin delete rule in the schema, so it is
5+
// worth being explicit about why it is not a hole.
6+
//
7+
// THE PROBLEM. Task 120 requires a planner to remove steps: "a real tie-in is
8+
// never exactly the generic runbook." Every collection in LoopCheck ships
9+
// `deleteRule: null` (superuser only) and docs/API.md publishes that as a
10+
// contract property, so as built there was no way to do it.
11+
//
12+
// WHY THIS IS SAFE, and why it is not the same question as the ledgers.
13+
// `shutdown_steps` is a DRAFT, not evidence. It is the plan a planner is still
14+
// writing days before the outage, and ADR 0019 already made it editable for
15+
// exactly that reason — a row you may freely reword, reorder and re-offset is
16+
// not one whose deletion needs protecting. What carries the evidentiary weight
17+
// is `shutdown_step_events`, which stays append-only AND superuser-only-delete,
18+
// untouched here. Nothing about the record of what actually happened changes.
19+
//
20+
// THE GUARD. The rule is not a bare auth check. A step that has ANY event
21+
// against it has been executed — started, held, completed, whatever — and is no
22+
// longer a draft; deleting it would strand real events pointing at a step that
23+
// no longer exists. So the back-relation must be empty:
24+
//
25+
// @request.auth.id != "" && shutdown_step_events_via_step.id = ""
26+
//
27+
// which means "signed in, AND nothing has happened to this step yet". The
28+
// transition from draft to record therefore happens by itself, at the moment
29+
// the first event lands, with no status column and nothing to remember
30+
// (constraint #4). Verified behaviour: an unexecuted step deletes for a
31+
// signed-in planner (204); a step with one event is refused for that same
32+
// planner (404); an anonymous delete is refused (404).
33+
//
34+
// Note the events' own `step` relation stays cascadeDelete:false, and events
35+
// carry `frozen_description` — so even in the superuser case where a step with
36+
// events IS force-removed, the ledger stays readable on its own terms.
37+
//
38+
// NOT extended to `shutdowns` itself, deliberately: deleting a whole shutdown
39+
// would cascade its steps (cascadeDelete:true) and orphan every event for the
40+
// outage at once. That stays superuser-only, where it belongs.
41+
42+
migrate((app) => {
43+
const steps = app.findCollectionByNameOrId("shutdown_steps");
44+
steps.deleteRule =
45+
'@request.auth.id != "" && shutdown_step_events_via_step.id = ""';
46+
app.save(steps);
47+
}, (app) => {
48+
const steps = app.findCollectionByNameOrId("shutdown_steps");
49+
steps.deleteRule = null; // back to superuser-only
50+
app.save(steps);
51+
});

‎pb_public/project.html‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ <h2 class="section">Safety</h2>
111111
<div class="sub">Every active lock, grouped by system, oldest first — visibility only, never authority</div>
112112
</a>
113113

114+
<!-- Planned outages (ADR 0019). Planning is office work and the page
115+
requires a sign-in; executing a shutdown is a separate, accountless
116+
view (task 130) reached from the shutdown itself. -->
117+
<h2 class="section">Shutdowns</h2>
118+
<a class="card" :href="'/shutdown_plan.html?project=' + project.id">
119+
<div class="title">Shutdown planning</div>
120+
<div class="sub">Turn a runbook into a planned outage — steps, roles, hold points, derived window times</div>
121+
</a>
122+
114123
<!-- Service cutover (ADR 0006) — only shown when the job has service
115124
connections, so plant-only projects never see it. -->
116125
<template x-if="hasServices">

0 commit comments

Comments
 (0)