Wave 3: predecessor references with a release gate, and critical-constraint reopen alerts

Predecessors are real references now
- data.predecessors holds work-package ids, replacing a free-text SOP phase label
  that couldn't express "WP04 waits on WP02" and gated nothing. The SOP phase
  survives beside it as the descriptive "Sequence phase" field.
- readiness() has two gates: constraints clear AND every predecessor Closed. The
  banner, sticky bar, left rail, dashboard Gates column and the ready counters all
  reflect the second one.
- Enforced server-side by enforce_release_gates() on every path that sets a status
  — the plain upsert included, since that's how the browser and the offline outbox
  save. /issue and /status would otherwise have been ways around it.
- Cycles are refused directly and through a chain, with a message naming the
  package that already waits on this one. The Creator's picker also hides itself
  and its own descendants, so a cycle is hard to build in the first place.
- A deleted predecessor does not block: it would freeze everything downstream of a
  package someone removed.
- The gate is refusable, on purpose. Planners release ahead of upstream close-out,
  so an explicit reason (data.gateOverride) allows it, gets a gate_overridden audit
  event naming what was skipped, and prints on the package. A blank reason is not
  an override, and changing the predecessor set clears it. The dashboard won't
  release a blocked package at all — it points at the form where the reason is
  captured.

Critical constraints reopened after release
- Reopening a SOP-critical constraint on a released package emails the owner, PM,
  CM and the package's distribution list (minus whoever did it) and writes a
  constraint_reopened audit event.
- Detected by diffing the incoming constraints against the stored ones inside the
  normal upsert rather than via a new endpoint: the sync outbox only replays
  POST /api/wps, so a dedicated route would be lost offline. It fires only on a
  real cleared→open transition, so re-saving an already-open constraint doesn't
  re-announce, and never before release or for a non-critical constraint.
- Bodies carry the constraint name, WP number and a link — never package contents.

Verified: 139 API checks on one fresh database (44 permissions + 22 password reset
+ 34 search/localization + 39 gates/notifications), including every bypass path,
cycle shapes, the deleted-predecessor case, blank-reason overrides, and the four
recipients confirmed both in the outbox and on the wire against a local SMTP sink.
27 driven UI checks against the real Creator page in headless Chrome covering the
picker, the override prompt (accept and cancel), override invalidation, the cycle
exclusions and the dashboard refusal. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 16:15:36 -07:00
parent 61d1cf4bff
commit b38348e6ae
4 changed files with 476 additions and 21 deletions

View File

@@ -346,6 +346,48 @@ hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
## Release gates (constraints + predecessors)
A work package reaches **Issued** only when both gates are met:
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
**Closed**.
Enforced by `enforce_release_gates()` on **every** path that can set a status —
`/api/wps` (the browser and the offline outbox both save through it),
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
A blank reason is not an override. The server writes a `gate_overridden` audit
event naming the reason and what was skipped, and the reason prints on the
package. Changing the predecessor set clears the override.
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
with a 400 explaining which package already waits on this one.
- **A deleted predecessor does not block.** It would otherwise freeze everything
downstream of a package someone removed.
- The Creator's picker hides itself and any package that already waits on it, so a
cycle is hard to build in the first place; the dashboard refuses to issue a
blocked package and points at the form for the logged override.
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
descriptive — it gates nothing.
## Critical constraints reopened after release
A constraint marked **Critical** on the SOP that reopens **after** the package was
released emails the **owner, PM, CM and everyone on the package's distribution
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
Detected by comparing incoming constraints against the stored ones inside the
normal upsert — *not* a separate endpoint, because the browser saves through the
sync outbox, which only replays `POST /api/wps`; anything hung off another route
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
re-saving an already-open constraint doesn't re-announce, and never for a package
that was never released or a non-critical constraint. Bodies carry the constraint
name, WP number and a link — never the package contents.
## Localization (dates, times, numbers)
Three levels, most specific first — resolved in `html/wp-format.js`: