Skip to content

Per-deliverable reports, the service catalogue, and one roster authority - #295

Merged
important-new merged 29 commits into
InspectorHub:mainfrom
important-new:batch1/wave4-closeout-and-services
Aug 3, 2026
Merged

Per-deliverable reports, the service catalogue, and one roster authority#295
important-new merged 29 commits into
InspectorHub:mainfrom
important-new:batch1/wave4-closeout-and-services

Conversation

@important-new

@important-new important-new commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Batch 1 of the open-work programme, plus the first four tasks of per-deliverable reports. Twenty commits; every one is independently reviewable and the branch is green on lint + test:unit (4,246) + test:web (1,994).

What this changes

One order can now deliver several reports. A standard inspection publishes on Tuesday and the radon report publishes on Thursday, each with its own document, its own signature chain and its own collaborative session — without the client waiting on the slowest one. uq_results_inspection made that impossible; a reports entity replaces it.

Two of those changes fix defects that fail silently, which is why they land in the same breath as the model rather than after it:

  • report_versions numbered and chained per inspection. Two reports publishing independently would interleave into one chain and break verification for every version — including ones published before the second report existed. Numbering and the prevHash walk are now per report.
  • The collaborative-editing Durable Object derived its id from the inspection, so two inspectors working two reports of one order shared a single Y.Doc. Nothing threw; the CRDT merged content from two different documents. Fixing the id alone was not enough — the DO also wrote its projection back keyed on inspection_id, which would have overwritten sibling documents. Both paths are closed.

The service catalogue is seeded. Production had 43 event types, 7 templates and zero services, so no inspection could ever carry a service line — a product gap rather than slow adoption. Seeding it also surfaced that two event-type seed lists had drifted apart (starter_sewer_scope vs sewer_scope were one thing under two slugs); they are now one list.

One authority for "who worked this inspection". Three answers were live in code and agreed only because two columns were empty on every row. The first write of a lead or helper would have made metrics — and collaborative edit access — answer differently from everything else.

Notable decisions, recorded in code rather than only in a plan

  • The agreement/payment gate is order-wide: an unsigned agreement holds every report on the inspection. Stated at the gate and in the settings copy rather than left implicit, with an owner/manager unlock (reason required, audited) as the release when a finished report is held back by paperwork for something else on the same job.
  • reports.inspection_service_id is deliberately not named service_id — in this schema that already means the catalogue entry, and a catalogue id there would make "which report did this billing line produce" unanswerable.
  • One access token opens every report on an order; the pay gate is the invoice's; repair requests gather across all published reports; a re-inspection remains its own order.
  • inspection_services gains is_active and soft-deletes, landed before anything points at a billing line — otherwise an on-site scope change silently orphans a report or a pay split, with no FK to catch it.

Migrations

00250029. All additive: ADD COLUMN, CREATE TABLE, and index moves — no table rebuild anywhere. 0028 reorders what db:generate emitted, because the generated order dropped the old uniqueness first and opened a window where two results rows could exist for one inspection.

Its backfill derives report ids from inspection ids, so a replay is a no-op, and a test compares contentHash/prevHash before and after: a backfill that renumbered versions would invalidate signatures on already-delivered reports, which row counts cannot see.

Also here

  • Canned comments can be deleted — Closes Canned comments can be created and edited but never deleted; the DELETE route has no caller #291 — 2,774 rows had no delete path at any surface while the endpoint existed with zero callers.
  • Card payments reach QuickBooks. recordPayment had one caller — the manual "mark as paid" route — so every Stripe-settled invoice was missing from the tenant's books. The push is now idempotent via QBO's requestid, keyed on the record rather than the attempt.
  • The seven fallback notice titles moved into the message catalogue, which unblocks translating them.
  • Pre-commit got measurably faster: the i18n cache stamp lived only inside the directory its own compiler wipes, so every npm run dev silently invalidated it.

Out of scope, deliberately

Generating reports from sold services, publish-per-report and notification coalescing are the next change and will rebase onto main after this lands. The remaining QuickBooks defects (pushing the amount actually paid, TxnDate, and refunds — createCreditMemo still has zero callers) wait on the payment ledger.

important-new and others added 29 commits August 2, 2026 18:58
… has none

fulfillBooking was the only writer, so every manually-created inspection kept a
NULL instant forever and conflict detection stayed on the sameDayHour bucket for
them — 09:59 and 10:01 collide under that rule. The wall-clock now comes from the
date suffix when there is no prior instant. Rows whose date carries no time stay
NULL: inventing midnight would be less honest than the bucket fallback.

Also retires scripts/backfill-scheduled-start.mjs. It matched zero production
rows (0 of 19 inspections carry a non-NULL scheduled_start_ms, verified against
the remote database), and the hole it patched — a reschedule that moved date
without moving the instant — was closed by the dual-write in InspectorHub#289, so the drift
cannot recur. Git history is the archive.
)

The library had create, read, update and a per-user touch counter, but no delete
at any surface — so 2,774 of the 3,654 production rows could only ever
accumulate. `/library/repair-items` deletes through RecommendationService, which
filters on `isNotNull(repair_summary)` and therefore only ever reached the other
880.

`DELETE /api/admin/comments/{id}` already existed, was on the OpenAPI surface and
RBAC-gated, and had zero callers. This adds the caller rather than a second
endpoint. Bulk delete loops the same route: the concrete case is the 80 seeded
rows per tenant that a tenant may decline wholesale, and a second delete path on
one table is how two paths end up behaving differently.

Both confirmations identify what they are about to remove — a library page is
dozens of near-identical rows, so "delete this comment?" cannot tell you whether
the right one is selected. One selected row is named rather than counted, which
also keeps the count message honestly plural.

The design rests on a claim nothing enforces: an inspection snapshots the comment
TEXT and holds no reference to the library row. comment-delete-isolation.spec.ts
pins it — the defect narrative and the published version's snapshot, content hash
and signature all survive the delete — so a future change to that relationship
fails there instead of in a delivered report.
… affect

Two defects, both measured on this repo rather than reasoned about.

1. The i18n guard's stamp lived only inside `app/paraglide/`, which the paraglide
   compiler CLEANS on every run — and `paraglideVitePlugin` in vite.config.ts
   writes to that same outdir with the same options. So every `npm run dev` and
   every `npm run build` silently deleted the stamp, and the next commit staging
   `messages/**` recompiled 4,249 keys from inputs that had not moved: 66s. The
   hash is now mirrored to `node_modules/.cache/`, either copy is enough to skip,
   and a skip re-writes whichever copy went missing. Verified: 806ms after
   deleting the in-output stamp, and still "inputs changed" when a message file
   actually moves.

2. `needs_typegen` fired on any added or deleted path under `app/routes/`,
   including colocated specs. `app/routes/foo.test.tsx` never enters routes.ts,
   so typegen's output cannot differ because one arrived — but the Test Layout
   REQUIRES frontend specs to sit beside what they test, so this fired on
   ordinary work and cost ~60s each time.

Worth recording alongside these: a running dev server makes the hook far more
expensive than either fix saves. It rewrites `app/paraglide/**`, which is inside
the app tsconfig program, so tsc's incremental build-info goes cold — measured
11.5s vs 295.5s for type-check:app, and 4.1s vs 37.6s for eslint. Stop the dev
server before committing.
…migrate

These exist to repair or populate data in an install that predates a change.
There are no open-source users yet, so there is no such install: every one of
them can only ever run against our own production, and each has already been
checked there.

- backfill-notice-headers.mjs — dry run against production returned "0 headers
  to create; 9 rows have no recipient id". All 9 candidate rows carry neither a
  recipient_contact_id nor a recipient_role_key, so no header can be derived
  from them by this or any script. Zero unstamped rows postdate the split, so
  there is no live defect feeding it either.
- backfill-rich-templates.mjs — seeds rich templates into tenants that predate
  them; POST /api/admin/backfill-default-templates covers the same ground for a
  live tenant and stays.
- migrate-rating-levels.mjs and migrations/data/2026-07-04-severity-normalization.sql
  — a matched pair normalising the retired {abbr,bucket} rating shape. The SQL
  file was never journalled and nothing read it.

The `noticeId` schema comment cited the deleted script as the reason the column
is nullable. It now states the invariant instead: a row whose recipient resolves
to neither a contact nor a user keeps NULL, the Outbox falls back to the interim
key, and pre-split rows stay NULL permanently because they recorded no recipient
at all.

Kept deliberately: backfill-route-metadata.ts and backfill-zod-descriptions.ts
are idempotent SOURCE codemods, not data migrations — they are still how a new
route gets its MCP metadata and field descriptions. verify-migration-equivalence.mjs
is db:check itself.

Verified after deletion: db:check green (86 tables, no drift), knip clean
(2 baselined, 0 new), no references left anywhere in the repo.
…ded seam

First Paraglide usage in server/. Four things had to agree on where the compiled
catalogue lives, and only one of them did:

- tsconfig.api.json mapped @core/api-types alone, so `~/paraglide/messages` did
  not resolve in the api program. Adding `~/*` keeps `include` at server/**;
  tsc follows the import out of the entry set without widening the program to
  all of app/.
- vitest.api.config.ts had no `~` alias either, so an api spec reaching a
  server-side message failed to IMPORT rather than to assert.
- vite.config.ts already aliases `~`, so the worker build was never a problem.
- eslint forbids server/ importing app/ at all — the BFF boundary. That rule is
  right, and app/paraglide/ is the case it did not anticipate: generated data,
  compiled from messages/**, living under app/ only because that is where the
  outdir points. Relocating it to a shared package would rewrite the import in
  392 files for no behavioural gain.

The exception is an inline disable on the seam module, not an eslint.config.js
entry, and that choice is the point: a config entry would exempt all of server/,
while one line exempts one file. Verified both directions — the seam lints
clean, and a probe file importing the catalogue directly still fails the rule.
So "the one place server code reads messages" is enforced, not just documented.
Same shape as server/lib/jwt-keyring.ts, the sanctioned wrapper for hono/jwt.
T6 moved the notification CALL SITES onto templates but left titleFor as the
fallback, so six hard-coded English cases plus a default survived where no
translator could reach them. The switch stays — a tenant can have no template,
and a trigger can exist in the enum before a template does — but its literals
are now message keys.

They are `comm_` prefixed, not `notice_`, and that is not arbitrary. The
catalogue already holds a `notice_title_*` family, and at a glance these look
like duplicates of it. They are not: `notice_title_report_published` is
recipient voice with no address ("Your inspection report is ready") and is what
app/lib/notice-view.ts renders for types it recognises, while these are
staff/ledger voice with the address ("Report published — 12 Oak St") and are
what gets STORED on the row and shown in the Outbox. The same split already
distinguishes comm_reason_sms_opt_out from notice_reason_sms_opt_out.

English output is byte-identical — the spec asserts exact strings, em dash and
all, rather than "contains". es-419 keys are deliberately absent so the catalogue
gate's English-fallback path applies; an empty target key would render blank,
which is a hard failure.

This does NOT make titles render in the recipient's language. Nothing resolves a
recipient locale, and in a cron or queue context there is no request locale at
all. It makes them reachable by a translator, which they were not before.

The spec carries a guard against the literals returning. Narrow by choice: a
general "no English literals in server/" lint would need an allowlist for logs,
error codes and SQL, and the allowlist is what would quietly grow. It was seen
red first — it listed all seven literals before the rewrite.
Two defects of the same shape — the function was written and one line was never
connected.

recordPayment had exactly ONE caller, the manual "mark as paid" route. A client
paid by card, Stripe confirmed, markPaid ran, and QuickBooks never learned. A
tenant reconciling their books found every online payment missing. Verified
against the code rather than assumed: `git grep recordPayment` returns the one
call site, and stripe-webhook.ts settled an invoice with no QBO call at all.

The push also carried no idempotency key, and Stripe redelivers webhooks. QBO is
the tenant's book of record, so a duplicate Payment overstates their revenue and
their tax position — the kind of error found at tax time rather than in a test.
The push now carries QBO's `requestid`, whose contract is that a repeated key
returns the original response instead of performing the operation again.

The key identifies the FACT, not the attempt: a per-attempt uuid is unique every
time and therefore protects nothing. Both push sites derive it from one function
so they agree — an invoice settled online and then also marked paid by hand is
one payment, and the second push collapses. That single derivation point is also
what makes the ledger migration safe later: when the fact becomes a ledger row
rather than an invoice, every caller moves at once, because re-pushing under a
new key would duplicate.

Also adds InvoiceService.findById. The manual route was reading one invoice's
amount out of a full listInvoices scan; the webhook runs on every card
settlement and must not do that.

Out of scope, and paired with the payment ledger in a later batch: pushing the
amount actually paid rather than the invoice total (correct today only because
payment is all-or-nothing), TxnDate being the push date rather than when money
moved, and refunds — createCreditMemo is still implemented with zero callers.
…sits

Production had 43 event types, 7 templates and ZERO services. Service lines can
only be attached from a catalogue, so an empty catalogue is why no inspection had
ever carried one — a product gap, not slow adoption. A tenant had a "Sewer Scope"
template and a sewer scope event type and no sewer scope they could sell.

Three corrections to the plan, all found by checking code before writing any:

1. `services.price` is NOT NULL, and `inspection_services.price_snapshot` copies
   it. "Seed the entry and leave price null" was never available. These are real
   starting prices the tenant is expected to edit, which is a deliberate trade
   against the alternative — making the column nullable would have put a table
   rebuild and a null path through getEffectivePriceCents, the booking page and
   the order picker on the critical path of three dependent plans.

2. There were TWO event-type seed lists and they disagreed. This one seeded three
   `starter_*` types at provisioning; server/data/event-type-seeds.ts held five
   more behind a manual endpoint, despite its own header claiming they were
   "seeded on every new tenant" — bulkSeed had exactly one caller and it was a
   button. `starter_sewer_scope` and `sewer_scope` were one real-world thing under
   two slugs, and a tenant who provisioned and then pressed the button got both.
   Merged into one list; bulkSeed now reads it too and is a gap-filling repair
   tool rather than a second source of truth.

3. Radon's two event types were not seeded at provisioning at all, so a service
   referencing them by id would have pointed at rows that did not exist.

Hence slugs, not ids, for `services.default_event_type_slugs`: they do not depend
on seed ordering inside one run, they survive a tenant deleting and re-creating a
type, and an unmatched slug degrades to a shorter proposal instead of dangling.
`proposeEventsForService` returns visits in the service's declared order — a
radon pickup proposed before its drop-off is a nonsense sequence, and the order
is the only thing carrying that meaning.

Also guards deleteTemplate against `services.template_id`, the second foreign key
to that table, which was unchecked. Latent only while the catalogue was empty;
seeding it ends that. The error names the service, because "Conflict" with no
subject sends a tenant hunting through their inspections for a reference that is
in their catalogue. A soft-deleted service still blocks — deleteService leaves
template_id in place, so the FK outlives the delete the tenant thinks they did,
and a guard that filtered on `active` would hand them the raw FK error instead.

The seeding and the batchInsert helper move into their own modules to stay under
the file-size ratchet; extracting them was preferable to bumping the baseline,
and seedServices reads better as a named unit than as an inline block anyway.

Migration 0025 is a single appended ADD COLUMN, no table rebuild. db:check green,
86 tables. Two pre-existing specs updated for the merged event-type list.
…late

The competitor keeps an FAQ entry for this exact confusion: "If you're seeing
multiple reports generating on your inspections, you may have the same template
defaulted for both your primary service and your add-on services." A tenant who
defaults the residential template for both Standard Home Inspection and Sewer
Scope gets two identical blank reports and no way to guess why.

Naming the other service is the whole value — "this template is already in use"
is not actionable. It warns rather than blocks: there are legitimate reasons to
want it, so the failure mode being prevented is surprise, not invalidity.

Two things the Chrome pass caught that the unit tests could not:

- The message pushed the Save button down 20px. The component already reserved a
  fixed-height slot for exactly this reason ("appearing and disappearing moved
  the form's Save button by two lines, under whatever the cursor was already
  aiming at") but it was sized for the one-line hint. Measured in the browser at
  341px vs 321px, tightened the copy, resized the slot to the tallest message,
  and re-measured: 341 vs 339, which is line-height rounding rather than movement.
- "{name} and 1 others" — the same plural bug as "Delete 1 comments?" earlier in
  this branch. Rephrased to "{count} more ... too", which reads correctly at 1
  and at N.

role="status", not "alert": this is advice about a choice the admin just made
deliberately, not an error, and assertive would interrupt them on every keystroke
through the select. Verified in both themes — amber #fbbf24 on #0f172a is ~9.5:1.
Task 2 of the roster-convergence plan, and on the measured evidence it is the
task that carries the plan's actual value: a pay split is money attributed to a
named person that nobody re-derives later, so it must read one function rather
than whichever table its author reached for.

Reads inspection_inspectors and never inspections.inspector_id. That is pinned by
asserting on the EXECUTED SQL rather than on the result, and the assertion was
proven red first: an implementation that also selected from `inspections`
returned an identical roster and passed all four result-based tests, which is
exactly the failure mode a result-only test cannot see.

Batch-first — getInspectionRosters takes a list and getInspectionRoster delegates
to it, because the calendar and the inspections list render many rows and a
per-row accessor turns one query into N. A test asserts the query count is one,
and it also went red against the sabotaged version.

Tenant scope is on the LINK row, not on the joined user: an agent user carries a
null tenant_id, so scoping on the join would silently drop rows rather than
protect them.

inspection_events.inspector_id is deliberately out of scope and the module says
so — it answers who performed THAT visit, and a radon pickup may legitimately be
a different person from the lead.
…ze the dead columns

Three answers to that question were live in code and agreed only by accident:

  inspections.inspector_id                          (17 refs)
  coalesce(lead_inspector_id, inspector_id)         (metrics)
  inspection_inspectors                             (39 refs)

lead_inspector_id is NULL on all 19 production rows and helper_inspector_ids is
'[]' on all of them, so the second collapsed to the first and nothing disagreed.
The first write of a lead or a helper would have ended that — in metrics, and in
collab access control, which decides who may EDIT an inspection. That is the
worst place to find out, so this closes it before anything writes one.

metrics.ts now attributes work to the roster's lead row. Joined on role = 'lead'
deliberately, NOT the whole roster: grouping over every link row would count an
inspection once per assigned person and double its revenue the moment a job has a
helper. One inspection is attributed to one person here; this moves the SOURCE,
not the meaning.

can-access.ts now takes the roster instead of three columns, which also makes it
fail CLOSED — an inspection with no roster grants access to nobody outside the
admin roles. Its malformed-JSON and null-helpers tests are gone along with the
column they guarded; a role lives in a column of its own now, so there is no
string left to fail to parse. The presence badge label moved too, so it cannot
say "Helper" about the person the roster calls the lead.

Both collab routes still call getInspection, now for the guard rather than the
row: it throws when the inspection is missing or belongs to another tenant, which
is the 404. Who may edit comes from the roster.

Both dual-write feed sites stop reading the dead columns. bulk.ts and core.ts
selected them back out and re-supplied them to syncInspectionAssignments to avoid
wiping "team mode" rows — there was never anything to preserve.

The columns are frozen, not dropped (D1 cannot rebuild an FK-referenced table),
with the evidence in the schema comment and the same treatment as
comments.rating_bucket. Neither name is ever reused.

Production was reconciled first, since metrics filters out rows with no lead and
three inspections had none: 3 rows backfilled under the D1 SOP with a fresh
export and bookmark 0000489a-00000000-000050bb-b61cbfa911c7445cd70556d1137c2a73.
Applied changes: 3, exactly the predicted count. After: 19 inspections, 19 with a
roster, 0 missing, 0 disagreeing.
…points at them

Task 0 of the per-deliverable-reports plan, numbered 0 because order matters: it
has to land before the first `reports` row points at a billing line. Reversed,
there is a window in which a client changing scope at the door silently orphans
a report.

A scope change at the door is routine — add a sewer scope, drop the pool
inspection, decline the radon — and it was a hard delete. Harmless only while
nothing hung off a line. Once a `reports` row or a pay split does, Schema Rules
forbid the foreign key that would catch it, so the delete leaves dangling rows
and nothing surfaces them. The invoice disagrees too: invoices.amountCents
outranks the line sum, so removing a line does not change what was billed while
a split keeps paying against it.

The column is the easy half. The half that gets forgotten is that EVERY reader
must filter on it, and getEffectivePriceCents is a pure function over
already-fetched lines — so the filter belongs at each fetch site, not in the
helper. Grepped first, then filtered, all of them:

  effective-price.sql.ts   the money chain itself
  api/invoices.ts          a declined line must not appear on the invoice
  api/metrics.ts           revenue by service
  inspection-analytics     feeds getEffectivePriceCents per inspection
  automation/conditions    or an automation keeps firing for a declined service
  service.service.ts       the live list every surface reads

Re-adding a declined service REACTIVATES the original row rather than inserting
a second one. A reports row or a pay split may already point at that id, and a
fresh row would strand them while the client is billed for the line they see.

The REFUSAL half of the guard is deliberately absent. Neither `reports` nor
`inspection_service_pay_splits` exists yet, so a check against them would be a
function that always returns "nothing blocks this" — a gate that passes
vacuously, which this repo has shipped before. Each of those tasks adds its own
clause, with a test, when it creates the table.

IA-26's inspector-qualification pair moves to service/qualification.ts: the file
crossed the 400-line ratchet, and which inspectors MAY perform a catalogue
service is a separate concern from what it costs. Thin delegates keep every
caller unchanged.

Migration 0026 is a single appended ADD COLUMN, no table rebuild. db:check clean.
One pre-existing spec updated: removal is a soft delete now, so it asserts the
row survives and leaves the live list rather than disappearing.
Task 1 of the per-deliverable-reports plan. A standard inspection publishes today
and the radon report publishes on Thursday, each with its own document, signature
chain and notification, without the client waiting on the slowest one.
`uq_results_inspection` made that impossible; this is the table it becomes.

Two naming decisions carry weight and are written into the schema comment:

`inspection_service_id`, NOT `service_id`. In this schema `service_id` already
means the CATALOGUE entry — both inspection_services.service_id and
service_inspectors.service_id are that — so naming this one `service_id` reads
as the catalogue to anyone who has seen the other two, and a catalogue id here
makes "which report did this billing line produce" unanswerable. That is exactly
the grain pay splits are built on.

`kind` distinguishes primary from ancillary because primary is not just another
one: it must exist, the pay gate keys on it, and it is what a client means by
"my report". One primary per inspection is enforced by a PARTIAL unique index
rather than by the service layer — which report the client means must not depend
on which caller wrote last — while ancillary reports stay unbounded, since a job
can deliver radon, sewer and mould.

The erasure-manifest entry lands in this task rather than later, because an
entity that enters the schema without one is how the manifest drifts from the
database. `title` is the only free text a human writes and it routinely carries
the address ("123 Oak St — Radon"), so it is anonymised rather than deleted: the
row is the spine of a signed, delivered document and removing it would strand the
version chain that proves what was delivered. The other eight columns are ids,
enums and a timestamp, declared out of scope. lint:erasure passes at 29 rules and
32 declarations.

Migration 0027 is CREATE TABLE plus three indexes, no rebuild. db:check clean at
87 tables. `reports` also had to be added to the top-level schema barrel, which
uses explicit named exports — without it drizzle saw no schema change at all.
Task 2 of the per-deliverable-reports plan. `inspection_results` and
`report_versions` gain a `report_id`, the uniqueness that used to be per
INSPECTION becomes per REPORT, and every existing inspection is backfilled with
one primary report carrying its current template.

The statement order in the migration is load-bearing and is NOT what db:generate
emitted. Drizzle put the `DROP INDEX` on the old results uniqueness FIRST, which
opens a window in which two results rows can be written for one inspection. The
shipped order is: add both columns, backfill, create the new unique indexes, and
only then drop the old ones. Verified no table rebuild — every statement is
ADD COLUMN or an index. db:check clean at 87 tables, lint:migchain intact at 29
snapshots.

The backfill derives each report id from its inspection id rather than generating
one. That makes it idempotent — a replayed migration creates nothing and changes
nothing, which a test asserts — and it makes a row traceable to what produced it.
Titles are generic on purpose: `reports.title` is anonymised by the erasure
manifest precisely because it usually carries an address, and a backfill has no
business inventing PII that was not there.

The test that earns its place is the chain one. `report_versions` carries
contentHash / prevHash / signature and the public verifier reads them, so a
backfill that renumbered or reordered versions would invalidate signatures on
reports ALREADY DELIVERED. Counting rows cannot see that; comparing the hashes
before and after can, and does.

Applied locally and checked: 3 inspections, 3 primaries, zero orphan results and
zero orphan versions. The REMOTE apply is deliberately not done here — production
holds 19 inspections and 6 versions, and that step goes through the D1 SOP with a
backup, a bookmark, and a human looking at the statements first.
Task 3 of the per-deliverable-reports plan, and it lands in the same breath as
the model because both defects go live the moment a second report can exist —
which, as of the previous commit, it can. Neither fails loudly.

THE SIGNATURE CHAIN. report_versions carries contentHash / prevHash / signature,
a tamper-evident chain that was numbered and linked per INSPECTION. Two reports
publishing independently — the standard report Tuesday, radon Thursday —
interleave into one chain, and every subsequent verification fails INCLUDING for
versions published before the second report existed. Version numbers and the
prevHash walk are now per report, and verification walks within its own report:
reading by inspection would pick up the other document's version and call a valid
chain broken, or a broken one valid.

THE COLLABORATIVE DOCUMENT. The Durable Object id came from
`${tenantId}:${inspectionId}`, so two inspectors working the standard report and
the sewer report of one order landed in the SAME object and shared one Y.Doc.
Nothing threw. The CRDT merged content belonging to two different documents, and
the corruption surfaced when a client opened a report containing someone else's
findings. The name is derived per report now, and the derivation lives in its own
function so a test can assert the INPUT: two report ids are trivially unequal, so
a test comparing only outputs would pass against an implementation that still
keyed on the inspection.

Resolving which report also fails CLOSED. An inspection with no report row gets
a 404 rather than falling back to an inspection-keyed document — that fallback is
precisely the shared-Y.Doc bug.

snapshotOnPublish takes an optional reportId and defaults to the inspection's
primary, so every existing caller keeps working while a caller that knows which
deliverable it is publishing can say so.
Re-keying the DO address was not sufficient, and this is the second path.

InspectionDocDO writes its Y.Doc projection back to `inspection_results`, and
that write matched on `inspection_id`. An inspection now has one results row PER
report, so a correctly-routed radon document would still have overwritten the
standard report's row — the same corruption as the shared Y.Doc, one layer down,
and just as silent. Fixing the id derivation alone would have looked complete and
left the bug.

The DO now carries an `x-report-id` header and writes by `report_id` when it
knows which document it is, falling back to the inspection predicate only when it
does not — a DO awakened before any client has connected, which cannot yet have a
sibling to clobber.

INSPECTION_PRESENCE is deliberately left keyed per inspection: it persists no
document, so there is no corruption path, only the question of whether two people
editing different reports of one order should see each other. That is a UX call,
not a correctness one.

inspection-doc.ts is already grandfathered at 900+ lines; the baseline moves by
the 16 lines this adds rather than splitting a Durable Object mid-change.
… unlock

The report gate is order-wide: any required agreement left unsigned, or payment
outstanding, blocks EVERY report on the inspection — not just the report belonging
to the service whose agreement is missing. That was already the behaviour, as an
accident of `agreement_requests` having no report dimension. It is now a stated
rule, written at the gate itself, because one rule an inspector and a client can
both recite without looking it up is worth more than a finer one neither can.

Its cost is real and this is the part that needed solving: an add-on's unsigned
addendum can hold back a report that is finished and that someone is waiting for.
The release is a deliberate human action — an owner or manager opening one
inspection and recording why — rather than resolving the gate per report, which
would mean putting a service dimension on `agreement_requests`, a signed-evidence
table with a retention rule and an erasure entry, to solve what one override
solves.

`reason` is required and stored, not defaulted. An override with no stated reason
is indistinguishable later from a mistake, and this one is client-visible: it
hands over a report the tenant's own rules said to hold.

Unlocking twice keeps the ORIGINAL timestamp, person and reason, so the record
shows who actually made the call rather than whoever pressed it last. Relocking
clears the reason with it — it described a decision that no longer stands. Both
routes are owner/manager only and both are audited, including the already-unlocked
case, because someone asking is part of the story of who wanted the gate open.
The two audit actions are registered in the AuditAction union, which is closed on
purpose — the type check refused them until they were declared.

Default stays locked. Reports being held until signed and paid is what a tenant
expects, and defaulting open would quietly change that for them.

The two routes live in their own module rather than in publish.ts: publishing is
about whether a document is finished, this is about whether the people waiting
for it may see it. Different questions, different reasons to change.

Migration 0029 is three appended ADD COLUMNs, no rebuild. db:check clean. The
append-order guard in pca-foundation-schema.spec.ts was extended rather than
worked around — it exists to keep db:generate emitting ALTER rather than a table
rebuild, and a new trio at the tail is exactly what it watches for.
…s copy that states the rule

Two surfaces for one rule.

SETTINGS. The gate's description only mentioned booking — clients must sign
before the booking is confirmed. It also holds every report, which is the rule
this branch committed to, so an operator would have met it the hard way. The
scope note appears only when the setting is on and says plainly that an unsigned
agreement holds back EVERY report on that inspection, not only the report for the
service the agreement covers, and how to release it.

THE CONTROL. Deliberately not a GateToggle. That component's own comment states
this hub's law — "a switch, not a button… nothing to confirm and nothing to lose
by flipping it back" — and it owns ONE gate on the card for the artifact it
gates. This owns BOTH gates, spans the order, and hands a client a report the
tenant's rules said to hold. Three failures of the switch test, so: a quiet text
control opening a modal, with the reason required to enable the confirm.

It lives on the REPORT card, by the same rule GateToggle follows — the card for
the artifact it gates — because what it gates is the reports.

The weight is on the STATE, not the action. Unlocked, it stops being a control
and becomes a standing record: who released it, when, and their reason QUOTED
rather than paraphrased, so it reads as somebody's words. Requiring a reason is
pointless if nobody reads it back, and that is the one place any visual weight is
spent. Locked, it is a line of grey text that does not compete with Publish.

The hub resolves the unlocker to a NAME server-side rather than shipping an
opaque id; a deleted teammate degrades to "a teammate" so it never renders
"Released by  on …". Admin-only in the UI as well as at the route.

Chrome, both themes: modal opens, confirm stays disabled until a reason is typed,
the round trip lands and the control becomes the record, and "Put the gate back"
returns it to the action. Amber on light is #b45309 on #f8fafc (~6.5:1).

One thing the browser caught that no test could: the control rendered as
permanently locked because the LOCAL database was a migration behind, so the hub
query selected a column that did not exist and the loader swallowed the failure.
Same silent-empty shape as the services catalogue earlier in this branch — a hub
loader failing quietly is indistinguishable from a feature that does not work.
…d, and erase report titles

Task 5 of the per-deliverable-reports plan. Each of these was a decision the spec
proposed; the plan asks for them in CODE rather than only in the spec, because
the next reader is in the file.

ACCESS TOKENS stay keyed on the inspection. One order, one client, one link — a
per-report token would mean three links in three emails for one job, and a client
who mislaid the middle one. The report dimension survives where it earns its
place: a view counter keys on (report, token) — the report says what was opened,
the token says who.

THE PAY GATE is the invoice's, and the invoice is the order's. Paying unlocks
whatever has been published, and a client who has paid is not asked again because
a second report arrived later. Per-report payment would need per-report
invoicing, which is a different product.

REPAIR REQUESTS gather across all published reports on the order. A client
negotiating repairs is negotiating about one house and does not care which
document a defect came from; splitting the list would make them assemble it
themselves before they could ask for anything.

RE-INSPECTIONS are a new ORDER, not a second report. Recorded at the column so
nobody "improves" it into an ancillary report and puts two fee-bearing visits,
two agreements and two invoices behind one order id.

The erasure work is the substantive part. Adding `reports.title` to the manifest
without implementing it left a rule with no orchestrator behind it, and
erasure-manifest-coverage caught exactly that — a manifest that promises more
than the code does is worse than one that promises less. Titles are now cleared
through the subject's inspections, and cleared to a sentinel rather than blank:
a reader of the version chain needs to see a document existed and was
deliberately cleared, not wonder whether the field was ever filled in.

Two things that had to be got right there. The scope goes through
inspection_people, NOT a column on inspections — clientContactId was dropped when
people became rows and the schema comment says not to reintroduce it, which my
first attempt did. And matching is by inspection, not by title text: an address
can be spelled several ways, and a title mentioning someone else's street is not
this subject's data.

The orchestrator crosses the 400-line ratchet and is baselined rather than split.
An erasure routine's worth is that its steps can be read in order in one place;
splitting it to satisfy a line count would damage the property that matters most.

Full serial gate green: lint 0, test:unit 4246, test:web 1994.
The table listed paths that are discoverable by looking, and it was one more
thing to keep in sync with the tree. What is worth writing down is the handful of
things the layout does not tell you — which is what Core Architecture below
already does.
CI's worker-runtime job failed after lint, test:unit and test:web had all gone
green. test:workers is deliberately not in that three-suite run, so both of these
reached CI — which is the interesting part, not the fixes themselves.

1. Four collab specs hand-wrote `CREATE TABLE inspection_results`, copy-pasted.
   The Drizzle table gained `report_id`, and the only thing that noticed was the
   Durable Object's persist() throwing D1_ERROR inside workerd. The DDL now lives
   once in tests/helpers/inline-ddl.ts beside the tenant_configs one that learned
   this same lesson in InspectorHub#164, and inline-ddl-schema-sync.spec.ts asserts it covers
   every Drizzle column — so the next added column fails a fast unit test instead
   of a slow runtime one. Proven red by deleting report_id from the DDL and
   watching it name the missing column.

2. vitest.workers.config.ts had no `~` alias, so the server-side message seam
   could not resolve and three suites failed to import at all. That is the FOURTH
   resolver that has to agree on where the compiled catalogue lives —
   tsconfig.api.json's paths, vitest.api.config.ts, vite.config.ts and this one —
   and it is the only one whose disagreement is invisible until workerd runs.

test:workers now 20 files / 91 tests green.
The E2E run caught a gap I opened. The collab route resolves an inspection to its
primary report and fails CLOSED when there is none — which is right, because the
alternative is falling back to an inspection-keyed document and that IS the
shared-Y.Doc bug. But nothing created that row for NEW inspections: generating one
report per sold service is Task 4, deferred. The production backfill covered the
19 existing rows, so the hole only opened for anything created afterwards, and the
symptom was collaborative editing silently 404ing.

This is the minimum slice, not the whole of generation: every order — including a
re-inspection, which is its own order — gets a primary report. Per-service reports
remain Task 4's job. Non-fatal by design: both callers have already written the
canonical inspections row, and throwing here would lose it over a row a backfill
can add later.

Also fixes a pre-existing bug the same run surfaced. seedDefaultServices names
`price` and `active` in its INSERT; the columns are `price_cents` and `is_active`,
so the statement always threw and was swallowed by its own catch. A standalone
tenant has never had a seeded service catalogue, and the only evidence was a
warning in a log nobody reads — the public booking page simply had nothing to
sell.

test:workers 20/91 green; inspections suite 537 green.
The status-literal gate caught the new reports row writing 'in_progress' as a
hand-typed string. That gate exists because a bare status bypasses the type
layer, which is the path by which ghost values reach runtime.

reports.status is a NARROWER axis than REPORT_STATUS — it has no 'submitted' —
but 'in_progress' means the same thing on both, and the point of the constant is
that the two cannot drift apart silently.
Five jobs need the generated route types and every one of them was running the
full ~60s typegen. Paraglide, which sits right beside it in the same five jobs,
has been cached on its inputs since the parallel split; typegen never got the
same treatment, so each CI run burned about five runner-minutes producing five
identical copies.

The key hashes the route tree rather than the generated output, which is what
makes it safe: a hit can only restore what those exact inputs produced. Hashing
the route file BODIES over-invalidates — editing a loader cannot change the
generated types, since those infer from `typeof loader` when tsc runs — but
over-invalidating only costs a regenerate, and that is the direction a cache
should err in.

package-lock.json is in the key because the generator is react-router's own.
The close-path assertion failed intermittently in CI with the departed user
still listed, and passed every time locally. The helper was a fixed
setTimeout(50): whether the DO has processed a webSocketClose yet is a question
about the runtime's schedule, not about elapsed time, so a sleep answers it
correctly only on a machine fast enough. A loaded CI runner is not.

Polling to a deadline is both faster when the event has already landed and
correct when it has not. sync-producer.spec.ts already waits this way, so this
follows the suite's existing convention rather than introducing one.

Verified the helper can still fail: with a deliberately wrong expectation the
spec goes red at the deadline and reports the value it actually observed, so a
real regression cannot hide behind the polling.
…nment lives

The columns were annotated DEAD and were not. inspection-core.service.ts still
wrote lead_inspector_id and helper_inspector_ids on every wizard create, the
clone path read them back off the cloned row, and two automation paths resolved
the inspector as 'leadInspectorId ?? inspectorId'. Meanwhile the link table's
own docstring called itself a denormalized mirror of those columns, and the
roster module's said the same. Half the code had been migrated and every comment
still described the old direction — the state most likely to produce a wrong fix
later.

So: stop writing the two columns, and move the reads.

- The wizard writes teamMode (still live) and passes lead/helpers as INTENT to
  syncInspectionAssignments. Same resolution, one place instead of three.
- The clone reads the SOURCE inspection's roster rather than columns copied onto
  the clone row. Those columns are no longer written, so a clone of any newly
  assigned inspection would otherwise come out with nobody on it.
- automation/trigger.ts and automation/recipients.ts read roster.lead. This is
  value-identical to what they did: 'leadInspectorId ?? inspectorId' is exactly
  how the lead row is resolved when written (buildSyncStatements), so resolving
  it once at the write and reading the answer cannot disagree with itself.
- inspections.inspector_id survives only as a fallback for rows created before
  the link table existed and never re-assigned since.

One consequence recorded rather than smoothed over: the sync failure handler
stays non-fatal, but its justification changed. It used to be 'the mirror can
lag harmlessly'; now a failure leaves the inspection genuinely UNASSIGNED. It
stays non-fatal because the inspection row is already committed and throwing
would lose it — assignment can be redone, a lost inspection cannot.

canEdit now takes assignedUserIds instead of the columns. It had to change even
though NOTHING CALLS IT: had it kept reading columns that are never written, it
would have denied every non-admin the moment anyone wired it up. Its docstring
claimed it guarded every write-bearing route; it does not. Write routes
authorize with requireRole + requireCapability only, with no per-inspection
membership test, so within a tenant any inspector with the capability may edit
any inspection. That may be the intended product behaviour — it is now written
down instead of implied by a function nobody runs.

file-size baseline: inspection-core.service.ts 1126 -> 1131. Comments only, and
I trimmed them once already; the remainder documents a correctness-critical
invariant. Splitting an 1131-line service is a refactor this change does not
justify.
…ed on by accident

Two things about the e2e job, found while working out whether it can be sharded.

The browser download was uncached — about 60-90s of every run spent fetching a
Chromium that a given Playwright version pins exactly. Keyed on the lockfile for
the same reason the paraglide and typegen caches are keyed on their inputs: a
hit can only restore what those inputs produced. "--with-deps" still runs on a
hit; it installs system libraries outside the cached path and is cheap once the
download is skipped.

The "inspector-portal" project declared no dependencies, yet its beforeAll seeds
the admin password and then logs in — both of which need the workspace that the
"api" project creates. It passes today only because workers:1 runs projects in
declaration order and "api" happens to come first. That is an ordering held by
accident; any reorder, any parallel run, and any shard that did not happen to
include "api" would fail it. Declaring the edge costs nothing and removes the
trap.

Sharding itself is NOT done here, and the reason is worth recording: the blocker
is not the spec coupling it looked like. globalSetup truncates all of D1 and all
of KV once per "playwright test" invocation, and webServer.reuseExistingServer
is true on a fixed port — so two shards on one machine share a worker and a
database and wipe each other. There is a cheaper win to take first. The
workers:1 cap exists for two specific reasons (several specs race
POST /api/auth/setup, and five shell out to "wrangler d1 execute --local"
mid-test); fixing those raises in-job parallelism with no extra runner-minutes
at all, which sharding cannot claim.
…eded

Three separate costs, all of them paid every run.

UNIT SUITES. Both are import-bound, not assertion-bound: a full API run
reported `import 1977s` against `tests 1246s`, because the default forks pool
rebuilds the whole module graph per spec file. Pre-bundling node_modules
attacks that directly and is cached in node_modules/.vite. The web suite also
defaulted every one of its 305 files to happy-dom, though 136 touch no browser
API at all; the default is now node and a file that needs a browser declares it
with a docblock. Declaring the browser rather than its absence means a
component test written without the docblock fails loudly, in the file whose
author can fix it — verified by removing one and watching it die on
`document is not defined`. Measured: API 611s -> 222s, web 130s.

Do NOT set `pool: 'threads'` in vitest.api.config.ts: 312 specs drive an
in-memory better-sqlite3 and the run dies with SIGSEGV. Capping maxWorkers was
also tried and cost 13% for memory that was never scarce.

E2E PARALLELISM. `workers: 1` had three causes, not the two that were obvious.
Specs raced POST /api/auth/setup with different company names, so whichever won
named the tenant — they now share one COMPANY_NAME and depend on `api`. Four
specs shelled out to `wrangler d1 execute --local` mid-test to re-hash a
password that already had that value, locking the SQLite file the dev worker
was serving; those are gone, and calendar-connect seeds through a fail-closed
worker hook instead. The third only surfaced when 3 workers were actually
tried: ten projects shared ONE seeded inspection, so SpeedMode found nothing
left to rate after a concurrent spec had rated it. There is now one inspection
per editing project, and `readEditorSeed()` resolves the caller's own rather
than falling back silently — handing an exclusive project someone else's
fixture is the failure this is meant to prevent.

That first 3-worker run also exposed a race the specs always had: four of them
waited for `<main>` under a comment claiming that proved hydration. It proves
the SSR shell arrived. `awaitEditorInteractive` retries the idempotent item
selection until the pane opens — a gate, not a sleep, since a genuinely broken
editor never opens it. All four are fixed, including the two that happened to
win this time.

Last local-only blocker: a real `.dev.vars` sets APP_BASE_URL to 8787 for
`npm run dev`, so the server stamped that port into emailed links and
agent-unified-link followed one to a port nothing served. CI's generated
.dev.vars omits the key entirely, which is why only local runs saw it. Pinned
via --var, like the other E2E-only bindings. webServer's timeout also had to
cover the full build it runs: 60s survived only because Linux CI finishes
inside a minute, while the same build takes 2m02s on Windows.

Verified: e2e 173 passed twice running (3.7m, 5.1m), unit 636 files, web 304.

Also here: `app/routes/inspections-list.test.ts` is deleted. It tested
`groupByInspectionStatus`, an exported pure helper that was never implemented —
the shipped list groups by attention and time, not status, and both halves of
what it actually does are already covered by dashboard-workflow.test.ts and
dashboard-buckets.test.ts. It survived because no gate could see it: tsconfig
excludes app/**/*.test.ts from the app tsc pass, so the import of a
non-existent symbol never failed, and describe.skip meant the body never ran.
The web suite now has no skipped files at all.

And test-hooks.ts routes through getDrizzle, which lint:provider-helpers wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016BzmFiHBLHN7HFFYW2whLr
The E2E section still said `workers: 1`. It is 3, and the two rules that make
that safe were learned the hard way when it was raised: a project owns the rows
it writes (the editor-seed setup mints one inspection per editing project), and
a spec gates on the editor being interactive rather than on `<main>` being
visible — the app is server-rendered, so the markup is on screen before React
attaches a handler, and every spec that waited for markup was racing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016BzmFiHBLHN7HFFYW2whLr
@important-new
important-new merged commit b3a57ea into InspectorHub:main Aug 3, 2026
14 checks passed
@important-new
important-new deleted the batch1/wave4-closeout-and-services branch August 3, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Canned comments can be created and edited but never deleted; the DELETE route has no caller

1 participant