Skip to main content

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.

WhenCheckWhereSeverity
Collaborator invitedWould this tenant hold both the abatement and clearance role on this project?domain/src/collaboration/collaboratorRoles.tscollaboratorRoleIndependenceWarningWarning, invite still allowed
Visits scheduledDoes the track's clearance visit share a tenant_id with one of its abatement visits?domain/src/field/serviceRequirements.tsclearanceIndependenceWarning, rendered on the Work tab trackWarning, scheduling still allowed
Field job completedThis is a clearance visit — confirm the firm splitdomain/src/field/fieldJobReadiness.ts, rendered in the field job's readiness panelWarning, 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.
  • createInspection accepts whatever tenantId the 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_acknowledgements row: (project_id, track_service_key, acknowledged_by, acknowledged_at, reason) where reason is 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 a clearance_sampling role as the owning tenant for that visit, not just the current tenant.
  • createInspection grows an explicit owning-tenant argument, validated against an active project_collaborators grant.
  • The Collaborators panel prompts for the missing side: an abatement-only roster on an abatement_dustwipe track 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.

  • domain/src/collaboration/collaboratorRoles.ts — role catalog + invite-time check
  • domain/src/field/serviceRequirements.tsclearanceIndependenceWarning (visit-level)
  • domain/src/field/fieldJobReadiness.ts — completion-time warning
  • src/pages/project-detail/CollaboratorsCard.tsx — invite workflow
  • src/pages/project-detail/work/TracksWorkPanel.tsx — track-level warning banner
  • supabase/migrations/20260820110300_project_collaborators.sql — the grant itself
  • supabase/migrations/20260901120000_collaboration_tenant_directory.sql — why the invite needed an RPC rather than a tenants query
  • docs/RULEBOOK.md §5 — the underlying rule