Skip to main content

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 phase to decide what a user can do.

and its acceptance criterion at :311-320:

grep -rn "phase" src domain/src supabase returns 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.

PreconditionTagWhy
Decisions recorded for every linked HPD ordersoft-warningThe 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 filledsoft-warningRequiredDocumentsCard (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 clearedsoft-warningThat 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 confirmationnoneThe 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.

PreconditionTagWhy
Field data captured for the inspection's service_typesoft-warning, matching ReportCard's existing precedentAn 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 handleCompletehard-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 completingsoft-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 pathnone this passReal 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.

LayerWhat it can enforceFits 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 functionSame 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

  1. 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.
  2. 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.
  3. 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'].blockedByOpen is only ['624'] — an open 617 alongside a 625 is not currently surfaced as a blocker anywhere (src/pages/project-detail/decide/OptionsExplorer.tsx's underlying blockingViolations() call, same domain function this backlog's commit 2 depends on for the 622↔617 case). Fixing this changes cureMethods[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").
  4. Should the advance_projects UI-only gap (no server-side permission check on setProjectPhase) 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.