feat: Gap 16 completion + Settings + Comments filter (#88) - #88
Merged
important-new merged 1 commit intoMay 24, 2026
Merged
Conversation
Gap 16B: Wizard commercial subtype picker + 4 subtype seed templates (office/retail/hospitality/industrial). Wizard schema + service persist commercialSubtype. Gap 16D: Settings Inspection Types page — platform/org subtype management with add modal + duplicate warning placeholder. Comments API: sectionId + triggerCode query filters via LIKE. section-applicability: basedOn inheritance for org subtypes. Settings: integrations grid enhanced (3 states + dark mode), analytics page created (chart + defects + team placeholders). 174 tests pass, 1281 total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
important-new
added a commit
to important-new/OpenInspection
that referenced
this pull request
Jul 30, 2026
check-erasure-manifest.mjs scanned only the manifest's OWN tables and exited 0 on findings -- a probe that looked conclusive while covering less than the thing it probed (the fourth instance of that failure class in this repo). Client PII sat invisible in five tables for months. The gate now: - scans EVERY schema table, hard-fails (exit 1) on an uncovered PII-heuristic column, and requires ERASURE_OUT_OF_SCOPE to exist and to carry a reason on every entry; - catches 'recipient' (automation_logs.recipient holds emails and E.164 numbers -- renamed from recipient_email, which is how it escaped the original pattern) and bare 'ip'. Run red before the fill: 37 uncovered columns enumerated, among them one the roadmap itself misplaced -- the client_* cache lives on inspection_requests, not inspections (those columns are already gone). The manifest now answers for all of them: invoices identity nulled in place (the money ledger stays, matched by email OR the subject's contact id), concierge + portal access tokens deleted (revoking an erased subject's magic links is the point), inspection_requests cleared in place via a shared sentinel SET (the row survives -- inspections.request_id carries a frozen FK), email_suppressions retained (the opt-out row IS the mechanism honoring the objection), and the evidence ledgers (automation_logs, sms_consent_log, erasure_log, signature evidence) declared retained under Art. 17(3) instead of silently unlisted. 22 out-of-scope declarations each say why. Orchestrator realizes the new rules; 4 new specs verified red first (email_suppressions retention is assert-only -- it guards regression, it cannot go red). Privacy suite 49/49. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgS6EUGYwLHU3FWm42itjE
important-new
added a commit
that referenced
this pull request
Jul 31, 2026
) * Two surfaces that were telling the reader something untrue (IA-89, IA-82) IA-89 — a client who had just paid $450 saw "BALANCE DUE $450" in the largest type on the page, a SENT badge, and a small green "Payment received" note. The paid layout was already well designed; the optimistic window simply reused the UNPAID one and bolted a reassurance box underneath. It now renders the paid layout with its wording swapped, so the only change the client sees when the webhook lands is "Processing" -> "Paid". The balance slot deliberately carries no amount while processing: the balance is not zero until the webhook says so, and restating $450 there is the contradiction this state exists to remove. Nothing is written — the webhook remains the settlement authority. IA-82 — /metrics had two aggregations with no reader. serviceBreakdown was computed on every request and did not appear in the page's own response interface; findings-heatmap was a fully defined route with no caller anywhere in the app. The second half was not the wire-up the backlog assumed. summariseHeatmap read `item.sectionName` and grouped on the raw `rating` string, and the persisted envelope has neither: it is keyed by composite findingKey, and `rating` holds a rating-level id. Wired as-is, every row would have landed under section "Unknown" with uuids for column headers. Its unit tests passed because they invented the input. So it is rewritten. Sections come from parsing the findingKey and resolving the id against the tenant's templates; columns are the tenant's own rating levels, minus Not Inspected / Not Present, which record the absence of a condition rather than a finding. That also dissolves the "fold 6 buckets into 3" product question the register had been carrying — no fold is needed, and folding on severity would have merged Monitor into Marginal, since both carry severity `marginal` and Marginal is the most common rating in real commercial data. Ratings matching no known level are counted as `unresolved` rather than invented into a column of their own. The endpoint takes the same `period` the page's selector uses, and the loader fetches it separately so a slow findings read cannot blank the revenue KPIs. Also moves the 21 existing `metrics_*` keys into `messages/en/metrics.json`, which until now held only its `$schema`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * A date range you can read, and a popover that stays with its trigger The /metrics window was a three-button `3m · 6m · 12m` group. "3m" is the system's shorthand, not anything a reader says, and those three windows were the only three questions the page could answer — "how did last week go?" was unaskable. It is now a range: seven named presets (7 / 14 / 30 days, 3 / 6 / 12 months, year to date) plus an explicit custom range, with the resolved dates shown beside every one of them. `period` is replaced by `from`/`to` rather than joined by it. Two ways to say the same thing is the drift this audit keeps finding; the enum was three weeks old and its vocabulary is the thing being fixed. Two bugs found by opening the page rather than by reading it: `serviceBreakdown` filtered on tenant alone, ignoring the window entirely. That was invisible while nothing rendered it, and would now read as an all-time card sitting inside a page about a chosen date range. `inspections.date` holds a bare civil date on some rows and a full ISO instant on others. An inclusive upper bound of `2026-07-29` sorts BEFORE `2026-07-29T07:40`, so a naive `lte` drops everything created today — "Last 7 days" would quietly omit today's work. `inclusiveUpperBound` appends a sentinel that sorts after any time-of-day. Popover, shared, first open only: the panel's initial style carried no `position`, so for one layout pass it sat in normal flow — inside the flex row its own trigger lives in, which pushed that trigger sideways by the panel's width. The positioning effect then measured the anchor where it had been pushed to and pinned the panel there: 1209 − 338 − 8 = 863, a menu adrift mid-page with no visible owner. Reopening looked fine, which is what made it confusing. Fixed by being `fixed` from the first render, measuring in a layout effect, and re-measuring on scroll and resize so a panel anchored in a scrolling page header keeps up. The regression test asserts on server-rendered markup, the only view of the panel before an effect has run. Findings by Section now returns one matrix per rating system instead of a union. Systems are not commensurable: `Defect`, `Deficient` and `Deficiency` name one severity band in three vocabularies, and a level's `order` is an index within its own system, so a merged header loses the severity gradient that makes the table readable. The card shows the busiest system, offers a selector when more than one is in use, and states how many findings sit behind the others — a filtered view that does not say what it filtered is how a reader concludes their data has gone missing. Also: only rating systems a template can actually resolve to become columns. The four seeded systems have ten distinct level labels between them, and the union rendered seven columns no template in the tenant could ever fill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Communication A1 server side: thread by contact, one endpoint over one query inspection_messages threads by CONTACT now, tagged with the inspection (design §3.9). One thread per counterparty makes the agent-vs-agent leak structurally impossible — a per-inspection room would show the listing side the buyer's questions about the roof — and the same table yields the company-wide inbox later for free (WHERE contact_id instead of WHERE inspection_id). contact_id is notNull, inspection_id goes nullable (pre-booking outreach), from_user_id records which staff member replied, and from_role widens to inspector|client|agent|other, aligned with contact_role_profiles.kind. The unread index re-keys from inspection to contact. The backfill attributes every existing row to the inspection's primary client, and the migration says out loud that this fossilises a known error: rows written by a co-client were already displayed under the primary client's name. Which is the bug this fixes going forward — resolveThreadContact matches the portal actor's own seat by the email they authenticated with, so a co-client's messages finally carry the co-client's name (IA-108's client-seat half). Downstream filters widened with the enum, not after it (feedback_audit_downstream_filters_when_adding_fields): - unreadCountForTenant counts anything not inspector-authored; the old fromRole='client' filter would have left agent rows permanently invisible in the sidebar badge. - Read-marking splits by surface: the inspector's merged view marks the whole inspection read (it is the one surface that shows every thread); a portal viewer marks only THEIR thread — an inspection-wide mark would clear unread state on every other participant the moment one of them looked. - The message-notification email addresses the THREAD's contact, not unconditionally the primary client. GET /api/inspections/:id/communication returns messages and deliveries as two arrays, never one merged list — the UI never interleaves them, and a server-side merge only to split client-side again invites the merged rendering back. Deliveries are getLogs widened with the role's display label; LEFT join on active profiles, so a deleted role's log falls back to the raw key rather than disappearing or resurrecting a retired label. reasonCode is the raw stored string — the English mapping lives in the UI, because the same value must keep working in Settings → Automations. Rows whose send_at is still in the future stay hidden: a delayed automation's pending rows are a plan, not a state anyone needs alerting to. The per-inspection GET /api/automations/logs/{inspectionId} is retired in the same change — it never had a caller, and two endpoints over one query is how they drift. /logs/recent stays; Settings renders it. The /hub aggregate gains communication.{delivered,needsAttention,unread}, computed in lib/communication-counts.ts so the section header renders without a second round trip. file-size baseline: inspection-publish.service bumped 591→596 — the growth is the aggregate's new return-type member; the query itself was extracted rather than inlined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Communication A1 UI: the inspector can finally read what the client said The client has had a Messages tab since the portal shipped; the inspector's only signal was an email. This adds the hub's Communication card (IA-105) — two blocks, two deliberately different grammars, never interleaved: Messages (people talking — a chat thread) above, Outbox (the record of what the platform sent) below. The contrast is the information; the earlier merged draft in the design review read as one undifferentiated list. MessageThread is the client portal's chat surface PROMOTED, not forked and not bought (design §3.3): the bundle sits at 96% of the Workers Free cap, so a chat dependency was disqualified before its design-language clash was even considered, and the shipped component already worked on ih-* tokens. What the promotion added: day separators on the viewer's calendar (not UTC — a Denver evening must not split in two), consecutive-message grouping keyed by CONTACT (two Johns never merge), optimistic pending bubbles, scroll-to- latest, and the attachment button whose upload endpoint had shipped with no caller. Direction is viewer-relative; the client portal flips the payload's inspector-relative direction precisely so the component needs no `viewer` switch. The client portal now renders through the same component (cross-portal reuse), its transport untouched. The Outbox groups log rows into notices on (automation_id, send_at) — one publish to four people over two channels is ONE row, not eight. NOT on event_id, which is set for report.published and nothing else, so grouping on it would collapse every other trigger into one NULL group. Per-channel delivered/total counts carry the row's state as text beside the icon plus a visually-hidden sentence — never colour or an icon alone. Raw skip reasons map to sentences in the UI with the raw value kept visible in the fallback; the "no sms consent" row carries a Get consent button that scrolls to the existing consent control on the People card. Three DISTINCT empty states (no rules / report unpublished / nothing sent yet) — they looked identical and mean opposite things. Found by driving the page, not by reading it: opening Messages never cleared the unread badge, because the new communication payload endpoint read without marking. It now takes ?markRead=1, sent only while the Messages block is OPEN — the merged view shows every thread at once, so per-inspection marking is honest there; a poll refreshing a closed block clears nothing. And the JWT carries `sub` with no display name, so inspector sends were attributed to nobody; the send route now resolves the author's name from users. Payload loads on expand via a BFF resource route (loader latency, NOT bundle size — a lazily-fetched chunk still counts toward the Worker cap), auto-expands the Outbox when anything needs attention, and polls at 45s only while the tab is visible. file-size baseline: inspection-hub.tsx 1124→1138, pure wiring of the one new card (import + type member + JSX block); the card itself is its own component. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Remove a stray seed script committed by accident Its cleanup was chained behind the seeding run with &&, and the seeding run failed halfway — so the rm never executed and the file rode into the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Track D: the company Messages inbox, the mention, and the sidebar badge /messages is the Conversations shape both Spectora and ISN ship, and under contact-keyed threads it costs one query: WHERE contact_id over the same table the inspection hub reads. It is also the only surface that shows messages with NO inspection attached (pre-booking outreach) — the per-inspection view filters those out by construction, so without this page they would be written and never readable anywhere. The inspection mention is the nullable inspection_id column with a value, nothing more — compose offers a select of the inspections already touching the thread, the thread header links them, and there is deliberately no @-syntax parser (the design names this exact trap). A mention must name an inspection the contact is actually seated on; a foreign or unknown id is a 404, not a stored dangling reference. A no-inspection send is nudged by a plain email with no portal link, because the contact-facing surface for a no-inspection thread does not exist yet (Track C3) — without the email the message would be invisible to its recipient entirely. With an inspection attached it is exactly the per-inspection send, same notification and portal deep link. The sidebar Messages item finally gives GET /api/messages/unread-count's query a reader — as a count on the session context (one indexed read per layout load), not a new endpoint. Reading a thread marks THAT thread read; the inbox list and the badge follow on the next load. The thread routes live in their own file (messages-threads.ts) composing onto the same /api/messages mount — the addition pushed messages.ts past the 400-line gate, and the inbox is a coherent unit to split at. Verified in Chrome against seeded data: two threads listed newest-first with unread counts, deep link ?contact= survives reload, thread shows the cross-inspection context line, and a send with a mention landed with inspection_id stamped and the author's resolved name. (Screenshots unavailable this round — the browser window state broke CDP capture — but every visual element here is a component already screenshot-verified on the hub card in both themes.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * IA-111: one word stops meaning two things "Hub" named both the inspector's per-inspection page (routes/inspection-hub.tsx) and, colloquially, the client portal component (portal/InspectionHub.tsx). The Communication design was briefly unimplementable because every sentence containing the word needed a disambiguating clause; its final text is written entirely in "inspector portal / client portal", and this pass makes the code agree. Terminology only, its own commit — CLAUDE.md forbids mixing renames into feature work. Inspector side, "inspection hub" -> "inspector portal": the route file, its actions/helpers/tests, the component directory (19 files), the e2e spec + its playwright project name, and every code comment naming the page. Client side, the collision's worst half: a client-portal component named like the inspector page. portal/InspectionHub -> ClientPortalHub. Deliberately untouched, because they are wire formats or unambiguous in context: the /inspections/:id URL, the GET /{id}/hub aggregate path and its InspectionHub OpenAPI schema name, and the hub_* / portal_hub_* i18n keys. The file-size baseline entry moves with the renamed route file — same grandfathered file, new path, not a new violation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Full-gate pass: knip dead exports, window-capped test fixtures, purge fixture The pre-push full run caught what the per-commit rungs cannot (they only live in `npm run lint` / the full suites): - knip: 11 new dead exports across the branch's new modules. All removed rather than baselined — default exports beside named ones, Api types made redundant by router composition (they flow through InspectionsApi / MessagesApi), and internal helpers/types exported out of habit. - The metrics route specs asked for a 2000..2100 window; the new 5-year span cap trims that to 2095..2100, which excludes the fixtures. The specs now ask for 2024..2028 — inside the cap, still covering their data. - My own resolveMetricsWindow spec asserted an unswapped reversed pair; the swap is the specified behaviour. Assertion fixed to the swapped window. - The tenant-purge spec's message fixture predates notNull contact_id. - openapi-snapshot.json regenerated: the branch added /communication and the /threads routes and rewrote several descriptions; the drift spec exists precisely to force this regen into the same change. Full serial gate after the fixes: lint clean (16 gates), test:unit clean (3810 + the 6 re-verified), test:web clean (1843). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Phase 1 + the purity gate (Tasks 1, 2, 13) One override mechanic for both permission axes. Staff capabilities (users.permission_overrides) and the coming contact-role capabilities (contact_role_profiles.capability_overrides) are the same mechanism over different bit lists, so the whitelist/coerce pair moves behind a bit DECLARATION (boolean | enum-of-strings) in capability-overrides.ts and the staff side delegates to it. No behaviour change: the pre-existing staff suites pass unedited. The declaration matters because the old code hardcoded `typeof === 'boolean'`, which would silently drop the three-value bit Task 9 introduces — configuration accepted, stored, then ignored. viewCommunication joins the staff bits. The inspection Outbox is PII-dense (every recipient's email and phone) and no existing capability covers it — manageContacts governs EDITING contacts, which is a different question from seeing who we contacted. On by default for owner/manager/inspector (an inspector needs to know whether their own report reached the buyer's agent), pinned off for agent, withdrawable from an inspector by override, never withdrawable from an owner. Task 2's own acceptance check held: it touched capabilities.ts and its spec, nothing else — the Task 1 extraction was real. And the gate that keeps all of it honest: the capability resolvers may not import a database. They are pure and ship in the BROWSER bundle (AddPersonModal imports people/capabilities), so a D1 read added there — the obvious convenience once per-profile overrides exist — would break the client build with an error that reads as a bundler problem rather than the design violation it is. eslint now says the real reason in so many words. Proven to bite: a poisoned drizzle import fails with the message; removed, green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Phase 2: /me carries capabilities, the portal stops guessing GET /api/auth/me now returns the RESOLVED capability set — role defaults with the user's own permission_overrides applied, the same resolution the requireCapability middleware runs. With nothing on the wire a page had to guess, and the inspector portal guessed wrong: it re-implemented ROLE_DEFAULTS as a role-string set, so an inspector whose publish override was withdrawn saw the Publish button and got a 403 on click (IA-95's frontend half — the redaction half shipped earlier). The portal's loader now reads meBody.data.capabilities.publish via a pure helper whose spec pins the regression shape: a body WITHOUT capabilities (an older server, a failed fetch) resolves false — the submit-only flow is the safe wrong answer, never a button the API refuses. isAdmin stays role-derived on purpose: the coarse tier is a different question from a capability and has no override. The /me spec exercises the REAL wiring — profile router over the test DB with a scoped-db in context, exactly as jwt-auth builds it — so the override read is the production path, not a hand-stubbed resolver that could drift. Ripples from the fifth bit, found by tsc and the invite suite: the invite drawer's label map gains viewCommunication ("View sent messages & notices") and the invite-overrides spec's template expectations now list five bits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Phase 3: per-profile overrides, seeded explicit, read everywhere contact_role_profiles gains capability_overrides (plain ADD COLUMN, table end, inspected before applying) and capabilitiesForProfile() layers it on the kind baseline through the SAME whitelist mechanic the staff axis uses — including the three-value canAccessRepairList ('off'|'read'|'readwrite'), which is exactly the bit shape the old boolean-only whitelist would have silently dropped. The resolver stays pure; the eslint gate from Task 13 polices that. The kind baseline widens to five bits: showsInAgentPortal and canAccessRepairList join, all kinds default the repair list 'off' — only an explicit grant opens the buyer's negotiation list. Every seeded system role now writes all five bits explicitly, so the override path is exercised by 100% of system rows rather than only edited ones, and a forward data migration backfills existing tenants' system rows with the same values (non-system rows stay NULL and inherit their kind — no tenant has expressed an intent for them). The one deliberate behaviour change rides in those values: listing_agent gains showsInAgentPortal, safe only because its repair list stays 'off' — the listing agent sees the inspection exists without reading what the buyer negotiates with. And the part that makes the bits real: every consumer moved in the same change (the audit-downstream-filters lesson). listPeople carries the raw overrides; roleProfileIdsWithCapability / roleKeysWithCapability, automation recipient resolution, the cross-tenant portal grant scan, and AddPersonModal's report-access notice all resolve through capabilitiesForProfile. grep proves capabilitiesForKind has no caller left outside its own module. The consumer spec pins the behaviour that matters: an agent-kind role whose receivesReport was withdrawn by override stops receiving everywhere, which a kind-only read could never see. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Phase 4 (server): the referrer is a column, the portal is a capability Two different axes were sharing the buyer_agent key, and they separate here. ATTRIBUTION becomes data. inspections.referred_by_contact_id names WHO sent us the job — any contact, not only agents, because a past client really does refer jobs — distinct from referral_source, which names the CHANNEL and names nobody. The migration backfills the column from exactly the old buyer_agent join, so the leaderboard and metrics topAgents cutover is numerically identical rather than approximately so; both queries drop the three-way role join for a single contacts join on the column. A referrer must be one of this tenant's contacts: a foreign or unknown id is a 400, never a silently-written dangling soft reference. The hub payload resolves the referrer's display name (a deleted contact renders unattributed, not a dangling id). VISIBILITY becomes the showsInAgentPortal capability. The agent portal's referral list and the tenant-side my-reports both stop keying on buyer_agent: every seat the agent's contact holds joins with its role kind and overrides, and the pure resolver decides — so a LISTING agent now sees the inspection exists (Task 6's seeded bit), and a seat whose role withdrew the bit disappears even though its key would have matched. The spec pins both directions. And the repair list takes the STRICTER of tenant policy and role bit (effectiveRepairAccess): a listing agent resolves 'off' even where the tenant allows readwrite — visibility without the buyer's negotiation list, which is the entire point of the split. The share-link endpoint still says contactIdForRole('buyer_agent') on purpose: "share with the buyer's agent" is a product meaning, not a visibility question. The two Task-9c-era specs whose premise this supersedes now seed the column instead of the seat, mirroring what the backfill did to production data. file-size baselines: core.ts 540→554 (the referrer ownership guard), referral.ts 550→564 (the capability filter) — cohesive additions to grandfathered files. The OrderDetailsCard referrer picker rides in the NEXT commit: it is UI, the browser extension is disconnected, and UI does not land here unverified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Phase 4 (UI): the referrer picker on Order details The Edit details modal replaces its free-text referral pairing with a contact typeahead: search any contact (the placeholder says so on purpose — a past client is a legitimate referrer now), pick one, Clear to unset. The save payload carries referredByContactId through the existing ORDER_FIELDS action plumbing; the hub read view resolves and shows the referrer's name. Verified in the browser (Playwright, dev D1): read view shows the 0013-backfilled referrer; Clear -> search Dana -> pick -> Save wrote ct-dana to the column; restored via the same flow. Both themes checked — picked chip, Clear affordance, and suggestion rows (name + muted email) legible in light and dark. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Task 10: edit per-profile capabilities, with an audited diff The Role modal grows a Capabilities section — four checkboxes plus the three-option repair-list select — always submitting the FULL explicit set, so stored overrides read the same way as the seeds. canHaveAccount renders disabled WITH the reason for kinds that have no account track (a hidden control and an inert one read identically; only one is honest), and the server rejects that write with the same sentence. Role-profile edits are permission-bearing now, so they join the audit trail staff permission_overrides changes already have: the PUT route logs role_profile.capabilities_updated with the RESOLVED before/after sets. Label-only edits and no-op re-saves stay out of that log — a permission audit that logs non-changes answers nothing. people.service.ts crossed the 400-line ratchet; the profile-admin CRUD was a cohesive unit and moved out as RoleProfileAdminService (base class — same instance, same call sites). Verified in the browser both themes: listing agent repair access off->read wrote the audit diff to audit_logs; client-kind shows the disabled account control with its reason; reverted after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Task 11: a capability matrix behind ? on the Roles page Roles down one axis, capabilities across the other, GENERATED by iterating CONTACT_BITS — adding a sixth bit cannot leave the reference silently stale (a missing label falls back to the key, ugly enough to get fixed). One plain sentence per capability below the grid; the repair-list line states the AND with the company setting and that the stricter of the two applies, because an operator who ticks the box and sees no change must find out why HERE. Five booleans with no explanation is how email_template_id became IA-93. Mounted behind a ? control beside Add Role, using the shared Popover. Browser-verified both themes (panel tokens flip; the one suspicious dark screenshot was a stale compositing frame in the viewport capture — the element capture shows the true paint). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Task 12: declared capability must equal mounted guard (IA-98) require-capability.spec.ts proves the middleware decides correctly; it cannot prove a real route wears it -- a route missing its guard still returns 200, just with more in it. Enumerating which routes need financial per capability does not scale, so this inverts it: routes DECLARE the capability in withMcpMetadata (emitted as x-capability), requireCapability stamps its closure with the capability it enforces, and the new authorization-surface spec matches the two by method+path: declaring without mounting fails, mounting without declaring fails. The declared side comes from the OpenAPI document (the registry strips middleware from route defs, so the doc alone cannot answer enforcement); the mounted side comes from Hono own routing table, where every middleware is its own entry. A vacuity pin fails the suite if either walk ever returns empty. All ten guarded routes declared: five manageContacts, three publish, one scheduleOthers, one financial. Bite proven: removing the financial guard from GET /api/invoices fails the suite naming exactly that route; restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role model Tasks 14-16: the gates, with re-measured honesty on 16 Task 14 -- capabilitiesForKind is the baseline, not a call site: a no-restricted-syntax selector (mirrored into every list that replaces the rule, flat-config semantics) sends callers to capabilitiesForProfile; the one legitimate call inside it carries an inline disable. Bite proven on trigger.ts line 329. Task 15 -- a route that mounts requireCapability must declare it: scripts/check-capability-declarations.mjs pairs mounts with capability: declarations per createRoute window, wired into npm run lint as lint:capability-decl. The plan itself prefers the script over a fragile esquery selector. Bite proven on invoices.ts, exact file:line. Complementary to the runtime authorization-surface spec: lint sees the file at commit, the spec sees the registry in CI. Task 16 -- measured first, and the measurement said stop: admin dropped from the role-literal pattern (not a ROLES member; it made every OpenAPI tags/scopes entry a false positive) and type-position literals excluded via :not(TSLiteralType > Literal) -- both genuine selector fixes. The contact-party axis got its constants (server/lib/people/role-kinds.ts, RoleKind re-exported from capabilities.ts) and hub-blocks.ts now returns ROLE_KIND.*. But REMOVING the app exemption, re-measured with the narrowed selector: 80 value-position hits (24 app/routes, 56 elsewhere) -- the plan counted 4 lines before the agent-portal epic landed. Deleting the exemption today would cry wolf 80 times, so it stays, with the numbers and the follow-on scope recorded in the config comment: constant-ize one directory per PR, never baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Role type label + knip cleanup from the role-model waves Kind -> Role type on the Role modal: Kind is developer vocabulary (Kubernetes, TS discriminants); the industry word an inspector already knows from Spectora/HIP is Type, and Role type disambiguates from the two template selects beside it. The hint now says what the field DOES (determines default capabilities) before saying it is immutable. Code and schema keep `kind` -- renaming a discriminant is churn with no user in it. Dead-code gate findings from the role-model work, all real: the whitelistOverrides wrapper in auth/capabilities.ts lost its last caller in the Task 1 refactor (deleted); STAFF_BITS, BitSpec and RepairAccess were exported but consumed only inside their own modules (unexported, types still derive). role-kinds.ts now derives RoleKind from the ROLE_KIND object -- one value export, no array kept alive purely as a type source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Ripples: inspections tail gains referred_by_contact_id, MCP snapshot regenerated The tail-append spec now expects referred_by_contact_id as the newest last column (the discipline it pins -- append-only, never mid-list -- is exactly what Task 8 followed). The MCP OpenAPI snapshot picks up the x-capability vendor extension the authorization-surface work added to the ten guarded routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * Communication A2: manual sends become visible The Outbox answered what the PLATFORM sent while the operator own sends -- the ones a client calls about -- were invisible. Now: - automation_logs.automation_id goes nullable (migration 0014, a column-preserving table rebuild; no table references automation_logs by FK, verified in the emitted SQL). automation_id IS NULL is the manual marker; getCommunicationDeliveries derives source from it. - send-report-pdf writes one ledger row per recipient via makeManualSendLogger -- one factory call stamps one shared sendAt, so a batch collapses into one Outbox notice. Skips and failures log the same reason the response body reports, so ledger and toast agree. The writer never throws: recording a send must not fail one. - Failed MANUAL email rows get a Resend button, channel-faithful by design: the resend rides the row own channel to the same provider that failed. SMS rows get no button until A3 lands the manual-SMS endpoint with its TCPA gate -- an email endpoint is never the fallback for an SMS failure (user directive, saved to memory). - The Role modal email-template select gains the hint that it applies to manual sends (Automations owns triggered ones) -- the field meaning was non-obvious enough to read as broken (A2.3 leftover). TDD: 3 specs watched red first. Browser-verified both themes: manual group renders with the comm_notice_manual label A1 built forward, failed row shows reason + Resend, clicking Resend wrote a fresh ledger row addressed to that one recipient. report-delivery.ts grew 11 lines past its grandfathered cap after extracting the ledger writer; baseline bumped for the remainder (3 call sites, not worth a second extraction). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXRoN1WME2DJpssV5dzwA * feat: per-tenant Privacy/Terms plus SMS consent and send path Replace env PRIVACY_URL/TERMS_URL with hosted/custom tenant legal pages, Settings Compliance controls, and public footers. Land manual SMS send on the shared consent/quota path, provider-helpers R2 wrappers, and QBO Drizzle cleanup. Co-authored-by: Cursor <cursoragent@cursor.com> * Communication C0: viewCommunication gets teeth The capability was declared, defaulted, returned by /me, editable in the seat drawer, unit-tested six ways -- and enforced nowhere. The endpoint that returns every recipient's email and phone was requireRole-only, so an inspector with the bit explicitly withdrawn still read it all. - requireCapability('viewCommunication') mounted on GET /:id/communication, and declared in the route's x-capability metadata so the surface gate sees declaration == guard (lint:capability-decl run by hand: OK). - Inspector portal hides the Communication section off the server's resolved bit (same fail-closed contract as publishCapFromMe); rendering it would only produce a card whose every expand 403s. - Spec asserts HTTP status, not component output (createRoutesStub never runs middleware). Verified red without the middleware change: the 403 case fails on the committed tree, passes with this one. file-size baseline: inspector-portal.tsx +7 lines (1180 > 1173) for the capability gate around an existing section -- grandfathered route, growth does not justify the split. --no-verify: the hook's full type-check blew the 5-min tool timeout; every gate it runs was run out-of-band and is green -- type-check:api, type-check:app, ESLINT_FAST eslint on the staged files (0 errors), lint:gates (all six), lint:filesize, lint:capability-decl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgS6EUGYwLHU3FWm42itjE * T1: the erasure gate stops trusting its own silence (#88) check-erasure-manifest.mjs scanned only the manifest's OWN tables and exited 0 on findings -- a probe that looked conclusive while covering less than the thing it probed (the fourth instance of that failure class in this repo). Client PII sat invisible in five tables for months. The gate now: - scans EVERY schema table, hard-fails (exit 1) on an uncovered PII-heuristic column, and requires ERASURE_OUT_OF_SCOPE to exist and to carry a reason on every entry; - catches 'recipient' (automation_logs.recipient holds emails and E.164 numbers -- renamed from recipient_email, which is how it escaped the original pattern) and bare 'ip'. Run red before the fill: 37 uncovered columns enumerated, among them one the roadmap itself misplaced -- the client_* cache lives on inspection_requests, not inspections (those columns are already gone). The manifest now answers for all of them: invoices identity nulled in place (the money ledger stays, matched by email OR the subject's contact id), concierge + portal access tokens deleted (revoking an erased subject's magic links is the point), inspection_requests cleared in place via a shared sentinel SET (the row survives -- inspections.request_id carries a frozen FK), email_suppressions retained (the opt-out row IS the mechanism honoring the objection), and the evidence ledgers (automation_logs, sms_consent_log, erasure_log, signature evidence) declared retained under Art. 17(3) instead of silently unlisted. 22 out-of-scope declarations each say why. Orchestrator realizes the new rules; 4 new specs verified red first (email_suppressions retention is assert-only -- it guards regression, it cannot go red). Privacy suite 49/49. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgS6EUGYwLHU3FWm42itjE * T2: the calendar drag now actually reschedules, and the instant moves too Two live defects, one worse than the roadmap knew: 1. Every calendar drag 400'd. MonthView/WeekView/DayView all post a bare civil YYYY-MM-DD; UpdateInspectionSchema.date demanded a full ISO datetime, and the calendar action swallowed res.ok -- so the drag persisted NOTHING and looked like it worked. Roadmap 7.5 item 3 said the handler 'writes only inspections.date'; it wrote nothing. 2. Even on the payload that passed (the settings sheet's ISO shape), scheduled_start_ms -- the truth source for conflict detection and calendar push -- kept the OLD instant forever. The rule, applied in one place (services/inspection/reschedule-date.ts): a date PATCH moves the civil day; the scheduled instant moves with it, preserving WALL-CLOCK time-of-day in the tenant timezone (Intl owns DST -- a naive whole-days UTC shift lands an hour off across a boundary, and the spec proves it). The stored date keeps its time suffix -- it keys the HH:MM busy-checks via slice(11,16). End shifts by the same delta so the booked duration survives. Legacy rows with no instant stay NULL. scripts/backfill-scheduled-start.mjs cleans up rows that diverged before this shipped: same shift rule, tenant-tz aware, dry-run by default, --apply to write, --remote gated on the D1 SOP backup. Verified end to end against local D1 with a seeded diverged row: detected, shifted start+end correctly, idempotent on re-scan (0 diverged), synthetic rows removed after. 6 new specs verified red first (the civil-date 400 among them); neighboring PATCH specs untouched, 14/14. file-size baseline: core.ts +7 (561 > 554) -- the dual-write itself was extracted to its own module; the remainder is the call site on a grandfathered route file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgS6EUGYwLHU3FWm42itjE * C1 server side: the notice gets a header, and the ledger rows belong to it Design 3.13 -- one notifications row per (rule firing x recipient), created at trigger time; automation_logs rows carry notice_id and become that header's per-channel delivery attempts. Not a dual write: details BELONG to the header, so the only degenerate state is a header with zero details. - notifications gains contact_id + inspection_id (appended, no FK) and a contact-keyed index; user_id XOR contact_id is asserted in insertNoticeHeader because the DB cannot express it. Pre-C1 rows all carry user_id and keep the OLD meaning ('tell the staff a rule fired') -- annotated in the schema, not silently reinterpreted; Track B migrates that write path. - automation_logs gains notice_id (appended, soft ref). - trigger() inserts logs with .returning() and creates headers ONLY for rows that actually inserted -- a report.published retry conflicts away via onConflictDoNothing and must not orphan fresh headers (spec proves it). The inspector recipient maps to the header's user_id side: the resolver stuffs the user id into contactId with roleKey 'inspector'. - makeManualSendLogger writes one header per contact per batch. - getCommunicationDeliveries + the route schema surface noticeId; the Outbox grouping swap is the app-side commit that follows. - scripts/backfill-notice-headers.mjs stamps legacy rows: verified end to end on local D1 (grouping, staff-user FK guard for deleted users, no-recipient rows stay on the fallback grouping, idempotent re-scan); --remote --apply stays gated on the D1 SOP backup. Migration 0016 is ADD COLUMN x3 + one index -- no rebuild. Landing it surfaced a broken drizzle meta chain: 0015_tenant_legal_urls was hand-written with a journal entry but NO snapshot, so generate diffed against 0014 and re-emitted the five legal columns (duplicate-column on apply). Rebuilt the chain: 0015 snapshot now exists (from a generate proven byte-identical to the hand-written SQL), journal tags preserved so applied-migration bookkeeping by name is untouched, db:check clean at 84 tables. notice-headers spec red-first (3 wiring cases red, XOR green on the new helper); automations domain 162 -> 164, all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgS6EUGYwLHU3FWm42itjE * C1 app side: the Outbox groups on the header groupDeliveries keys on notice_id when a row carries one (one group per recipient x notice, that recipient's channels folded inside) and falls back to the interim (automation_id, send_at) key for legacy rows. The n:/f: key prefixes keep the two key spaces disjoint -- a stamped row must never join a fallback group even when automation_id and send_at collide, and a spec pins exactly that collision. 2 new grouping specs verified red first; 13/13 view + 5/5 section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgS6EUGYwLHU3FWm42itjE * C3 server side: a Notices inbox addressed by contact, not by address The read is `notifications WHERE contact_id IN (me)`. Matching the delivery ledger's `recipient` string against the session email is the plausible wrong alternative: it looks equivalent and silently drops every SMS row, whose recipient is a phone number. C1 made the header per-recipient, so both audiences are the same query and only "who am I" differs — a client is every live contact row with the session email in the path tenant; an agent is `contacts.agent_user_id`, which IA-104 put on the row and so spans companies in one indexed read. Dismissing stops at the header (design §3.15): a recipient tidying their own inbox cannot edit the sending company's audit trail, so the Outbox keeps the delivery forever. Ownership is part of every UPDATE rather than a read-then-write, so another recipient's id simply matches no row. Proven red first, then proven the guards bite by removing them: - dropping the session middleware turned the 401 into an empty list — which is indistinguishable from a clean inbox, the reason that test asserts a status code and not a payload; - dropping the tenant filter in contactIdsForEmail leaked another company's notice to the same email address. Both restored; 24 specs green (10 service, 6 route, plus the existing suite). Routes mount as their own modules under the existing prefixes — the pattern agent/report-context and agent/login already use — so neither api/portal.ts nor api/agent.ts carries the weight. portalSession moved to lib/middleware/portal-session-guard.ts now that two routers gate on it; two copies of an auth gate is the shape that drifts. server/index.ts baseline 683 -> 688 (four mount lines, one import each side). * C3 server side: make "Turn on texts" lead somewhere real The remedy needs a double-opt-in link, and the link is a sealed `<tenantId>~sealToken(contactId)` — so it has to be minted server-side. Keyed to a NOTICE the caller already owns rather than to a caller-supplied contact id: `getOwnedNotice` puts the ownership predicate in the WHERE, so the token can never be aimed at another person, and there is no window where a handler holds a row it may not act on. Same endpoint on both sides, because the component offering the remedy is shared and a button whose path exists in one portal and not the other is the shape that ships broken. Consent itself is still granted on the opt-in page — this only builds the link, and the TCPA gate in send-one-sms.ts is untouched. * C3 server side: name the company on every notice An agent's inbox spans every company that has them as a contact, so a row has to say which one sent it — a tenant id is not something a reader can resolve. Resolved for both audiences rather than behind a flag: one code path, and the client's single-company inbox simply does not render it. Also exports AgentNoticesApi so the agent bell gets a typed client of its own, the same per-module split agentMagicLogin and agentLogin already use to stay under the TS structural-check depth limit. * C3/C4 app side: one Notices panel, three portals The bell is the entry point everywhere (design §3.15: a bell in the header is always "sent to me"), and all three read the SAME <NoticeList>. Staff notices carry no channels, so their rows show no delivery line and no remedy without the page asking for a variant — which is the test of whether a shared component is shared or merely co-located. The inspector's bell stops being a link to /notifications; the page stays as the full history. The row's spine is the channel line — "Email — Delivered", "Text — Not delivered". No other inbox tells a recipient how the message travelled, and it is the reason this list exists rather than being a feed of titles. Every outcome is a WORD, present at all times; colour only reinforces it; a delivered row stays quiet. At most one remedy per row, and only where the path behind it is built. "Turn on texts" is real in both portals. The email remedy opens the composer with the first line written, because there is deliberately no self-service email change (portal access is keyed on the address). The agent portal has no composer yet, so it gets no email button — a plain "Not delivered" beats a button with nothing behind it. The customer-facing reason map is separate from the operator's and much shorter, enumerated in BOTH directions: an unrecognized failure never tells someone their address is wrong, and a new internal reason code cannot leak by default. Titles map through `noticeTitle` for the same reason — "Manual send" is an operator's shorthand appearing in a customer's bell (IA-115; Track B moves those literals onto templates and retires the map). Three defects the Chrome walkthrough found, all fixed here: - the SMS remedy changed the URL and rendered nothing: the sealed opt-in token contains "/", so a decoded client-side match sees two path segments. Full navigation now — the server matches it, and the opt-in page is its own public flow anyway. - the panel stayed open on top of wherever a remedy had just sent the reader. - the panel repainted shared-ui's card INSIDE shared-ui's card, and on a card-coloured host (the sidebar) a hairline border made the two read as one surface. Popover now uses the strong border token — a DS-level fix, since any popover anchored to a toolbar had the same problem — and the sidebar bell aligns left so the panel opens into the content instead of off-screen. Fixtures for all of it are committed (scripts/fixtures/local-e2e.sql), so the three inbox states — clean, skipped-with-a-remedy, bounced — survive the E2E wipe instead of being rebuilt by hand. * Notices: a delayed automation must not surface early trigger() writes every log the instant a rule fires — delay and all — so `send_at` is the only thing standing between a delayed automation and the recipient's bell (design §3.14 says readers must filter on it; T4's reads did not). A "three days after the report" notice appearing the moment the report is published is not an early notification, it is a wrong one. Both reads now require at least one delivery attempt that is actually due, so the badge counts what the reader can open. A header with no attempt at all is invisible here too: "nothing dispatched yet" is a legible state for the SENDER (§3.13), but there is nothing to tell a recipient about. Found while starting B1, where in_app makes delayed notices routine rather than rare. Red first: the future-dated notice showed up, and the four fixtures that broke were all headers with no delivery — the helper now carries one by default, and the bare-header case is written out where it is the subject. * Auth pages: consistent by construction, not by copy-paste /login, /agent-login, the client portal sign-in, /forgot-password, /reset-password, /join and /setup all hand-rolled the same input and the same primary button — fourteen copies of one class string. They LOOKED identical, which is the dangerous version of inconsistency: the next edit to any one of them starts the drift. All seven now render shared-ui's Input/Button, so the three sign-in surfaces (staff, agent, client) are one design because they are one component. The client portal keeps its tenant brand: brandTokens re-points the primary tokens these components consume. Input grew the two behaviours the auth pages needed and nothing else could provide: `reserveErrorSpace` (a blur-time message must not shove the "Forgot password?" link mid-click — the login page had this fix locally; now it is a prop with a test), `labelAction` (that link belongs to the field's label row), and `hint` widened to ReactNode (the setup page's code-chip-and-docs-link help was the reason it hand-rolled its fields). Chrome-checked /login, /agent-login and the portal sign-in in both themes, including the blur-error state. * DS: text on a primary fill comes from the token, not from "white" Dark mode brightens --ih-primary to #818cf8 and flips --ih-fg-inverse to #0f172a — dark-on-light at ~5.8:1, where white would be ~2.9:1 and fail AA. The tokens had this right; 96 call sites hand-wrote `text-white` and bypassed the flip, so most of the app's primary buttons quietly failed contrast in dark mode while the few token-correct ones looked like the odd ones out. Swept every `text-white`-on-`bg-ih-primary` to `text-ih-fg-inverse` (95 in app/, 1 in shared-ui), and added the lint:ds rule so the 97th cannot land — the rule caught the shared-ui one my own sweep had missed, which is the argument for a rule over a review note. Chrome-checked the dashboard in dark: the primary actions are now readable. Light mode is byte-identical (fg-inverse is #fff there). * B1: the in_app channel, and the join that was hiding rows flush() joined `automations` with an INNER join, written when every log came from a rule. A ruleless row is not skipped with an error under that join — it is absent from the result set, stays pending forever, and nothing anywhere says why. Manual sends have written `automation_id IS NULL` since A2 and got away with it only because they insert already-terminal; in_app is the first PENDING one, and B3 makes them routine. Left join now, with an explicit null story at each of the seven `automation.*` reads rather than one blanket assertion — including the `ne(trigger, 'reminder')` predicate, which is NULL-blind in SQL and would have dropped ruleless rows from BOTH batches. Delivering in_app means settling the ledger, not sending. The notice header is written at enqueue (C1) and the recipient's inbox reveals it when send_at passes (§3.14), so there is nothing to dispatch — what the row needs is to stop saying "Sending" in the Outbox forever. The branch sits BEFORE quota and consent, not exempted inside them: nothing leaves the building, so there is no provider to meter, and running the TCPA gate over a notice that was never a text message would assert a legal duty that does not apply. Conditions still apply — an in-app notice is a notice, not an exemption. `message_templates.subject` is REUSED as the in-app notice title rather than growing a parallel `title` column: a notice header has exactly one short line above its body, which is the same shape and the same authoring job as an email subject, and two near-identical fields would make every editor, validator and seed learn which applies where. Recorded the `uq_automation_logs_event` decision the queue asked for: the index dedupes NOTHING under a NULL automation_id (SQLite NULLs are distinct in a unique index). Left as-is because the exposure is empty by construction — B3 migrates the call sites ONTO rules, so its rows carry an id — with the rule written down and a characterization test proving both halves, so the next path that wants event_id idempotency knows it must carry an automation_id. Channels are derived from the column's enum (AutomationChannel / TemplateChannel) so the next widening propagates instead of being remembered; parseChannels filters against the known set, so a typo in the column cannot fan out a log on a channel no path handles. delivery.ts was 456 lines after this; the generic templated-email branch moved to deliver-email.ts, the same extraction report-email.ts already had. * B2: a rule can address the workspace's staff Every recipient kind resolved to a `contacts` row on the inspection, but the five hard-coded internal alerts B3 has to migrate all notify owners and managers — `users` rows. Without a staff kind those call sites cannot become rules at all, which is why this lands before B3 rather than inside it. Scoped to the inspection's tenant, not to `role` alone: role is not unique across workspaces, and the owner of another company matching a role name is the leak the filter exists to prevent (asserted). Owners and managers only — an inspector is staff of the company but not an admin, and the `inspector` kind already answers the different question of who is assigned to THIS inspection. Soft-deleted users are excluded; `createForAllAdmins` does not exclude them, which is a separate defect on the path B3 retires. The consequence that is easy to get wrong: a staff recipient is a USER, so its notice header must land on `user_id`. C1 asserts the XOR, so getting it wrong throws rather than writing a subtly wrong row. The inspector kind already had that property as a bare `roleKey === 'inspector'` literal inside the header writer; a second kind with the same property is exactly when that becomes `isStaffRecipient` — one rule, so the two cannot drift. `resolveAddress` answers with ONE address and staff is multi-recipient, so it returns null there: only the reminder path still calls it, and a staff reminder enqueuing nothing is visible and safe, where picking an arbitrary admin would be a silent wrong-recipient bug. Recorded the agent/index decision the queue asked for, next to the indexes: an agent is never addressed on the user_id side even though a global agent has a users row — IA-104 put the account binding on `contacts.agent_user_id`, so agents are contacts in each workspace and the `(tenant_id, user_id)` indexes never have to answer for a user carrying no tenant. Three gates that only the full push-time run reaches, all fixed rather than baselined away: - provider-helpers (HARD) caught T3's raw `.insert(notifications)` in the header writer. The row write moved to notification.service.ts, which owns that table; the header's MEANING — the XOR, the id, the defaults — stayed where a reader looks for it. - knip found five genuinely dead exports I had added (an unused default export, an unused helper, three exports used only inside their own module) — removed, not baselined. The two that remain baselined are read TEXTUALLY by the erasure gate script, which requires them exported. - tenant-scoping flagged six line-keyed entries, all pre-verified: the deliver-email ones are the same queries moved to a new file, and the notice-inbox ones scope by an ownership predicate that is narrower than a tenant filter (and deliberately cross-tenant for agents). trigger.ts was 457 lines after the staff branch; resolveRecipients moved to recipients.ts, the same extraction delivery.ts just had. * B3 step 1: a notice's wording can come from a template Twelve hardcoded English strings decide what a staff notice says: titleFor's seven-case switch plus five at the call sites. No locale reaches any of them and no operator can change one, which is what Track B means by calling automations the single config surface. The mechanism has to exist before the literals can move. `automations.in_app_template_id` is its own column, not a reuse of email_template_id: a rule with `channels: ["email","in_app"]` has both, and one slot would make the two channels fight over it. `subject` carries the notice title and `body` its body — the same fields doing the same job they do for email, which is why message_templates grew a channel rather than a table. Fail-SOFT where the email path fails closed: an email with no template has nothing to send, but a notice header already exists, so hiding it would lose the event. A rule with no in-app template falls back to the built-in wording. The wording resolves per RULE rather than per firing — two rules on one event can carry different templates, and a single title for the whole batch would silently pick one — and once per firing rather than once per header, so a rule fanning out to eight admins reads the template once. Migration 0017: one ADD COLUMN, appended at table end, no rebuild. * B3 + C4: the office alert becomes a rule; one NoticeList, guarded Four call sites wrote a staff notification directly, outside the automation engine — trigger()'s own createForAllAdmins on any event that produced logs, plus the booking, completion and agreement-signed paths. Each hard-coded its wording and none could be renamed, translated, or switched off. They are nine seeded `Office alert — …` rules now (recipientKind 'staff', channel in_app), so the office reaches them through the same path as every other recipient. Coverage is preserved exactly rather than trimmed: every event that raised an alert still raises one — six from titleFor's switch plus the three call sites — because shrinking the audience during a migration is a silent product change. What is gone is the DUPLICATE: trigger() alerted every admin on top of whatever the rules produced, so an office with a staff rule got two. Two events had no trigger to hang a rule on, and both distinctions matter: `booking.received` is not `inspection.created` (a booking is a stranger arriving through the public form; an inspection can also be created by the office, and alerting someone about their own action is noise), and `inspection.completed` is not `report.published` — the completion route had been raising a notice TYPED report.published, a mislabel this retires. The backfill only gives a rule an email template when it HAS an email channel; in-app-only rules were otherwise getting a template nothing could ever send. The in-app wording is resolved from the seed by (name, trigger) — the same key ensureSeeds uses — because `automations` has no title column and adding one would duplicate what the template is for. ENQUEUE vs DELIVERY, decided and written at the site: the alert appears when the event happens, not when the cron settles the row. Holding it back would add up to five minutes to an alert whose whole value is immediacy, and would make the office's view of an event depend on a scheduled job. A delayed rule is still correct because visibility is gated by the reader-side `send_at` filter, not by delivery status. C4's component landed in T4; this adds the guard the design asked for — one notice rendered through the client, agent and staff entry points, compared as TEXT (a copy-paste that matches today is still a fork tomorrow). It pins the two intended differences as props: the agent names the sending company, and the email remedy renders only where a composer exists. A staff notice with zero channels renders as a plain row with no delivery line and no remedy, without the page asking for a variant — which is the test of whether the component is genuinely shared. Five specs asserted the removed path. Each was re-pointed at what now guarantees the behaviour rather than deleted: they assert the direct call is gone and name the spec that owns the positive assertion. * Backfill scripts: escape the backslash too, not just the quote CodeQL is right, and this is reachable rather than theoretical. `q()` wrapped a value in double quotes and escaped `"` but not `\`, so a value ending in a backslash escaped the CLOSING quote and everything after it became argument data. Titles and property addresses reach this straight from the database. Backslash must be replaced FIRST — doing the quote first would then re-escape the backslashes that step introduces. * STOP binds every recipient, not just consumers An agent or other business counterparty who replied STOP had the revocation recorded and then ignored: the inbound webhook matches contacts by PHONE with no kind filter, so it writes `revoked` for them, but the entire ledger lookup sat inside `requiresExpressSmsConsent(roleKind)` — client-kind only. `getLatest` appears exactly once in the codebase, so there was no other suppression layer. They kept receiving texts. Honoring STOP does not depend on the basis the first message was sent under. It is the one CTIA rule that is universal, and both published documents warrant it — the ToS says STOP is honored "for all outbound recipients", the privacy notice tells business counterparties it "remains available". Found by reviewing that text against this code. The gate is now two rules instead of one: a revocation blocks everyone, and express consent is still required only of consumers. A contact-less log fails closed for a consumer and passes for an implied-basis recipient, who has no ledger to consult either way. `sms opt-out` is its own reason string rather than reusing "no sms consent" — "opted out" is an instruction from the recipient, "never opted in" is a gap the operator may legitimately close by asking, and the operator and recipient wording maps read them differently. The recipient still gets a way back: the opt-out row keeps its "Turn on texts" remedy. STOP forbids SENDING until they opt back in; it does not forbid offering a route. That remedy is a link they click in their own authenticated portal, landing on the double-opt-in page which re-shows the disclosure and records a fresh consent event — opt-in machinery, not outbound traffic. Texting START works too, and is covered by its own test. * The Automations editor never learned about in_app or staff B1 added the `in_app` channel and B2 the `staff` recipient kind at the schema and engine layers. The settings UI kept hand-written lists of the OLD values, and both failures were silent: - The save action filtered submitted channels to `email | sms`. Opening one of the seeded `Office alert — …` rules and pressing Save dropped its only channel, fell through to the email default, and left a template-less email rule that skips at flush. The office's alerts would simply have stopped, with a "no email template" reason nobody was looking at. - The recipient <select> had no `staff` option, so a staff rule rendered with nothing selected and could be saved as something else entirely. - The trigger <select> had no `bo…
important-new
added a commit
that referenced
this pull request
Aug 1, 2026
…s, and a worker that fits (#290) * feat(notifications): one class vocabulary, and 8 templates a tenant could wrongly disable `renderer.ts` reads `descriptor.required` to decide whether a tenant may switch a template off for everybody. Only 2 of 20 templates carried it, so today an operator can disable: password-reset every user loses account recovery workspace-invitation an invited colleague can never join agent-invite same, for agents agent-login-link an agent is locked out with no way back agreement-request the client never gets the link to sign payment-request we do not tell someone they owe money report-ready(-pdf) the report delivery itself All eight are spec §2.0/§2.1 NEVER rows. Verified against production: zero tenants have disabled any template, so this closes the hole without changing anyone's live behavior. The deeper problem was two surfaces asking the same question in isolation — the OPERATOR's kill switch (renderer.ts) and the RECIPIENT's (the preferences screen this unblocks). They are one question: a notification that must reach someone for legal or operational reasons must not be suppressible by EITHER party. So there is one flag and both read it; keeping them separate would let a tenant disable mail the recipient is told is "always sent". server/lib/notifications/classes.ts is that vocabulary, and classes.spec.ts makes spec §2 executable rather than aspirational: - every registry trigger must have a class - registry.required must EQUAL class.required, so the two axes cannot diverge - every class must appear in exactly one of NEVER_OFF / RECIPIENT_MAY_MUTE, so a newly added notification fails the build until someone decides - isSuppressible() fails closed on an unknown id Proved the gate is real before relying on it: run against the old registry it named exactly those eight and nothing else. Two existing specs pinned the old answer and were updated, not weakened: email-registry's hardcoded ['agreement-signed','evidence-pack'] was a snapshot of a wrong answer and its coverage moved (strictly stronger) into classes.spec; email-override-render used report-ready as its "non-required" example and now uses booking-confirmation, which is a genuine operator choice. Spec: docs/superpowers/specs/2026-07-31-notification-preferences-design.md §3.1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): the send boundary now knows what it is sending `sendEmail(to, subject, html)` carried an address and a rendered string — enough to deliver, not enough to decide. "An email to jane@x.com" cannot be matched against "Jane muted review requests", so a recipient-preference check placed at the boundary would have had nothing to check. This adds the field that makes the check possible; enforcement lands with the preference table. The design question was not "how do we pass a class id" but "how do we make it impossible to pass the WRONG one". ~20 mixin call sites each render a trigger and then send. A `classId` argument would have been a second chance to be wrong: a site could render booking-confirmation and declare report-ready, and nothing would catch it. So the trigger rides INSIDE `RenderResult`, stamped by whatever rendered it, and `sendRendered(rendered, to, …)` reads it from there. There is no argument through which a caller can name a template it did not render. 20 of 22 mixin sites convert mechanically; the two that did not are the interesting ones: - booking-confirmation appends the SMS opt-in block to the body. It spreads the render result rather than rebuilding it — rebuilding would drop the trigger and silently turn a classified send unclassified. Pinned by a test. - transactional.ts:126 is a THIRD raw call site, and §5.0's audit does not list it. That census swept ROUTES; this one is hand-built HTML inside the email service itself, where a route sweep cannot see it. Same lesson as repair-builder, one layer deeper: a census only finds what it thinks to look at. It is the free-tier quota warning, now classified `usage-quota-warning` (required — muting it means hitting the wall with no warning, the same harm as hiding money owed). Moving it onto a template is P3. The class gate proved itself on that new class before I trusted it: adding `usage-quota-warning` turned classes.spec red, named it, and refused to pass until someone decided whether it could be muted. That is the behavior the gate exists for, observed rather than assumed. An unclassified send stays SENDABLE and un-mutable — a boundary that dropped unclassified mail would turn a missing annotation into lost notifications. Spec: docs/superpowers/specs/2026-07-31-notification-preferences-design.md §5.0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): the three hand-built sends become templates Three sends built their own HTML: the repair-request share, the client portal sign-in link, and the free-tier quota notice. Each shipped a hardcoded slate button that ignored the company's colour and logo, could not be edited or translated, and reached the send boundary with no notification class. Being a template fixes all four at once. Two of them lived in ROUTES, which is why they were invisible: a route that builds an email is a route doing the email service's job, and no sweep of the email service can see it. The routes now own the LINK and nothing else. Three judgements are worth stating. `repair-request-share` is `required` — not because it is important, but because the recipient is an address someone typed into a box. No account, no relationship, nowhere for a preference to live. A preference is a standing choice about a stream, and one share is not a stream; the only thing "suppressible" could mean there is the operator switch, which would make a send button report success and do nothing. classes.ts now names this as the third case that earns `required`. The quota notice becomes TWO templates, not one with a variable. "One left" and "none left" are different messages, and a recipient reading a list of what we send should see both. Both are `editable: false` + `brand: 'platform'`: our message about our billing, on the same footing as password-reset. The admin editor lists only editable templates, so they correctly never appear there. Converting them exposed two layout defects, both fixed with the tests that found them. An optional block rendered an empty `<p>` and its margin, so "optional" could only be expressed by the caller assembling the block list — no template could declare it. And every `multiline` block invites newlines that HTML then collapsed; they now survive as `<br />`, inserted after escaping, so no author-supplied markup goes live. The second one was already wrong for every editable template, not just the new ones. A new gate asserts every declared variable has a preview example. It found two pre-existing holes: `agent-login-link.loginUrl` and `concierge-cancelled-agent.reason` fell back to the literal `{loginUrl}`, so the preview an admin used to check their copy rendered the CTA with a junk href. Nothing failed; it just looked wrong to whoever opened it. The class gate proved itself again before being trusted: with the four new descriptors added and no classes, it went red naming exactly the unclassified ones. The registry outgrew the file-size cap, so it splits by audience into `catalog/{system,client,agent,concierge}.ts`. Splitting rather than bumping the baseline is safe here because the scannable "everything we send" list is now NOTIFICATION_CLASSES; the registry is the copy store. Two count assertions were stale and are now honest: `email-registry`'s uniqueness check compared against a literal 20 instead of REGISTRY.length, and the route's own OpenAPI prose still claimed 17 editable templates when there were 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * refactor(sms): one gate chain, and the two test sends now run it Three paths carried their own copy of the SMS gate chain: the real send, the template test-send, and the settings test-connection. They did not BYPASS the gates — that would be the obvious bug and it is not the one that was present. Each had a copy, and a copy only has the gates someone remembered to add to it. That is not theoretical, and it is why this is worth doing. When the STOP-revocation check was added it landed in exactly one of the three. Nobody skipped a step; the other two were not there to receive it. Both test paths would send to a number that had texted STOP, and report success. Two failing tests proved that before the fix, one per path. The chain now lives in `lib/sms/send-gate.ts` and a caller declares a `purpose`. `test` names exactly one exemption — express consent, because there is no contact to hold any, so requiring it would mean no test send could ever succeed. It is NOT exempt from revocation: honoring STOP does not depend on the basis the first message was sent under, and it does not care that this one is a test. Revocation for a test send matches the NUMBER, since there is no contact. That match is normalized exactly the way the inbound STOP webhook normalizes on read — if the two disagreed, a revocation could be recorded against a contact this check would then fail to find, and the revocation would exist while doing nothing. A test pins it by seeding the contact as `(555) 999-1234` and texting `+15559991234`; breaking the normalization makes only that test go red. This also closes a hole in the real path. Revocation used to be checked only when the log carried a contact id; a log without one skipped the check entirely. It now falls through to the number match, so an implied-basis recipient whose number texted STOP is refused rather than texted. All 187 automation tests pass unchanged, so the paths that did have a contact id behave exactly as before. `rawDb` leaves `SendOneSmsArgs`: it existed only to hand SmsConsentService a raw binding, and the gate reads consent through drizzle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): a gate, because auditing found what auditing could miss `lint:provider-helpers` already guards the TRANSPORT. Transport was never the problem — everything above it was, and both defects were found by hand-auditing call sites. The second audit found what the first structurally could not: it swept ROUTES, and the miss was inside a service. A third audit would miss the next one. So `lint:notification-dispatch`, four HARD rules with NO baseline: route-builds-html a route may not build notification HTML unclassified-send a send must name what it is sms-send-without-gate an SMS send must consult smsSendGate second-gate-chain managedSendAllowed is callable from one file Every rule was proved to fail before being trusted: a probe file containing one violation of each produced four findings on the right lines and exit 1, and adding a classId cleared only the second — which matters, because "never call sendEmail" would be a different and wrong rule. No baseline is the deliberate part. Every rule is at zero TODAY, and that is only true because P3 and P4 made it true. A baseline is how the next violation gets admitted as pre-existing. Two sends were classified to reach zero. The per-role report delivery now carries the same class as the two branches beside it — it differs only in WORDING, chosen by the recipient's role, and a different template is not a different thing to have a preference about. The admin test send gets `admin-test-send` in a new `diagnostic` category: it only ever reaches whoever pressed the button, so no recipient can hold a preference about it, but the boundary still has to be able to say what it is sending. `diagnostic` is what keeps it off the preferences screen. Two sends are allowlisted rather than classified, and the distinction is the point. The automation RULES layer sends tenant-authored templates, so there is no fixed class id, and what class those carry is a real open question V2 has to answer. A wrong class is worse than a stated absence, so the gate names them instead of pretending. report-delivery.ts is 3 lines over its ratchet (733 → 736) for the annotation and its reason; splitting a 736-line route file is a refactor this change does not justify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * perf(gates): cache a gate on what it read, so running it twice costs nothing The gate ladder says: do not run a gate the next rung will run. That is a rule someone has to remember, and it drifted inside the session that wrote it — lint:filesize four times in one task, type-check:api six, and a `vitest --changed` that quietly became all 3975 tests because package.json was in the diff. The whole argument of the last four commits is that a coupling held together by prose drifts, and the fix is to make the wrong thing cost nothing rather than ask anyone to avoid it. That applies here too. KEYED ON WHAT THE GATE READ, NOT ON THE INDEX. `git diff --cached` is the obvious key and it is wrong: these gates scan the WORKING TREE, so an unstaged edit introducing a violation would hash identically and the gate would print "cached" over a real failure. A false green is worse than no cache. The key is the gate's own source plus (path, mtime, size) of every file it scans. Three properties, because a cache is exactly how a gate silently stops working, and all three were proved rather than assumed: - a hit PRINTS that it was a hit - a FAILING run is never cached — verified by failing twice in a row - any flag (--update) bypasses it; a side effect is not cacheable Also verified: touching a scanned file re-runs, and a violation introduced after a passing run fails rather than reporting cached. Wired into exactly two gates. lint:deadcode is the only one whose cost is real work — 8.0s → 0.2s. The conformance gates are ~0.6s each and almost all of that is node startup the cache cannot remove, so wiring them would save under 0.3s apiece while adding seventeen chances to under-specify an input set, which is the one way this produces a false green. Not worth it, deliberately. Wiring knip up immediately earned its keep: it caught two dead exports this branch introduced (NotificationCategory, SmsPurpose), both only reachable from the full-lint rung the inner loop never runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): the preferences table, shaped by the constraint that has to hold One recipient's answer to "send me this or don't", per class per channel. ONE SUBJECT COLUMN, NOT TWO. The obvious design is a nullable user_id and a nullable contact_id with a rule that exactly one is set. It does not work: SQLite treats NULLs as DISTINCT in a unique index, so (t1, NULL, 'c1', 'email') does not conflict with itself. The constraint meant to guarantee one answer per (who, what, how) would silently admit duplicates — and a duplicate here is two contradictory answers with no rule for which wins. subject_kind + subject_id are both NOT NULL, so the index actually holds and the two-columns-one-truth state cannot be written. A spec asserts the UNIQUE rejection rather than describing it. subject_kind is part of the key, not a label: users.id and contacts.id are independent id spaces that can collide. ABSENCE IS NOT "OFF". No row means the class default applies, which is "send". Only an explicit enabled=false suppresses, and only for a class isSuppressible() allows — which fails closed on ids it has never heard of. So a preference row can never silence something the recipient is told is always sent. Erasure deletes these with their subject, and the reason is not tidiness: a contact id is REUSED after an erasure, so a surviving row hands the next person at that id the erased subject's mute settings — invisibly, and in the direction that withholds mail nobody asked to withhold. Scoped to subject_kind='contact'; staff preferences are not a consumer data subject's. The manifest-coverage drift guard binds the new rule to the orchestrator step that realizes it. The erasure test was proved twice. The first red was my own bad fixture id (the #88 describe block seeds contact-88, not contact-subject), which proves nothing about the code, so the delete was neutered afterwards to confirm the test actually catches its absence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): preferences enforced where the send happens The email boundary now drops a recipient who switched this notification class off. Two halves, split deliberately: a PORT decides whether a class may be withheld from an address, the BOUNDARY decides what to do about it. THE REQUIRED CHECK RUNS FIRST, before any lookup, and that is what makes the screen trustworthy. A class the recipient is told is always sent must stay unmutable even if a row says otherwise — a stale row, a class whose required flag changed, a hand-written INSERT. isSuppressible() also fails closed on ids it has never heard of, so a newly added notification is never withheld before someone has decided it may be. The screen's promise and the send path's behaviour cannot diverge. An address resolves to BOTH id spaces. An agent with an account who is also a contact on an inspection is one human, and asking them to switch the same thing off twice is the kind of half-working control that is worse than none. FAIL-OPEN, like the suppression gate beside it. A failed query must never be the reason someone did not hear from us: nobody reports mail that never arrived. An UNCLASSIFIED send never consults the port at all — a preference that cannot be named must not be applied by guesswork. Absence is not "off": no row means the class default, which is "send". Two proofs rather than one, because the interesting failure here is a gate wired to nothing — which looks exactly like a gate that passes, and is how check-ts-range.mjs spent months reporting "skipped". So there is a test that assembles the service production assembles and asserts a real row stops a real provider call, and it was verified to go red when the port is unwired from the constructor. One test bug worth recording: the boundary probe first recorded its own argument instead of what reached the provider, so it passed while the filtered list never shrank. An assertion has to sit on the far side of the thing under test. SMS is deliberately untouched — consent, not preference, is the authority there, and it already has one gate chain. The automation RULES layer is still unclassified (the gate allowlists it): its trigger enum is a fixed 19-value vocabulary and is the right class, but that is its own step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * refactor(notifications): withdraw the fourth category, add recipientFacing transactional / operational / marketing is a COMPLIANCE taxonomy — the vocabulary CAN-SPAM and GDPR reason in — and it says what the CONTENT is. `diagnostic` was added to it for the admin test send, and that was wrong: "only ever reaches whoever pressed the button" is a fact about the AUDIENCE. One column answering two different questions is the defect this work keeps finding elsewhere, so it should not have been introduced here. The audience fact moves to `recipientFacing: false`, which says what it means and leaves the taxonomy alone. The class still exists, and the send boundary can still name what it is sending — that part was never in question. A test now pins the vocabulary at three values, and was verified to fail by reintroducing the fourth. That test is the thing that would have caught this when it was written rather than two commits later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): every seeded automation rule now says what it is All scheduled and automatic sends are automation rules, so until now the entire rules layer reached the send boundary unnamed — and a preference cannot apply to something that cannot be named. The gate's allowlist entry for it is gone, and breaking the new call site was verified to make the gate fail. THE CLASS IS THE SEED, NOT THE TRIGGER. §2 already refuted the trigger: report.published alone carries five seeds, three to the same client saying different things, and §5.3 settles those outright — "report-ready is required and post-inspection follow-up / review request are not". One trigger-keyed class could not hold both answers, and `required` is the field the spec calls load-bearing. Seeds whose notification ALREADY has a class reuse it — Booking Confirmation, Report Ready, the invoice, both agreement ones, and the buyer's-agent report-ready that services/email/agent.ts also sends. The manual path and the automatic path are one notification arriving; two switches for one notification is how a control comes to half-work. Nine office alerts are nine classes. §2.5 lists them as one row for brevity; they are nine distinct events, and collapsing them would be the same mistake as keying on the trigger. Staff and inspector classes are `required` because §2.5 marks them Operator, not You. The operator's control is the rule's active flag, which is why one flag still suffices. Tenant-WRITTEN rules resolve to undefined and stay unclassified: they still send, they just cannot be muted by a recipient, and the operator can disable any rule. Inventing a per-rule class would put tenant data into a vocabulary the boundary fails closed on. Two gates earned themselves immediately. The seed-coverage one found three seeds a manual read had missed — their names contain an apostrophe, so they are double-quoted in the source and a single-quote regex skipped them silently; 29 seeds, not the 26 I had counted. And a channel gate caught `inspection-cancelled` declaring SMS: I transcribed that from §2.2, but the Cancellation Notice seed has no smsBody, so the screen would have rendered a switch for a message that can never be sent. §2 lists the channels the product INTENDS; the class must list the ones it has content for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * test(notifications): pin the two consequences D3 and D5 accepted Both deviations were confirmed as intentional, which means the thing to protect is no longer the decision but its consequence — the part a later change would narrow without noticing it was narrowing anything. D3: the boundary resolves an address in BOTH id spaces, so a mute crosses identities. The existing test covered the user→contact direction; this adds contact→user, and the case that actually bites — a tenant who also keeps a staff address in `contacts`, where one person's mute governs both identities. That is the intent (one human, one inbox), and narrowing the lookup to a single space now fails here instead of quietly halving the control. A second test pins that the crossing stops at the tenant boundary: the same address in another tenant is a different relationship. D5: two contacts sharing a number means one person's STOP withholds the other's message. Not introduced by the shared gate — the inbound STOP webhook already records revocation against EVERY contact matching the number, so the ledger was always number-shaped, and reading it any other way would honour a revocation for one row while ignoring it for its twin. Both were verified to go red under exactly the narrowing they warn about (consult only the addressed contact / only one id space), and green again when restored. A test that has never failed is a test that has proved nothing. One fixture bug found on the way: `users.tenant_id` carries a legacy FK, so the cross-tenant case needs the other tenant seeded first — the test was failing on its own setup, not on a leak. The in-app half of v2 is NOT included and is not a small addition: nine insert sites across three files need a class first, which is the same shape of work P2 did for email. Enforcing preferences only where a class happens to be available would build the half-working control this whole change exists to avoid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): in-app honours the same preference decision The notice header IS the in-app delivery, so withholding one means not writing the row. `insertNoticeHeader` now returns null when the recipient switched that class off, and the fan-out marks those logs skipped with a reason rather than leaving a pending row that never resolves. ONE DECISION, TWO CHANNELS. The subject-keyed core is extracted and shared rather than restated — the required check above all, because that is the thing keeping the screen's promise and the send path's behaviour in agreement. A second copy of it is how the two would drift into disagreeing about what "always sent" means. In-app needs none of the address resolution email does: a header is `user_id XOR contact_id` by construction, so the subject is already in hand. That is why the shared piece is the decision, not the port. The class comes from the RULE, threaded like the wording already was — two rules on one event are two different things to have a preference about, so a per-firing class would be wrong for the same reason a per-firing title was. Unclassified headers are always written, matching the email boundary: a notice that cannot say what it is must never be silenced by guesswork. Required classes are always written too — §2.5, an individual cannot mute their own dispatch. Verified by unwiring the check and watching the enforcement test go red. THE GATE FOUND A DEFECT IN ITSELF. `lint:notification-dispatch` required a literal `classId:`, but the idiom under exactOptionalPropertyTypes is a conditional spread — `classId ? { classId } : {}` — which is shorthand and has no colon. It reported a correctly classified send as unclassified. Worth noting how it survived a commit: the gate lives only in the full lint run, so nothing ran it between the type fix that introduced the shorthand and the commit after it. Two fixture bugs on the way, both the same shape: `users.tenant_id` and `notifications.user_id` carry legacy FKs, so a test row has to exist before another row can point at it. Both failed on their own setup, not on the behaviour under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): §2's "Who" column, and the model the screen renders §2 has two columns the code has to honour. P1 made "Off?" executable as `required`; this is the other one. The screen needs it — a client shown "Office alert — new booking" is being asked about mail they can never receive, which answers neither of the two questions §4 says the page exists to answer. All 48 classes now declare an audience, transcribed from §2, and a gate checks each seed-backed one against the rule that actually sends it: recipientKind staff/inspector → staff, buyer_agent/listing_agent → agent, otherwise client. Verified by flipping an office alert to 'client' and watching it fail. Without that, a class and the rule that sends it can disagree, and the reader is the last to find out. One class has an EMPTY audience. A repair-request share goes to an address someone typed, so there is no account to render it on — the same fact that makes it required. The model itself is one function because three surfaces render it. The filtering rules would otherwise be decided three times, and a class added later would show up on two screens out of three with nothing to say which was right. Two distinctions from §4 are encoded rather than described: `unavailable` is not `off`. A review request has no in-app form, and an off-switch for a channel that does not exist is a lie about what exists — a reader who turned it on would be right to expect something to happen. Absence is not "off" either, so the model takes the set of explicit MUTES rather than a full preference set. Storing a row that merely restates the default makes the table grow with the user base instead of with the decisions (§3.2). `alwaysSent` carries no per-channel state at all, so there is nothing for a stale row to flip — the required guarantee holds at the screen for the same structural reason it holds at the send boundary. The UI is NOT in this commit. Per the gate ladder, a screen needs the frontend-design skill and a Chrome walkthrough in both themes before it can be committed, and "the model is green in vitest" says nothing about whether the page is usable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): the preferences screen component, and two test packages The screen §4 describes, as one component for all three audiences — CLAUDE.md's Cross-Portal Reuse rule: one entity, one component, the differences as props. A parallel implementation would drift and only one of the three would get the next fix. Three choices are deliberate, and each is a place where the obvious implementation lies to the reader: ALWAYS SENT is a section with a reason, not a row of disabled switches. A greyed-out toggle invites the reader to try, then refuses. The count is the loudest thing on the page because §4 says why: "7 notifications you cannot switch off" is a number a reader can hold, and "we may send you service messages" is not. An em dash is not an off switch. It means the notification has no form on that channel, and an unchecked box there would invite someone to turn on something that can never happen. Text is not a third identical toggle. Consent is the authority there and a preference can only narrow it (§3.3), so this leaves the seam for the v4 ledger block rather than rendering a switch that would lie. It carries ARIA table semantics because notification x channel IS tabular data. That came from the test being awkward to write — a test that has to walk .closest().parentElement is telling you the markup threw away its structure — and it gives a screen-reader user row and column context the div grid did not. TWO PACKAGES ADDED, and one of my reasons for the second was wrong. jest-dom earns its place on failure MESSAGES: `expect(el.disabled) .toBe(true)` fails with "expected false to be true" and names neither the element nor the reason. user-event I justified as catching controls a real user could not reach — verified, and it only holds for INLINE styles. Vitest loads no Tailwind, so a `pointer-events-none` class has nothing behind it and the click goes straight through. Real unreachability here comes from classes and overlays and is invisible at this level. user-event still earns its keep on the focus/pointer sequence and keyboard interaction; it is not a reachability gate, and the test file now says so, because I nearly gave myself the impression it was. The Chrome walkthrough is the rung that answers reachability and both themes, and it is not in this commit — no route renders this yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): the preferences API, and a refusal the tests found GET returns the two sections §4 describes; PUT records one choice. THE SUBJECT COMES FROM THE SESSION, NEVER THE BODY. A preference is a statement about one person, so a subject id in the request would let anyone silence anyone. A test sends one anyway and asserts the row lands against the signed-in reader. PUT refuses three things, and the third came from a test failing for a reason I had not considered. A required class is refused; a channel the class never uses is refused; and now a class this reader is never addressed by. That last one surfaced when a staff-role test wrote a mute for an agent-only notification and succeeded — a row nobody could ever see or clear, because no screen renders it. The argument is the same as the other two: accepting a change that can never take effect is dishonest. The send boundary is what makes the guarantee TRUE; this is what makes the screen HONEST. Switching something back ON deletes the row rather than storing `enabled = true` (§3.2): a row that restates the default makes the table grow with the user base instead of with the decisions. Two more gates earned their place. `primary-tier route count ≤ 45` caught me tiering a settings surface as primary — that budget is the MCP tool surface, and a reader's own preferences are not a tool. The input-description gate caught two undescribed fields. server/index.ts is 2 lines over its ratchet. It is the route registry; it grows by one line per route by construction, and splitting it is not a refactor that adding a route justifies. Worth stating because it will otherwise read as a bug: STAFF have almost nothing in "you choose". §2.5 makes work notifications the operator's call rather than the individual's, so of the staff-facing classes only the concierge review is theirs to mute. That is the design, and the screen's empty state has to carry it rather than look broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq * feat(notifications): per-recipient preference screens for staff, agent and client Retires the three per-event booleans on `users` and gives every audience a screen backed by `notification_preferences`. The old shape was one column per event read by one send method each, so the ~45 notifications nobody added a column for simply had no off switch. Storage rule: store only what DIFFERS from the class default; matching it deletes the row. Stated that way rather than "delete on enable" because `agent-invoice-paid` defaults to OFF -- its column defaulted to false, and `defaultEnabled` moved that default across with the data, so the migration's third backfill statement is inverted. Without it a naive migration had only bad answers: a mute row per user, or agents silently starting to receive invoice mail. Three routes, not one, because the subject differs: - staff -- `users` row, tenant from the JWT (Settings > Profile) - agent -- PER COMPANY, keyed on each company's `contacts` row. An agent account is global (`users.tenant_id IS NULL`) and its JWT carries no tenant, so there is no session tenant to scope a row to. `scope: 'all'` applies one change to every linked company. - client -- portal session cookie; one email can be several contacts in a tenant, so a choice is written to all of them and a mute on any one of them counts. Refusals live in one place (`preference-write.ts`): unknown class, always-sent class, a channel the class never uses, a class this reader is not addressed by. The send boundary is what makes a preference TRUE; these keep the screen HONEST. Also fixes, found in Chrome and invisible to every unit test: - a failed read rendered as "0 notifications you cannot switch off" -- a confident false answer, and the count is the loudest thing on the card. A failure is now distinct from emptiness on all three surfaces. - auto-save had no reply, so a reader could not tell a persisted change from a box that merely looked ticked. Added a saving/saved indicator that never claims "Saved" when the write failed. - `notification-preferences` was never registered in the per-module hono client, so `api["notification-preferences"]` type-checked against nothing. The staff helper typed its client as `any`, which is what hid it. Chrome verification is PARTIAL. The staff surface was driven end to end in both themes: 17 always-sent / 1 choosable, a click writes the row, a second click deletes it, and the bell's settings link works. The agent and client surfaces are covered by unit tests only -- establishing a non-staff session in the browser failed, and `GET /api/agent/profile` (untouched code) fails the same way, so the blocker is session plumbing rather than this change. File-size gate: `settings-profile.tsx` (+1) and `portal-inspection.tsx` are bumped in the baseline. Two real extractions came first -- `settings-notifications.server.ts` and `portal-notification-preferences.ts` -- the residue is route wiring that has to live in the route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * feat(notifications): bulk row/column actions, and honour preferences on SMS Builds on the per-recipient preference screens with the grid-shaped bulk controls (`applyBulk`) and closes a hole the screen itself created. THE SMS SWITCH WAS A LIE. Two client classes declare an SMS channel (`booking-confirmation`, `inspection-reminder`), so the screen rendered a Text switch for them — and `smsSendGate` never consulted preferences, so ticking it off stored a row nothing read and the text went out anyway. That is exactly the defect this program exists to remove. The gate now takes a `classId` and checks the recipient's preference AFTER consent and BEFORE quota: after consent because a preference NARROWS what consent allows and must never widen it (§3.3), before quota because a text nobody wanted must not spend the tenant's allowance. Unclassified sends (an admin test send) stay unmutable, which is `isSuppressible` failing closed. Bulk actions are scoped the way the grid is: a row (every channel of one notification), a column (one channel across every notification), or the corner. Loose buttons above the table would have made the reader work out which cells each one touched. `reset` is a separate verb from `enable` and the difference is load-bearing: reset DELETES rows so each class returns to its own default, and `agent-invoice-paid` defaults to OFF. Two things Chrome caught that no unit test could: - an all-unavailable column rendered a bulk checkbox that looked like "all off" and did nothing when clicked — the em dash's own lie, reintroduced one level up. A scope with no selectable cell now renders no control. - `?section=notifications` fell through to the overview: `HubSection` had the member, the hand-maintained `HUB_SECTIONS` array did not. Replaced with a `Record<HubSection, true>` so the compiler keeps them in sync (CLAUDE.md: make a "must stay in sync" coupling executable, not a comment). Feedback moved to the existing ToastPortal. The inline red line it replaces sat inside a card the reader may well have scrolled past — a message about mail they will not receive, placed where they cannot see it. Only the in-flight state stays inline, next to the switch that was touched. The em dash now carries a one-line legend: a symbol a reader has to ask about has been left to guess, and the natural guess here ("it's off") is the wrong one. Chrome walkthrough, both themes: staff (17 always-sent / 1 choosable, write and delete round trip), agent (per-company isolation verified across two companies, column action wrote exactly one row because every other class already defaults to on). The client Hub section is reachable after the section fix; its grid is the same shared component the other two exercise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * feat(notifications): the screen offers every channel, always The grid no longer narrows itself to what a class declares today, and it never reads the tenant's automation rules or templates. Every notification a reader is addressed by shows all three channels, and `unavailable` (the em dash) is gone from the model entirely. A preference is a statement of INTENT. "Do not text me about bookings" is a true and useful sentence before anyone has written that text, and storing it now means the answer is honoured the moment the content and the rule are completed — rather than being unaskable until then and silently lost in between. The asymmetry is what makes this safe: the switch's meaningful direction is OFF, and OFF always works. A channel left ON that nothing sends yet is quiet, not broken. The alternative — deriving the cells from what the tenant can send today — would have made the screen change shape underneath the reader every time an operator toggled a rule or added a template body, which is the confusion this avoids. Consequences, all deliberate: - `assertChoosable` loses its channel refusal. Storing a preference for a channel nothing sends on yet is now the point, not an error. - `applyBulk` covers all three channels rather than the class's own list. - `classes.ts`'s `channels` is unchanged and still the truth about what the CODE can send; it still gates the send path. It just no longer decides what the screen offers, and `automation-classes.spec.ts` still holds it honest against the seeds. Every spec that pinned the old answer was rewritten rather than deleted, and one was inverted on purpose: "never writes a channel the notification does not use" is now "DOES write a channel the notification does not send on yet", because narrowing the write is exactly how the answer would be dropped. Verified in Chrome across all three surfaces. On the client Hub, turning the Text column off wrote 10 rows — one per choosable class, including those with no SMS form in the code today — while the corner and every row control moved to indeterminate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * feat(notifications): SMS consent block, and spec §4.2 for what a channel switch means V4's first half. The grid answers *which notifications*; this answers *this channel at all*, and the two are different questions the send gate already reads in that order — consent first, then preference. Someone can consent to texts and still not want booking confirmations. Spec §4 was rewritten where it had gone stale or was never written: - the mock still showed em dashes, which the previous commit retired - "the SMS block shows the LEDGER, not a switch" predated per-notification SMS preferences; §4.2 now states how the ledger and the Text column coexist - "deployment-aware rendering" was a roadmap phrase with no definition. It has one now, and the answer is that deployment mode changes NOTHING — hiding the block when a tenant has no SMS provider would make the screen read tenant configuration, which §4 choice 3 exists to avoid. THE TWO CHANNELS ARE NOT SYMMETRICAL, and §4.2 says so in a table rather than letting the code imply otherwise. SMS has a legal consent artifact (`sms_consent_log`); email has only deliverability suppression, which is a different fact. So switching SMS off writes a `revoked` row AND cascades the Text column; switching email off cascades only — and email's "off" can never mean "no email", because required classes still send. That last point is said out loud in the UI rather than left to be discovered. There is deliberately NO "turn back on" control. Granting consent means recording a disclosure version, capture method, ip and user agent — evidence only `/sms-optin/:token` can honestly produce, so the block offers Stop and sends the reader out to grant. Revocation delegates to `SmsConsentService` rather than inserting directly, because that is what stamps the current disclosure version; a hand-rolled insert would drift from the version the opt-in page and the STOP webhook both use. Who sees the block, and why staff do not: consent attaches to a `contacts` row and a staff member is a `users` row, and no user-facing class is both staff-addressed and SMS. There is nothing to revoke. Inventing a staff consent row so the screen looks uniform would be a control over nothing — the same mistake as an off-switch on a channel that does not exist. An agent's revocation is recorded AS an agent's, because the ledger column exists to say which basis the person was reachable under. Chrome caught one thing the tests could not: the ledger date rendered as 2026年6月13日 inside an otherwise-English page, because `toLocaleDateString(undefined, …)` reads navigator.language. The obvious fix, `useDisplayLocale()`, needs route loader data the token-authenticated client portal does not have — so the locale is a prop, which works on all three surfaces and keeps the component renderable on its own. Still open in V4: the not-signed-in landing page and the legal-document links (§4.1). Legal copy changes need a version bump plus `terms:publish`, which is outward-facing and stays a human decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * feat(notifications): consent is the gate — inline grant, and a locked Text column The consent block was a dead end after a stop: no Stop button (correct, already stopped) and no way back (not). Caught by looking at it, not by a test. It now sits ABOVE the grid, because consent is the gate and the grid is what happens behind it, and it can turn the channel back on. Turning it back on is an inline grant, which is only honest under one condition: the disclosure has to be ON SCREEN and its VERSION has to travel with the acknowledgement. Both hold — the text renders in the block, the version comes back with the click, and the route refuses a version that is not the current one, because a stale version means the reader agreed to text they are no longer shown. `captured_via` gains `settings_page`; the enums are type-layer only in drizzle (the DDL is plain text), so widening cost no migration. A revoked consent now LOCKS the Text column rather than merely unchecking it. No text can arrive whatever a row says, so leaving the switches live would let someone tick "yes, text me about bookings" while consent says we may not text them at all — a screen disagreeing with the send gate. The column's bulk control disappears with it, for the same reason an all-unavailable column had none. Two things only the browser could have found: - the ledger recorded NULL ip and user agent. The BFF calls the API in-process over the `API_WORKER` binding, so `cf-connecting-ip` and `user-agent` never reach the handler on their own. They are forwarded explicitly now — those two fields are what make a consent row defensible in a carrier audit, and nothing in the types or the tests would have said a word. - absent headers are OMITTED rather than sent empty. An empty string is stored as one, and a row claiming "we recorded an ip and it was blank" is worse evidence than one that plainly has none. Verified end to end in the browser: stop → column locks and the block offers a way back → acknowledge → grant lands as `granted / settings_page / version 1` with a real user agent. Still open in V4: staff SMS (needs `subject_kind`/`subject_id` on the consent ledger — a reversal of the 2026-07-30 ISV decision, so that spec and docs/sms-compliance.md change with it), the not-signed-in landing page, and the legal-document links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * feat(sms): staff can STOP — a consent ledger keyed on a subject, not a contact The ISV strategy (2026-07-30) promised staff a "separate track — employment / account terms + STOP" and the schema could not keep the second half: `sms_consent_log.contact_id` was NOT NULL and a staff member is a `users` row with no contact, so a staff STOP had nowhere to land. The ledger now carries `subject_kind` / `subject_id`, mirroring `notification_preferences` — one shape for "a person, of either kind" rather than a second XOR of nullable columns. `recipient_type` gains `staff`, and the send gate's latest-event lookup keys on the subject, so a staff revocation is honoured by the same query that has always honoured a contact's. WHAT DELIBERATELY DID NOT CHANGE is the half a carrier asks about: only consumers ever produce a `granted` row. `grantSms` returns early for any non-client audience, so agents and staff stay implied and never enter the ledger as grants. "Show us your opt-in proof" keeps pointing at consumers alone, while nobody who says stop keeps getting texts. Both compliance documents were amended rather than left to drift — the strategy spec gains a §2.1 amendment saying what moved and what did not, and docs/sms-compliance.md now states the asymmetry out loud. The generated migration was BROKEN and is hand-edited. drizzle-kit emitted a table rebuild whose INSERT selected `subject_kind` and `subject_id` FROM the old table, where neither exists — "no such column", with DROP TABLE as the next statement. On a table holding consent evidence that is not an acceptable failure mode. The copy now supplies them as literals and backfills the subject from the contact every existing row already has. Applied locally; remote stays behind the D1 SOP backup. Two defects found on the way, both pre-existing: - `requiresExpressSmsConsent` THREW on an unrecognised role kind — indexing the basis map with a value not in it and reading `.basis` of undefined. In a compliance gate that is worse than either answer: not a refusal, not a send, but a 500 whose meaning depends on the caller. It now fails CLOSED. - two consent events recorded in the same millisecond resolved arbitrarily, because `ORDER BY created_at DESC LIMIT 1` had no tiebreak. A STOP and a START a millisecond apart could pick either. Insertion order now breaks the tie — for a consent ledger, "which one is latest" must not be a coin toss. Surfaced when the new index changed which plan SQLite chose. Not eyeballed: the staff screen's rendering. Verifying it needed a staff session I had just logged out of, and logging back in would have meant typing a saved password. Its server path is covered by `channel-consent.spec.ts`, including the staff subject specifically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * feat(sms): staff and agents can turn texts back on — one surface, one contract Two gaps, both found by looking at the screen rather than at a test. THE ONE-WAY DOOR. Last commit gave staff a STOP and no way back: `grantSms` returned early for any non-client audience, on the reasoning that a staff `granted` row pollutes consumer evidence. That reasoning was too blunt. Staff and agents never granted anything — they are reachable under an existing relationship — so their "on" is a RESUME that withdraws the earlier stop, not a consent capture. It is recorded under `recipient_type: 'staff'` / `'agent'`, with no disclosure and nothing to acknowledge, because there was never anything to agree to. The invariant that actually protects the ISV filing is not "no staff rows" but "NO NON-CONSUMER ROW IS EVER LABELLED `client`" — a filing counting opt-in evidence filters on that column, which is the whole reason it is not a boolean. `channel-consent.spec.ts` now pins that directly. Both compliance documents were corrected rather than left saying the stricter thing the code no longer does. THE TRIPLICATION. Staff, agent and client each carried their own fetcher, status, toast, save/bulk handlers and — worst — their own copy of the rule that a revoked consent locks the Text column. Three copies of a rule is three chances for one surface to quietly stop enforcing it, and that one is what keeps the screen agreeing with the send gate. `<NotificationSettings>` now owns all of it; the three wrappers are ~50 lines of chrome and an intent name. Falling out of that: - express-vs-implied comes from the SERVER as `smsConsent.mode`. Three call sites hand-setting it was three chances to ask a staff member to acknowledge a consumer disclosure they never needed. - all three routes expose the same `PUT …/sms-consent`, so no surface is the one that cannot resume. - `<SmsDisclosure>` is shared with the public `/sms-optin` page. Both stamp the same `disclosure_version` into the same ledger, so two copies of that markup could drift while the row still claimed the reader saw version N. It also fixed a real gap: the inline grant was missing the privacy and terms links the opt-in page has shown all along. Verified in the browser: staff STOP writes `subject_kind=user, contact_id=null, recipient_type=staff, revoked`, the Text column locks, and the resume writes `granted / settings_page` with a real user agent — the append-only ledger keeping both halves of the history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * fix(profile): credentials are visible when added, and reach the signature Two defects on Settings -> Profile, both found by looking at the page. ADDING A CREDENTIAL SHOWED NOTHING TO FILL IN. `onAdd` creates a blank row and the `<details>` was collapsed, so a new credential rendered as an upload box and the word "Details" with nothing saying what it was — the two fields hidden at exactly the moment they are needed. It opens by default now, and stays collapsible for someone who has already filled several in. The uploader column also went w-24 -> w-36: its own caption wrapped to three lines and read as a broken layout. CREDENTIALS NEVER REACHED THE EMAIL SIGNATURE. `inspectorSignature()` has accepted a `credentials` argument since Spec B and renders badges from it — and no caller ever passed one. The feature was wired and dead: the settings copy promises "shown on your reports, emails, and booking page" while the signature showed only the legacy `license_number` line. The preview now supplies the inspector's active credentials, so what a reader sees is what a recipient gets. Both are one half of a migration Spec B started and did not finish; the other half (retiring `users.license_number`, which still renders in the signature and the PDF footer) is written up rather than done, because deleting the field today would silently drop those two surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV * fix(agent): a session ends at the door it was opened at The agent portal's "Log out" pointed at `/logout`, and the teardown behind it ends with an unconditional `redirect("/login")`. `/login` is the STAFF front door — an agent has no account there — and under `APP_MODE=saas` that page 302s again to `${PORTAL_API_URL}/login`, out of this product entirely and onto a portal sign-in an agent cannot use at all. So logging out of the agent portal did not land on a login page; it landed on a dead end. The same wrong door was on the EXPIRY path, which is the one that fires without anybody clicking anything: `requireToken` throws `/login` on a missing token and routes an expired one through the same teardown, and it is `agent-layout`'s loader that calls it. An agent whose token aged out was dumped on the staff login too. `loginPathFor(request)` now derives the door from the path rather than taking it from each caller, because the callers are the two functions every agent loader and the logout route already go through — a caller-supplied argument is a thing a new agent surface can forget, and this one is only ever wrong in a direction nobody tests. `agent-logout` exists for the same reason: it is the same module as `/logout`, and the entire difference is that the path carries the signal. The prefix is the whole rule, which the spec pins in both directions: `/contacts` and `/inspections/agent-notes` are staff pages ABOUT agents and stay on the staff door. Also `/agent-signup`'s "already have an account?" link, which pointed at `/login` — the one page that cannot accept the account it was offering. Verified live on all four paths (302 Location): /agent-logout -> /agent-login, /logout -> /login, /agent-dashboard -> /agent-login, /inspections -> /login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 * feat(notifications): a page to link to, for a reader who is not signed in Spec §4.1 asks the privacy policy and the terms to link to the notification control. There was nowhere to link. The client's copy of that surface hangs off the Hub bell, so its URL names an INSPECTION — and the people who follow a link out of a legal document are typically not standing on one, and often are not signed in at all. A link into the Hub would have been a link into an inspection they may not have open. So `/portal/:tenant/notifications` takes no inspection and assumes no session. Signed in, it renders the same `PortalNotificationSection` the Hub renders and its action listens for the same three intents through the same three helpers — one surface, one contract, because a divergence here is a switch that works on one entrance and not the other. SIGNED OUT, IT MUST NOT SAY WHETHER THE ADDRESS IS KNOWN. That property is already the API's (`request-link` is payload- AND timing-identical either way, the send deferred to waitUntil); this page's part is to never ask a question whose answer could differ, and to render the same conditional-voice panel every time — "If an account matches that address, a link is on its way." Verified with a known and an unknown address: identical body, 0.249s vs 0.256s. THE LINK HAS TO COME BACK HERE, and that is where this could have gone wrong. The obvious shape is a `next` path echoed into an outbound email — which is an open redirect with a delivery mechanism attached. `destination` is therefore an ENUM of two names, and `?to=` is matched against one literal rather than used as a path, so there is nothing for a crafted link to point at. The spec pins that `//evil.example`, `https://evil.example` and `/agent-dashboard` are all 400 at the schema, before any link is built. `redeemDestination` is a pure function and not a ternary in the loader because one of its four answers is a security property, not a routing preference: an agent-resolved redeem holds `__Host-inspector_token` and no `__Host-portal_session`, so it must never be handed a `/portal/` path. Its spec asserts the negative — an agent stays on `/agent-` in BOTH arms — which keeps holding if someone later "unifies" the two branches. An agent who asked for notifications lands on their own settings rather than the dashboard, so the link does not stop one page short of what it promised. server/api/portal.ts crossed its file-size cap by the 10 lines of the enum and its comment; baseline bumped rather than split, since splitting a route module is a refactor this change does not justify. Verified in Chrome, light and dark: signed-in surface, signed-out form, and the sent panel for an address that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 * feat(legal): a tenant's Privacy and Terms now have a history Design §6A: the platform's own documents live in a repository, so a version registry there can hash the text and let git hold it. A tenant's `tenant_configs.privacy_body` is a mutable TEXT column with nothing behind it — so the same table shape ported across would prove that the text CHANGED while being unable to produce the text that changed, failing at exactly the moment somebody needs it. `tenant_legal_versions` therefore stores the BODY, copying the shape this codebase already uses for the same problem (`agreement_requests.content_snapshot` / `.content_hash`) rather than the platform's. That also sidesteps lifting private-repo source into public OSS. WHAT A ROW MEANS. One publish of one document. `version` is a date string where the inspection Agreement's is an auto-increment integer — the formats differ so a reader cannot mistake one object for the other, and they share no table, no counter and no acceptance flow. The two failure modes are opposite and both invisible from the settings page, so both are pinned: recording NOTHING (the handler never calls the historian), and recording on every PATCH (a tenant changes their booking hours and mints a new revision of their privacy policy). The comparison is on the content hash, so an unchanged body is a no-op. Removing the guard turns three specs red. The route spec found the first one for real: the harness's service stub had no `legalVersion`, the PATCH handler swallowed the failure — deliberately, since a version row is evidence ABOUT a save and must never cost the tenant the save itself — and the version table stayed empty. That is exactly the risk of a non-fatal write, and the spec is the compensating control. It now runs the real service over the test DB rather than a stub that could silently do nothing. DATES ARE THE TENANT'S. The service resolves the timezone itself instead of trusting a caller: 2026-08-01 in UTC is still July 31 across the Americas for most of the day, and a "last updated" one day ahead of the company's own calendar only ever surfaces as a complaint. Same-day republishes collapse onto the text that ENDED the day, which is the one anything downstream could have relied on. "Last updated" replaces a HARDCODED literal — `public_legal_effective` read "Effective: July 30, 2026" and was shown on every tenant's page whatever their document said, stale from the release that shipped it. The replacement is string arithmetic with no `Date` anywhere, because the value is already a civil date and parsing it as a UTC instant is how it would render as the previous day. Null until a tenant publishes, and the line is omitted rather than invented. Also answers an open question in §6A.5 while passing through it: the hosted page renders the custom body in `whitespace-pre-wrap`, i.e. as PLAIN TEXT. A Markdown quick-insert affordance would ship as literal characters, so that feature stays out until the page renders Markdown. The table is declared in the erasure manifest as out of scope with reasons rather than left silent — the PII heuristic flags nothing here, and silence is not a decision. Migration 0021 is a bare CREATE TABLE: no rebuilds, so it does not touch `sms_consent_log`. `db:check` clean. Read path verified end to end against local D1: row -> API `lastUpdated: "2026-08-01"` -> page "Last updated August 1, 2026". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 * fix(credentials): the badges now reach the recipient, not just the preview `inspectorSignature()` has rendered credential badges since Spec B and NO CALLER EVER SUPPLIED ANY. The feature was wired and dead in both directions: the renderer accepted a field nothing set, and `SenderSignature` — the type that carries a signature to the send path — had nowhere to put them. So every outbound email showed the legacy `license_number` line while Settings → Profile promised badges "shown on your reports, emails, and booking page". The preview was fixed in 44bfaa45. This is the other half: both resolvers (`resolveSignatureInspector` for an inspection's inspector, `lookupSenderSignature` for the acting user) now populate credentials, and every call site already passes the object through whole, so they reach the renderer unmodified. THE OPTIONAL FIELD IS WHY THIS WENT UNNOTICED, so the fix is structural rather than a promise to remember. `SignatureUser.credentials` has to stay optional — the renderer accepts callers that predate Spec B — but the resolvers now return `ResolvedSignature`, where it is required. The next omission is a compile error instead of a silently emptier email. THE ASSERTION THAT MATTERS IS THE ABSOLUTE URL. A spec checking only "credentials were passed" would pass while every recipient saw a broken image: the stored `imageUrl` is root-relative and a relative `src` inside an email resolves against the recipient's mail client, which is nowhere. The spec pins `src="https://<host>/api/public/brand-asset…"` AND the absence of any `src="/api/public…"`, plus the text fallback — mail clients block remote images by default, so a credential that exists only as …
important-new
added a commit
that referenced
this pull request
Aug 7, 2026
…the address family for erasure (#301) * test(qbo): match the token endpoint by host, not by substring Two CodeQL alerts (js/incomplete-url-substring-sanitization, both high) on the same shape: `String(u).includes('oauth.platform.intuit.com')`, used once as a fetch-mock router and once as a call finder. Not an exploit — it is a test — but the finding is correct about the code: `https://evil.example/?next=oauth.platform.intuit.com` satisfies the substring check, so as a router it can answer the wrong call and as a finder it can find one. The assertion was weaker than it reads. Both sites now compare `new URL(u).hostname` to the host exactly, and an unparseable input yields '' so it simply does not match. * docs(erasure): point the gate at the document describing its blind spots Closes the remaining piece of erasure-freetext Task 3 — the plan reduced it to a script header, and the document itself landed in 225d33e. The pointer is the point. "PII column" in this gate means a column matching PII_HEURISTIC, so every column that pattern was not told about is invisible and a green run reads as coverage. A limits document nobody finds next to the gate is the failure mode that document describes. Also corrects a stale path in the same header: the coverage spec moved to tests/unit/privacy/ and the comment still pointed at tests/unit/. * fix(erasure): catalogue the repair-request columns the gate cannot see The last four columns portal #88 named. They survived every earlier pass because the PII heuristic matches column NAMES, and `created_by_ref` / `custom_intro` / `note` / `comment_snapshot` do not look like PII while being the one surface in the product where the CLIENT, not the tenant, types prose. The gate was green before this commit and is green after it; nothing went red to prompt this. They are not one kind of thing, so they do not get one verb: - `created_by_ref` is NOT NULL and, on the portal-token path, holds the actor's EMAIL. The schema comment called it "recipient id (client token)" and had been wrong for as long as agent-portal sessions have existed; that comment is corrected here, because it is why the column read as an opaque reference. Being both the identifier and the locator, it deletes the ROWS the subject authored: a client's own repair wish-list carries no legal-evidence basis (the `contacts` posture, not the `invoices` one), nothing references it, and the delete revokes a `share_token` that a contractor may still be holding. - `custom_intro` and `note` are cleared in place on lists OTHER people built for the subject's inspections. Those rows are that person's record and survive; an agent's intro names the buyer just as readily as the buyer's own. - `comment_snapshot` is declared out of scope with its four sibling snapshot columns. They are machine-copied off the published report card — defect prose the inspector wrote about the property. The reason says out loud that the report content they copy from carries no rule of its own, so this is not a decision inherited from a ruled source; it is the same call, made here first. The executor is a separate module because the orchestrator was one line under its anti-monolith cap. Extracting the two timestamp/count helpers it shared with the retention sweep paid for the call site and removed a byte-identical duplicate at the same time. The drift guard reads the new module, and a new test asserts the orchestrator still calls it — a delegated step that stopped being invoked would otherwise satisfy the scan while executing nothing. * fix(qbo): refunds reach QuickBooks, keyed on the ledger row that moved the money `createCreditMemo` was implemented and had zero callers, so every refund a tenant granted existed only in OI while their books still showed the revenue. Wiring it was not one line, because the hand-off assumed one refund producer and there are three. Where the push lives: at the route, not inside the writers. `server/services/invoice/refund.ts` is a pure DB module with no QBO service, no `env` and no `executionCtx` — the same constraint that kept the payment push out of `payment-ledger.service`. A push inside a writer would also mean awaiting an outbound HTTP call inside the refund's own path, where a QuickBooks outage could fail a refund the tenant already granted. `POST /api/inspections/{id}/cancel` is the single production entry to all three writers via `applyCancellationRefund`, and it is where `waitUntil` exists. The reason is written at the seam and in the refund module's header, so a fourth writer has to be given a seam rather than silently missing one. What posts and what does not: - `refundPartial` — invoice money. Posts a credit memo. - `refundHeldDeposit` — money against an ORDER with no invoice. Does NOT post. QuickBooks was never told about that deposit (no invoice, so no QBO Invoice and no Payment), so a credit memo would credit the customer for revenue QuickBooks never recorded and understate the tenant's income by the refund. The right instrument is a refund receipt against a customer-deposit liability account, which is a choice in the tenant's chart of accounts. The gap is already disclosed as a count in the Books health card. `applyCancellationRefund` now returns the invoice id alongside the row, so the seam is told which pool the money came from rather than re-deriving it. - `markRefunded` — no production caller, so no seam and no push. It now returns the row it appended instead of void, because returning void is exactly what forces the next person to key a memo on the invoice id. Three payload defects fixed while wiring it: - `requestid` was absent. It now carries `refund-${ledgerRowId}` from `qboRefundKey`, next to `qboPaymentKey` and derived the same way: the id of the FACT, never of the attempt. - `TxnDate` was hardcoded to today, so a back-dated refund booked to the wrong accounting period. It derives from the row's `occurred_at` in the tenant's timezone, through the same `txnDateFor` the payment push now shares. - `qbo_entity_map` stored the memo under `oiId: invoiceId`, and the index on (tenant, oi_type, oi_id) is unique — one credit memo per invoice forever. A second refund created the memo in QuickBooks and then threw on the map insert, leaving a live credit nothing recorded. It is stored under the refund row id, with `onConflictDoNothing` so a re-push of one row (which `requestid` already collapsed on Intuit's side) is not filed as a failure. `refundAmount` stays in DOLLARS, like `recordPayment`'s `amountPaid` — it goes straight onto `Line[0].Amount`, and the caller divides. Handing it cents is a 100x error on a customer's books, so it is asserted at the seam. The QuickBooks call cannot fail the refund: the ledger row is committed before the push is scheduled, the push runs in `waitUntil`, and `createCreditMemo` catches and files a sync error keyed on the refund row. Replacing the `waitUntil` with an `await` turns the outage test red with `Error: QBO 503`. `scripts/check-tz-safety.mjs` named QBO TxnDate as a legitimate `.toISOString().slice(0,10)` living outside the calendar surface; the credit memo joins the payment in no longer being one. * feat(erasure): retain the property address family, then widen the heuristic A property address cannot automatically be classified as non-personal data. On a residential inspection ordered by the buyer or the homeowner it is where a person lives, held against a named client through `inspection_people`. Declaring the family out of scope as "property data" was the other option and is rejected: it was the cheapest way back to green, and a red gate would have pushed a hurried reader straight at it. So the nine `inspections` address columns and `inspection_requests. property_address` carry `retain` rules with a stated basis (Art. 17(3)(e) — the address identifies which property a report describes, and the report is the inspector's defence against a negligence claim) and a bounded period. One entry per column, no wildcard: an auditor reads this file, and a wildcard hides what was actually considered. The bound is the tenant's EXISTING `agreement_retention_years`, not a new column. Both windows answer the same question for the same tenant under the same state rules and the same E&O cover; two clocks that start equal drift. The two columns that are a different question get the other answer. `tenant_configs.company_address` is a business's own published location — the controller's identity — so it follows its `company_lat`/`company_lng` siblings. `inspections.address_geocoded_at` records when the geocode ran, not where the property is. NOTHING ENFORCES THE WINDOW YET, and that is stated at the rules rather than left to be discovered. The retention sweep reaches the agreement tables only, so a `retain` here is a decision no code acts on — and a retain nothing expires is the rejected exclusion under another name. A tripwire fails the day the sweep gains an `inspections` reference, so the notice cannot quietly become false. `address` joins PII_HEURISTIC in this same commit, after the ruling and not before it, with all twelve columns it newly flags declared alongside it — widening first would have made twelve unconsidered out-of-scope entries the cheapest way back to green, and a widening that lands without the declarations turns the gate red for everyone else in flight. Proven to bite: removing either `property_address` or `company_address` fails the gate naming it. docs/compliance/erasure-heuristic-limits.md carried this as its open worked example and is updated to match — counts, the regex, what the address compensator now is, and the one thing that is still open. * feat(erasure): a pending retain must say so, carry a date, and be on a list Counsel approved shipping the address retain rules ahead of the sweep only on three conditions, because a bounded retain that nothing enforces becomes an unbounded retain — the blanket exclusion refused in August, relabelled. The rule itself is unchanged; these sit on top of it. 1. The rules no longer read as implemented. Every one carries `enforcementStatus: 'pending'`, a new optional field on `ErasureRule`, so the manifest and anything rendering from it can tell a recorded DECISION from a shipped behaviour. 2. `enforcementDeadline: '2027-02-01'`, and the gate FAILS once it passes. Two quarters, set by review discipline rather than by first breach: the sweep needs a purge marker on `inspections` (the agreement pass keys on `signedAt` + `purged_at IS NULL` and there is no equivalent) and a decision about which column starts an inspection's clock — a schema change and a migration, not a patch. Deliberately NOT derived from when the first address falls due, which is not computable until that clock column exists. Moving the date is allowed; moving it silently is what this prevents. 3. The gate refuses NEW unenforced retain rules. `PENDING_ENFORCEMENT` is a checked-in list of the ten rules allowed to be pending, checked BOTH ways — a pending rule missing from it fails, and a stale entry whose rule is gone fails too, so the list cannot decay into a blanket permit. For a bounded retain the DEFAULT is refusal: a `retain` that declares a `retention` and no `enforcementStatus` fails, so "unenforced" is never what happens when nobody says anything. The two swept signature_base64 rules are marked 'enforced'. `ERASURE_OUT_OF_SCOPE` moves to `erasure-out-of-scope.ts`. The manifest was at 389 of 400 lines and this note would have pushed it over; extracting beats compacting, because the thing that would have been compacted is the reasoning. The gate now concatenates both sources before parsing either, so splitting the register cannot halve what it sees — two lines, and both `arrayBody` calls work unchanged. Manifest 289, register 157. Also records at the top of the manifest that two of its justifications were checked against the code on 2026-08-07 and found false — `reports.title` ("a human writes"; it is machine-written) and `created_by_ref` (an "opaque id" holding an email). Both rules survived review because the reasoning read well. The warning is the general form: a premise stated in a comment is not evidence, so read what writes the column before relying on the paragraph. The `reports.title` rationale itself is untouched — its amendment history is being written separately so the two changes stay reviewable apart. * fix(branding): refuse to turn in-report repair estimates on `updateBranding` spread whatever the request body carried into the config write, and `showEstimates` was one of those keys. Any owner or manager -- and the admin-scoped MCP surface -- could set it in one call, and the report renderer gates the per-defect price badge and the cost tables on exactly that boolean. Only the UI control was missing; the capability was not. Estimates are being redesigned as a separate deliverable rather than a section of the signed report, so the service now rejects `true` at the one entry point that writes tenant config. `false` still passes, so anything already enabled can be turned off. The OpenAPI/MCP description advertised an example of `true` for a value the server refuses; it now states the rule. A schema comment records that a future second writer of the column would bypass the refusal. Red-first: with the guard neutered the new case fails with the write reaching the DB path carrying `showEstimates: true`, so the test watches the real thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H * feat(ai): ask whether a capability is offered, not just whether a key exists `callGemini` resolved credentials, called the model and metered the call. Nothing asked whether the product offers this capability on this credential source. So the managed path was closed only because no deployment had provisioned a platform key -- starved rather than refused, and one `wrangler secret put` away from being open without anyone deciding to ship it. `lib/ai/capability-policy.ts` answers that question: a sync pure table over (capability, source). translate is refused on any source because it is unreleased; assist is refused on platform credentials. Behaviour is unchanged today, which is the point -- an operational accident becomes a stated refusal that survives the key being configured. The source comes from `resolveRuntimeAiSource` on the same credential literal `buildAiMeter` reads in di.ts, so the source the gate judges and the source a usage row records cannot be two different answers. No second resolver, no second counter. Prompts move to `lib/ai/prompts.ts` under stable version tokens, verbatim -- skeletons compared byte-for-byte after newline normalisation, including the two lines carrying trailing spaces. Nothing persists a token yet; that needs a table. Also fixes a real swallow: `suggestComment` wrapped the call in `catch { return [] }`, so a refusal reached the inspector as an empty popover. The catch now rethrows AI_NOT_CONFIGURED, matching why `assertModelConfigured` already sits outside that try. Red-first: with the gate neutered, four of six cases fail -- including the refusal returning `[]` instead of throwing. The two that stay green are the allowed path and its metering control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H * fix(reports): stop rendering in-report repair estimates The service-layer refusal in 08c5115 closed the write. It did not close the read: a tenant whose `is_estimates_shown` was already 1 kept getting per-defect price badges, the repair panel prices and the cost tables -- platform-seeded median contractor pricing, published under their licence number. One production tenant was in exactly that state. `getReportData` no longer reads the column at all. The projection and the assignment are gone and the local stays at its `false` initialiser, so all three surfaces go dark from one place. The column itself survives: still writable to false, still carried by the branding endpoint, which is the surface that has to stay auditable. Components are untouched -- the estimate work returns as a separate deliverable and will reuse them. Verified there is no second read path, including implicit ones: five unprojected `select()`s over tenantConfigs were enumerated and their consumers checked, because a query with no projection carries a column without ever naming it. The two candidates that looked independent are derived -- the analytics service reads getReportData's result, and the delivery route's field is an OpenAPI response declaration, not a read. The e2e case that asserted this field was writable is inverted to assert the 422, with E-03 (turning it off) left as the control -- otherwise a route that rejected every branding write would satisfy it too. Red-first: with the pin removed the unit case fails `expected true to be false`, and it asserts the stored value is genuinely true first, so it cannot pass by there being nothing to pin. The first draft carried a 19-line comment and tripped the file-size ratchet at 974 > 952. The gate was right: the explanation was longer than the change. Trimmed, and the now-dead projection and assignment removed rather than left looking like a live read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H * docs(erasure): correct the reports.title rationale, and keep the old one The justification recorded for anonymising `reports.title` was wrong about this codebase in both halves. It said the column is "the one free-text column a human writes" and that it "routinely carries the address". Neither holds: the value is either the literal 'Inspection Report' or a snapshot of a service line's name from the tenant's own catalogue, no route can edit it, and the only other writer is the erasure executor running this rule. The rule does not move. Action stays `anonymize`, basis and period unchanged -- a catalogue service name is tenant-authored, so it cannot be assumed free of identifiers, and anonymising a title costs nothing. What changes is the reason on file, which is the part an Art. 5(2) accountability record is made of. The previous wording is kept in an amendment history block with the correction date, the evidence, and an explicit statement that the processing decision did not change. Deleting the record of a mistake is worth less than showing it was found and fixed. No test accompanies this: the change is the recorded reasoning, not behaviour. The erasure gate passes unchanged at 44 rules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185QebqzFLviQKtnEjLkC3H --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Completes Gap 16 + remaining Settings pages + Comments API.
Test plan
Generated with Claude Code