DESIGN-C — Gating: candidate preconditions for phase closure and inspection completion
Status: design doc only, per owner ruling (2026-08-31 handoff planning). No behavioural code in this change — every candidate below is a proposal for a future commit, not something this document's companion commits implement. Written alongside the eight-item handoff backlog plan (commits 1–5), item 3 of that backlog.
The constraint this doc must work against
docs/history/RUNG-6-PROMPT.md:19-22 (verbatim — that file was retired 2026-09-03, see
README.md; the quote below is the whole of what it said here):
the client does not want future compliance programs locked into a six-phase inspection-firm lifecycle. Nothing in the app may read
phaseto decide what a user can do.
and its acceptance criterion at :311-320:
grep -rn "phase" src domain/src supabasereturns only label rendering, column reads, and queue grouping. No branch anywhere decides user capability from phase.
Rung 6 deleted domain/src/phases/gates.ts, the three */gate.ts modules, src/data/ projectTransitions.ts, supabase/functions/advance-project-status/, and PhaseStepper.tsx —
the entire gate-condition machinery that used to sit behind phase transitions — specifically so
that phase became "a manually-set ops label, nothing more" (:13). The load-time argument for
deleting it (:80-86): the old gate panel fired ~20 extra queries after useProjectDetailData's
main Promise.all and blocked first paint on !field || !docs || !billing.
Any proposal below must either respect the no-phase-gating rule, or explicitly ask the owner to
overrule it. None of the candidates below propose reading phase to decide capability — they
gate specific actions (closing, completing an inspection) on data facts, the same shape RUNG-6
left phase itself as (a label, with real behaviour driven by data the label doesn't carry).
Candidate preconditions
Each candidate is tagged soft-warning (surface a warning, let the user proceed), hard-block (disable the action until the condition clears), or none (don't gate this at all — status quo).
1. Setting phase = closed
Today: setProjectPhase (introduced this backlog's commit 4, mirroring the pre-existing pattern)
writes projects.phase directly from the client with zero validation — any phase is settable from
any other, in either direction, gated only by the client-side advance_projects permission check.
Recall the client walkthrough's definition (now docs/PROJECT_LIFECYCLE_GUIDE.md
§14, which absorbed it): "Closed" means commercially complete
(gates + paid), not "HPD accepted." Agency acceptance is a human parallel track this app doesn't
model. So a closure gate should check commercial completeness, not HPD outcome.
| Precondition | Tag | Why |
|---|---|---|
| Decisions recorded for every linked HPD order | soft-warning | The whole point of this backlog's commits 1–2 is that a decision drives which document gets generated. Closing a project with undecided orders is very likely a mistake, but there are legitimate reasons to close anyway (order withdrawn, project abandoned, building sold) — a hard block would need an escape hatch, which is just a soft-warning with extra steps. |
| Required document slots filled | soft-warning | RequiredDocumentsCard (src/pages/project-detail/file/RequiredDocumentsCard.tsx) already computes exactly this — decided orders' required doc codes vs. what's attached. Reusing that computation for a close-time warning is close to free. Soft, not hard, for the same reason as above (a slot can legitimately go unfilled — e.g. a third-party lab report the client is chasing separately). |
| Vendor payments cleared | soft-warning | That same definition of "commercially complete" names this explicitly ("Vendor payments (lab, etc.) clear before close"). Soft because payment timing is a business/cashflow decision the app shouldn't block on. |
| HPD-accepted / agency confirmation | none | The walkthrough is explicit that this is a human parallel track outside the app's model. Do not build this. |
None of these read phase to decide anything — they're facts read at the moment phase is being
set to closed, the same "gate a transition on data, not on the current phase value" shape.
2. Marking an inspection completed
InspectionWizardPage.tsx:117-127's handleComplete today: no validation, no confirmation
dialog, no permission gate (the page never calls usePermission, even though InspectionsCard.tsx:26
gates the surrounding card on manage_inspections), and no way to un-complete once set — the
"Mark complete" button (:143) unconditionally disappears the moment status === "completed".
RUNG-6 explicitly ruled inspections out of scope (retired docs/history/RUNG-6-PROMPT.md:31-34,
verbatim: "Do not touch inspections... out of scope"), which is why no inspection→phase (or inspection→anything)
coupling was ever considered during that work — this is a genuinely new gap, not a regression.
Everything a check would need is already loaded on the page: readings, dustWipeSamples,
paintChipSamples, abatementComponents, rooms, checklistResponses (see the reload() calls
at :90-108). Precedent for a soft check already exists on the same page: the nested ReportCard
component (:755-784) disables its Generate button on readingsCount === 0.
| Precondition | Tag | Why |
|---|---|---|
Field data captured for the inspection's service_type | soft-warning, matching ReportCard's existing precedent | An XRF inspection with zero readings, or a dust-wipe inspection with zero samples, being marked complete is very likely an operator mistake — but "very likely a mistake" is exactly ReportCard's own bar for a soft warning, not a hard block (a legitimately empty/negative inspection is possible). |
usePermission("manage_inspections") gate on handleComplete | hard-block (this one is a real gap, not a design judgment call) | Every other write action on this page's surrounding card is gated; the wizard's own completion action isn't. This isn't a "should we add a business rule" question, it's closing an existing inconsistency. |
| Confirmation before completing | soft-warning (a confirm dialog, not data-driven) | Matches the "no way to un-complete" gap below — if completing is a one-way door, the UI should say so before the click, not after. |
| An un-complete / revert-to-scheduled path | none this pass | Real gap, but it's an availability question (should completed inspections ever be edited?) that needs an owner ruling, not a default. Flagged as its own open question below. |
3. side_state
Recommend none. Commit 4 (this backlog) already builds setProjectSideState on the
"free to set and clear, no gate" assumption — DESIGN-B's original design explicitly calls
side_state "orthogonal to phase," entered and cleared independently, with phase itself never
touched while a side-state is active (so it already remembers where to return). Gating entry into
blocked/on_hold/cancelled would fight that design's whole point, which is to let an operator
record "this project isn't moving right now" without first satisfying some other condition —
that's the state you reach for precisely when things aren't going according to plan.
Enforcement point per gate
The relevant asymmetry, worth stating plainly before picking an enforcement layer: setProjectPhase
has no server-side permission check at all today — it's a plain client .update(), and
advance_projects is a UI-only gate (src/auth/usePermission.ts reads a session-cached Set
computed from the caller's permission bundle). RLS (projects_update: tenant_id = current_tenant_id()) enforces only tenant scope, not the advance_projects permission itself — a
tenant member without that permission bundle grant could still call setProjectPhase directly
against the REST API and it would succeed, RLS-wise. This is pre-existing (true before this backlog
too), not introduced by these gates, but any hard-block gate proposed above inherits the same
weakness if implemented the same way.
| Layer | What it can enforce | Fits which candidates |
|---|---|---|
| Client-only (disable a button, show a warning) | Nothing against a direct API call — purely a UX nudge. | All the soft-warning candidates above: they're explicitly meant to be dismissable, so a client-side check that a determined caller could bypass is not a security gap, it's the intended design. |
RPC (a SECURITY DEFINER Postgres function replacing the plain .update()) | Real enforcement — the function body can refuse the transition regardless of what called it, closing the advance_projects gap noted above for free if the RPC itself checks has_permission. | The one hard-block candidate (the manage_inspections permission check on inspection completion) — the same pattern has_permission-gated RPCs elsewhere in this repo already use. |
| Edge function | Same enforcement strength as an RPC, plus the ability to run side effects (e.g. a notification) and call other services. | Not obviously needed for any candidate here — none of them require an external side effect, just a data check. Prefer an RPC over an edge function when only a DB check is needed; it's one fewer network hop and matches the trigger-based pattern project_phase_transitions already uses for phase-history capture. |
Recommendation if any candidate above is picked up: soft-warnings stay client-only (cheap, matches
ReportCard's existing precedent). The one real hard-block (permission-gating inspection
completion) should become a SECURITY DEFINER RPC, not a second client-only check layered on top
of a page that already skips usePermission once — closing the gap only in the UI would leave the
direct-API hole open, same as setProjectPhase's today.
Domain gate modules: restore, or keep checks inline?
Recommend inline, not restored. domain/src/phases/gates.ts and the three */gate.ts modules
existed specifically to answer "can this project move from phase X to phase Y," keyed on the phase
value itself — exactly the coupling RUNG-6 removed on purpose. None of the candidates above are
phase-transition gates in that shape: they're single-purpose checks ("are there undecided orders,"
"were readings captured") that don't need a generic gate-registry abstraction to express. A domain/ src/programs/nyc-lead-paint/ (or field/) module exporting one narrow, named function per check
— e.g. undecidedOrdersBlockingClosure(bundles), matching this backlog's decidedFormBundles.ts
naming style — is simpler, is easier to delete piecemeal if a check turns out wrong, and doesn't
resurrect a generic engine the client explicitly asked not to have.
Open questions for the owner
- Which of the soft-warning candidates above should actually ship, and in what order? This doc proposes candidates and tags; it does not decide priority.
- Should completed inspections ever be editable / revertible to scheduled? Flagged above as "real gap, not resolved" — needs an owner ruling on whether that's a workflow the business actually wants, since it changes what "completed" means as a status.
- The 625 cure/dismiss cert-option gap from the handoff's correction 6, restated here since
it's a form-level HARD RULE the same way the 622←617 blocker is, but is not currently enforced
the same way: RULEBOOK gates 625's Opt-4 route on "no open 624/617," but
orderRegistry.HPD_ORDERS['625'].blockedByOpenis only['624']— an open 617 alongside a 625 is not currently surfaced as a blocker anywhere (src/pages/project-detail/decide/OptionsExplorer.tsx's underlyingblockingViolations()call, same domain function this backlog's commit 2 depends on for the 622↔617 case). Fixing this changescureMethods[0]implications the same way the 621/623 fix in commit 1 did — flagged for an explicit ruling, not fixed here, per the handoff's own scoping ("Ruled out of scope — unlike 621/623, fixing 625 changes bundle grouping"). - Should the
advance_projectsUI-only gap (no server-side permission check onsetProjectPhase) be closed at the same time as any hard-block RPC work above, or left as its own follow-up? Noted in "Enforcement point" above as pre-existing, not introduced by this backlog.
Cross-reference
docs/domain/SCHEMA.md documents side_state and project_phase_transitions — link back to this
doc from there once this file lands, so a reader of the schema doc can find the gating design
discussion without already knowing it exists.