# Architecture decisions — append-only

One entry per non-obvious choice: what changed and why. Newest at the
bottom. When a feature is removed or replaced, the ADR here is the
only place its history survives (the feature doc gets deleted).

## ADR #001 — Founded from the rebuild plan (2026-07-07)

Built from the rebuild plan at `/srv/apps/work/docs/rebuild/` — that
package (vision, per-feature parity specs with numbered behaviors,
domain model, stack decision, architecture, migration plan, test plan,
33 judgment calls, tooling analysis, and the M0–M9 build ladder) is
this project's **founding spec**. Decisions recorded there are not
restated here; project-local ADRs start with this file.

Corollaries:

- The 33 judgment calls in `07-judgment-calls.md` are decided — do not
  relitigate them here. Rian overrules by editing that file; a
  project-local ADR then records the consequence for this codebase.
- If this project ever deviates from the founding spec, the deviation
  gets its own ADR below, naming the spec section it departs from.

## ADR #002 — M1 auth: code+PKCE everywhere; sessions stateless, 12h,
keyed off the client secret (2026-07-07)

M1 ships "Sign in with BW" per the kit
(`/srv/system/id-auth/app-auth/`) and 04-architecture. Five choices
either deviated from a kit scenario or filled a gap the specs left
open; recorded here so they aren't re-derived:

1. **Full OAuth code+PKCE instead of the kit's ACCOUNT-scenario
   header path.** PATTERN-B.md §1 "Scenario ACCOUNT" materializes the
   session from the gate's trusted `X-Auth-User` header +
   `/app/userinfo` — no code exchange. We do NOT: 04-architecture
   binds T-AZ-045 (*identity comes only from the app session
   established via the verified id_token; identity headers are never
   honored*), and the registered redirect-uri
   (`https://with.bowden.works/auth/callback`) presumes the code
   flow. The middleware therefore reads no headers at all, and every
   session begins with a cryptographically verified, audience-bound
   id_token. Bonus: the app enforces account-mode *itself*
   (default-deny), so it is safe even while the host's Caddy conf is
   un-gated — which it IS: `app-client-register` strips `import
   id-auth` from a registered app host (PATTERN-B §1b guardrail),
   so the "account mode" of 09-build-plan is delivered by the app's
   own middleware, not by a Caddy gate. `userinfo()` survives in the
   kit port for possible non-auth profile refresh, unused for
   authentication.
2. **Session = stateless HMAC-signed cookie, TTL 12h** ("short-refresh
   per the PATTERN-B guidance" — the spec named no number). Expired
   sessions re-establish silently through `/auth/login` (a live BW
   SSO session round-trips with no form). Consequence, per
   04-architecture's own note: id-auth force-logout does not
   instantly kill app sessions; the 12h TTL bounds the gap, and
   rotating the BW client secret is the break-glass instant kill
   (see 3). Stateful/revocable sessions were deliberately skipped
   for M1 — no table to migrate later if the authz engine (M2) wants
   per-request DB reads anyway.
3. **Session signing key derives from `BW_CLIENT_SECRET`**
   (HMAC-SHA256 over a fixed context string) when no explicit
   `SESSION_SECRET` env is staged. Zero extra secret staging, and
   secret rotation doubles as a full session revocation. Bare-dev
   fallback is an ephemeral per-process key.
4. **Dev stub shape (judgment #13):** `AUTH_DEV_USER` resolves a
   zero-capability stub identity only when `APP_ENV != production`;
   `create_app()` raises at startup if it is set in production
   (T-AZ-043). The compose file pins `APP_ENV=production`.
5. **users table lands as alembic 0002 with `person_party_id` as a
   PLAIN nullable uuid** — the parties table only exists at M2, whose
   migration adds the FK + unique constraint (02-domain-model §1 is
   otherwise followed exactly; `created_at` added). Capability
   changes (e.g. a super-admin grant) reach a live session at its
   next establishment (≤12h) — acceptable until M2's authz engine
   reads the DB per request.

## ADR #003 — M2 schema + transforms: deviations and gap-fills (2026-07-07)

The R1 schema (alembic 0003) and transforms follow 02-domain-model (as
amended by 07 #32/#33) and 05-migration-plan step 2. Choices that
deviated from a literal reading, or filled gaps the specs left open:

1. **No `project_engagements` table in M2.** 02 §2 defines it as the
   permission engine's backbone, but 09-build-plan's M2 table list
   deliberately omits it and M3 implements "the R1 capability grid
   ONLY — the full engagement-visibility derivation table is R2+".
   Nothing in R1 populates or reads it; adding it later is additive
   metadata-only (same argument as judgment #32 for sets/tasks). R2
   creates it from the live design.
2. **Column naming for money**: the `_usd` suffix dies (02 §3
   currency), so legacy `billout_cost_usd`/`billout_amount_usd` land
   as `cost_amount`/`billout_amount` + `cost_currency`/
   `billout_currency`; `manual_total_usd` → `manual_total` +
   `invoices.currency`; `income_usd` → `income` + `income_currency`.
   T-MIG-009's "carried columns" assertions are satisfied by VALUES
   carrying (the test suite checks the new names).
   `source_rate`/`source_amount` keep 4dp (rebuild delta #2 says
   "decide explicitly" — decided: keep source precision; harness sums
   them at 4dp).
3. **Engagement capability columns are explicit**, not jsonb:
   `can_manage_imports` (02 §4 parity capability, granted to
   owner/manager org members' worker engagements) and
   `consolidate_as` (the import-mapping config of 02 §7). A settings
   blob would hide load-bearing semantics.
4. **users are upserted, never truncated**, by the transforms —
   login-provisioned rows (and `preferences`) must survive nightly
   rebuilds; `person_party_id` is re-linked each run because parties
   are rebuilt with fresh uuids. Parties are DELETEd rather than
   TRUNCATEd (users references the table; TRUNCATE would demand
   truncating users too).
5. **rate_overrides land right after projects** (FK ordering), though
   05 lists them in step 2.3 — semantics unchanged, near-verbatim
   carry including the zero-uuid-sentinel unique index.
6. **`time_entries.source` derivation**: legacy entries carry no
   source column; it derives from the batch (`toggl`/`backfill`/
   `clockify` verbatim) and `manual` for batch-less rows; a batch with
   a NULL source defaults its entries to `clockify` with a warning
   (none exist in the real data).
7. **Audit_log is included in the rebuild wipe** (judgment #23: the
   rehearsal-native copy is discarded on every rebuild-from-legacy;
   enumerating discarded native state for acceptance is the cutover
   runbook's job). migration_runs is the run log and is never wiped.
8. **Text-cache drift (rebuild delta #4) is a REPORTED warning, not an
   abort** — nightly rehearsals must keep running while rian fixes
   data bugs in the old app; the cutover runbook treats a nonzero
   drift count as a blocker. Real-data count today: 0.
9. **The tenant party merges with the same-named operator party**
   ("Bowden Works" is both tenant and operator — one party wearing two
   hats), and a tenant is created per legacy org (the second, empty
   org gets an empty tenant; harmless and keeps reconciliation total).
10. **Judgment #22 fired on real data** (1 straggler, entry
    `adeb5a3f-5b7d-4807-8a7a-493ac7b42036`): attached to the existing
    "Legacy transfers" invoice, warned in the run report, pinned
    row-for-row in `harness/allowlist.json` per 05 step 0. Owner
    review pending; if rian fixes it old-side, the allowlist entries
    go stale (compare reports them) and get removed.
11. **`pytest` now also runs `harness/tests/`** (testpaths) — they
    were previously outside the CI gate. Transform tests run against
    a scratch database on the project's own sidecar (SQLite cannot
    host the legacy schema or the Postgres-only DDL).

## ADR #004 — M3 read-only mirror: deviations from the legacy RPC semantics (2026-07-07)

M3 ships the authz engine (services/authz) + the reporting ports
(services/reporting) + the three read pages. The parity bar is
`/srv/apps/work/docs/rebuild/01-current-system/` and the equivalence
oracle is the M2 harness baseline (`harness/out/e2e-new.json`), matched
cent-for-cent across all 8 months of real history. Deviations — each
either mandated by the rebuild plan or deliberate, all pinned by tests:

1. **ONE status predicate, ONE hours definition** (rpcs §2.4 binding
   decision): the per-description expansion uses the canonical §1.1
   predicate and `duration_seconds`, so it sums to its parent row
   (T-RPT-004). The legacy RPC's stale pre-invoice semantics were NOT
   ported. Eligibility is FK-based: `worker_party_id` replaces
   `team_member_id`; `project_id` subsumes the operator/client/project
   text-triple checks (a row with partial raw text but no resolved
   project is Blocked — same user-facing semantics, entries.md delta).
2. **`billout_seconds` duplicate column dropped** (rpcs §2.1: it was
   byte-identical to source seconds, kept only for old-UI compat).
3. **`?invoice=` scopes the totals too** — the legacy summary-card-
   ignores-invoice wart (entries.md B13) is fixed, per T-ENT-07.
4. **Options arrays normalize SQL NULL → `[]`**, sorted in Python
   (codepoint order) rather than DB collation; and the options
   endpoint also carries the batches + importers lists (one call for
   the whole FilterBar instead of the old three data paths).
5. **Money is per-currency all the way down.** Aggregates SELECT
   `count(DISTINCT currency)` beside every SUM; >1 currency in a
   window raises MixedCurrencyError → HTTP 500 `MIXED_CURRENCY`
   (loud), never a cross-currency sum. All-NULL sums are 0.00 CAD
   (the R1 default currency, a Settings value not a literal).
6. **The one Python-side aggregation** (04-architecture requires
   written justification): the summary/projects pages' totals rows are
   folded from the SQL GROUP BY result (~60 group records, not the
   4,593-row set — Postgres still does the row-set aggregation). Two
   reasons: (a) the footer must equal the sum of the DISPLAYED,
   per-row-rounded adjusted-billout values to the cent (counts-match-
   reality is rian's top trust rule; summing unrounded in SQL and
   rounding once can differ by cents from the visible column), and
   (b) the adjusted math (`raw × (1+pct/100) + amount`) is per-group
   scalar arithmetic, not aggregation. Seconds/cost/billout sums are
   exact either way (2dp stamps, integer seconds).
7. **Money confidentiality is server-side truth** (rpcs §8.5): non-
   `can_see_income` payloads have billout/income/margin/adjusted keys
   ABSENT (not nulled, not UI-hidden) — /api/entries, /api/entries/
   totals, /api/summary, /api/projects all shape in the router layer.
   The margin (`income − cost`) and the headline margin rule
   (Σincome − Σ cost-of-income-rows only) are computed server-side so
   the frontend never does money arithmetic.
8. **View-as is strict where the legacy app was silent** (T-VAS-12
   inversion): entering as a non-super-admin → 403, unknown target →
   404, nesting → 409, self → 400; enter/exit write audit_log rows
   recording BOTH identities (the old app had no impersonation audit);
   logout deletes the view-as cookie (kills the legacy stale-cookie
   wart E7). A forged/stale cookie is still silently ignored at
   RESOLVE time (fail-safe), matching legacy.
9. **View-as narrows batch metadata** to the target's own batches —
   carrying the legacy overlay's known stricter-than-reality behavior
   (permissions B48) rather than the org-wide real-manager view, so a
   view-as session never shows MORE than the target could see.
10. **can_manage_team encodes the EFFECTIVE legacy rule** (owner or
    super admin at tenant level) — NOT the dead `canManageTeam`
    function's any-manager grant (02 §4 binding). Manager × team
    management = deny in the T-AZ-001 grid.
11. **Month presets resolve in the BROWSER's local clock** (the old
    app used the server's local clock; both parties are in the same
    timezone household, and `start_at` is timezone-naive by design).
    The /api/summary month fallback ("no/invalid param → current
    month") uses the server clock, same as legacy.
12. **Summary page UX fixes carried from the spec's delta list**:
    next-month paging exists (the old app could only go back), and
    "Clear filters" preserves the month (the old app silently jumped
    back to the current month). Free-text search is debounced 300ms
    (the old app navigated per keystroke).
13. **The R1 tenant context is a setting** (`APP_TENANT_SLUG`,
    default `bowden-works`) resolved via the tenant party's slug —
    no hard-coded org id in code paths (04 per-tenant configuration).
    The migrated second tenant (Tingang, empty) is invisible: scope()
    pins every query to the app tenant.
14. **Expansion pin params are their own names** (`raw_operator`/
    `raw_client`/`raw_project` + the `unassigned` group id) — the
    legacy `__null__`-sentinel-collides-with-contains-filters bug
    (projects B20) is structurally gone.

## ADR #005 — M4 import pipeline: deviations and gap-fills (2026-07-08)

M4 ports the import pipeline per imports.md (B1–B77 + its "Rebuild
deltas") and 06-test-plan §2. Choices that deviated from a literal
port, or filled gaps the specs left open:

1. **No format pickers, fixed fallback.** The legacy upload form's
   date-format selector was "a fallback only" (B2); the rebuild UI has
   no pickers at all (imports.md UI notes: auto-detection over
   configuration), so when `detect_date_format` abstains the parser
   falls back to `MM/DD/YYYY` (the Clockify US default) — a constant,
   not a form field. The `time_format` plumbing (stored, never read)
   and the `task` header (indexed, never read) were dropped as dead
   code; `parse_time_only` keeps its ignored fmt argument so the
   CLK-026/027 fixtures port verbatim.
2. **`pending_imports.tenant_id` has no FK** (alembic 0004): the
   nightly rebuild-from-legacy transforms TRUNCATE/DELETE the entity
   tables (tenants included) and an FK from staging would break that
   pipeline. Staging rows are ephemeral by design — expiry is now
   ENFORCED (purge on upload, not-found + delete on read; T-IMP-031),
   fixing legacy wart #5 ("24h" was UI copy with no teeth). The
   staged payload also carries the parse report (errors/warnings/
   total_rows) so skipped rows are shown to the user (wart #1 fixed,
   T-IMP-011 both halves).
3. **Decision wire contract is structured JSON**, not the legacy
   pipe-delimited FormData keys — names ride as fields, killing the
   "a name containing `|` corrupts matching" wart #3 structurally.
   The semantic encodings survive: map/create per unknown name,
   map/create/skip per bucket, create-by-default (B47), bucket
   map-with-no-selection = silent skip (B56/T-IMP-020), bucket with
   NO submitted decision = skip (legacy signature-miss parity — NOT
   create, deliberately, so a stale form can't create entities the
   user never saw).
4. **Commit is ONE database transaction.** T-IMP-023 pins the visible
   half (failed chunk → entries + batch gone, staging survives, error
   names the row range); the rebuild goes further per 04-architecture
   ("commit becomes transactional… fixes edge cases 1–4"): entities
   created along the way roll back too — the legacy entity-debris
   wart #2 is gone. The 500-row chunking survives as the unit of the
   error message. Entity matching uses exact `lower()` comparisons,
   never `ilike` (wart #4 gone).
5. **Entity-model mapping for resolve-or-create**
   (`services/entities/resolve.py`, the ONE implementation): operator
   = org party in the tenant's operator set (tenant party ∪
   direct_client operator sides ∪ project operator parties); client =
   org party + active `direct_client` engagement under its operator;
   project = row keyed (tenant, client party, lower(name)) per
   judgment #33. Creating a client whose name matches an operator-set
   party REUSES that party and adds the edge ("one party wearing two
   hats" — mirrors the migration merge rule); same-named clients
   under different operators stay separate parties.
6. **Worker resolution replaces the team lookup**: entry email →
   person party (`parties.email`, lowercased) holding an ACTIVE
   subcontract/freelance engagement in the tenant (earliest-created
   wins). No match = unresolved row (NULL worker/stamps — T-CLK-018
   port*). The legacy fail-fast "you don't have a team" upload guard
   maps to "actor must have a tenant context + users row" (uploads
   need attribution); there is no importer-team dependency anymore.
7. **Stamping semantics** (`services/money/stamping.py`, the only
   stamping site): rates in force **at write time** (T-LOCK-002's
   current-rates semantic), hourly-model terms only, project-scoped
   term > engagement default, then rate_overrides for billout
   (worker-specific > project-wide; absolute is the rate; pct
   modifies the term-resolved default; pct with NULL default = NULL,
   T-ADJ-008). NULL = never priced / 0.00 = priced at zero (judgment
   #19) on the write path; re-stamp actions are M5's job. Rounding is
   Decimal ROUND_HALF_UP at computation time (matches JS Math.round
   on these non-negative inputs; CLK-023 pins it).
8. **raw_* is never canonicalized** (02 §3): B66's text-rewrite half
   is moot — resolved rows display via the project FK chain, raw_*
   keeps the CSV spelling as immutable provenance (T-IMP-018's
   rebuild assertion). Bucket-mapped rows get the target project_id;
   their raw blanks stay blank.
9. **Batch delete guard re-keyed to invoice attachment** (rebuild
   delta; T-IMP-003): `invoice_line_id IS NOT NULL` entries block
   deletion, message carries the real count ("…already been applied
   to an invoice. Detach them from the invoice first."). Delete is
   one transaction (entries + batch — T-IMP-028's FK-orphan dance is
   moot). Batch metadata stays tenant-visible (B74) with the view-as
   narrowing from M3's scope rules; batch entry counts come from one
   grouped query (legacy N+1 not ported).
10. **Parser cross-validation harness**
    (`scripts/cross_validate_csv.py` + `scripts/ts_parse_clockify.mjs`
    + `harness/csv-archive/`): runs a CSV through the Python port AND
    the legacy TS parser (via the old app's tsx), field-level diff
    must be empty; the only normalized deltas are the renamed
    source_rate/source_amount fields. Verified green on synthetic
    edge-case CSVs at build time; the standing check runs on every
    archived real weekly CSV until cutover (Day-0 #4 feeds the
    archive).

## ADR #006 — M5 entries editing + bulk safety: deviations and gap-fills (2026-07-08)

M5 builds the entries write paths, the bulk-mutation safety
architecture (judgment #11), the audit/undo mechanism, and entity
manage with first-class merge (judgment #10), per entries.md,
projects-and-entities.md §manage, 04-architecture, and 06-test-plan
§3/§5/§7. Choices that deviated from a literal port or filled gaps:

1. **The entity-cell cascade became a guided drill-down.** The legacy
   inline edit held PARTIAL text states (operator typed, client blank)
   because the text columns could store them; the FK model cannot — an
   entry's entity identity is `project_id`, full stop. The B46 cascade
   translates as: picking a project syncs operator+client (the FK
   carries them); changing the client clears the project and requires
   a re-pick/create INSIDE that client; changing the operator requires
   both. The server enforces the same rule: a PATCH sends either
   `project_id` (or explicit `project_id: null` = unlink, replacing the
   B50 silent-null wart per T-ENT-29) or the full operator+client+
   project name trio (`PARTIAL_ENTITY_CHAIN` otherwise). Same
   user-visible semantics ("client change clears project; operator
   change clears both"), impossible-to-drift representation.
2. **expected_count is required in BOTH selection modes.** The spec
   demanded the precondition for all-matching mutations (T-ENT-44);
   we also require it for explicit-id selections — a row that vanished
   (or left the actor's scope) between render and submit aborts with
   the same "Safety stop:" message instead of silently shrinking the
   operation. This also gives guardEntryIds-parity for free: the id
   list is filtered through scope(), so out-of-scope ids surface as a
   count mismatch, never as a partial write.
3. **Undo is replay-from-audit-row with three refusal/skip rules**
   (04 "Audit" made concrete): (a) an audit row undoes at most once —
   the undo itself is the marker (an `entries.undo` audit row pointing
   at the original; no schema addition needed); (b) only the acting
   user or an owner may undo; (c) rows whose LOCK state changed since
   the mutation are skipped and reported exactly — undo never touches
   `invoice_line_id` (locks belong to M6's invoice actions). Deleted
   rows re-insert verbatim with their original ids.
4. **Bulk recalculate skips locked rows for everyone** (the rebuild
   target T-ADJ-012/017, not the legacy behavior that re-stamped
   invoiced rows), and **bulk delete skips locked rows for everyone**
   (new operation — detach first; single-row owner delete remains for
   surgery). Bulk edit keeps the legacy split: managers skip locked
   rows, owners include them (T-ENT-25).
5. **Merge retires parties, deletes projects.** "Delete the husk"
   (judgment #10) meets "parties are never hard-deleted" (02 §1):
   merged-away PROJECT rows are deleted (nothing references them
   afterwards); merged-away operator/client PARTIES have their
   direct_client edges ended and are archived (`is_active=false`) only
   when no live reference remains — invoices are re-pointed to the
   merge target first (T-ENTITY-009/010). A party still wearing other
   hats (other edges, other roles) stays active and keeps its
   invoices.
6. **Project-scoped compensation terms and CC auto-rules follow a
   project merge to the target; rate overrides are dropped** (legacy
   parity — the target's rules win). Terms are rate HISTORY and keep
   applying to the same work; silently deleting them would corrupt
   future re-stamps.
7. **Re-resolve is tenant-wide.** The legacy per-team lookup ("the
   batch uploader's team roster") retired with teams (02 §2); worker
   resolution is email -> active worker engagement in the tenant —
   identical for every actor (also fixes T-ADJ-016's invoker-RLS
   divergence). The "in this batch's team" clause is dropped from the
   error message. Clearing a conversion clears ALL money stamps —
   the B54 stale-billout wart does not port (T-ENT-33).
8. **The manage page has no create actions** (legacy B39): entity
   creation happens through resolve-or-create at its call sites
   (import resolver, cell editors, bulk quick-create — all
   create-in-place). Rename/move/merge/delete-empty is the manage
   page's whole job.
9. **Manual entries stamp identity from the worker party**
   (email/name); a worker party without an email produces a
   source-email-blocked row — visible in the Blocked lane rather than
   silently invented (legacy team_members always had emails; parties
   may not).
10. **No new alembic revision.** M5 needed nothing beyond the M2
    schema: audit_log (with complete before-images) was designed for
    exactly this, and the "Manual entries" bucket is an ordinary
    `import_batches` row with `source='manual'`.

## ADR #007 — M6 invoices + locks: deviations and gap-fills (2026-07-08)

Parity bar: 01-current-system/features/invoices.md (B1–B76) + 02 §3
invoices/invoice_lines + judgment #9/#22/#24. What we decided where the
spec left room (or where the rebuild deliberately diverges):

1. **Undo for lock ops = the inverse operation, not audit replay.**
   The M5 undo machinery deliberately never touches `invoice_line_id`
   (its `_PROTECTED_COLUMNS`), and replaying an apply would have to
   resurrect deleted `invoice_lines` rows with their original ids —
   a second lock-mutation pathway to keep correct forever. Instead,
   per 04's mutation-feedback policy (apply/detach are PESSIMISTIC
   verified-count mutations), **detach IS the undo of apply and
   re-apply IS the undo of detach**: the toasts offer exactly that
   over the exact applied/detached id lists, and each inverse is
   itself safety-stopped, verified, and audited.
   `invoices.apply_entries` / `invoices.detach_entries` are excluded
   from `UNDOABLE_ACTIONS` (generic undo → 409 NOT_UNDOABLE) but still
   carry COMPLETE entry before-images (+ line images on detach) for
   forensics.
2. **Guarded delete replaces detach-on-delete.** Legacy delete
   detached every entry and wiped the apply audit in one click (B70;
   B71 explicitly flags it as unacceptable for real bookkeeping). The
   rebuild refuses to delete an invoice with lines
   (`INVOICE_HAS_LINES`); the flow is detach (audited, counted) →
   delete (audited). T-INV-005's user story still holds — it just
   takes the two deliberate steps.
3. **Apply is refused on paid invoices server-side** (`INVOICE_PAID`).
   Legacy only hid paid invoices from the picker (B14) — a stale form
   could still post one. Flipping back to sent/open restores apply.
4. **The single-currency document rule is enforced at apply.** An
   eligible entry whose `billout_currency` differs from
   `invoice.currency` aborts the WHOLE apply (409 CURRENCY_MISMATCH)
   before any write — 02 §3's R1 rule (error loudly, never mix).
   NULL-stamp (unpriced) entries attach fine with a NULL line amount.
5. **Eligibility = THE ONE predicate, evaluated in SQL.** Apply
   re-checks candidates against `status_condition("pending")` — the
   same object the display filter uses — so T-INV-010's legacy
   display-vs-apply disagreement is structurally impossible. Blocked
   and already-attached rows are skipped AND counted separately
   (`skipped_blocked` / `skipped_already_applied`); an all-ineligible
   scope raises (NOTHING_TO_APPLY), matching the M5 engine convention.
6. **Paste-block literals are tenant settings with code defaults.**
   `tenants.settings.paste_block.{team_label,operator_label,category_label}`
   override the Bowden Works defaults ('Bowden Works Team' / 'Bowden
   Works' / 'Labour') — per 04's per-tenant-config rule, without
   requiring a settings write for R1 (the byte-diff gate pins the
   default output against the legacy formatter).
7. **Line `rate` stays NULL for R1 time lines** (migration parity —
   05 step 2.7 wrote none). `quantity` = hours 2dp half-up, `amount` =
   the entry's billout stamp; no R1 read path consumes `rate`. The
   invoice engine (R4) will populate it when lines become generated
   documents.
8. **invoice_date is calendar-validated.** The legacy regex accepted
   `2026-99-99` and surfaced a raw PG error (B4 edge case). Here an
   impossible date is "malformed": create falls back to the default,
   update writes NULL — the exact T-INV-012 semantics with no raw
   database error path.
9. **Invoice aggregates are scope()-transformed SQL.** List totals,
   detail stats, and the breakdown all run as grouped SQL over
   `invoice_lines JOIN time_entries` through `scope(actor,
   "entries:read")` — killing the unbounded client-side reductions
   (B17/B42) and making partial-visibility sums (T-INV-023) fall out
   of the one authz implementation. Breakdown groups by the LINE
   SNAPSHOTS (judgment #9), so renames never move history.
10. **The byte-diff gate emulates the straggler rule.** The oracle for
    'Legacy transfers' includes legacy rows with `transferred_at NOT
    NULL AND invoice_id IS NULL` (judgment #22 — the real data has
    exactly one, the Villa del Mar row already flagged for rian's
    review), because the migration deliberately attached them to the
    sentinel. Without this the gate would "fail" on a decided,
    documented delta rather than a bug.
11. **No new alembic revision.** M2's `invoices` + `invoice_lines`
    shape was sufficient exactly as shipped; M6 added zero schema.

## ADR #008 — M7 (CC expenses + comments + team admin): deviations and gap-fills (2026-07-08)

M7 ships as three parallel slices (A = CC expenses, B = comments,
C = team admin); each records its decisions in its own lettered
subsection below.

### A. CC pass-through expenses (slice A)

Parity bar: 01-current-system/features/cc-expenses.md (B1–B54) + 02 §3
`cc_*` tables + 07-judgment-calls §15. R1 keeps the paste → parse →
assign → rules → export workflow verbatim; the deliberate divergences:

1. **The parser is a FAITHFUL port, gate-proven byte-identical.**
   `app/services/cc/parse.py` reproduces the legacy `lib/cc-expenses.ts`
   exactly — including `parse_currency` matching JS `Math.round(n*100)/100`
   on IEEE-754 doubles (`math.floor(x + 0.5)`, NOT Python round-half-even).
   `tests/cc/test_cc_parser_gate.py` runs the 102 REAL migrated
   `raw_line` values AND every real batch's export through the legacy TS
   via `tsx` (`scripts/ts_cc_parse.mjs` / `ts_cc_export.mjs`) and
   byte-diffs — 0 divergences. Faithfulness (not "improved") is the point
   because the byte-diff IS the acceptance contract.
2. **Warts carried, not fixed, to keep the gate exact.** Loose date
   validation ("Feb 31" → "2026-02-31", no calendar check), sign-agnostic
   credits (`amount = debit ?? credit`, both positive), and no paste
   dedupe all port verbatim; each is logged as an open consideration in
   the feature doc. Real statements never hit these, so the gate on real
   data is unaffected.
3. **Column-6 literal is a TENANT SETTING, not a code literal.** The
   legacy export hard-codes `'Bowden Works'`; `format_cc_paste_block`
   reads `tenants.settings["paste_block"].operator_label` (reusing M6's
   `paste_block_config`), defaulting to the same string so the Bowden
   Works tenant renders byte-identically. (04 "no business literals".)
4. **Separate client/project end-to-end in the export** (kills the legacy
   `' : '` split wart): the paste-block query joins `project → client`
   directly instead of round-tripping a `"{client} : {project}"` label.
5. **RLS → owner-only service-layer authz.** The four legacy RLS policies
   (any org member) do not port. EVERY cc read and write requires
   `can_transfer` (owner / super-admin-not-viewing-as) — the feature is
   owner-only (no manager use case). Tenant scoping is now explicit on
   every mutation (lines scope through their batch — the table has no
   `tenant_id`), fixing the RLS-only reliance flagged in the spec.
6. **Project-tenant re-validated on rule create AND update** — the legacy
   `updateAutoRule` skipped the check the create did; both validate now.
   Duplicate keyword/rule (case-insensitive, per tenant) is PRE-CHECKED
   with a SELECT so the friendly "already exists" message reaches the user
   without depending on a raw 23505.
7. **Empty vs whitespace paste are distinct messages** (T-CCE-A-011/012):
   a truly empty field → "Paste some lines first."; non-empty content
   that parses to nothing → "No usable lines found in the paste."
8. **Rule precedence stays `created_at` DESC (newest wins)** — no
   `priority` column added (deliberate v1 tiebreak, now documented, and
   pinned by tests that stamp explicit times under second-granular SQLite).
9. **`invoice_line_id` is never written.** R1 bill-through is a stateless
   export; the invoice-lock wiring (a CC line attached to a non-void
   invoice becoming immutable) is future work. Every cc mutation writes an
   audit row (`cc.*`) via the shared `services/audit.write_audit`, but CC
   is NOT in `UNDOABLE_ACTIONS` (parity: CC has no undo).
10. **No new alembic revision.** M2's `cc_expense_batches` /
    `cc_expense_lines` / `cc_highlight_keywords` / `cc_auto_rules` (with
    `tenant_id`, `(batch_id, line_index)` unique, and `invoice_line_id`)
    were sufficient — M7 CC added zero schema. UI: the legacy
    SearchableCombobox/datalist become the single `EntityPicker`
    primitive; the saved/auto-matched/highlight trio become semantic
    tokens; row saves are optimistic (TanStack Query, no full reload);
    zero inline styles.

### B. Comments board + bell (slice B)

Parity bar: 01-current-system/features/comments.md (B1–B27) + 02 §3
`comments` + 06-test-plan §7.3. R1 scope is the GLOBAL board only (the
schema's nullable `entity_type`/`entity_id` anchor columns exist but no
UI touches them). What we decided where the spec left room or the
rebuild deliberately diverges:

1. **RLS → service-layer authz on the resolved Actor.** The four legacy
   RLS policies (`comments_org_select/insert/update/author_or_admin_delete`)
   become plain checks in `services/comments.py` reading the same Actor
   fields `can()`/`scope()` read — no new capability was added to the
   grid (there is no `can_comment`; comments are not a capability, they
   are tenant-membership + authorship). "Member" = a resolved user with
   an active tenant engagement (owner/manager/worker) OR a super admin
   who is not viewing-as (the `is_super_admin && !is_viewing_as` idiom).
2. **Author-only delete is ENFORCED server-side (the wart is fixed).**
   The current system's `comments_author_or_admin_delete` RLS made a
   non-author's delete a SILENT no-op (0 rows, no error). The rebuild
   refuses it with an explicit `403 NOT_COMMENT_AUTHOR`. Delete is
   allowed for the author or a not-viewing-as super admin.
3. **ONE (effective) identity for attribution AND gating.** The old app
   attributed posts to the *effective* user but gated the Delete button
   on the *real* user — so a super admin in view-as saw Delete on their
   own comments yet the delete silently no-op'd. Here both use
   `actor.user_id` (the effective user). The server computes a per-row
   `can_delete` with the SAME predicate the delete handler enforces, so
   button visibility and enforcement can never disagree (T-COM-109).
4. **The badge is a REAL `count(*) where resolved_at is null`.** The
   legacy badge counted only unresolved rows *within the 50-row display
   window* (undercount past 50). The rebuild returns a tenant-wide count
   (served by the partial index `ix_comments_tenant_unresolved`)
   independent of the list window (T-COM-106). The panel still lists the
   newest 50 (`BELL_WINDOW`).
5. **No lifecycle precondition (B9 preserved).** Resolving an
   already-resolved comment RE-STAMPS `resolved_at`/`resolved_by` to the
   new actor/time; reopening an already-open comment is a no-op write.
   Deliberately no "already resolved" guard — matches the current
   system and keeps the shared board frictionless (T-COM-115).
6. **Nonexistent / cross-tenant id → 404, not silent success.** Every
   mutation resolves the comment tenant-scoped first; a miss is
   `404 COMMENT_NOT_FOUND` (the legacy silent success-shaped no-op is
   gone — T-COM-113).
7. **Every comment mutation writes an audit row** (`comments.post/
   resolve/reopen/delete`, effective + impersonated identities) via the
   shared `services/audit.write_audit`, riding the caller's transaction.
   Comments are NOT part of the generic entry-undo engine (not in
   `UNDOABLE_ACTIONS`); the before-images are for forensics only.
8. **Display-name resolution is app-side** (users LEFT JOIN parties):
   name = party name, else login email, else absent. A null/absent
   author renders "Unknown"; a null resolver renders "Resolved by
   someone" — the `ON DELETE SET NULL` account-deletion path (T-COM-110).
9. **No new alembic revision.** M2's `comments` table already carries
   `tenant_id` + `author_id`/`resolved_by` (users FK, SET NULL) + the
   two indexes; M7 comments added zero schema. @mentions stay cosmetic
   (a `/@\w+/` client highlight — no notifications, no validation).

### C. Team & engagement admin (slice C)

Parity bar: 01-current-system/features/organizations-and-team.md
(rebuild deltas) + 02 §1–2, §4 + 06-test-plan T-TEAM/T-ORG. Decisions
and deliberate divergences:

1. **`can_manage_team` = the EFFECTIVE rule (owner / super-admin, not
   while viewing-as); managers are denied in R1.** This resolves the
   legacy `canManageTeam`-vs-RLS mismatch the domain doc flagged (02
   §4): the dead any-manager grant is NOT the parity bar, and the RLS
   "team owner" branch has no R1 analogue because teams dissolved.
   Per-team-owner management (Adi maintaining his Tingang roster) is
   resource-scoped and lands in R2. The whole page (read + write) gates
   on `can_manage_team`; billout-rate fields + the override list are
   additionally gated on `can_transfer` so the payload stays correct
   once R2 introduces team-scoped managers (T-TEAM-12).
2. **Rate history is append-only; the change never touches
   `time_entries`.** A rate change ENDS the current engagement-default
   term (`effective_to` = change date − 1 day) and OPENS a new term
   effective today, so historical dates still resolve the old rate and
   the next import stamps the new one. A same-value change is a no-op;
   a **same-day correction** (the open term started today/future, so no
   historical stamp resolves through it) amends the amount in place —
   still append-only w.r.t. HISTORY, which is the invariant that
   matters. Clearing a rate ends the term with no successor (NULL going
   forward). Re-stamping existing entries is deliberately NOT offered
   here — it is the explicit `/entries` recalculate action (M5). The
   GATE proves all of this on a real migrated worker (Adi): new term
   row, old `effective_to` set, existing stamps byte-for-byte
   unchanged, next stamp at the new rate.
3. **Worker notes live on the ENGAGEMENT, not the party.** The
   migration writes `team_members.notes` onto the engagement (not
   `parties.notes` as the rebuild-delta doc's field-map line reads);
   the Team page reads/writes engagement notes so migrated and
   newly-added workers render identically. Deliberate divergence from
   the delta doc, chosen to match the shipped migration.
4. **Deactivate = END the engagement (`ended_on`), not a status flag;
   an ended worker stops resolving on import.** Legacy `is_active` was
   display-only (inactive members still matched at import). The R1 rule
   ends the working relationship in the resolver while keeping the
   person party, its terms (incl. Gary's cost-0 / billout-25), and all
   history — soft archive replacing the legacy hard-delete SET-NULL
   orphaning wart. "Active" is read from `engagement.ended_on IS NULL`,
   NOT `party.is_active` (the migration leaves an inactive member's
   person party active). This is the R1 replacement for T-TEAM-09.
5. **`rate_overrides` management lives on the Team page, not
   `/projects/manage`.** M5's ManagePage already handles dropping
   overrides on project merge/delete but never had an override CRUD UI;
   rather than duplicate a manage surface, the owner-only override
   editor (project-scoped, worker-specific > project-wide, absolute-rate
   XOR signed-pct, one rule per (project, worker)) sits with the rates
   it modifies. Stamp-time precedence stays `services/money/stamping`'s
   job — this page only authors rows.
6. **Retired/rescoped parity tests.** T-TEAM-03 (per-team email
   uniqueness) has no R1 analogue — teams dissolved, uniqueness is
   per-tenant per person. T-TEAM-02's "rate change leaves history"
   becomes the append-only gate. TEAM-003/004 stay retired (base-rate ×
   proportion model, gone with the Toggl phase-out).
7. **No new alembic revision.** `engagements`, `compensation_terms`
   (kind cost/billout, `effective_from`/`effective_to`, nullable
   `project_id`, model/currency CHECKs), and `rate_overrides` (rate XOR
   pct, currency-pair CHECK, unique `(project, coalesce(worker, zero))`)
   all exist from M2 — verified sufficient; team admin added zero
   schema.

## ADR #009 — M8 (hardening + saved views + UX debt): decisions and gap-fills (2026-07-08)

M8 is the last build milestone before cutover readiness. It adds the
saved-views centerpiece, a 100x perf pass, app-level security hardening,
the tier-A inventory, Playwright smokes, and a 390px pass — no new
domain semantics beyond the two guards noted below.

1. **Saved views get a dedicated `saved_views` table (alembic 0005), NOT
   `users.preferences` jsonb.** A `preferences` jsonb column already
   exists, but a table earns its keep: the unique `(user, page,
   lower(name))` name guard, the `pinned`/`sort_order` columns, and
   race-free single-row insert/rename/delete/pin are all first-class on
   a table, whereas jsonb would force a read-modify-write on every
   mutation (a lost-update hazard). One new revision, as the milestone
   anticipated.
2. **Saved views key on the REAL user, and are NOT tenant-scoped or
   audited.** They are a personal navigation preference (a browser
   bookmark), so they follow `actor.real_user_id` even under view-as —
   a super admin managing views while impersonating manages *their own*
   views, never the target's. They carry no tenant data (only a saved
   filter string), so they are deliberately excluded from the tenant
   audit trail; the M8 route-audit meta-test allowlists them.
3. **The hardcoded quick-chips retire — losslessly.** The entries and
   projects quick-chips are deleted; their equivalents are **seeded** as
   pinned views by idempotent CODE (`seed_default_saved_views`, run at
   boot and re-asserting after any rebuild-from-legacy rehearsal), not
   migration data. rian's known recurring views are preserved verbatim,
   including the load-bearing `Hourly blocks` (`date=all` is the
   support-block drawdown math — projects-and-entities B17).
4. **Perf gate = no-seq-scan on the indexed hot paths + honest 100x
   latency numbers, NOT a full-EXPLAIN assertion.** `scripts/seed_100x.py`
   builds a *separate* scratch DB (never the production `with` books —
   it hard-refuses that name) by multiplying the real migrated shapes to
   ~460k entries. `tests/perf/test_no_seq_scan.py` asserts the default
   date-ordered list page, a selective date range, a month-bounded
   totals aggregate, and single-row-by-id all ride an index on
   `time_entries` (a 90k scratch DB, large enough that a small-table
   seq-scan artifact can't mask a real regression). The unbounded
   `date=all` totals/summary/projects aggregates DO scan — that's the
   correct O(N) plan and is deliberately not asserted; their full-scale
   latencies live in `docs/PERF.md`. This realizes T-RPT-006 (§0
   re-tiered it to "the M8 perf gate"). `pg_stat_statements` is enabled
   via the sidecar compose `command` flags for future observability.
5. **ONE eligibility predicate, enforced by a meta-test (T-PHX-009).**
   `_sort_expr` in `reporting/entries.py` had re-inlined the 5-field
   eligibility conjunction for the status sort key. It now reuses a new
   `filters.status_sort_rank()` built from the SAME `_locked()`/
   `_eligible()` predicates, so the conjunction is defined in exactly one
   place (`filters.py`). `tests/meta/test_phase_out_guards.py` scans the
   tree to keep it that way (+ T-PHX-007 no-RLS/no-Supabase-auth/no-JWT-
   minting, T-PHX-010 no-Toggl-export).
6. **New guard: percentage rate overrides are floored at -100%
   (T-ADJ-009).** `team_admin._validate_override_kind` now rejects a
   pct below -100 (`PCT_TOO_LOW`) — a discount past -100% would make
   billout negative. -100 (free) is the allowed floor. This closes a
   real money-path parity gap the legacy app enforced but the rebuild
   had dropped. Not a `services/money/` change (validation lives in
   `team_admin`), so outside the single-writer money lock.
7. **Playwright is a dev-only tool, kept OUT of the shipped SPA.** The
   e2e suite (`e2e/`, `playwright.config.ts`) is wired as a separate
   `make e2e` target, never in `make check` (needs browser binaries + a
   seeded e2e DB). `@playwright/test` is installed `--no-save` by the
   target so it never enters `frontend/package.json`/the lockfile — that
   keeps the hermetic `npm ci` in the Docker build intact.
8. **Version-drift fix.** `frontend/package.json` had gone stale at
   0.6.0 while the backend advanced to 0.8.0 (M6/M7 bumped
   `app/version.py` but not the SPA build version), which would fire the
   deploy-skew banner on every response. M8 bumps BOTH to 0.9.0 and
   this is called out so the two stay locked going forward.
9. **Deferred, surfaced for the orchestrator (not built in M8):** the
   project **income/adjustment WRITE surface** (owner setting
   `billout_adjustment_pct/amount`/income on a project) is not
   implemented — only the migrated values are read. That leaves
   T-ADJ-020, T-PRJ-028/029 without a testable implementation. This is
   an earlier-milestone parity gap, out of scope for M8's hardening
   pass; it is logged in `docs/TIER-A.md` and `docs/UNDOCUMENTED.md`.

## ADR #010 — Project income + billout-adjustment editing (owner-only): the M8 last-gap close (2026-07-08)

Closes the deferral in ADR #009 #9. The owner can now set a project's
`income` and its two display-only billout adjustments
(`billout_adjustment_pct` / `billout_adjustment_amount`) — the one
genuinely-unbuilt tier-A feature. New `PATCH /api/projects/{id}/financials`
over `app/services/projects_admin.py`; frontend inline editor on the
/projects Income cell. No migration — the columns shipped in the M2
schema (alembic 0003) and reporting already consumed them at read time;
only the write path was missing.

1. **Owner-only, gated on `can_transfer`.** Parity with the legacy
   `updateProjectIncome`→`canTransfer`. Message verbatim: "Only an owner
   can edit project income." `can_see_income` and `can_transfer` share
   the R1 owner-only truth table, so the read column and the write gate
   coincide; the write uses `can_transfer` (the money-write convention).
   Refused BEFORE any project read. 403 matrix tested for manager /
   worker / none (synthetic + the real migrated Adi).

2. **pct and amount are NOT XOR — they COMBINE.** The orchestrator brief
   said "adjustment pct XOR amount"; the parity bar overrules it (THE
   PLAN IS THE BOSS). The billout adjustment is `raw × (1 + pct/100) +
   amount` (adjustments-and-rates B32); T-ADJ-018 sets BOTH; the migrated
   "Site Rebuild" project carries pct 10 AND amount 250; neither the
   legacy nor the R1 `projects` table has an XOR CHECK on those columns.
   Enforcing XOR would break T-ADJ-018 and contradict migrated data, so
   it is deliberately NOT enforced. (The only XOR in the system is
   `rate_overrides` rate-XOR-pct — a separate feature, team_admin.)

3. **The constraint we DO enforce: income⇔currency pairing.** The real
   R1 `projects` CHECK is `ck_projects_income_currency_pair`
   (`(income IS NULL) = (income_currency IS NULL)`). The service always
   writes `income_currency` = tenant currency (CAD, 07 #21) when income
   is present and NULL when it is cleared, so the pair can never violate
   the CHECK. This is the parity of the legacy "income is a bare number"
   in the new currency-aware schema.

4. **One save writes all three; blank clears.** Parity B23/B34: income,
   pct, and amount are each optional and a blank field clears its column.
   Re-stamps NOTHING (ADR #040) — display-only. So it is NOT a
   `services/money/` change and sits outside the single-writer money lock
   (same rationale as ADR #009 #6's `team_admin` override-floor). Every
   save is audited with a complete before-image (team_admin convention).

5. **The "Fix the entries first" guard is structural in R1.** The legacy
   NULL-tuple guard (can't set income on a group with a missing
   operator/client/project) has nothing to guard: financials attach to a
   project *entity id*, so an unassigned/"(missing)" rollup group has no
   id to target. The UI omits the editor on such rows; the endpoint
   requires a real project id and answers a clean `PROJECT_NOT_FOUND`
   (never a silent no-op). This is how T-PRJ-029 is satisfied.

6. **Frontend: pessimistic save + verified feedback (M5 pattern).** The
   owner-only pencil on the Income cell opens a small popover; on success
   the /projects query is invalidated so income, billout-adjusted and
   margin all re-render from the server's recomputed numbers rather than
   an optimistic guess. Pure logic (`features/projects/financials.ts`) is
   vitest-pinned; the component stays thin.

## ADR #011 — View-as stays app-native; not migrated to the bw_view_as drop-in (R1)

**Date:** 2026-07-08. **Decider:** rian.

**Context.** BW Auth shipped a standard "View As" drop-in
(`/srv/system/id-auth/app-auth/bw_view_as.py` + VIEW-AS.md, 2026-07-10)
after we built our app-layer view-as in M3 (judgment #7). We evaluated
standardizing onto it.

**Decision.** Keep our bespoke implementation unchanged for R1; do NOT
adopt the drop-in, and keep **full act-as** (not the drop-in's
read-only default).

**Why.**
- Mechanically identical (app-session effective-user swap; real user
  kept for audit; authz re-checked server-side; BW's own session
  untouched). Nothing is broken.
- Ours is *stricter* than the minimal drop-in on the guards that
  matter: nesting prevention, structured `error_code` responses the
  SPA consumes, both-identity audit rows, and per-request DB
  re-verification of the super-admin flag (a forged/stale cookie
  impersonates nobody).
- Full act-as preserves judgment #7's intent ("exactly as if I logged
  in as them"); with one super admin, the drop-in's `rank_of`
  no-escalation guard and read-only default add little today.

**Known gaps accepted (revisit at R5).**
1. No `report_impersonation` / `report_presence` to the BW hub — we
   report nothing, so impersonation isn't visible on the hub Live
   page. Accepted: single-owner app, low oversight need in R1.
2. No `rank_of` escalation guard — moot at one super admin; becomes
   load-bearing at **R5** when relationship-scoped view-as (managers
   view their clients; client/commission portals) is real. R5 is the
   natural point to reconsider adopting the drop-in's two-hook shape
   (`can_view_as` / `viewable_targets` / `rank_of`) wholesale.

**Separate, unrelated to this decision:** our copy of `bw_auth.py`
(the *login* path, not view-as) predates the kit's 2026-07-10 revision
and its MIGRATING.md. That drift is a maintenance/security question
about the sign-in code, tracked in BUILD-STATE, not settled here.

## ADR #012 — Split the `parties` identity table into People + Organizations over a thin party supertype (2026-07-10)

**Date:** 2026-07-10. **Decider:** rian. **Status:** ACCEPTED —
implementation is the next `with` workstream (R2), not yet built. This
ADR is the *propose-before-code* record (per README's DoD) and the basis
the satellite contract ([SATELLITE-CONTRACT.md](SATELLITE-CONTRACT.md))
already builds on.

**Deviation notice** (per ADR #001 corollary): this departs from the
founding spec `02-domain-model.md §1 "Identity and actors"`, which
defines a single `parties` table carrying `kind` = person|org plus name/
contact fields. The engagement + roles-on-relationships model (§2) is
**unchanged** — only the identity-table *shape* changes.

### Context

The current model (shipped M2, alembic 0003) is ONE `parties` table with
a `kind` discriminator; person-specific and org-specific data share the
same columns, and person-vs-org is a flag. Standing up `hosting` forced
the question "what does a satellite link to — a client? a project?" and,
underneath it, rian's longer-running discomfort that a person and a
company are being stored as one kind of thing. Three real cases the
single-flag table models awkwardly:

- **Heather Bowden** — a person who is also her own one-person business.
  Under one row with one `kind`, she is forced to be *either* a person or
  an org; she is genuinely *both* (a person, and — optionally — a company
  she owns). She should be two linked records, not one ambiguous one.
- **Login users with no organization** — "people who log in who may or
  may not be part of PlusROI/Bowden Works/a client." A person must be able
  to exist standalone, not only as an employee sub-record of a company.
- **Vendors** (Google, Airtable, Gravity Forms) — not a new type; an
  organization playing the *vendor* role in a relationship. Confirms the
  existing "roles live on relationships" principle; no schema pressure,
  but it clinched that people and organizations are the first-class
  objects users think in (matching rian's Airtable Contacts/Companies).

### Decision

Move to a **supertype/subtype** identity model:

- **`parties`** shrinks to a thin identity anchor: `id`, `kind`,
  `slug`, `is_active`, timestamps. It stops holding names/contact fields.
  It exists so any reference that must accept "a person OR an
  organization" has one stable id to point at. It is **invisible
  plumbing** — never UI vocabulary.
- **`people`** (1:1 to a party, `kind=person`): the human's natural
  fields — name, email, phone, etc.
- **`organizations`** (1:1 to a party, `kind=org`): the company's natural
  fields — legal/display name, contact, and room for org-only facts
  (currency, and the shared reference facts satellites read: Drive
  folder, Mosiah folder — per the satellite contract's data-ownership
  rule).
- **`party_affiliations`** (person ↔ organization, with a role +
  validity): employee-of, contact-for, owner-of, cc-on-reports, etc. A
  person may have many affiliations or none; this is why people are
  first-class, not buried under an org. (The Heather case = a person
  record + an organization record + an `owner-of` affiliation.)

In the UI and all human language: **People and Organizations** are the
first-class objects (rian's words, and his Airtable's). "Party" never
appears in copy.

### Why this shape (and not the alternatives)

- **Not two fully-separate tables with no shared parent.** Breaks the
  moment a reference must accept "person or org" (an invoice's billed-to;
  a hosted site's owner). You'd bolt polymorphic either/or columns onto
  every such FK and lose DB-enforced integrity.
- **Not the single `kind`-flag table** (today) — mushes distinct fields
  together and models Heather ambiguously; it was the source of the
  discomfort.
- **Supertype/subtype gives both:** two real first-class tables with
  their own fields, AND one stable anchor so "either/or" references stay
  a single clean FK. It is the pattern accounting/CRM systems use
  precisely because it scales to many people, many orgs, and messy
  real-world dual roles.

### The three reference levels (how FKs choose their target)

Each relationship points at the level it actually means:

- **either a person or an org** → `parties.id` (the umbrella): an
  invoice's from/to, an engagement's party_a/party_b, **and the
  cross-app `owner_party_id` a satellite stores.**
- **specifically an organization** → `organizations` (or the party with a
  `kind=org` guard): a person's employer, a project's client-company.
- **specifically a person** → `people`: a report's cc list, a login
  account's owner (`users.person_party_id`).

### Blast radius (why this is a contained, safe change to make now)

Measured against the shipped M2 schema:

- **The 8 FKs into `parties.id` DO NOT CHANGE** — they keep pointing at
  the thin party anchor: `engagements.party_a_id`/`party_b_id`,
  `users.person_party_id`, `invoices.from_party_id`/`to_party_id`,
  `tenants.party_id`, `projects.operator_party_id`/`client_party_id`. The
  connective tissue is untouched.
- **What moves:** name/email/phone/address/notes migrate off `parties`
  into `people`/`organizations`; ~6 `Party.kind == …` branch sites
  (`services/entities/resolve.py`, `services/money/stamping.py`,
  `services/money/restamp.py`, `team_admin`, `entries_write`) update to
  the subtype tables; every place that reads a party's *name* joins to
  the detail table (the widest touch — mechanical); one alembic revision
  creates the detail + affiliation tables and backfills by `kind`; the
  transforms (`05-migration-plan` step 2.1) write detail rows; fixtures
  update.
- **Harness-proven behavior-preserving.** The diff-harness compares
  computed outputs (entity inventories, per-month aggregates, invoices,
  CC) — none of which change — so green after the refactor *proves* it
  preserved behavior, exactly as it proved the M2 migration. Nothing is
  in production yet (R1 pre-cutover), so this is the free moment to do it;
  the cost is the opposite of the "rewire it later at scale" fear that
  motivated it.

### Cross-app consequence

`parties.id` becomes the canonical cross-suite entity anchor. Satellites
(`hosting` first) store `owner_party_id` and resolve via a planned
registry API on the `with` side (R2). This ADR is **decoupled** from
satellites: they only ever see party ids and registry responses, so the
People/Organizations split is invisible to them and safe to land
independently. Contract: [SATELLITE-CONTRACT.md](SATELLITE-CONTRACT.md).

### Naming

Keep the table name `parties` (invisible plumbing). Do **not** rename it
to `organizations` — that is one of its two kinds, not the umbrella.
`entities` is an acceptable alternate name for the anchor but is deferred
(a pure rename with churn and no behavior gain); revisit only if it ever
surfaces in a place a human reads.

### Implementation note (for the session that builds this)

Single-writer rules apply (alembic migrations + the transforms + a name-
read sweep of `services/`). Sequence: draft the migration + detail/
affiliation models → update the transforms → sweep name reads → re-run
the full harness to green → then the registry API for satellites. Update
the `features/` docs (team, projects-financials, imports touch the entity
tables) and BUILD-STATE when it lands. Not started as of this ADR.

## ADR #013 — As-built: the party supertype KEEPS the common identity; detail tables carry only what differs (2026-07-10)

**Date:** 2026-07-10. **Decider:** rian (go) + implementation refinement.
**Status:** SHIPPED v0.10.0 (alembic 0006). Refines ADR #012 — the intent
and the People/Organizations model stand; this ADR corrects one
implementation detail #012 got wrong on paper.

**What #012 said, and why it changed.** ADR #012 proposed that `parties`
"shrinks to a thin identity anchor… stops holding names/contact fields,"
with name/email moving INTO `people`/`organizations`. Reading the code
before writing it (the "propose before code" payoff) surfaced two
problems with that literal shape:

1. **Every name read would churn.** A party's name/email is read by SQL
   joins all over — the diff-harness new-side emitter (`harness/
   extract_new.py`: `JOIN parties … pa.name/pb.name/pb.email`), the
   reporting ports, `entities/resolve.py`, comments' display-name
   resolution. Moving the columns means rewriting all of them.
2. **Worse, it would move the trusted oracle.** `extract_new.py` IS half
   the equivalence oracle. Rewriting its SQL to join the detail tables
   means the thing that proves parity is itself edited in the same change
   — exactly when you want it fixed.

**Decision (as-built).** Keep the COMMON identity every actor has —
`kind, name, slug, email, phone, address, notes, is_active` — on the
`parties` supertype, unchanged. Add detail tables ONLY for the fields
that genuinely differ by kind:

- **`organizations`** (1:1, kind=org): `legal_name`, `currency`,
  `drive_folder_url`, `mosiah_folder` — the org-only facts, incl. the
  reference facts suite satellites read (SATELLITE-CONTRACT.md).
- **`people`** (1:1, kind=person): thin today (name/contact are common);
  the first-class home for person-only fields as they arrive (job title,
  avatar, app-account linkage).
- **`party_affiliations`**: person↔org relationships with a role
  (owner/employee/contractor/contact/billing_contact/cc_recipient/
  member/other), append-only (started_on/ended_on), ≤1 active per
  (person, org, role). This is the Heather link — a person party + an org
  party joined by an `owner` affiliation — and the home for
  employees-of-clients, cc-recipients, etc.

**Why this is better, not a compromise.** Name is genuinely universal to
every actor; duplicating it into two subtype tables invites drift and
buys nothing. "Supertype carries the shared attributes, subtypes carry
their specializations" is the textbook supertype/subtype split. The
user-facing model is identical to #012's promise — People and
Organizations are the first-class objects (queried as `parties WHERE kind`
+ the detail join); "party" stays invisible plumbing.

**Consequence: additive, so the harness proves it for free.** No column
moved; `extract_new.py` is byte-for-byte unchanged; parity holds by
construction — `tests/migrate/test_harness_parity.py::
test_compare_green_after_clean_run` is green with ZERO harness edits. The
full backend suite is green.

**The 1:1 invariant** (every org party ↔ one `organizations` row, every
person party ↔ one `people` row) is held at ONE creation choke point:
`services/entities/parties.create_org_party` / `create_person_party`
(ORM path — resolver, team admin) and the raw-SQL mirror in the transform
`_insert_party`. Detail + affiliation FKs are `ON DELETE CASCADE`, so the
rebuild-from-legacy `DELETE FROM parties` clears them without adding the
tables to `TRUNCATE_TABLES`. Proven by `tests/migrate/test_subtypes.py`
(both-direction 1:1 count check + cascade check).

**As-built blast radius** (vs #012's estimate — smaller, because
additive): alembic 0006 (3 tables + backfill); models
`Organization`/`Person`/`PartyAffiliation`; new `services/entities/
parties.py`; 3 runtime creation sites routed through it (`resolve.py` ×2,
`team_admin.py` ×1); the transform `_insert_party` mirror; new tests.
`parties`, every FK into it, every `Party.kind` branch, and every name
read are UNCHANGED.

**Naming.** Kept `parties`. NB: `public.organizations` (the new org detail
subtype) is unrelated to `legacy.organizations` (the old multi-org table,
which became `tenants`) — same word, different schema, different concept.

**Not in this ADR:** the satellite **registry API** (`/api/registry/*`
resolve/search/create-org/reference) that hosting consumes — the next R2
slice; its auth mechanism (service credential) is decided there.

## ADR #014 — Satellite registry API + Bearer-service-token auth (2026-07-10)

**Date:** 2026-07-10. **Decider:** rian (go) + implementation. **Status:**
SHIPPED v0.11.0. This is the "next R2 slice" ADR #013 deferred: the public
edge (`/api/registry/*`) that suite satellites (`hosting` first) call to
resolve/search parties, create organizations, and read a client's shared
reference facts. Contract: [SATELLITE-CONTRACT.md](SATELLITE-CONTRACT.md).

**No schema change.** The endpoints are a read/write surface over the
tables ADR #013 already shipped (`parties`, `organizations`, `projects`).
Creation goes through the existing 1:1 choke point
`services/entities/parties.create_org_party` — the registry never touches
`Party(...)` directly, so the org-detail invariant is preserved for free.

### Decision — auth is a Bearer service token in an env file (Option A)

Every request carries `Authorization: Bearer <token>`, matched against
`settings.registry_service_token` (`REGISTRY_SERVICE_TOKEN`, staged at
`/srv/apps/with/.registry.env`, mode 600) with `hmac.compare_digest`
(timing-safe). The token is never logged or echoed.

- **Fail closed.** An unconfigured token ⇒ `503 REGISTRY_DISABLED` on
  every route — the surface is OFF until the owner wires the secret, never
  an accidental open door. Missing/malformed bearer ⇒ `401
  REGISTRY_AUTH_REQUIRED`; wrong token ⇒ `401 REGISTRY_AUTH_INVALID`.
- **Revocation = rotation.** There is one consumer class (server-to-server
  satellites); rotating the file's value + redeploying instantly cuts off
  everyone until they get the new token. Good enough for v1; per-satellite
  credentials + scopes are a later refinement if the consumer set grows.

**Why not Pattern-B client-credentials (reusing BW Auth)?** Pattern B is
the right tool for *user* sign-in (an OAuth code+PKCE browser flow that
yields a per-user identity token — what M1 uses for `with`'s own login).
The registry is *machine-to-machine* with no user in the loop: a satellite
backend resolving an id or minting an org. A full client-credentials grant
(discovery, token endpoint, JWT verification, rotation, an id-auth client
registration) is real complexity to buy revocability and scoping we don't
need at one consumer. A shared secret compared in constant time delivers
the same security property (only a holder of the secret can call) for a
fraction of the surface. If/when there are many satellites or per-app
scopes matter, revisit — the contract is auth-scheme-agnostic to callers
(they just present a credential), so upgrading later is a server-side change.

### Decision — these routes are session-EXEMPT but NOT public

The default-deny middleware (M8, T-AZ-041) 401s any request without an app
session. Registry callers have NO session — so `is_public_path` bypasses
the SESSION gate for the `REGISTRY_PREFIX = "/api/registry/"` prefix, and
the router-level `require_registry_token` dependency becomes the ONLY gate.

- **Why a middleware bypass and not the allowlist.** `ALLOWLIST_EXACT` is
  the *browser/session* public set (healthz, the OAuth flow routes, hashed
  assets). The registry is a *different auth mechanism* on a different
  credential, so it is deliberately kept OUT of that set; the prefix bypass
  documents the intent inline. The "no unauthenticated surface" invariant
  (the qBittorrent-breach lesson) still holds — it is enforced by the
  token, not the cookie.
- **Why session auth is NOT accepted here (on purpose).** A satellite is a
  server, not rian's browser; letting a `with` app-session cookie open a
  registry route would (a) blur the machine-vs-user boundary the suite is
  built on, and (b) create a confused-deputy path (any authenticated
  browser could drive the hub's write endpoint). The route-auth sweep test
  is extended to assert every `/api/registry/*` route is token-gated
  (503/401), and a dedicated test proves a valid app session with NO bearer
  still gets 401 — the token is the sole credential.

### Create-org refinements (contract additions)

The frozen shape gained two concrete behaviors, documented in the contract:
`409 ORG_EXISTS` (with the clashing `existing` party) when an ACTIVE org of
the same case-insensitive name exists, overridable with `allow_duplicate:
true` for genuinely-distinct same-name companies; and `near_matches` on a
`201` — up to 5 similar ACTIVE orgs as a soft "did you mean to link one?"
hint (search-first over mint-duplicate). The write is audited
(`registry.create_org`) with NULL tenant + NULL actor, since a registry
call has neither a tenant context nor an app user.

## ADR #015 — Request-scoped tenant context (multi-tenant foundation) supersedes ADR #004 #13 (2026-07-11)

**Date:** 2026-07-11. **Decider:** rian (go) + implementation. **Status:**
SHIPPED (code-complete, pending deploy). This **supersedes ADR #004 #13**
("The R1 tenant context is a setting"): the current tenant is no longer a
process-wide `APP_TENANT_SLUG` pin — it is derived per user and selected per
request, so a super admin (rian) can switch between the Bowden Works and
PlusROI books while partner users (`rob`, `danielle`) land ONLY in PlusROI
and can never see a single Bowden Works row.

**Deviation notice** (per ADR #001 corollary): departs from ADR #004 #13.
The R2 books rollout (ADR #016) makes a real second tenant live, so the
single-tenant simplification of R1 no longer holds.

### Decision

1. **Membership is engagement-derived server truth** (not a stored ACL). A
   user's allowed tenants = the tenants where their person party holds an
   ACTIVE role-bearing engagement — the SAME engagement types the authz
   grid already derives roles from (owner via `official_partnership`,
   manager/worker via `subcontract`) — PLUS **every** tenant for super
   admins. `derive_role` stays the ONE authority on which engagements grant
   a role; `allowed_tenants()` reuses it rather than re-encoding the type
   rule (candidate tenants = the person's active engagements; derive_role
   decides). An ENDED engagement (`ended_on` set) grants nothing. Ordering:
   alphabetical by name, with the DEFAULT tenant (`settings.app_tenant_slug`)
   pulled first for a super admin so his own books open first.

2. **Current tenant = an HttpOnly `with_tenant` cookie holding the tenant
   SLUG.** Per-request resolution: the cookie slug if it is in the allowed
   set; else the first allowed tenant; else None. A user with ZERO allowed
   tenants gets a structured **403 `NO_TENANT`** on tenant-scoped `/api`
   data routes (the SPA shows a friendly "no books here yet" state) — EXCEPT
   `/api/me` (returns identity + `tenants: []`), `/api/tenant` (the switch
   endpoint), `/api/view-as` (a zero-tenant super admin must still exit),
   and `/api/saved-views` (a per-USER nav pref, not tenant data). The gate
   lives in the `get_actor` dependency (`app/routers/deps.py`), path-scoped.

3. **`tenant_id` now enters the Actor at ONE place** — `resolve_actor`
   (`app/services/authz/context.py`), replacing the old
   `resolve_tenant(settings.app_tenant_slug)` lookup (now deleted). `scope()`
   and `can()` are UNCHANGED: they already read `actor.tenant_id`, so
   switching tenants needs zero server-side invalidation — the next query
   simply scopes to the new id.

4. **View-as never widens tenant access.** Under view-as the tenant context
   follows the REAL user's allowed set INTERSECTED with the target's; the
   current-tenant cookie resolves within that intersection (so viewing as a
   PlusROI-only user from Bowden Works moves the context to PlusROI rather
   than showing nothing). View-as ENTRY validates the same rule: if
   real∩target is empty, entry **409s `VIEW_AS_TENANT_MISMATCH`** (a
   zero-tenant target, e.g. a brand-new account, can't be impersonated).
   Since only a super admin can enter view-as, real is always all-tenants,
   so the intersection = the target's tenants.

5. **API.** `GET /api/me` gains `tenants: [{slug,name}]` (the switcher menu)
   and `tenant: {slug,name}|null` (current). New `POST /api/tenant {slug}`
   validates membership, sets the cookie, returns `{tenant:{slug,name}}`; a
   non-member slug → **403 `TENANT_FORBIDDEN`**. Switching is a NAVIGATION
   preference (like a saved view) and is deliberately **not audited**.

6. **`settings.app_tenant_slug` survives ONLY as the default-ordering hint /
   single-tenant-DB + test default.** A grep found exactly two readers —
   `config.py` (definition) and `context.py` (the one place it orders a
   super admin's list) — and no seed/nightly/script read it (the boot
   `seed_default_saved_views` seeds per-user by username, tenant-agnostic;
   the snapshot/rehearsal boot path builds no Actor and takes no request, so
   it stays tenant-context-free). Nothing else may read it.

### Frontend

A `TenantSwitcher` in the AppShell header (replacing the M0 reserved
tenant slot) renders ONLY when `me.tenants.length > 1` — so Adi/rob/danielle
with one book never see it. Pick → `POST /api/tenant` → full react-query
cache invalidation → navigate to `/`. Visuals mirror the View-as switcher
(same tokens, no new colors). Pure logic lives in
`features/tenant/tenantSwitch.ts` (vitest-pinned); the component is a thin
shell. All saved views / filters are per-URL, so no extra state work.

### Why a cookie (not a path prefix, subdomain, or session claim)

A cookie is stateless, per-request, and HttpOnly (server-validated — a stale
value pointing at a since-revoked tenant just falls back), and it matches the
saved-views "nav preference" model. Baking the tenant into the session claim
would make a switch require a session re-mint (and force-logout would strand
the choice); a path/subdomain scheme is a bigger surface for no R2 benefit.

**No schema change.** Every operational table already carries `tenant_id`
(02 §1); this is purely how the request picks which one. New error codes:
`NO_TENANT`, `TENANT_FORBIDDEN`, `VIEW_AS_TENANT_MISMATCH`. Tests:
`tests/tenancy/` (membership derivation, endpoint isolation, cookie flow,
view-as intersection) + `tests/authz/test_tenant_context.py`.

## ADR #016 — PlusROI books: extend `invoices`, add `expenses`/`client_profiles`/`project_billing`, import the sheet in the rebuild pipeline (2026-07-11)

**Date:** 2026-07-11. **Decider:** rian (the books rollout) + implementation.
**Status:** SHIPPED (schema + import code-complete, pending deploy). Lands the
receivables/payables substrate the PlusROI settlement runs on (08 §1–§4), as
the PlusROI tenant's books — the real second tenant ADR #015 was built for.
This is NOT the settlement engine (that is R4, in `services/money`); it lands
the ledgers + registries + the per-settlement-month totals the settlement gate
verifies against the sheet's own Monthly Split.

### Decisions

1. **Extend `invoices`, don't add a new receivables table.** BW's existing
   invoice shape (name unique per tenant, `manual_total`, party links, lines)
   is reused; alembic 0007 adds only nullable columns (`fee`,
   `commission_pct`/`commission_amount`/`commission_party_id`,
   `settlement_month`, `relevant_period`, `original_amount`/
   `original_currency`, `invoice_timing`, `billing_type`, `external_id`,
   `due_date`) + an index `(tenant_id, settlement_month)`. Additive-only, so
   the diff harness stays green by construction (as with ADR #013). A separate
   invoices table would fork the invoice model and duplicate the lines/locks.

2. **`expenses` is a first-class NEW table, not CC-expense reuse.** The
   payables journal has its own grain (paid-by either partner nets out in the
   settlement — 08 §3; `cc_expense_*` is the CC-statement-paste pass-through, a
   different thing). `source IN ('sheet','manual')` separates rebuilt-nightly
   sheet rows from future app entries. `client_profiles`/`project_billing` are
   thin per-tenant / per-project defaults tables (kept OFF the core party /
   project rows so those stay stable), keyed `(tenant, client_party)` and
   `project_id` respectively.

3. **Widen the invoices status CHECK; keep `open`/`sent`/`paid`.** The sheet
   lifecycle maps Pending Approval→`pending_approval`, Ready to Invoice→
   `ready`, Invoiced→`sent`, Paid→`paid`, Cancelled→`cancelled`, Delayed
   Payment→`delayed`. R1's three values are untouched (BW time-invoice flows
   depend on them). A blank/unknown sheet status defaults to `paid` + a warn
   (98.5% of non-blank statuses are Paid; these are historical closed rows).

4. **Books-currency = CAD; original face retained.** `invoices.currency`
   stays CAD and `manual_total` holds the CAD book value (the sheet's "Invoice
   Amount", already FX-converted); `original_amount`/`original_currency` keep
   the USD face (the "Project Amount") ONLY when the sheet Currency is USD
   (08 §2 realized-rate practice). Same for expenses (`currency='CAD'`).

5. **The sheet step lives in the rebuild transaction, opt-in.** It runs in
   `transforms.run_all` AFTER `step_comments`, INSIDE the one transaction, and
   its tables join `TRUNCATE_TABLES` — so it inherits TRUNCATE-and-rebuild
   idempotency and rolls back with everything else. It is gated on a new
   `books_source` arg: **None (default) = skipped** — the legacy-only behavior
   every harness/parity/reporting test relies on (the harness reads `invoices`
   and `projects` GLOBALLY, so PlusROI-tenant sheet rows would otherwise break
   the byte-identical parity comparison); `"auto"` resolves the app-relative
   `books-import/source`; a path is used by the sheet tests. The two pipeline
   entrypoints (`scripts/migrate_legacy.py`, `scripts/nightly_snapshot.py`)
   pass `"auto"`. Missing export → LOUD warn + skip. **Consequence:** a sheet
   reconciliation failure fails the whole nightly rebuild (by design —
   `parallel_run`).

6. **Reconciliation is per-tab and FAILS on any unaccounted row.** Every CSV
   row of every consumed tab (Clients/Vendors/Projects + live & 2022-archive
   Invoices/Expenses) lands in exactly one disposition; `report.reconcile`
   errors on a delta, exactly like the legacy transform. Cross-cutting issues
   (rounding, status-default, name-suffix, on-fly party, unmatched project,
   unmapped paid-by) are counted as warns, not partition dispositions.

7. **Live↔archive dedupe, live wins.** An archive row is dropped when it
   matches a live row by non-blank `external_id`, else by a tuple (invoices
   `(company, invoice_date, amount)`; expenses `(vendor, date, amount,
   project)`); unmatched archive rows import with disposition `archive_only`.
   `external_id` is NOT unique (the sheet dupes) — within-source dupes both
   import (their invoice `name` is suffixed to satisfy
   `uq_invoices_tenant_lower_name`); reconciliation surfaces the counts.

**Deviation notice** (per ADR #001 corollary): none from a founding spec —
08-current-tooling.md is the requirements doc and this implements §1–§3
faithfully. The `parallel_run` tenant + the invoice/expense enrichment were
anticipated by 08 §9 and ADR #015.

## ADR #017 — The settlement engine: formula, inclusion rule, and the full-precision rounding policy (2026-07-11)

**Date:** 2026-07-11. **Decider:** rian ("Split + BW reimbursement" +
the three real Wave invoices as ground truth). **Status:** SHIPPED
v0.12.0 (`services/money/settlement.py`, `/api/settlement`).

**The formula** (08 §4 as CALIBRATED against the live sheet 2026-07-10 —
three visible target months matched to a fraction of a cent from the raw
ledger CSVs):

    gross       = Σ invoices.manual_total   where settlement_month = M
    revenue     = gross − Σ fee − Σ commission_amount
    expenses    = Σ expenses.amount         where settlement_month = M
    bookkeeping = revenue × bookkeeping_pct/100   (tenant setting; 3%)
    net         = revenue − expenses − bookkeeping
    split       = net × partner_split             (tenant setting; 0.5)

Refinement over 08 §4 as written: commissions are subtracted from
revenue BEFORE the split (the doc folded them into "AfterFees" loosely;
the sheet's own Monthly Split right-block makes it explicit:
gross − fees − commissions = the Revenue line).

**Inclusion rule:** `settlement_month` tag presence IS the grouping rule
— NO status filter, matching the sheet's own formulas exactly
(calibration: every tagged row in the target months is Paid, and the
sheet formula carries no status condition). Untagged rows are outside
every settlement and surface as the `unsettled` bucket for triage.

**Rounding policy (the load-bearing decision):** ledger inputs are 2dp,
so gross/fees/commissions/revenue/expenses are exact sums. The derived
chain (bookkeeping → net → split) stays FULL-PRECISION internally;
each REPORTED line quantizes HALF_UP. This is the sheet's own math and
the policy under which the real Wave invoice 2026050's split
($13,546.48) reproduces exactly — the alternative (quantize each stage)
lands a cent high on that very month (13,546.485 → .49) and is pinned
REJECTED by test_april_matches_the_real_wave_invoice. Consequence: on
knife-edge months the displayed waterfall can show a 1¢ sub-cent
artifact between lines; the diff-gate classifies those honestly.
Trade-off accepted because matching the money that actually changed
hands beats cosmetic line-arithmetic, and the artifact is rare.

**The suggested BW invoice** (the weep-with-joy feature): split + every
expense fronted by a NON-tenant payer that month (the principled rule:
the tenant reimburses any other fronting party; in practice the payer
is Bowden Works), grouped into the Wave line vocabulary proven by
invoices 2026035/2026050/2026059 — Labour → "BowdenWorks Team Cost",
Client Advertising Spend → "PPC Spend", everything else → "Licences /
Fees" — plus GST (tenant setting `gst_pct`, 5%). Ad-hoc negotiated
lines (e.g. 2026035's capped AISV recovery) stay human — the panel is a
computed SUGGESTION, not a generated document ("track now, generate
later").

**Authz:** owner-only via `can_see_income` (403 INCOME_FORBIDDEN);
tenant-scoped end to end (rob/danielle see only PlusROI settlements).

**Known live-ledger caveat:** the sheet is live; historical months can
drift after their invoice was cut (observed: May 2026 sheet split
15,519.98 vs invoiced 15,569.42 — a $49.44 post-invoice ledger edit).
The gate reports such deltas classified, never hides them.

## ADR #018 — Books UI: a separate `/api/books` router (meta + ledgers) beside the frozen settlement engine; read-only v1 (2026-07-11)

**Date:** 2026-07-11. **Decider:** rian (the PlusROI-sheet-replacement
task). **Status:** SHIPPED v0.13.0 (`app/services/books.py`,
`app/routers/books.py`, `frontend/src/features/books/`). Consumes the ADR
#017 settlement engine unchanged.

**Context.** The Books UI needs three things the settlement engine
(`services/money/settlement.py`, FROZEN by the task boundary) does not
provide: (a) a `parallel_run` flag for the "these books rebuild nightly"
banner, (b) a filtered/paginated receivables list, (c) the same for
payables. The engine is calibrated to the cent and pinned by the
settlement gate; widening its response shape to carry ledger rows or a
tenant flag would put UI concerns inside the money-critical module.

**Decisions.**

1. **A new `/api/books` router owns everything the UI needs beyond the
   split.** `GET /api/books/invoices` and `GET /api/books/expenses` are
   thin list endpoints in a NEW service (`services/books.py`) — they never
   recompute settlement math, only list the underlying ledger rows the
   sheet used to hold. Money is emitted as 2dp decimal STRINGS to match the
   engine's wire convention. Both are tenant-scoped via `actor.tenant_id`
   ALWAYS, sums are over the FULL filtered set (not the page), and both are
   owner-only (403 `INCOME_FORBIDDEN`, the engine's exact error shape).

2. **`parallel_run` rides on a new `GET /api/books/meta`, NOT the settlement
   summary's `settings` block.** The obvious place to read `parallel_run`
   is the summary response's `settings` dict — but that dict is built
   inside the frozen engine, and adding a key there is out of bounds and
   would couple a UI banner to the money module. Instead a tiny
   `{parallel_run: bool}` endpoint in the Books router reads the Tenant row
   directly. Cheap, isolated, and it keeps the engine's contract untouched.

3. **Expense rows carry a `paid_by_is_tenant` boolean** (beyond the columns
   the brief listed). The payables page accents rows a partner fronted
   (they get reimbursed in the settlement) vs mutes the tenant's own — the
   SAME "fronted by a non-tenant party" rule the engine's reimbursement
   logic uses. Computing it server-side (compare `paid_by_party_id` to the
   tenant's `party_id`) keeps the client from re-deriving tenant identity
   and keeps the rule in one conceptual place.

4. **Read-only v1.** The PlusROI tenant is in parallel run (ADR #016): the
   nightly transform TRUNCATE-rebuilds `invoices`/`expenses` from the
   sheet, so any write in the app would be silently overwritten. The Books
   UI is therefore deliberately read-only until cutover; a
   dismissable-per-session banner (fed by decision 2) makes the reason
   visible on all three pages. Edit paths — and a hard write-block for
   sheet-sourced rows — are the post-cutover follow-up.

**Alternatives rejected.** Adding ledger endpoints inside
`routers/settlement.py` (conflates a frozen money surface with UI plumbing);
threading `parallel_run` through the engine's settings (out of bounds +
couples the banner to the money module); deriving the reimbursement accent
client-side (would duplicate the tenant-identity rule the engine owns).

**No schema change.** Everything reads existing columns; the only new
surface is three GET routes and one frontend feature folder.

## ADR #019 — Adversarial-review hardening: fail-fast nightly + the parallel-run write-block (2026-07-11)

**Date:** 2026-07-11. **Trigger:** the v0.11–0.13 release's multi-agent
adversarial review (4 findings confirmed, each by two independent
refuters; 4 refuted). **Status:** SHIPPED v0.13.1.

1. **A requested-but-missing sheet export is FATAL, never a skip**
   (CRITICAL finding). The old skip-with-warning committed AFTER the
   truncate — deleting the PlusROI tenant, its owner engagements, and
   every book row for the day; partners hit 403 NO_TENANT. Semantics
   now: `books_source=None` (tests/harness) = the step is off by
   design; any REQUESTED source (production passes "auto") that is
   missing, incomplete (any of the 7 required tabs absent), or
   suspicious (either money tab shrunk >20% vs the last OK run's
   reconciliation counts — a truncated/corrupt export) raises
   MigrationError → the ONE transaction rolls back → yesterday's books
   survive. Stale export (>9 days) is a report warning, not fatal.
2. **Parallel-run invoice write-block** (HIGH). The Books UI's
   read-only-v1 was advisory; the R1 /api/invoices mutations were fully
   live against sheet-sourced rows, so a partner's "fix" would silently
   revert at 03:30 (and audit_log is itself rebuilt nightly — no trace).
   Now `_require_not_parallel_run` refuses create/update/delete/apply/
   detach with 409 PARALLEL_RUN_READ_ONLY whenever the actor's tenant
   carries `settings.parallel_run`. BW (no flag) is untouched — pinned
   by the guard-passes test + the whole M6 suite. Cutover = clear the
   flag; the block lifts with no code change.
3. **Comparison-chip color = meaning, not direction** (MEDIUM):
   `deltaTone` — rising expenses render bad/red even though the delta
   is positive; all other metrics keep up=good. (The review's 4th
   finding — the same-transaction blast radius of a sheet reconcile
   failure on the legacy rebuild — is ACCEPTED as designed: one
   transaction is the invariant that makes rollback-to-yesterday safe,
   and the failure is loud in migration_runs/nightly-status.)
4. Cosmetic, same session: sheet invoice ids normalized via `_ext_id`
   ("6357.0" → "6357" — xlsx float artifact).

## ADR #020 — Tenant modes as data (WorkspaceProfile), + the app-owned data model for dual-entry (2026-07-11)

**Date:** 2026-07-11. **Decider:** rian. **Status:** ACCEPTED — building
v0.14. Full spec: `docs/features/tenant-modes.md`.

**Context.** PlusROI and Bowden Works have genuinely different workflows
(PlusROI is books-centric, not time-centric) and rian wants them to LOOK
different too — PlusROI matched to the host.bowden.works design (Rob's
Flywheel taste), BW its own look — "two branded apps, one data." He also
wants to hand-manage modes eventually via a UI, without a rewrite.

**Decision 1 — the UI is data.** A declarative `WorkspaceProfile` per mode
(nav + table columns + `theme` + `shell` + terms) named on
`tenants.settings.workspace_profile`, resolved once and shipped on
`/api/me`; generic components render from it. THE rule: no component
carries `if (tenant === …)` — every difference is a profile field.
Hardcoded TS registry now; DB-table + editor later is a one-resolver swap
(the rip-out guarantee). Look/feel varies in three layers: **A** skin
(token set via `data-theme` — cheap, vary freely), **B** shell (≤3 layout
templates), **C** bespoke pages (exception only). Columns follow Airtable's
fields-vs-views split: data endpoints return full rows, the profile
picks/orders/hides. PlusROI theme overrides `with`'s EXISTING token
variable names with the host palette (teal #0aa5b8 / ink #1f2b33 / canvas
#eef1f3 / white 14px cards) so shared components restyle with zero forks.

**Decision 2 — the app-owned data model (supersedes `parallel_run`).**
Dual-entry (rian re-keys the sheet's week manually — chosen over
auto-sync) requires the app to OWN PlusROI's data, else the nightly
rebuild erases it. Replace `parallel_run` with `books_owner`: `"sheet"` =
nightly rebuilds + writes blocked (old behavior); `"app"` = nightly skips
this tenant + writes allowed. Flip PlusROI to `"app"` after one fresh
final import (the seed). The sheet importer skips `books_owner != "sheet"`
tenants; the invoice/expense write-block keys on `books_owner == "sheet"`.
BW is untouched (separate legacy pipeline). Re-import wipes manual entries,
so the flip is deliberate + one-time; the sheet remains the safety-net
record during the trial. This is a soft cutover of PlusROI's *ownership*,
not the full R1 cutover.

## ADR #021 — Stable party identity + `books_owner` (the app-owned flip) (2026-07-12)

**Date:** 2026-07-12. **Decider:** rian (design pinned in ADR #020 /
`docs/features/tenant-modes.md` "Data-ownership model" + the ⚠ DEPENDENCY
note). **Status:** SHIPPED — PlusROI flipped app-owned on the live sidecar
2026-07-12. Phase 2 of `docs/goals/plusroi-soft-launch.md`.

**Context.** The nightly BW rebuild did `DELETE FROM parties` + fresh
uuids per run (ADR #003 #4) and TRUNCATEd every book table globally.
PlusROI SHARES parties with BW (Bowden Works, Adi, Heather — matched by
name; deliberate, ADR #012/#013). Fine while PlusROI was rebuilt from the
sheet in the SAME transaction — fatal the moment PlusROI becomes
app-owned: the next nightly would orphan every PlusROI invoice/expense/
project (party FKs pointing at deleted uuids) and wipe its rows.

1. **Parties are UPSERTED BY SLUG, never bulk-deleted** — a party keeps
   the SAME uuid across rebuilds. The slug is deterministic: `slugify
   (name)` plus a `-2`/`-3`… suffix allocated in iteration order, and
   every creating pass iterates its legacy rows in a stable
   `ORDER BY created_at, id`, so the same legacy row lands on the same
   slug run after run (same-named clients under different operators keep
   distinct, stably-paired identities). Match → reuse the uuid and
   refresh name/email/notes/is_active to exactly what a fresh build
   would write; miss → insert a new uuid. A slug held by the OTHER kind
   bumps the suffix (identities never change kind). Subtype rows
   (`people`/`organizations`) are re-asserted `ON CONFLICT DO NOTHING`
   (ADR #012/#013 invariant). Consequences accepted: a legacy-gone party
   LINGERS (is_active untouched — app-owned history may reference it),
   and a renamed legacy row mints a NEW party while the old one lingers.
   `users.person_party_id` is no longer NULLed at reset (there is no
   party delete to sidestep) — partner logins keep their links even when
   the sheet step stops running for an app-owned tenant. Harness-NEUTRAL:
   the canonical document keys parties by NAME/email, never id/slug.
2. **The rebuild reset is TENANT-SCOPED.** With no app-owned tenant the
   reset is the exact historical one-statement TRUNCATE (every harness/
   parity DB — byte-identical by construction). Otherwise, child-before-
   parent `DELETE … WHERE tenant NOT IN (app-owned)` per table
   (`_SCOPED_DELETES`), scoping through the parent where the table has no
   tenant_id (invoice_lines→invoices, cc_expense_lines→batches,
   project_billing→projects, compensation_terms→engagements). Tenant-less
   audit rows are still discarded (judgment #23); an app-owned tenant's
   audit trail is REAL history and survives. Gotcha pinned in code: the
   NOT-EXISTS anti-join column must be table-qualified or it self-joins
   the temp table and silently deletes nothing.
3. **`tenants.settings.books_owner` (`"sheet"`|`"app"`) supersedes
   `parallel_run`.** Effective semantics (`app/services/tenancy.py`):
   `"app"` → nightly leaves the tenant untouched (scoped reset + the
   sheet step SKIPS it, checked BEFORE the missing-export fatal so a
   stale export can never fail an app-owned nightly), writes allowed;
   `"sheet"` → nightly reimports from the sheet, writes refused; missing
   flag + legacy `parallel_run: true` reads as `"sheet"` (back-compat);
   neither → legacy-mirrored (BW): rebuilt from `legacy.*`, writes
   allowed. The invoice write-block is renamed
   `_require_writable_books` and returns 409 `SHEET_OWNED_READ_ONLY`
   (was PARALLEL_RUN_READ_ONLY; no frontend consumer of the code —
   `GET /api/books/meta` keeps its `{parallel_run: bool}` shape, now
   computed from the flag, so the banner lifts by itself on flip).
4. **The flip is a script, deliberate and idempotent:**
   `scripts/flip_books_owner.py --project-tenant plusroi --to app` sets
   the flag (preserving bookkeeping_pct/partner_split/…, dropping the
   superseded `parallel_run`); `--to sheet` (revert) requires
   `--confirm-wipe` because the next nightly then WIPES in-app rows.
   Executed 2026-07-12 after a fresh final import: 2,391 invoices /
   4,724 expenses / 403 projects seeded, flip, settlement gate GREEN
   (32/32 to the cent), then a simulated next nightly — sha256-identical
   PlusROI snapshot (ids + party FKs), zero dangling FKs, shared parties
   uuid-stable.
5. **The harness extractor is now tenant-scoped by construction**
   (`harness/extract_new.py` `MIRRORED_TENANTS`): books tenants (sheet-
   or app-owned) are excluded from every query — the parity comparison
   is legacy↔mirror only, exactly what sheet_books' docstring had always
   CLAIMED. This was latent breakage, not a new need: the first
   sheet-enabled nightly (2026-07-11) crashed extract_new with
   "duplicate client name 'Alluvial Consulting' under operator
   'PlusROI'" (the PlusROI tenant's sheet-created direct_client edges
   colliding with BW's legacy-built ones) and the nightly had been RED
   since. On a legacy-only DB the predicate matches every tenant —
   fixture parity byte-identical; against the live DB the compare is
   GREEN again (27 diffs, all allowlisted meta/#22).
6. **The gate is a test:** `tests/migrate/test_app_owned_survival.py`
   builds sheet-owned books on a scratch DB, proves a sheet-owned rerun
   REGENERATES ids (non-vacuousness), flips, reruns the full transform,
   and asserts id-and-FK-identical survival, the sheet-step skip, audit
   survival, partner-login link survival, BW still rebuilding, and
   harness compare green with the app-owned tenant present.

**Rejected:** keying party identity on a new immutable `legacy_ref`
column (more schema for the same guarantee — slug is already unique and
deterministically derivable); deleting lingering parties (app-owned FKs
may point at them); a config-file flag instead of tenant settings (the
flag must travel with the tenant row the reset reads).

## ADR #022 — Phase-3 schema: the recurrence model, milestone schedules, USD realized-FX, and the vendor-listability marker (alembic 0008) (2026-07-12)

**Date:** 2026-07-12. **Decider:** rian (design pinned in
`docs/design/plusroi-forms.md` §3 + "New schema — alembic 0008").
**Status:** SHIPPED — additive migration; deploys on the next
`alembic upgrade head`. Phase 3 (slice 2a) of
`docs/goals/plusroi-soft-launch.md`.

**Context.** The phase-3 write forms (Clients/Vendors landed in slice 1)
need a little new schema. All of it is ADDITIVE (as ADR #016's 0007 was),
so the diff harness stays green by construction — `harness/extract_new.py`
reads a fixed `NEW_TABLES` list (the two new tables aren't in it) and reads
`invoices` by an explicit column list (the two new columns aren't in it),
so the emitted canonical document is byte-identical and
`test_harness_parity::test_compare_green_after_clean_run` stays green.

1. **`project_billing` gains the CLEAN recurrence model** —
   `is_recurring` (bool, default false), `cycle_months` (int, 1/3/12 =
   monthly/quarterly/annual), `billing_timing` (`month_end`|`mid_month`,
   CHECK-constrained). This REPLACES the sheet's conflated free-text
   `billing_type` (kept for back-compat/import mapping) with the model
   Danielle/Rob actually reason in (recurring y/n + cycle + timing — not a
   "type" string). A recurring project carries a default `amount` per
   cycle; a non-recurring one leaves `amount` NULL and carries a milestone
   schedule instead.
2. **NEW `project_milestones`** — a non-recurring project's payment
   schedule (deposit / milestone / final): `id`, `project_id` (FK CASCADE),
   `label`, `amount` Numeric(12,2), `currency`, `sort_order`,
   `generated_invoice_id` (FK invoices SET NULL — a milestone generates AT
   MOST one invoice; deleting that invoice re-opens the milestone),
   created/updated. Index `(project_id, sort_order)`. A schedule EDIT
   replaces only un-generated milestones; a milestone that already
   generated an invoice is preserved (its `generated_invoice_id` is set).
3. **`invoices` gains `collected_amount` + `collected_currency`** — the
   realized CAD actually collected, recorded at Paid for a USD invoice.
   `manual_total`/`currency` stay the CAD book value; `original_amount`/
   `original_currency` stay the USD face (08 §2 realized-FX practice). Only
   the columns land here (slice 2a); the lifecycle write that populates
   them is slice 2b.
4. **NEW `tenant_vendors`** — the per-tenant vendor marker slice 1
   deferred. PK `(tenant_id, vendor_party_id)`; `default_category` +
   `default_paid_by_party_id` (FK parties SET NULL) hold this tenant's
   vendor defaults. It makes a freshly created vendor immediately listable
   (before any expense) and lets `list_vendors` return `tenant_vendors ∪
   expense-referenced parties`. **Deliberately NOT an engagement:** an
   engagement is a commercial edge in the entity graph that can grant a
   party tenant data access; a payee we merely pay must never gain that.

5. **Both NEW tables are tenant-scoped and MUST survive the nightly for an
   app-owned tenant** — the ADR #021 hazard applies verbatim. They join
   `transforms._SCOPED_DELETES` AND `TRUNCATE_TABLES`:
   - `project_milestones` has NO tenant_id — it scopes THROUGH its project,
     the EXACT qualified-column anti-join idiom `project_billing` uses:
     `project_id IN (SELECT id FROM projects p WHERE {gone:p.tenant_id})`.
     Placed before `invoices`/`projects` (children-before-parents).
   - `tenant_vendors` has its own tenant_id → the direct `("tenant_vendors",
     "tenant_id")` scope, placed before `tenants`.
   - **They MUST also be in `TRUNCATE_TABLES`** (the no-app-owned fast
     path): `project_milestones` references `projects`+`invoices` and
     `tenant_vendors` references `tenants`, all truncated — so omitting the
     children would make `TRUNCATE ... projects/tenants` fail ("referenced
     in a foreign key constraint"). This is the load-bearing reason, not
     just tidiness.
   The survival gate `tests/migrate/test_app_owned_survival.py` is EXTENDED:
   it plants an app-era `project_milestones` row (on a PlusROI project) + a
   `tenant_vendors` marker after the flip, runs the simulated nightly, and
   asserts both survive with the same ids and resolving FKs (a wrong scope
   — e.g. deleting all — would wipe them and fail the test).

6. **The Projects form** (`services/projects_plus.py` +
   `routers/projects_plus.py`, `/api/plus-projects`) reuses the slice-1
   gate (`plusroi_gates`: owner + writable-books + tenant) and service
   shape EXACTLY. Create is attached to a client (must be a
   `client_profiles` row for THIS tenant — the tenant-scope anchor AND the
   inheritance source) and INHERITS currency/terms/invoice_timing/
   commission from that profile (a provided non-blank value overrides);
   then EITHER recurring (cycle + timing + a required default amount) OR a
   milestone list. Uniqueness stays `(tenant, client_party, lower(name))`.
   It's ADDITIVE — `/api/projects` (the Bowden Works rollup) and the
   `/projects` page are untouched; the PlusROI profile's "Projects" nav
   repoints to the new `/projects/new` surface (label unchanged).

**Rejected:** widening `projects` with the recurrence columns (billing
facts belong on the 1:1 `project_billing`, ADR #016); a `vendor_defaults`
column on `expenses` (defaults are per-tenant-per-vendor, not per-expense);
a `vendor` affiliation kind for listability (an affiliation is entity-graph
access — the marker is inert, ADR #022 point 4); storing milestone amounts
back onto `project_billing.amount` for non-recurring projects (leaving it
NULL keeps "recurring default vs milestone schedule" unambiguous).

## ADR #023 — PlusROI invoice generation + lifecycle: a separate receivables service, the guarded status model, settlement_month-at-generation, realized-FX at Paid (2026-07-12)

**Date:** 2026-07-12. **Decider:** rian (design pinned in
`docs/design/plusroi-forms.md` §4 + the persona flows in
`docs/goals/plusroi-soft-launch.md`). **Status:** SHIPPED. Phase 3
(slice 2b). NO migration — everything writes to alembic-0008 columns
that already exist.

**Context.** Danielle/Rob/Rian's real monthly work is: generate each
project's invoice (twice-monthly recurring batch; milestone schedules
all-at-once), walk it Pending Approval → Ready → Invoiced (QuickBooks #)
→ Paid, and for USD receipts record the realized CAD collected. The
existing `services/money/invoices.py` is the Bowden Works TIME-invoice
service (open|sent|paid coerced by `clean_status`, invoice_lines
apply/detach, margin math) — its semantics are load-bearing parity and
must not bend.

1. **A separate service, same table.** PlusROI generation + lifecycle
   live in NEW `services/money/invoices_plus.py` +
   `routers/invoices_plus.py` (`POST
   /api/plus-projects/{id}/generate-invoice`, `POST
   /api/plus-invoices/generate-batch`, `PATCH /api/plus-invoices/{id}`),
   writing the SAME `invoices` table. Tenant scoping keeps the rows
   apart; the vocabularies keep the LIFECYCLES apart: BW's `open` is not
   a key in the PlusROI transition map, so a BW-shaped invoice gets a
   409 (and no Manage control in the UI) rather than being coerced —
   and `clean_status` never sees a PlusROI status. `invoices.py` is
   untouched. Gates + audit reuse slice 1's `plusroi_gates`
   (owner `can_transfer` → writable-books 409 `SHEET_OWNED_READ_ONLY` →
   tenant) + `write_audit` with full before/after images on EVERY
   mutation (`invoices_plus.generate` / `.generate_batch` / `.update`).

2. **The status model.** `pending_approval → ready → sent → paid`, one
   guarded step at a time; `cancelled`/`delayed` reachable from any live
   state; `delayed` resumes to any live state but never straight to
   `paid` (paid requires passing through `sent` — the QB #); `ready` may
   be sent BACK to `pending_approval`; `paid`/`cancelled` are terminal.
   Illegal jumps are 409 `ILLEGAL_TRANSITION` naming both states.
   Crossing into `sent` REQUIRES `external_id` (422
   `EXTERNAL_ID_REQUIRED`); sent_at/paid_at keep the BW first-crossing
   stamp convention. `manual_total` is editable only while
   `pending_approval`/`ready` (409 `AMOUNT_LOCKED` after), and an edit
   keeps the non-CAD face + the stamped commission_amount in sync.

3. **Amount default is a COPY, never a link.** Generation copies
   `project_billing.amount` (milestone: `project_milestones.amount`)
   into `invoices.manual_total`; a per-invoice edit writes ONLY the
   invoice (Rob's $1,500 project → first invoice corrected to $2,000 →
   next month still defaults $1,500). Commission is stamped at
   generation from the billing profile (pct + party + computed amount)
   so the frozen settlement's `revenue = gross − fees − commissions`
   stays right without Danielle re-keying it.

4. **Generation identity (recurring) without an invoice→project FK.**
   Invoices deliberately carry no project_id (R2). A generated recurring
   invoice is named `"{project} — {YYYY-MM}"` (suffix ` (2)`… on a
   collision with an unrelated invoice, per-tenant case-insensitive) and
   stamped `relevant_period="YYYY-MM"` + `billing_type="recurring"`. The
   (project, month) already-generated predicate = to_party is the
   project's client AND relevant_period is the month AND the name starts
   with the deterministic prefix. Single-generate refuses a repeat (409
   `ALREADY_GENERATED`); the batch SKIPS it (`skipped_exists`) so
   Danielle's run is idempotent. Honest limitation: renaming a project
   between generations re-opens the month (accepted over a notes-embedded
   UUID marker, which would leak machine text into a user-facing field;
   an invoice→project column is a future migration if this ever bites).
   Milestone identity is exact — `generated_invoice_id`, at most one
   invoice per milestone, ever; a repeat generate returns `created: []`.

5. **Batch cycle guard (money safety).** The batch takes {month, timing}
   and covers ACTIVE recurring projects with that `billing_timing`. A
   multi-month cycle (quarterly/annual) generates only on its due months
   anchored on `project_billing.start_date`
   (`months_between % cycle == 0`); with NO anchor it is skipped
   (`skipped_no_anchor`) rather than silently over-invoiced monthly —
   the single-project path still generates it deliberately. Per-project
   results (`created`/`skipped_*`) are returned and audited.

6. **settlement_month at generation.** Every generated invoice is tagged
   `settlement_month = first-of-billed-month` — the sheet's own "BW
   Invoice" tag, stamped at birth so Rian's month-end apply never has to
   hunt for app-generated rows. The FROZEN engine groups by tag with no
   status filter, so the month reads as a projected split immediately;
   because of that, **cancelling CLEARS the tag** — a dead invoice must
   never pollute the split (the engine stays untouched). `delayed` keeps
   its tag (re-tagging is the §6 settlement-apply slice).

7. **Realized FX at Paid.** A generated USD invoice carries
   `currency='USD'`, `manual_total` = the USD face, and
   `original_amount/original_currency` = that face. Crossing into `paid`
   on a non-CAD invoice REQUIRES `collected_amount` (422
   `COLLECTED_AMOUNT_REQUIRED`): it lands in
   `collected_amount/collected_currency` AND becomes the book value —
   `manual_total = collected`, `currency = 'CAD'`, face preserved. That
   is byte-for-byte the shape the sheet importer gives USD receipts, so
   the frozen engine (Σ manual_total as CAD) sums realized CAD, never a
   USD face. A CAD invoice records `collected_*` only if offered (no
   book-value rewrite — no FX happened). `/api/books/invoices` now also
   returns the `collected_*` pair (additive).

8. **Frontend, config-driven.** The receivables ledger gains a `manage`
   columnCatalog entry (per-row lifecycle modal: legal next steps, QB #
   at Invoiced, CAD collected at Paid, draft-amount edit) that ONLY the
   PlusROI profile picks — Bowden Works' Books view stays action-free
   with zero `if (tenant)` (ADR #020). Projects gains per-row "Create
   invoice"/"Generate schedule" (month picker) + the batch
   month-end/mid-month run. All pessimistic-save + verified refetch
   (M5); transition/requirement logic is pure + vitest-pinned
   (`features/books/invoiceLifecycle.ts`) mirroring the server map,
   with the server remaining the enforcer.

**Rejected:** reusing `invoices.py` update for the lifecycle (its
`clean_status` coerces unknown → `open` — correct for BW, destructive
here); a status filter added to the settlement engine for cancelled
invoices (services/money/settlement.py is FROZEN; clearing the tag
achieves the same without touching it); rewriting `manual_total` to the
collected CAD *without* preserving the face (loses the USD receivable
history the sheet keeps); auto-provisioning `due_date` from terms text
(fragile parsing, not asked for); an invoice→project FK migration in
this slice (single-writer scope: NO migration).

## ADR #024 — Expenses writes + the CC bridge: manual/multi-line entry, per-user batch privacy, port-to-expenses provenance, settlement apply-to-month, and the BW-endpoint guard (2026-07-12)

**Date:** 2026-07-12. **Decider:** rian (design pinned in
`docs/design/plusroi-forms.md` §5–§6 + the persona flows in
`docs/goals/plusroi-soft-launch.md`). **Status:** SHIPPED. Phase 3
(slice 3 — the LAST forms slice). NO migration — everything writes
columns that already exist.

**Context.** The remaining month-end work the personas do by hand:
Danielle keys vendor bills (including Yulia's one-invoice-split-by-
project), Rob runs his TD statement through the CC extractor and moves
his PlusROI charges into the shared expenses, and Rian tags the month's
paid invoices + unapplied expenses so the (FROZEN) settlement engine
computes the split. All of it is owner-gated, refused on sheet-owned
books, tenant-scoped, and audited — the `plusroi_gates` triple every
slice-1/2 write already applies.

1. **Expense writes are a money-path service, DTO-mirrored to the
   ledger.** NEW `services/money/expenses.py` (`POST/PATCH/DELETE
   /api/expenses`, `POST /api/expenses/batch`, `GET
   /api/expenses/options`) writes `source='manual'` rows and emits the
   EXACT row shape `services/books.list_expenses` emits, so the
   frontend has one payables type. The multi-line entry (Yulia) is one
   transaction and ONE audit row (`expenses.create_batch`) carrying
   every created image — the audit group is the submit, not the row.
   PATCH/DELETE are tenant + writable-books gated but do NOT
   discriminate on a row's historical `source`: after the app-owned
   flip the nightly no longer rebuilds these rows, so `sheet`-sourced
   history is the owner's data like any other (the tenant-level
   SHEET_OWNED_READ_ONLY block remains the protection where the sheet
   IS still the truth).

2. **CC batch privacy is a LIST-scope, keyed on the effective user.**
   `cc.list_batches` now returns only batches whose `created_by` is the
   EFFECTIVE user (`actor.user_id`) — Rob never sees rian's statements —
   plus ownerless rows (`created_by IS NULL`: migrated batches whose
   legacy creator didn't map, or a deleted user; hiding those from
   every owner would strand them). ONLY the list scopes; batch detail /
   lines / rules stay tenant-scoped exactly as before (a shared batch
   URL still opens — the privacy goal is "my list is mine", not
   document-level ACLs). Consequence: `create_batch` now stamps
   `created_by = actor.user_id` (effective) instead of `real_user_id` —
   with ownership as the list key, a batch created under view-as-Rob
   must be ROB's or it vanishes from his list mid-demo; outside view-as
   the two ids are the same person, and under view-as the real human
   stays recoverable on the audit row (`impersonated_by`). Safe for BW:
   one owner, who created every batch.

3. **Port-to-expenses provenance rides `external_invoice_id` — no new
   column.** `POST /api/cc/batches/{id}/port-to-expenses` turns each
   assigned line (project + amount) into one `source='manual'` expense
   (batch currency, line category/date, notes = the bill-through
   description, `paid_by` = the caller's own party unless a party is
   chosen — the fronting partner the settlement reimburses;
   `settlement_month` left NULL for the month-end apply). The created
   row carries `external_invoice_id = "cc:<line-id>"`: a CC charge has
   no vendor invoice #, so the source-document ref IS the cc line, and
   the marker doubles as the idempotency guard — a re-port skips any
   line whose marker already exists in the tenant
   (`skipped_already_ported`), so porting in waves is safe. Deleting
   the ported expense frees the marker (deliberate: delete = undo the
   port). Rejected: `cc_expense_lines.invoice_line_id` (that is THE
   entry-lock FK, reserved for real invoicing); a new `ported_at`
   column (single-writer scope: NO migration); natural-key matching
   (two identical $7.15 FACEBK charges would false-positive). The
   machine-text-in-a-user-field trade-off ADR #023 #4 rejected for
   invoice NAMES is accepted here because this field is a reference
   field ("which source document"), shown only in the row drawer, and
   exact provenance is the point.

4. **Settlement apply-to-month only sets the tag — the frozen engine is
   the arithmetic.** NEW `services/money/settlement_apply.py` (`POST
   /api/settlement/apply {month, invoice_ids?, expense_ids?}`) stamps
   `settlement_month = first-of-month` on the tenant's rows. Because
   the FROZEN engine groups by tag with NO status filter, the apply
   enforces the calibration invariant at the write edge: an invoice
   tags only when `paid` (`skipped_not_paid` — cancelled/pending can
   never pollute a split), and an already-tagged row is NEVER silently
   moved between months (`skipped_already_tagged`; corrections happen
   on the row, deliberately). Ids that don't resolve in-tenant are a
   hard 404 (all-or-nothing — a stale picker can't half-apply). The
   picker needs no new reads: paid-untagged invoices =
   `/api/books/invoices?month=unsettled&status=paid`, untagged expenses
   = `/api/books/expenses?month=unsettled`.

5. **The BW guard: `update_invoice` refuses PlusROI-lifecycle rows.**
   `PATCH /api/invoices/{id}` (BW time invoices) runs everything
   through `clean_status`, which coerces any status outside
   open|sent|paid to `open` — correct for BW, destructive for a PlusROI
   receivable mid-lifecycle (a stray BW-side edit would silently reset
   `pending_approval` to `open` and detach it from the PlusROI
   transition map). `update_invoice` now 409s
   (`PLUS_LIFECYCLE_INVOICE`) when the CURRENT status is
   `pending_approval`/`ready`/`cancelled`/`delayed`. `sent`/`paid` are
   shared vocabulary and stay editable on both surfaces (BW's own
   documents live there; tenant scoping keeps the ledgers apart in
   practice). One guard, pinned by
   `tests/invoices/test_plus_lifecycle_guard.py`.

6. **Frontend, config-driven.** `/books/expenses` gains "+ New expense"
   (one modal, Single / Multi-line modes; vendor pick pre-fills the
   `tenant_vendors` defaults); `/books/settlement` gains "Apply to
   month…" (paid-untagged invoice + untagged expense checklists → one
   apply); the CC batch page gains "Move into shared expenses" (port
   panel, paid-by defaulting to YOU); the PlusROI profile nav adds
   CC expenses (`can_transfer`-gated) — all pessimistic-save + verified
   refetch, pure logic vitest-pinned in
   `features/books/expensesLogic.ts`, zero `if (tenant)`.

**Rejected:** a settlement-engine change for any of this
(`services/money/settlement.py` stays FROZEN — the apply and the port
work purely through the tag + `source='manual'` rows it already sums);
per-user scoping on cc batch detail/lines (spec says list-only; a
shared URL keeps working); a vendor party per CC merchant string
(vendor stays NULL on ported rows — the merchant lives in notes; a
registry of one-off merchants would pollute the vendor list Danielle
picks from); a timed/queued apply (the picker + skips make re-runs
safe).

## ADR #025 — The BW-endpoint guard extends to `apply_entries` and `delete_invoice` (2026-07-13)

**Date:** 2026-07-13. **Decider:** rian. **Status:** SHIPPED. NO
migration.

**Context.** ADR #024 #5 guarded `update_invoice` against a PlusROI
receivable (`pending_approval`/`ready`/`cancelled`/`delayed`) because
`clean_status` would silently coerce its status to `open`. That guard
missed two other BW-endpoint writes that reach the same row by id:

- `apply_entries` (`POST /api/entries/bulk/apply-to-invoice`) takes an
  arbitrary `invoice_id` in its body. An owner in the PlusROI tenant who
  deep-links to the (unlinked-from-nav, but still-routed) BW `/invoices`
  page could attach pending time entries to a PlusROI invoice as
  `time`-kind `invoice_lines` — a shape `invoices_plus.py`'s own module
  docstring calls out as invariant ("PlusROI invoices are RECEIVABLES —
  no lines, no apply"). That would corrupt the invoice's margin/
  aggregate reads (which join through `invoice_lines`) and, once lines
  existed, would also make the invoice deletable-after-detach through
  the BW flow instead of the PlusROI lifecycle.
- `delete_invoice` only refused on `INVOICE_HAS_LINES` — irrelevant for
  a receivable that, absent the bug above, always has zero lines. A BW
  owner could `DELETE /api/invoices/{id}` a `pending_approval`/`ready`
  receivable outright: no lifecycle transition, no `plusroi_gates`
  audit action, the row just gone.

**Decision.** Extracted the inline check `update_invoice` already had
into `_reject_plus_lifecycle(invoice)` (`services/money/invoices.py`)
and call it from all three: `update_invoice`, `apply_entries` (right
after `_get_invoice`, before the `INVOICE_PAID` check — ordering doesn't
matter since `paid` is shared vocabulary and never in
`PLUS_LIFECYCLE_STATUSES`), and `delete_invoice` (right after
`_get_invoice`, before the line-count check). Same 409
`PLUS_LIFECYCLE_INVOICE`, same four statuses, same pointer to `PATCH
/api/plus-invoices/{id}`. `open`/`sent`/`paid` are completely
unaffected — apply/detach/delete on those still runs exactly as before
(pinned by the existing BW parity tests plus two new same-file
regression tests).

**Rejected:** guarding `detach_entries` the same way. Detach only acts
on rows a caller explicitly selected by time-entry id, and (with the
guard above in place) a PlusROI invoice can never accumulate a line to
detach in the first place; scoping a detach to a PlusROI invoice_id
already no-ops through the existing `NOTHING_TO_DETACH` path. Adding a
third identical check would be guarding against a state the other two
guards make unreachable.

**Tests.** `tests/invoices/test_plus_lifecycle_guard.py` — 21 tests
(was 8): the four PlusROI statuses × {update, apply, delete} all 409
`PLUS_LIFECYCLE_INVOICE` and leave the row untouched (status/name
uncoerced, nothing attached, nothing deleted), plus open/sent/paid
regression checks that update/apply/delete all still behave exactly as
before the extension.

## ADR #026 — The Hub v0: a super-admin-only, cross-tenant read boundary (2026-07-17)

**Date:** 2026-07-17. **Decider:** rian. **Status:** SHIPPED. NO
migration.

**Context.** rian wants a single "what do we have?" view across ALL the
books — projects, organizations, people, tenants, accounts — the seed of
a future hub surface. This runs directly against the app's founding
guarantee: every list is `authz.scope()`-narrowed to the current tenant so
a partner (rob/danielle/adi) can never see another tenant's row
([tenancy.md](features/tenancy.md), ADR #015). The hub is the deliberate
opposite: it shows EVERY tenant's rows in one place.

**Decision.** A new read-only surface, `app/services/hub.py` +
`app/routers/hub.py` (five `GET /api/hub/*` endpoints), that deliberately
does NOT apply `scope()` / the tenant filter — and is therefore gated to
**super admins only**:

1. **The gate is `is_super_admin && !is_viewing_as`**, asserted by
   `services.hub.require_super_admin` at the TOP of every service function,
   BEFORE any query runs — the SAME idiom `scope()`/`policy()`/`comments()`
   already use. A non-super-admin → 403 `FORBIDDEN`, zero rows. A super
   admin **viewing-as** anyone (even another super admin) is ALSO refused:
   under view-as `actor.is_super_admin` is the target's flag and
   `is_viewing_as` is true, so impersonation can never open the surface.
   The router is a thin `ServiceError → ApiError` mapper; the gate lives in
   the service so it can't be bypassed by a future second caller.
2. **`/api/hub` is tenant-exempt** (`deps._TENANT_EXEMPT_PREFIXES`). Hub
   data is not tenant-scoped, so a cross-tenant read must not trip the
   `NO_TENANT` gate (and a super admin with zero tenants must still read
   it). Consequence: a non-super-admin ALWAYS fails with `FORBIDDEN`, never
   `NO_TENANT` — one clean refusal, tested end-to-end.
3. **The nav "Hub" group is hidden declaratively.** It's appended to both
   workspace profiles with every item gated on a synthetic `is_super_admin`
   capability; `AppShell` folds `me.is_super_admin` into the capability map
   `resolveNav` reads. No `if (isSuperAdmin)` in any component — the same
   mechanism `can_see_income` uses (ADR #020's "no `if (tenant)`" rule
   extended to identity). Page-level guards are belt-and-suspenders for a
   deep-link; the server 403 is the control.

Money-free by design (structure, not amounts → no `can_see_income`
concern). Each endpoint is a single grouped-count query (no N+1). No schema
change, no new domain — `with.bowden.works/hub` for now.

**`invoice_count` definition.** A project's invoice_count is distinct
invoices reached via its time entries' `invoice_line_id` lock (the
structural entries→invoice edge, ADR #038 semantics). Milestone-generated
receivables (no time entries) aren't counted in v0; revisit at a
per-project drill-down.

**Rejected:** (a) putting the super-admin check in the router — the house
rule is that permission logic lives in the service, and a service-layer
gate protects any future caller; (b) reusing a `NO_TENANT`-style flow —
the hub isn't a tenant with no books, it's a non-scoped read, so the
tenant-exempt path + the super-admin gate is the honest model; (c) a
capability-grid entry for "hub access" — the grid governs what you may do
WITHIN a tenant; a cross-tenant read is orthogonal, driven by the
super-admin flag directly.

**Tests.** `tests/hub/test_hub_service.py` (shapes/counts on a two-tenant
fixture + the gate for every function, non-super and viewing-as) and
`tests/hub/test_hub_routes.py` (end-to-end: super admin gets rows; every
endpoint 403 `FORBIDDEN` with no rows for a non-super-admin and under
view-as; projects span both tenants). Frontend:
`profiles.test.ts` (Hub nav hidden from non-super, shown to super, both
modes).
