# Changelog — with.bowden.works

Newest first. The top entry's version is THE app version: `/api/meta`
and the SPA footer must surface it (the deploy-confirmation rule —
a visible version is how you confirm a deploy actually landed).
Every code change bumps the version and adds a line here.

## 0.21.0

**The Hub — Organizations expand-a-row drill-down** (`docs/features/hub.md`).
Rian ran into exactly the confusion the previous session's docs flagged as a
risk: discovering `client_profiles` lives on its own table, separate from
`organizations`, felt like a gap in his mental model rather than an expected
design choice. Rather than a "select a tenant, columns repopulate" lens (which
would hide multi-tenant spread — the very thing being audited for), the fix
reuses the expand-row mechanism the live `/projects` page already has.

- **`GET /api/hub/organizations/{party_id}/detail`** (`hub.organization_detail`,
  same `require_super_admin` gate) — an org's `client_profiles` (one row per
  tenant it's a client of, each with THAT tenant's own terms/commission/
  currency/credit-limit/contacts; an org spread across N tenants correctly
  returns N rows) and `tenant_vendors` (one row per tenant it's a listed
  vendor of).
- **Organizations rows are now expandable** (▶) via `DataTable`'s existing
  `renderExpanded`/`expandedKeys`/`onToggleExpand`, lazy-fetched on expand
  (`OrganizationDetailBreakdown` in `HubPage.tsx`), same pattern as the
  `/projects` page's description breakdown.
- No schema change; no new ADR (no lock/permission/precedence rule changed).

## 0.20.0

**The Hub — v0 shipped, then expanded into a real spreadsheet-style data
review surface** (`docs/features/hub.md`, ADR #026). Both land under this
one version: v0 was deployed live earlier this session without a version
bump (a docs-debt gap, corrected here alongside the same-session
expansion) rather than churn two bumps for work nobody saw an intermediate
release of.

- **Hub v0:** five super-admin-only, cross-tenant, read-only endpoints —
  `GET /api/hub/{projects,organizations,people,tenants,accounts}`
  (`app/services/hub.py` + `app/routers/hub.py`, tenant-exempt in
  `deps.py`) — the seed of a "what do we have?" view across every
  tenant's books. No `scope()`; the security boundary is
  `require_super_admin` (403 before any query, closed even under
  view-as). The `/hub` SPA area (neutral DataTables + index), nav group
  gated on `is_super_admin`. No schema change.
- **Full field expansion + per-column filtering** (rian's first
  hands-on review pass): every endpoint now returns every column its
  underlying model(s) carry, not just the original derived counts —
  Organizations gained `slug`/`email`/`phone`/`address`/`notes`/
  `is_active`/`created_at`/`updated_at` (party) + `legal_name`/
  `currency`/`drive_folder_url`/`mosiah_folder` (the 1:1 `organizations`
  detail row, LEFT JOINed so a missing detail row surfaces as nulls, not
  a dropped row); Projects gained `operator`/`notes`/`created_at`/
  `updated_at` (money fields stay deliberately excluded); People/Tenants
  gained the same party-level fields plus Tenants' raw `settings` JSON;
  Accounts gained `id`/`person_id`/`preferences`/`created_at`. Column
  headers now show the raw field id (`project_count`, not "Projects (as
  client)") since this is a technical review surface. Every column is
  sortable AND filterable — a new opt-in `filterable` prop +
  `Column.filterValue` on the shared `DataTable`
  (`frontend/src/components/DataTable.tsx`), off by default so
  `/entries`/`/projects`/CC batches are unaffected. This pass immediately
  surfaced the PlusROI review findings as visible row/column data: 403
  vs 64 projects (sheet-import bloat + the Second Wind cross-tenant
  duplication), and only 17 people (the missing-contacts bug). No schema
  change; no new ADR (no lock/permission/precedence rule changed).

## 0.19.0

**Phase 3 slice 3 (the last write forms) — expenses, CC→PlusROI, settlement
apply-to-month, + a BW-invoice guard** (ADR #024). No migration; the settlement
engine + harness are untouched.

- **Manual + multi-line expenses** (`POST /api/expenses`, `/api/expenses/batch`
  — Danielle transcribing Yulia's per-project invoice as N rows in one audited
  transaction); `PATCH`/`DELETE`; `GET /api/expenses/options`.
- **CC extractor → PlusROI:** the batch list is now **per-user private**
  (`created_by` scope — Rob sees only his batches); `POST
  /api/cc/batches/{id}/port-to-expenses` creates `source='manual'` expense rows,
  idempotent via `external_invoice_id="cc:<line>"` provenance. BW paste/assign/
  export unchanged.
- **Settlement apply-to-month** (`POST /api/settlement/apply {month, invoice_ids?,
  expense_ids?}`): tags **paid** invoices + expenses into a settlement month; the
  frozen engine reflects it with zero engine change (only-paid invariant enforced
  at the write edge; never re-tags).
- **BW guard:** `/api/invoices` PATCH now 409s on a PlusROI-lifecycle invoice
  (`PLUS_LIFECYCLE_INVOICE`) — no `clean_status` coercion. 970 backend / 191
  frontend.

## 0.18.0

**Phase 3 slice 2b — PlusROI invoice generation + the status lifecycle +
realized-FX** (ADR #023). A separate `services/money/invoices_plus.py` — Bowden
Works invoice semantics untouched. No migration.

- **Generate from a project** (`POST /api/plus-projects/{id}/generate-invoice`):
  recurring → one `pending_approval` invoice for the month (amount COPIED from the
  project, so editing it never drifts the project default — Rob's $1,500 project
  stays $1,500); milestone → all un-generated milestones at once
  (`generated_invoice_id`, exactly idempotent). **Batch** (`POST
  /api/plus-invoices/generate-batch {month, timing}`) for Danielle's twice-monthly
  run — idempotent, cycle-guarded.
- **Lifecycle** (`PATCH /api/plus-invoices/{id}`): `pending_approval → ready →
  sent` (record the QuickBooks #) `→ paid` (USD → record the realized CAD
  collected, which becomes the book value; face preserved in `original_*`);
  cancel/delay; illegal jumps 409. Owner + writable-books + tenant + audit on
  every mutation.
- `settlement_month` stamped at generation (the sheet's tag); **cancel clears
  it**; realized CAD (never a USD face) flows to the FROZEN settlement engine
  (pinned by test). +28 backend / +23 frontend (910 / 183). Per-row lifecycle
  modal on `/books/invoices` (PlusROI profile only — BW Books stays action-free,
  zero `if(tenant)`); generate actions on the Projects page.

## 0.17.0

**Phase 3 slice 2a — the recurring/milestone project model + Projects form +
vendor listability** (alembic 0008, ADR #022).

- **Schema (additive):** `project_billing` +`is_recurring`/`cycle_months`/
  `billing_timing` (the clean recurrence model, replacing the sheet's conflated
  "type"); NEW `project_milestones` (deposit/milestone/final schedules with a
  `generated_invoice_id` link); `invoices` +`collected_amount`/`collected_currency`
  (realized CAD recorded at Paid for USD invoices); NEW `tenant_vendors` (a
  per-tenant vendor marker so a zero-expense vendor is listable + holds default
  category/paid-by — a marker, NOT an engagement). **Both new tenant-scoped
  tables joined `_SCOPED_DELETES` + `TRUNCATE_TABLES`** so app-owned PlusROI rows
  survive the nightly (survival test extended + green); harness byte-identical.
- **Projects form** (`/api/plus-projects`, `/projects/new`): create attached to a
  client, **inherits** currency/terms/commission/timing from the client profile
  (editable), then recurring (cycle + month-end/mid-month) OR a milestone list.
  Owner-gated + writable-books + audited (`plusroi_gates`). Bowden Works
  `/projects` untouched.
- **Vendors:** create writes a `tenant_vendors` marker; list = markers ∪
  expense-referenced parties. 882 backend / 160 frontend.

## 0.16.0

**Phase 3 slice 1 — Clients / Contacts / Vendors write forms**, and the
**PlusROI brand skin**. No migration (writes to existing tables).

- **Clients** (`/clients`): org party + `client_profiles` (currency, terms,
  invoice timing, commission owner+%, credit limit, archived) + a
  `direct_client` edge, via the `create_org_party` choke point; reuse an
  existing org by name (shared identity, non-destructive enrich).
  **Contacts** = person parties linked to a client by a `cc_recipient`
  affiliation (email on the contact); `/contacts` lists + removes (ends the
  affiliation). **Vendors** (`/vendors`): create org|person payee.
- Every mutation goes through `services/plusroi_gates` — owner
  (`can_transfer`) + **writable-books** (409 `SHEET_OWNED_READ_ONLY`, same
  predicate as the invoice block — lifts together at a flip) + tenant-scope
  + audit. +28 backend / +12 frontend tests (859 / 146).
- **Brand skin (taste, from plusroi.com):** the PlusROI theme retunes off
  the borrowed host teal to the brand — **orange `#f1a109`** accent (CTA /
  active nav / links / focus ring), **navy `#173c57`** ink (headings) + a
  **navy masthead strip** on the sidebar echoing the site header, brand blue
  for info/locked. Pure `[data-theme="plusroi"]` token + one scoped shell
  rule — no component logic, THE RULE intact.

## 0.15.0

**Tenant modes, phase 2 — stable identity + the app-owned flip** (ADR #021,
`docs/features/tenant-modes.md`). PlusROI is now **app-owned**: writes unlocked,
the nightly leaves it untouched, seeded fresh from the latest sheet export. This
required making shared party IDs survive the Bowden Works nightly rebuild.

- **Stable party identity** — the legacy transform now UPSERTS parties by a
  deterministic slug (same name → same uuid across runs), dropping the old
  `DELETE FROM parties` + fresh-uuid recreation. Harness-neutral (it keys on
  names): `test_compare_green_after_clean_run` is byte-identical.
- **`books_owner`** (`sheet`|`app`, `app/services/tenancy.py`) supersedes
  `parallel_run` (legacy `parallel_run:true` reads as `sheet`). The sheet
  importer SKIPS app-owned tenants; the nightly reset is **tenant-scoped** so an
  app-owned tenant's rows survive; the invoice write-block keys on `sheet`
  (409 `SHEET_OWNED_READ_ONLY`); the Books banner lifts automatically.
- **`scripts/flip_books_owner.py`** — idempotent flip (preserves
  bookkeeping/split). PlusROI flipped app-owned 2026-07-12.
- **Also fixed:** the nightly had been RED since 2026-07-11 (`extract_new`
  duplicate-client-name across tenants) — the harness extractor is now scoped to
  legacy-mirrored tenants; live compare GREEN.
- **The survival gate** (`tests/migrate/test_app_owned_survival.py`): a
  simulated BW nightly leaves an app-owned tenant's books sha256-identical (ids +
  party FKs). Live: fresh import (2,391 invoices / 4,724 expenses) → flip →
  settlement gate GREEN 32/32 → re-import skips PlusROI, snapshot identical.
  Suite: 831 backend / 134 frontend.

## 0.14.0

**Tenant modes, phase 1 (presentation)** — ADR #020, `docs/features/tenant-modes.md`.
Each tenant renders as its own workspace (nav + columns + theme + shell) from a
declarative WorkspaceProfile. PlusROI is matched to the host.bowden.works design
(teal / white / left sidebar); Bowden Works is unchanged. THE RULE: no
`if(tenant)` in any component — every difference flows through the profile (the
`plusroi` literal lives only in the profile registry + the theme block).

- WorkspaceProfile registry (`frontend/src/features/workspace/profiles.ts`) +
  `useWorkspace()`; `/api/me` gains `workspace_profile` (from `tenant.settings`,
  default by slug).
- The PlusROI `[data-theme="plusroi"]` token block maps the host palette onto
  `with`'s EXISTING token names, so shared components (DataTable, MoneyCell,
  pills, buttons) restyle automatically. Sidebar shell (B layer) alongside the
  unchanged topnav.
- Config-driven nav + config-driven columns proven on the Books ledgers.
  Placeholder routes for Clients / Contacts / Vendors / Reports (forms = phase 3).
- Tests: +4 backend, +13 frontend. Suites 823 backend / 134 frontend; build clean.

## 0.13.1

Hardening from the release's **adversarial review** (4 confirmed findings,
each validated by two independent refuters; ADR #019):

- **CRITICAL — a missing sheet export can no longer delete the PlusROI
  tenant.** The nightly's sheet step used to skip-and-commit when
  `books-import/source` was absent — AFTER the truncate, so the tenant,
  partner engagements, and all PlusROI books vanished until the next good
  night (partners stranded on NO_TENANT). Now: when a books source is
  requested (production passes `auto`), a missing dir, ANY missing required
  tab, or a **>20% row-count shrink vs the last OK run** (truncated/corrupt
  export) raises and ROLLS BACK the whole rebuild — yesterday's books
  survive untouched. A stale export (>9 days) warns in the run report.
  `books_source=None` (tests/harness) still skips by design.
- **HIGH — parallel-run invoice write-block.** All five R1 invoice
  mutations (create / update / delete / apply / detach) now refuse with
  `409 PARALLEL_RUN_READ_ONLY` on a tenant whose books rebuild nightly from
  the sheet — an edit would look saved, then silently vanish at 03:30.
  The Books-UI banner was advisory; this is the enforcement. Lifts at
  cutover by clearing `settings.parallel_run`.
- **MEDIUM — comparison-chip colors now encode MEANING, not direction:**
  rising expenses read as bad (red) even though the number went up
  (`deltaTone` replaces direction-based coloring on /books/settlement).
- Also: sheet invoice ids normalized (`"6357.0"` → `"6357"`, `_ext_id`) —
  a float artifact of the xlsx export, spotted in the browser walkthrough.
- Tests: +9 backend (fail-fast fatal paths ×3, the 5-mutation guard matrix,
  guard-passes-without-parallel-run) and +1 frontend (deltaTone); suites at
  **819 backend / 121 frontend**, gate re-run GREEN 32/32 after re-import.

## 0.13.0

The **Books UI** — the owner-only pages that replace rian's PlusROI
bookkeeping Google Sheet, over the frozen settlement engine plus two thin
ledger-list endpoints (ADR #018, `docs/features/plusroi-books.md` "Books
UI"). Three routes under a new **Books** nav group, gated on
`can_see_income`. **Read-only v1** — the PlusROI tenant is in parallel run,
so the app rebuilds these ledgers nightly from the sheet and any edit would
be overwritten; a dismissable banner says so. No schema change.

- **`/books/settlement` — the Monthly Split.** The formula rendered as a
  top-to-bottom waterfall with the operators visible (gross → − fees →
  − commissions → = revenue → − expenses [expandable to the category
  breakdown] → − bookkeeping (3%) → = net → ÷ 2 → the split, big), so
  Danielle reads it like the sheet's rows. Plus a left-rail month list
  (split/net + a revenue-vs-expenses spark) with an unsettled-bucket chip,
  a comparison strip (vs previous month / vs same month last year, computed
  client-side), a Wave-invoice-shaped "This month's Bowden Works invoice"
  panel with a tab-separated "Copy lines" button, and the "Fronted by …"
  reimbursements table.
- **`/books/invoices` + `/books/expenses` — the ledgers.** Filterable
  (settlement month + Unsettled + All, status chips / category + paid-by,
  debounced search), paginated, with a footer count + Σ, a per-row
  read-only detail drawer, and a USD-face subtext on USD invoices. The
  expense "paid by" badge is accented when a partner fronted the bill
  (reimbursed) vs muted for the tenant's own.
- **Backend** (`app/services/books.py`, `app/routers/books.py`):
  `GET /api/books/invoices` + `GET /api/books/expenses`
  (`?month=&status=|category=&paid_by=&q=&page=&page_size=`) →
  `{rows, total_count, sum_*, …}`, tenant-scoped ALWAYS, sums over the FULL
  filtered set, LIKE-escaped `q`, month `"YYYY-MM"`|`"unsettled"`|all, page
  size default 50 cap 200; `GET /api/books/meta` → `{parallel_run}`. All
  owner-only (403 `INCOME_FORBIDDEN`). Consumes `GET /api/settlement[/{month}]`
  exactly as-is (the engine is frozen).
- **Tests:** `tests/books/` (26 — authz, tenant isolation, filters, `q`
  escaping, pagination + full-set sums, empty states);
  `frontend/src/features/books/booksLogic.test.ts` (13 — delta math, copy
  block, filter builders). Suite: backend 810, frontend 120.

## 0.12.0

R2 — the **multi-tenant foundation**: per-user tenant membership, a
request-scoped current tenant, and a header tenant switcher, so rian can run
the Bowden Works and PlusROI books from one login while partner users land
ONLY in PlusROI and never see a Bowden Works row (ADR #015,
`docs/features/tenancy.md`). Supersedes the R1 "tenant is a setting" model
(ADR #004 #13). **No schema change.**

- **Membership is engagement-derived** (`allowed_tenants()` in
  `app/services/authz/context.py`): the tenants where a user's person party
  holds an ACTIVE role-bearing engagement (owner/manager/worker — reusing
  `derive_role`), PLUS every tenant for super admins; ended engagements grant
  nothing. Ordering is alphabetical, with the default tenant first for super
  admins.
- **Current tenant = the HttpOnly `with_tenant` cookie** (tenant slug),
  resolved per request (cookie if a member, else first allowed, else none).
  `tenant_id` now enters the Actor in `resolve_actor` — the ONE change point;
  `scope()`/`can()` are untouched, so switching invalidates nothing
  server-side. A user with zero tenants gets `403 NO_TENANT` on data routes
  (except `/api/me`, `/api/tenant`, `/api/view-as`, `/api/saved-views`).
- **API:** `GET /api/me` gains `tenants: [{slug,name}]` + `tenant:
  {slug,name}|null`; new `POST /api/tenant {slug}` validates membership, sets
  the cookie, and returns the new current tenant (`403 TENANT_FORBIDDEN` off
  the user's list). Switching is a nav preference — not audited.
- **View-as never widens tenant access:** the context follows the real
  user's allowed set ∩ the target's; entry `409 VIEW_AS_TENANT_MISMATCH` when
  they share no tenant.
- **Frontend:** a `TenantSwitcher` in the AppShell header (shown only for >1
  book — Adi/rob/danielle never see it); pick → `POST /api/tenant` → full
  react-query cache invalidation → navigate home. Mirrors the View-as
  switcher's tokens. Pure logic in `features/tenant/tenantSwitch.ts`.
- **`APP_TENANT_SLUG`** now survives ONLY as the default-ordering hint /
  single-tenant-DB default (the two readers are `config.py` + `context.py`);
  nothing else reads it.

R2 — the **PlusROI books schema + sheet importer** (ADR #016, alembic 0007,
`docs/features/plusroi-books.md`): the legacy Google-Sheet books (an Invoices
receivables ledger, an Expenses payables ledger, and the client/project
registries) now import into the R2 schema as the PlusROI tenant's books,
rebuilt nightly alongside the legacy transform. NOT the settlement engine
(that lands separately in `services/money`) — this lands the ledgers + the
per-settlement-month totals the settlement gate verifies against the sheet's
own Monthly Split.

- **Schema is ADDITIVE** (the diff harness stays green by construction):
  `invoices` gains `fee` / `commission_pct` / `commission_amount` /
  `commission_party_id` / `settlement_month` / `relevant_period` /
  `original_amount` + `original_currency` / `invoice_timing` / `billing_type`
  / `external_id` / `due_date`, and a WIDENED status CHECK for the sheet
  lifecycle (`pending_approval` / `ready` / `sent` / `paid` / `cancelled` /
  `delayed` — R1's `open`/`sent`/`paid` kept untouched for BW time-invoice
  flows). Three NEW tables: `expenses` (the payables journal — paid-by either
  partner nets out in the settlement), `client_profiles`, `project_billing`.
- **The sheet→DB transform** (`app/services/migrate/sheet_books.py`) runs as
  a step in `transforms.run_all` AFTER `step_comments`, INSIDE the rebuild
  transaction, with the same discipline: TRUNCATE-and-rebuild idempotency
  (its tables join `TRUNCATE_TABLES`), per-tab reconciliation that FAILS the
  run on any unaccounted CSV row, live↔archive dedupe (external-id then
  tuple; live wins), and a per-settlement-month invoice/expense summary in
  the run report. Opt-in via `run_all(books_source=...)` (default None = the
  legacy-only behavior every harness/reporting test relies on); the two
  pipeline entrypoints pass `"auto"` to auto-detect `books-import/source`
  (now mounted read-only into the container).
  Books are CAD (`manual_total`); the USD face survives in `original_amount`.

R2 — the **settlement engine** (ADR #017, `services/money/settlement.py` +
`GET /api/settlement` / `GET /api/settlement/{month}`): the Monthly Split,
computed from the imported ledgers with the formula calibrated to the cent
against the sheet's own report AND the real BW→PlusROI Wave invoices.

- **The formula** (08 §4, refined by calibration): revenue = Σ manual_total
  − Σ fee − Σ commission; − Σ expenses − bookkeeping (3%, tenant setting)
  = net; split = net × partner_split (0.5). Tag presence
  (`settlement_month`) IS the inclusion rule — no status filter, exactly
  like the sheet. Untagged rows surface as the `unsettled` bucket.
- **Full-precision rounding policy** (ADR #017): bookkeeping/net/split
  derive on the unrounded chain and quantize HALF_UP per reported line —
  the policy under which the real invoice 2026050's split ($13,546.48)
  reproduces exactly (round-each-stage is a cent off; pinned by test).
- **The suggested BW invoice**: split + reimbursement of every expense
  fronted by a non-tenant party, grouped into the Wave line vocabulary
  (BowdenWorks Team Cost / PPC Spend / Licences / Fees) + GST (tenant
  setting, 5%) — the month-end lines rian keys into Wave, computed.
- **Owner-only** (`can_see_income` → 403 `INCOME_FORBIDDEN`); tenant-scoped
  throughout; +10 pytest (`tests/money/test_settlement.py`) incl. the
  April-2026 real-invoice pin and tenant isolation.

## 0.11.0

R2 — the **satellite registry API** (`/api/registry/*`): the public edge
suite satellites (`hosting` first) call to reference the People,
Organizations, and Projects `with` owns (ADR #014,
`docs/SATELLITE-CONTRACT.md`, `docs/features/registry-api.md`).

- **Five endpoints** (`app/routers/registry.py` over
  `app/services/registry.py`): resolve `GET /parties/{id}`, typeahead
  `GET /parties?query=&kind=&limit=`, the ONE write `POST /organizations`,
  `GET /projects/{id}`, and `GET /organizations/{id}/reference` (Drive /
  Mosiah folder facts). API `kind` is `person`/`organization`, mapped to
  the DB `person`/`org` at every boundary. Creation goes through the
  existing `create_org_party` 1:1 choke point (slug from the shared
  allocator); the create is audited (`registry.create_org`, NULL
  tenant/actor) in the same transaction. **No schema change** — a
  read/write surface over the ADR #013 tables.
- **Bearer service-token auth (ADR #014).** `Authorization: Bearer
  <token>` vs `settings.registry_service_token`
  (`REGISTRY_SERVICE_TOKEN`), timing-safe, never logged. **Fails closed:**
  unconfigured ⇒ `503 REGISTRY_DISABLED`; missing/malformed ⇒
  `401 REGISTRY_AUTH_REQUIRED`; wrong ⇒ `401 REGISTRY_AUTH_INVALID`. The
  default-deny SESSION middleware bypasses `REGISTRY_PREFIX =
  "/api/registry/"` (kept OUT of `ALLOWLIST_EXACT`) so the router token
  check is the only gate; a `with` app session is deliberately NOT accepted
  (a valid session with no bearer still 401s).
- **Create dedup + hints.** ACTIVE same-name (case-insensitive) org ⇒
  `409 ORG_EXISTS` with the `existing` party, overridable with
  `allow_duplicate:true`; INACTIVE same-name does not block. A `201`
  carries `near_matches` (≤5 similar ACTIVE orgs) as a soft
  link-don't-duplicate hint. Search escapes LIKE wildcards (`% _ \`), caps
  `limit` at 50, orders exact-lower-match first. Malformed path UUIDs are a
  clean 404, never a 500.
- **Tests.** +37 pytest (`tests/registry/`): token gate (503/401 incl.
  session-never-substitutes), resolve + kind mapping + 404/invalid-uuid,
  search substring/kind/wildcard-escape/limit-cap, create 201-via-choke-
  point + audit + 409 + allow_duplicate + near_matches, project + org-
  reference reads. The security route-auth sweep is extended to assert
  every registry route is token-gated (not session-gated). Full backend
  suite green.
- **Deploy note (orchestrator):** version bump (`app/version.py` +
  `frontend/package.json`), the `REGISTRY_SERVICE_TOKEN` secret at
  `/srv/apps/with/.registry.env` (600), and a security audit of the new
  network-reachable write surface are the owner session's to wire.

## 0.10.0

R2 — **People + Organizations** as first-class objects over the party
supertype (ADR #012/#013). The single `parties` table stays the shared
identity anchor every relationship (and every suite satellite) points at;
new 1:1 detail tables carry only what differs by kind.

- **Schema (alembic 0006, additive).** New `organizations` (billing
  currency + the Drive/Mosiah folder refs suite satellites read), `people`
  (first-class growth home), and `party_affiliations` (the person↔org
  relationship graph — owner-of, employee-of, contact, cc). No column
  moves off `parties`, so the diff harness stays green by construction
  (`test_compare_green_after_clean_run` still passes).
- **1:1 invariant** at one creation choke point:
  `services/entities/parties.create_org_party` / `create_person_party`
  (ORM) + the transform's `_insert_party` (raw SQL). Detail FKs cascade on
  party delete so the rebuild-from-legacy DELETE stays clean.
- **Foundation for hosting.bowden.works** — a satellite links to a
  `party_id` and resolves via the coming registry API
  (`docs/SATELLITE-CONTRACT.md`).

## 0.9.1

M8 — project **income + billout-adjustment editing** (owner-only): the
last genuinely-unbuilt tier-A parity gap (`docs/TIER-A.md` T-ADJ-020,
T-PRJ-028/029), now closed.

- **Write surface.** New `PATCH /api/projects/{id}/financials` +
  `app/services/projects_admin.py`: an owner sets a project's `income`
  and the two display-only billout adjustments
  (`billout_adjustment_pct` / `billout_adjustment_amount`) in one save.
  Owner-only (`can_transfer` — error "Only an owner can edit project
  income."), audited with complete before-images, and it **re-stamps
  nothing** (ADR #040) — reporting already applies the adjustment at read
  time (`reporting/projects` + `summary`), so the values flow into the
  /projects rollup, billout-adjusted and margin on the next fetch. Not a
  `services/money/` change (no stamp touched), so outside the money lock.
- **Parity semantics (ADR #010).** A blank field CLEARS (one save writes
  all three); income must be ≥ 0; pct and amount are **independent and
  COMBINE** — NOT XOR (the T-ADJ-018 shape / the migrated "Site Rebuild"
  carries both). The only constraint enforced is the real R1
  income⇔currency pairing (`ck_projects_income_currency_pair`). The
  legacy "Fix the entries first" NULL-tuple guard is structural now:
  financials attach to a project id, so an unassigned rollup group has no
  edit surface (a bad id is a clean `PROJECT_NOT_FOUND`).
- **Inline editor on /projects.** Owner-only pencil on the Income cell
  opens a small popover (income / adj % / adj $) with **pessimistic
  save + verified feedback** (M5 pattern): the table refetches on success
  so margin recomputes from authoritative numbers. `financials.ts`
  (pure, +vitest), `updateProjectFinancials` in `lib/api.ts`.
- **Tests.** +19 pytest (owner-only 403 matrix, income/adjustment
  parse + clear, adjustment-math parity vs the old formula, income⇔
  currency pairing, audit before-image, unknown-project 404, HTTP
  round-trip) and +12 vitest. `docs/TIER-A.md` re-tallied;
  `docs/UNDOCUMENTED.md` cleared. No migration, no schema change — the
  columns already existed.

## 0.9.0

M8 — hardening + saved views + UX debt (the last build milestone before
cutover readiness).

- **Saved views (the "easier to navigate" centerpiece).** New
  `saved_views` table (alembic **0005**) + `/api/saved-views` CRUD
  (`app/services/saved_views.py`, `app/routers/saved_views.py`).
  Per-user named views over the filtered tables: a **Views ▾** picker in
  each page header (`/entries`, `/projects`, CC batches) names the
  current URL filter/sort state, applies a view in one click, and
  manages (rename/delete/pin) inline; **pinned views surface in the
  nav** (`AppShell` PinnedViews). Views key on the REAL user (a personal
  nav pref, never impersonated) and are not audited. The hardcoded
  quick-chips **retire** — replaced by idempotently **seeded** recurring
  views for rian (entries: Last month's PlusROI / Pending backlog /
  Blocked triage; projects: Hourly blocks / Missing info / Last month
  admin — lossless). Frontend: `lib/savedViews.ts` (+ vitest),
  `components/SavedViewPicker.tsx`. ADR #009 #1–#3.
- **Performance pass @ 100×.** `scripts/seed_100x.py` builds a *separate*
  ~460k-entry scratch DB (hard-refuses the production `with` books) by
  multiplying the real migrated shapes. `tests/perf/test_no_seq_scan.py`
  gates the indexed hot paths (default date-ordered list, selective date
  range, month totals, single-row) against a Seq Scan on `time_entries`.
  Numbers + methodology in `docs/PERF.md`: cold dashboard **859 ms**
  (< 1 s), warm scoped list **57 ms** (< 300 ms); unbounded `date=all`
  aggregates scale O(N) with no regression. `pg_stat_statements` enabled
  via the sidecar compose `command` flags.
- **Security hardening (app-level).** `tests/security/`: a route-auth
  sweep (every `/api` route denies an unauthenticated request; the
  allowlist is exactly the four public paths), consistent 401/403/404/422
  structured-error shapes, and upload/paste size bounds — added a **1 MB
  cap** on the CC statement paste (`MAX_PASTE_BYTES`) to match the CSV
  25 MB cap. `tests/meta/test_phase_out_guards.py`: no RLS / no Supabase
  auth client / no identity-JWT minting (T-PHX-007), ONE eligibility
  predicate (T-PHX-009 — deduped `_sort_expr` → `filters.status_sort_rank`),
  no Toggl export (T-PHX-010).
- **Money-path guard:** percentage rate overrides are floored at **-100%**
  (`team_admin._validate_override_kind`; `PCT_TOO_LOW`) — closes T-ADJ-009.
- **Tier-A cutover inventory** — `docs/TIER-A.md` maps all 282 tier-A IDs
  to their tests (253 mapped; cores fully green). **UX checklist** —
  `docs/UX-CHECKLIST.md` (all 7 flagged warts fixed). **390px pass** —
  `styles/responsive.css` + a no-inline-styles vitest guard; mobile-first
  pages don't overflow. **Playwright smokes** — `e2e/` + `make e2e` (the
  3 critical flows; out of `make check`, see `docs/E2E.md`).
- **Fix:** `frontend/package.json` was stale at 0.6.0 while the backend
  was 0.8.0 (M6/M7 didn't bump the SPA build version) — would fire the
  deploy-skew banner on every response. Both now locked at 0.9.0.
  InvoiceDetail status buttons now disable mid-mutation.

## 0.8.0

M7 — CC expenses + comments + team admin (three parallel slices; the
orchestrator sets the version and date once on integration).

- **CC pass-through expenses (slice A).** Rebuilt the PlusROI bill-through
  helper on the M2 `cc_*` tables (no schema change). Backend:
  `services/cc/parse.py` (a FAITHFUL port of the legacy
  `lib/cc-expenses.ts` — `parse_currency` matches JS `Math.round(n*100)/100`
  on IEEE doubles; loose-date/credit-sign/no-dedupe warts carried) +
  `services/cc/service.py` (owner-gated batches; statement paste with
  auto-rule stamping [newest-rule-wins] continuing `line_index`; per-line
  assignment that flips `auto_assigned` false = green; re-apply over only
  unassigned lines; the PlusROI paste-block export with the column-6
  literal moved to a tenant setting [M6 parity]; explicit tenant scoping;
  project-tenant re-validated on rule create AND update; audit row per
  mutation; `invoice_line_id` untouched [R1 stateless export]);
  `routers/cc.py` (`/api/cc/batches`, `…/{id}/lines|reapply|paste-block`,
  `/api/cc/lines/{id}`, `/api/cc/rules|keywords`). Frontend:
  `features/cc/**` — batch list, batch detail (paste panel, 3-color
  rows [saved > auto-matched > highlight as `tokens.css` design tokens],
  EntityPicker assignment, verified copy button), rules & keywords page,
  and the render-time `ccState.ts` — wired at `/cc-expenses`,
  `/cc-expenses/$batchId`, `/cc-expenses/rules` with the BILLING → CC
  expenses nav link live. **GATE:** the parser + export are byte-diffed
  against the legacy TS (via `tsx` — `scripts/ts_cc_parse.mjs` /
  `ts_cc_export.mjs`) on the 102 REAL migrated statement lines — 0
  divergences — plus an end-to-end paste→rules→assign→export on a clone
  of the real data. Tests: +72 pytest (`tests/cc`, CCE-* suite + service
  + gate + e2e + router), +9 vitest (`ccState`, CCE-H/V). ADR #008.A.
- **Comments board + bell (slice B).** Ported the org-wide comment feed
  behind the 💬 nav bell. Backend: `services/comments.py` (post with
  effective-user attribution, resolve/reopen with no state precondition
  [B9], author-only delete ENFORCED server-side as an explicit 403 [the
  legacy silent RLS no-op is fixed], a REAL tenant-wide unresolved
  `count(*)` for the badge [not window-derived], tenant-scoped reads,
  per-row `can_delete` = the SAME predicate the delete handler enforces
  [one identity for attribution + gating, fixing the view-as wart],
  404 on nonexistent/cross-tenant ids, an audit row per mutation);
  `routers/comments.py` (`GET/POST /api/comments`, `POST
  /api/comments/{id}/resolve|reopen`, `DELETE /api/comments/{id}`).
  Frontend: `components/CommentsBell.tsx` (Open/Resolved tabs over the
  newest-50 window, cosmetic `@mention` highlighting, timeAgo buckets,
  confirm-guarded delete) wired into the AppShell header; pure display
  logic in `lib/comments.ts`. Tests: +15 pytest (`tests/comments`,
  06-test-plan §7.3 T-COM-101…115 incl. the B9 pair + server-side
  authorship), +9 vitest (T-COM-107 mentions, T-COM-114 timeAgo,
  partition/empty-state/resolver copy). No schema change (M2's
  `comments` table sufficed). ADR #008.B.
- **Team & engagement admin (slice C).** Rebuilt the legacy
  `team_members` CRUD as the ADMIN → Team page over parties +
  engagements + compensation_terms. Backend: `services/team_admin.py`
  (list workers with current cost/billout terms + entry counts; add
  worker = person party [reused by email, never duplicated] + subcontract
  engagement + terms; **append-only rate history** — a rate change ends
  the current term [`effective_to` = change − 1 day] and opens a new one
  effective today, existing entry stamps NEVER touched [re-stamp is the
  explicit /entries action]; deactivate = end engagement [history kept,
  worker stops resolving, Gary's 0/25 representable]; project billout
  overrides moved onto this page [worker-specific > project-wide, rate
  XOR pct]; `consolidate_as` label on the engagement), `routers/team.py`
  (`GET /api/team`, worker POST/PATCH + deactivate/reactivate, rate
  override create/delete). Gate: `can_manage_team` [owner/super-admin —
  the EFFECTIVE rule, 02 §4]; billout fields gated on `can_transfer`.
  Frontend: `features/team/TeamPage.tsx` (roster DataTable, add/edit
  worker forms, deactivate, EntityPicker-driven override editor) at
  `/team`, ADMIN → Team nav link live. Tests: +18 pytest
  (`tests/team_admin`, T-TEAM rows + the append-only GATE proven on a
  clone of the REAL migrated worker Adi: new term row, old `effective_to`,
  byte-unchanged stamps, next import at the new rate). No schema change
  (M2's engagements/terms/rate_overrides sufficed). ADR #008.C.

## 0.7.0 — 2026-07-08

M6 — Invoices + locks (the money milestone). Backend:
services/money/invoices.py (THE invoice service — create with
first-crossing sent_at/paid_at stamps + last-day-of-previous-month
date default + created_by = the REAL human; PATCH-style update w/ B66
empty-date-writes-NULL parity; GUARDED delete [detach first — the
legacy audit-wiping detach-on-delete does not port]; apply on the M5
bulk engine [same-filter scope, expected_count safety stop, ONE
eligibility predicate shared with the display, skip-and-count
already-applied/blocked, paid-invoice + foreign-currency refusals, one
transaction, verified counts, complete audit before-images]; detach
[EXPLICIT selection only, per-invoice scoping, line images in the
audit]; scope()-transformed SQL aggregates so partial visibility =
partial sums), services/money/paste_block.py (the PlusROI paste block
— byte-format pinned; 'Bowden Works Team'/'Bowden Works'/'Labour' are
now TENANT SETTINGS with BW defaults). Each apply creates a time-kind
invoice_line SNAPSHOTTING client/project/description/hours/amount as
of attach (judgment #9) and stamps invoice_line_id — THE lock; undo
for lock ops is the inverse operation (ADR #007). Routers:
/api/invoices CRUD + detail (breakdown over snapshots + capped lines
table) + /paste-block; /api/entries/bulk/apply-to-invoice +
/api/entries/bulk/detach. Frontend: BILLING→Invoices list (status
pills, margin ± w/ pct, manual-override ★ note, inline create w/
sensible defaults), invoice detail (stats, breakdown, lines, status
transitions, guarded delete, paste block w/ pessimistic verified copy),
bulk bar gains Apply-to-invoice (open/sent picker newest-first +
create-new-inline) and Detach (explicit-only), ?invoice= filter chip.
GATES (in-tests, on a commit-safe clone of the REAL migrated data):
paste block byte-identical to the legacy lib/invoices.ts via tsx for
ALL 3 real invoices (incl. the judgment-#22 straggler rule on 'Legacy
transfers'); full lifecycle create→apply 5 real pending→locked
(edit 409, re-stamp skips, bulk excludes)→paste→detach→delete with the
diff-harness green after the cycle. pytest 515, vitest 61. No schema
change (M2's invoices/invoice_lines carried unmodified).

## 0.6.0 — 2026-07-08

M5 — Entries editing + the bulk-mutation safety architecture. Backend:
services/entries_write.py (field-level PATCH with the FK-model entity
cascade — project_id XOR full trio, explicit unlink replaces the
silent-null wart; manual entry into the per-(tenant,user) "Manual
entries" auto-batch; tenant-wide re-resolve that clears ALL stamps;
delete w/ undo token), services/money/restamp.py (the ONE explicit
re-stamp orchestrator — worker/project/duration changes recompute from
current terms + overrides, NULL-never-0), services/bulk.py (judgment
#11 made structural: mutation scope derives from the SAME
scope()+apply_filters builder as the display; expected_count REQUIRED
in both selection modes with the exact "Safety stop:" abort BEFORE any
write; one transaction; verified counts matched/changed/skipped_locked;
bulk edit / recalculate [locked skipped for everyone] / delete),
services/audit.py (every mutation writes actor+impersonated_by with
COMPLETE before-images; undo = replay-from-audit-row, once, skipping
since-locked rows, re-inserting deleted rows verbatim),
services/entities/manage.py (rename with no propagation layer — FK
display; move; first-class MERGE at all three levels per judgment #10
with recursive same-name child merges [the Palms incident],
transactional, invoice-snapshot-safe, husk parties retired + invoices
re-pointed; delete-empty). Routers: PATCH/POST/DELETE /api/entries[…],
/api/entries/bulk/{edit,recalculate,delete}, /api/entries/undo/{id},
/api/entries/edit-options, /api/entities/* (manage payload with true
aggregate counts). Frontend: cell-level editing as the primary
interaction (click cell → EntityPicker/inputs, create-in-place,
Tab/arrows/Esc, re-stamp flash on money cells, locked-row 🔒), the
bulk bar (select-all-matching, count-confirm quoting exact counts,
pessimistic progress, verified-count toast with Undo), side-panel
full-row editor (re-resolve + delete w/ undo toast), "+ New entry"
strip, /projects/manage (rename/move/merge/delete w/ searchable
pickers), Toast host, EntityPicker + selection/cellEdit state machines
(vitest-pinned). Tests: +104 pytest (entries_write 48, entities_manage
18, bulk safety 24 incl. THE GATE — the 3,274-row incident replay on
migrated data with harness-compare GREEN after undo, and the
same-filter-scope property on the REAL 4,593-row sidecar read-only —
plus 11 write-API contracts, minor fixture additions), +18 vitest.
No new alembic revision (audit_log from M2 carries undo). NOT yet
deployed — the orchestrator deploys after review.

## 0.5.0 — 2026-07-08

M4 — The import pipeline: Clockify CSV parse → stage → resolve →
commit, end-to-end. Backend: services/imports/clockify.py (the
faithful lib/clockify.ts port — 44-fixture CLK suite verbatim incl.
the tightest-span date detector and AM/PM auto-detect; Toggl dialect
retired per judgment #8; invalid-END rows now surfaced), staging
(pending_imports, alembic 0004 — jsonb payload, effective-user
attribution, ENFORCED 24h expiry), resolver (chained case-insensitive
known-ness + incomplete buckets + entity catalog), commit (bucket
chains first → operators → clients → projects via the ONE
services/entities resolve-or-create; 500-row chunked insert; ONE
transaction — a failed chunk rolls back entities+batch+entries and
the staging row survives for retry), and services/money/stamping.py —
THE stamping site (terms in force at write, project-scoped > default,
rate_overrides worker-specific > project-wide > term, pct-on-default,
NULL = never priced / 0.00 = priced-at-zero per judgment #19, CAD
currency stamped as an (amount, currency) pair). Batch management:
scope()-filtered list with one grouped count query, post-import
matched/unmatched summary with unknown emails, delete guard re-keyed
to invoice attachment with the real count. Routers: POST /api/import
(multipart), GET/POST/DELETE /api/import/pending/{id}[/commit],
GET/DELETE /api/import/batches[/{id}] + /{id}/summary. Frontend:
/import (upload panel — no format pickers, auto-detect messaging;
batch table with Locked/delete-confirm-with-real-count; green
import-complete card) and /import/resolve (parse-issues panel, ONE
known-entities accordion, map-first inline radios with live
effectiveClientId candidate scoping, prominent bucket identity lines
with datalist project combobox + prefilled create grid +
de-emphasized skip, Importing…/Cancelling… pending states); TIME →
Import nav live. Cross-validation harness: scripts/
cross_validate_csv.py diffs the Python port field-by-field against
the actual TS parser (via the old app's tsx) — green on edge-case
CSVs; harness/csv-archive/ convention documented for the weekly
shadow uploads. 112 new tests (pytest 357 total, vitest 36).
Deviations: ADR #005; docs/features/imports.md; ACCEPTANCE §M4.

## 0.4.0 — 2026-07-07

M3 — The read-only mirror: /entries, /summary, and /projects over the
migrated real data, with the authz engine underneath. Backend:
services/authz (capability truth tables as data per the R1 parity grid;
resolve_actor from the engagement graph; can() with reason chains;
scope() as the one Select transformer — importer-scoped for
managers/workers, tenant-wide for owner/super-admin; app-layer view-as
with audit rows on enter/exit) and services/reporting (the §1 filter
contract as ONE composable builder + ONE canonical status predicate;
entries list/totals/options, summary_by_project with the month-window
math, project rollup + per-description expansion — all aggregation in
SQL, per-currency money maps asserting single-currency). Routers:
/api/entries[/totals|/options], /api/summary, /api/projects[+
descriptions], /api/view-as, extended /api/me (capabilities + view-as
state). Money is shaped OUT of non-owner payloads (absent, not
UI-hidden). Frontend: the three pages with URL-addressable filters,
date presets, cascading pivot dropdowns, quick chips, the whole-filter
summary card, $X.XX (h:mm) stacked owner cells, hide-null-rows, 140h
target tracker, expandable project rows, and the view-as switcher +
amber banner. Equivalence: reporting output matches the M2 harness
baseline (e2e-new.json) across ALL 8 months, cent-exact. ~150 new
tests (pytest 250 total, vitest 31).

## 0.3.0 — 2026-07-07

M2 — Schema + core migration transforms: full R1 schema (alembic 0003 —
parties / tenants / engagements / compensation_terms kind-model /
rate_overrides / projects / import_batches / time_entries / invoices +
lines / cc_* / comments / audit_log / migration_runs; users.person_party_id
FK completed; no sets/tasks per judgment #32). Idempotent legacy→R1
transforms (app/services/migrate + scripts/migrate_legacy.py) with full
reconciliation (every legacy table AND column dispositioned), all-CAD
currency stamping, verbatim money stamps, and the judgment-#22 straggler
rule — which fired on real data (1 entry, pinned in the harness allowlist
for owner review). New-side harness emitter (harness/extract_new.py;
shared builders in harness/common.py). The nightly job is now the FULL
rehearsal (snapshot→scrub→legacy→transform→extract both→compare) and also
fires at boot when the sidecar has no legacy schema (or
RUN_SNAPSHOT_ON_START=1). Host-side legacy mirror
(scripts/sync_legacy_host.py, COPY-based — host pg_dump is v16). Proven
end-to-end against live data: 4,593/4,593 entries accounted, compare
GREEN across all history, second run byte-identical. 43 new tests
(+ the 4 existing harness tests folded into the pytest gate).

## 0.2.0 — 2026-07-07

M1 — Sign in with BW (Pattern B): OAuth2 code+PKCE against
auth.bowden.works, audience-bound id_token verification, default-deny
middleware (allowlist: /healthz, /auth/*, /assets/*), 12h signed app
session with silent re-establishment, zero-capability user
auto-provisioning (users table, alembic 0002), /api/me + header user
menu + sign-out, AUTH_DEV_USER dev stub that refuses production.
Also fixes the SPA reading the wrong version header (X-App-Version →
X-With-Version) so the skew banner can actually fire.

## 0.1.0 — 2026-07-07

M0 walking skeleton: FastAPI+SPA scaffold, Postgres 17 sidecar, design
tokens + shell + first primitives, diff-harness old side, nightly
snapshot job.
