Clearance independence — current behaviour and target gating
28 RCNY requires the post-abatement clearance dust-wipe sampler to be independent of the abater. In Complied's two-tenant launch shape that is the SecureEnv (inspection/clearance) ↔ Abated (abatement) split — but the rule is about firms, not about these two tenants, and the platform is built to onboard more.
This document records exactly what the app enforces today, and defines the gating we intend to reach. It exists because "warn-only" was previously a property of one function's implementation rather than a stated product decision.
1. Why it is warn-only today (DESIGN-C)
The app cannot know the subcontracting facts of every job. A single tenant may legitimately hold both an abatement and an inspection licence and subcontract one side to a firm that has no Complied login at all. Blocking on the data the app does have would produce false positives on legitimate jobs, and the usual response to a false block is to work around it — which is worse for compliance than a warning that gets read.
So: the app surfaces the conflict, the human adjudicates it. That remains true after this pass.
2. What exists now
There are three checks, at three different moments. They are complementary, not duplicates — each fires at the earliest point its own evidence exists.
| When | Check | Where | Severity |
|---|---|---|---|
| Collaborator invited | Would this tenant hold both the abatement and clearance role on this project? | domain/src/collaboration/collaboratorRoles.ts → collaboratorRoleIndependenceWarning | Warning, invite still allowed |
| Visits scheduled | Does the track's clearance visit share a tenant_id with one of its abatement visits? | domain/src/field/serviceRequirements.ts → clearanceIndependenceWarning, rendered on the Work tab track | Warning, scheduling still allowed |
| Field job completed | This is a clearance visit — confirm the firm split | domain/src/field/fieldJobReadiness.ts, rendered in the field job's readiness panel | Warning, completion still allowed |
The role catalog (COLLABORATOR_ROLES) is what made the first of these possible. Before it, project_collaborators.role was free text, and a rule about who abates versus who samples is not evaluable against a free-text string. Each role now declares a performs discipline (abatement | clearance | inspection | none), which is the data model the gate needs.
What is explicitly NOT enforced
- Nothing prevents scheduling both an abatement and a clearance visit under the same
tenant_id. createInspectionaccepts whatevertenantIdthe caller supplies; there is no cross-tenant split at schedule time. In practice a visit can only be created by the tenant that will own it, so a single-tenant project's abatement track will nearly always trip the warning.- Nothing prevents completing either visit.
- There is no override record. A user who reads the warning and proceeds leaves no trace of having decided anything.
3. Target gating
Three steps, in dependency order. Step 1 is the prerequisite for the other two.
Step 1 — Make the decision recordable (do this first)
A warning nobody can respond to cannot become a gate. Add an explicit acknowledgement:
- A
clearance_independence_acknowledgementsrow:(project_id, track_service_key, acknowledged_by, acknowledged_at, reason)wherereasonis a required free-text explanation — typically "clearance subcontracted to, not performed by us". - The Work tab warning gains an "Acknowledge — clearance is subcontracted" action.
- Once acknowledged, the warning renders as acknowledged (with who and why), not as an open issue.
This alone is a real compliance improvement: it converts an unread banner into an auditable statement, and it produces the data needed to tell a false positive from a genuine violation.
Step 2 — Gate completion, not scheduling
Once acknowledgement exists, promote the field-job check from warning to blocker for the narrow case where the app's evidence is unambiguous:
A clearance dust-wipe visit may not be marked complete while an abatement visit on the same track is owned by the same tenant, unless an acknowledgement exists for that track.
Gate completion rather than scheduling, because scheduling is provisional (a dispatcher pencils in a visit before knowing who will run it) while completion is the assertion that goes into the filing package. The fieldJobReadiness blockers list is already the mechanism.
Step 3 — Steer the split at schedule time
Rather than blocking, make the compliant path the easy one:
- When a track implies
abatement_dustwipe, the clearance task's scheduler offers the project's collaborators holding aclearance_samplingrole as the owning tenant for that visit, not just the current tenant. createInspectiongrows an explicit owning-tenant argument, validated against an activeproject_collaboratorsgrant.- The Collaborators panel prompts for the missing side: an abatement-only roster on an
abatement_dustwipetrack shows "no independent clearance sampler invited".
At that point the warning becomes rare, because the product has offered the correct shape first.
4. Financial isolation is not part of this
Collaboration grants never expose the project's invoices, rate cards, or vendor payments (REQUIREMENTS §4.3), enforced by absence of a code path — no financial table's RLS references has_active_collaboration_grant(), and COLLABORATOR_ROLES_GRANTING_FINANCIALS is an empty constant with a test asserting it stays empty.
5. Related files
domain/src/collaboration/collaboratorRoles.ts— role catalog + invite-time checkdomain/src/field/serviceRequirements.ts—clearanceIndependenceWarning(visit-level)domain/src/field/fieldJobReadiness.ts— completion-time warningsrc/pages/project-detail/CollaboratorsCard.tsx— invite workflowsrc/pages/project-detail/work/TracksWorkPanel.tsx— track-level warning bannersupabase/migrations/20260820110300_project_collaborators.sql— the grant itselfsupabase/migrations/20260901120000_collaboration_tenant_directory.sql— why the invite needed an RPC rather than atenantsquerydocs/RULEBOOK.md§5 — the underlying rule