# Test plan for the rebuild

The complete test catalog for the rebuilt system — rian's standing
requirement: "a running log of unit tests that can be easily
transferred into the new versions of the app."

Sources, in precedence order: the "## Test specs" sections of every
`01-current-system/features/*.md`, `01-current-system/tests.md` (the
verified inventory of the current 116 vitest tests),
`01-current-system/rpcs-and-authz.md` (RPC semantics + truth tables),
and `04-architecture.md` (where each kind of test lives). This doc is
the union: every test here either ports an existing green test, turns
a specified-but-never-automated behavior into a test, or pins a
rebuild-only requirement.

## 0. Test tiers — what gates cutover (added 2026-07-07, plan critique)

Every test carries an implicit tier; the milestone gates in
09-build-plan reference tier A only:

- **Tier A — cutover-gating:** money stamping/locks (§3), the authz
  parity grids (§4.1–4.2), the import pipeline ports (§2), migration
  + diff-harness checks (§10), bulk-mutation safety (§7.2),
  RPC-equivalence (§6), the currency stamps/constraints subset of
  §3.1b (T-CUR-001/004/005/007, T-MIG-015).
- **Tier B — build-with-feature:** written in the milestone that
  ships the surface (router contract tests, frontend logic for
  shipped screens, E2E smokes as flows land).
- **Tier C — post-cutover backfill:** pixel/interaction detail tests
  (§8.3 visual states, tooltip variants, mention highlighting),
  R2-scenario tests (T-CFG-001 second-tenant config, T-AZ-003 reason
  chains — their consumer is the R2 debug view), mixed-currency
  RENDERING tests (T-CUR-002/003/006 — no second currency exists in
  R1), T-RPT-006's EXPLAIN budget (replaced by the M8 perf gate).

The parity promise is carried by tier A + the harness; tiering cuts
weeks off the critical path without touching it.

## 1. Runners, layout, and conventions

| Runner | Scope | Location |
|---|---|---|
| **pytest** (+httpx) | services/* domain logic, routers/API contract, authz grids | `tests/` mirroring `app/services/` 1:1 (04-architecture) |
| **Vitest** | frontend logic: filter serialization, formatting, table interaction hooks, component state | `frontend/src/**/*.test.ts` |
| **Playwright** | 3 critical end-to-end flows, behavior assertions not render assertions | `e2e/` |
| **diff harness** | migration/parity oracle vs the live app (R0, built FIRST) | its own script pair (05-migration-plan step 0) |

Rules carried from `tests.md` / `04-architecture.md`:

- **Fixture language**: rebuilt fixtures use the real business graph
  (Adi, Brentwood, PlusROI, Gary) via slices of `scripts/seed.py` — not
  user1/project1. The current suite's anonymous fixtures
  (`dev@example.com`, 'Test Client') are re-expressed on the seed
  slices during the port.
- **Clock pinning**: any test depending on "now" pins the clock
  (current suite: `vi.setSystemTime('2026-05-15T12:00:00Z')`, re-pinned
  to `2026-01-15T12:00:00Z` for year-rollover cases). pytest ports use
  `freezegun` or an injected clock — same pinned instants.
- **Money is Decimal**: Python money math uses `decimal.Decimal` with
  `ROUND_HALF_UP` (Postgres `round(numeric)` is half-away-from-zero;
  float/banker's rounding will fail the CLK-023 / restamp equivalence
  tests).
- **CI from R0**: pytest + vitest + tsc on every change
  (00-vision-and-scope).
- **Quirks that must NOT be "fixed" silently** (tests.md): `' : '`
  space-colon-space separator split on FIRST occurrence (strict —
  `'Foo:Bar'` does not split); naive local `'YYYY-MM-DD HH:MM:SS'`
  timestamps; half-open `[start, end)` ranges with inclusive-end-day
  for custom ranges; em-dash `'—'` for null display; 2dp rounding at
  computation time for `computeBilloutAmount` and at format time
  elsewhere; the `detectDateFormat` tightest-span heuristic and its
  "don't guess" null.

### ID conventions and renames

- IDs here are canonical for the rebuild. `Source` column legend:
  - **port \<id\>** — existing green vitest test, mechanical port.
  - **port\* \<id\>** — port with deliberately rewritten semantics
    (rebuild target differs from current behavior; the delta is stated).
  - **spec \<doc\>** — specified in a feature doc or tests-catalog
    (🟡, never automated); implemented fresh.
  - **spec\* \<doc\>** — specified behavior implemented fresh, but with
    deliberately rewritten semantics vs the spec (the delta is stated
    in the row — spec's analogue of port\*).
  - **new** — rebuild-only behavior with no current-app equivalent.
- **Collision resolutions** (do not reuse the old IDs):
  - `clockify.test.ts` reused CLK-021 and CLK-022 twice → renumbered
    `T-CLK-021a/021b` and `T-CLK-022a/022b` (per imports.md).
  - The un-ID'd `parseDurationSeconds` garbage test → `T-CLK-013b`.
  - `projects-and-entities.md` used `T-ENT-001…015` while `entries.md`
    used `T-ENT-01…44` — zero-padding was the only difference. The
    entity-management specs are renamed **`T-ENTITY-001…015`** here;
    `T-ENT-*` now refers exclusively to entries.md's specs.
  - `summary.md` defined its own `T-FLT-001…005` overlapping the
    `filters.test.ts` FLT-001…010 ports → renamed **`T-SFLT-001…005`**.
- **Dropped, not ported** (phase-outs; kept out of this catalog):
  TGL-001…008, TGL-013…017 (Toggl CSV format), CLK-019
  (consolidate_as), CLK-020 (rate_proportion), TRN-001…003,
  ENT-005/006/007 (mark/unmark transferred), TEAM-003/004 (base-rate ×
  proportion), AUTH-001/002 and most T-AUTH-001…015 (Supabase password
  auth; survivors are rewritten below), RBAC-001 (stale — contradicts
  the permissions matrix; retired per tests.md), RBAC-006 (`/transfer`
  route no longer exists — superseded by T-PHX-002), T-VAS-02/03 (JWT
  minting mechanics — superseded by T-AZ-030/031).

---

## 2. services/imports — the import pipelines

### 2.1 Clockify CSV parser (pytest; the CLK-* fixture port)

Functions to reimplement with identical semantics: `parse_date_only`,
`parse_time_only` (fmt arg ignored; AM/PM auto-detect), `detect_date_format`
(tightest-span heuristic), `format_date_range_label`,
`parse_duration_seconds`, `parse_clockify_csv` (17-column Clockify
header `Project,Client,Description,Task,User,Group,Email,Tags,Billable,Start Date,Start Time,End Date,End Time,Duration (h),Duration (decimal),Billable Rate (USD),Billable Amount (USD)`),
`split_client_project`. Port the fixtures verbatim.

| ID | Given / when / then | Source |
|---|---|---|
| T-CLK-001 | Given `'2026-05-20'`/`'2026-1-5'` + fmt `YYYY-MM-DD` / parsed / zero-padded ISO out | port CLK-001 |
| T-CLK-002 | Given `'5/20/2026'` + `MM/DD/YYYY` / then `'2026-05-20'` | port CLK-002 |
| T-CLK-003 | Given `'2/12/2025'` / then result differs by fmt: DD/MM→`'2025-12-02'`, MM/DD→`'2025-02-12'` | port CLK-003 |
| T-CLK-004 | Given `'not a date'` or `''` / then `None` | port CLK-004 |
| T-CLK-005 | Given month>12 or day out of range (`'2026-13-01'`, `'2026-02-32'`) / then `None` | port CLK-005 |
| T-CLK-006 | Given 2-digit years `'5/20/26'` / then interpreted 20xx | port CLK-006 |
| T-CLK-007 | Given 24h strings (`'13:45:30'`, `'0:00:00'`, `'9:05'`) / then normalized `HH:MM:SS`, missing seconds → `:00` | port CLK-007 |
| T-CLK-008 | Given 12h with AM/PM (case-insensitive) / then 24h (`'1:45:30 PM'`→`'13:45:30'`) | port CLK-008 |
| T-CLK-009 | Given `12:xx AM` → hour `00`; `12:xx PM` → hour `12` | port CLK-009 |
| T-CLK-010 | Given `'25:00:00'`, `''`, `'13:45:30 PM'` (hour 13 + PM) / then `None` | port CLK-010 |
| T-CLK-026 | Given a 24h string / then identical result for fmt `'12h'`/`'24h'`/omitted (fmt ignored) | port CLK-026 |
| T-CLK-027 | Given a 12h PM string / then identical result regardless of fmt arg | port CLK-027 |
| T-CLK-028 | Given `'1:45:30'` no suffix / then 24h `'01:45:30'`, not None (regression) | port CLK-028 |
| T-CLK-011 | Given `'1:00:00'`/`'0:01:30'`/`'5:20:36'` / then exact seconds | port CLK-011 |
| T-CLK-012 | Given `'1:30'`/`'0:30'` (HH:MM) / then minutes×60 | port CLK-012 |
| T-CLK-013 | Given decimal hours `'1.5'`/`'0.25'`/`'5.34'` / then `round(h × 3600)` | port CLK-013 |
| T-CLK-013b | Given `''` or `'forever'` / then `None` | port (un-ID'd test) |
| T-CLK-029 | Given samples with 4-digit-year prefix / then `'YYYY-MM-DD'` | port CLK-029 |
| T-CLK-030 | Given ANY first slot >12 (`'25/05/2026'`) / then `'DD/MM/YYYY'` | port CLK-030 |
| T-CLK-031 | Given ANY second slot >12, no DD signal / then `'MM/DD/YYYY'` | port CLK-031 |
| T-CLK-032 | Given `[]` or all-gibberish / then `None` | port CLK-032 |
| T-CLK-032b | Given ambiguous week `01/06..08/06/2026` / then tightest-span picks DD/MM (2026-06-09 incident regression — fixtures verbatim) | port CLK-032b |
| T-CLK-032c | Given US export `06/01..06/08/2026` / then MM/DD (June 1–8, not Jan–Aug) | port CLK-032c |
| T-CLK-032d | Given `['1/2/2026','2/1/2026']` equal spans / then `None` (don't guess) | port CLK-032d |
| T-CLK-033 | Given blanks/gibberish + one real `'25/05/2026'` / then unparseables ignored → DD/MM | port CLK-033 |
| T-CLK-034 | Given mixed ISO + numeric rows / then first unambiguous signal wins, no crash | port CLK-034 |
| T-CLK-040 | Given `{min:'2026-06-02', max:'2026-06-08'}` / then `'Jun 2 – Jun 8, 2026'` | port CLK-040 |
| T-CLK-041 | Given cross-month same-year / then `'May 28 – Jun 3, 2026'` | port CLK-041 |
| T-CLK-042 | Given cross-year / then `'Dec 28, 2025 – Jan 3, 2026'` | port CLK-042 |
| T-CLK-043 | Given min == max / then `'Jun 2, 2026'` | port CLK-043 |
| T-CLK-044 | Given `None` / then `None` | port CLK-044 |
| T-CLK-014 | Given header + 2 rows / then 2 entries, 0 errors; naive `start_at`/`end_at`; `billable` true from `'Yes'`; CSV Client→`operator`, CSV Project split | port CLK-014 |
| T-CLK-015 | Given CSV missing required headers / then 0 entries, error matching `missing required columns` naming the keys | port CLK-015 |
| T-CLK-016 | Given duplicate emails + scattered dates / then deduped `unique_emails` and `date_range` min/max | port CLK-016 |
| T-CLK-017 | Given `'Mixed.Case@EXAMPLE.com'` / then `source_user_email` lowercased | port CLK-017 |
| T-CLK-021a | Given `'Foo : Bar : Baz'` / then split on FIRST `' : '` → client `'Foo'`, project `'Bar : Baz'`; `'NoColon'`/`'Foo:Bar'` → client None | port CLK-021 (1st use) |
| T-CLK-022a | Given rows `'DaxTech : Website'` and `'PromptVictoria'` / then csv-level split; colon-less → client None | port CLK-022 (1st use) |
| T-CLK-018 | Given a worker email matching no active engagement / then the entry stays unresolved (worker_party_id NULL, no stamps) | port\* CLK-018 (converted_* fields dropped; the "unknown email = unresolved" semantic survives via engagement resolution) |

### 2.2 Import pipeline — stage → resolve → commit (pytest)

| ID | Given / when / then | Source |
|---|---|---|
| T-IMP-001 | Given a valid Clockify CSV / when upload→resolve→commit / then one batch + N time_entries rows | spec IMP-001 (🟡) |
| T-IMP-002 | Given rows with unknown emails / when committed / then those rows unresolved (NULL worker/stamps) and post-import summary lists the distinct emails | port\* IMP-002 (team_member → engagement resolution) |
| T-IMP-003 | Given a batch with ≥1 **invoiced** entry / when delete attempted / then server refuses with the count in the message (UI state is not the guard) | port\* IMP-003 ("transferred" lock → invoiced lock) |
| T-IMP-004 | Given no/empty file / when upload / then "Please choose a CSV file to upload." | spec imports.md |
| T-IMP-005 | Given a 26 MB file / then "File too large (25MB max)." | spec imports.md |
| T-IMP-006 | Given actor without manage-imports capability / then upload, commit, and delete all refuse | spec imports.md |
| T-IMP-007 | Given importer with no team/engagement scope / when upload / then fail-fast before staging | spec imports.md (covers T-TEAM-16) |
| T-IMP-008 | Given blank batch name + parseable date range / then auto-name via date-range label; no range → filename sans extension; typed name wins | spec imports.md |
| T-IMP-009 | Given a Clockify-only header set / then required-header validation passes; **no Toggl alias mapping exists** | port\* IMP-009 (Toggl dialect retired, judgment call #8) |
| T-IMP-010 | Given billable column `yes`/`TRUE`/`1`/`y`/`no` / then true×4, false | spec imports.md |
| T-IMP-011 | Given invalid START date/time / then row skipped + error `Row <n>: invalid start date/time (…)`; invalid END / then end null, row kept, **error surfaced to the user** | port\* imports.md (current app swallows end errors — documented wart, rebuild surfaces both) |
| T-IMP-012 | Given upload succeeds / then a staging row holds the parsed entries + `uploaded_by = effective user`, and the client is routed to the resolver for that staging id | spec imports.md (mechanics re-expressed for FastAPI/SPA) |
| T-IMP-013 | Given resolver with all names known / then "all names exist" state and single-step commit; with unknowns → decisions required | spec imports.md |
| T-IMP-014 | Given an unknown client mapped to an existing client / then project pickers immediately scope to that client's projects | spec imports.md |
| T-IMP-015 | Given a mapped decision whose target is under a different parent / when commit / then refused, nothing inserted | spec imports.md |
| T-IMP-016 | Given a stale form missing a decision for an unknown name / then per-level "No decision provided…" error | spec imports.md |
| T-IMP-017 | Given create decisions / then entities inserted with raw CSV names, chained org→operator→client→project, entries stamped with the new project_id | spec imports.md |
| T-IMP-018 | Given a CSV typo mapped to an existing entity / then entries display the CANONICAL entity name, not the CSV spelling | spec imports.md (FK display makes this structural) |
| T-IMP-019 | Given an incomplete bucket resolved by map / then all bucket rows get the target project + chain; by create → chain created; by skip → unresolved, Blocked on /entries | spec imports.md |
| T-IMP-020 | Given incomplete-bucket "map" with no selection / then treated as skip, no error | spec imports.md |
| T-IMP-021 | Given incomplete-bucket "create" with a blank field / then `Create-new requires operator + client + project for the bucket "<key>".` | spec imports.md |
| T-IMP-022 | Given a matched worker / then cost stamp = round2(source hours × cost term) and billout = round2(source hours × effective billout rate); project-scoped term wins over default | spec imports.md (the write-path stamping contract; pairs with §3.1) |
| T-IMP-023 | Given >500 parsed rows / then chunked inserts; a failing chunk rolls back entries + batch, staging row survives, error names the failed row range | spec imports.md |
| T-IMP-024 | Given successful commit / then staging row deleted, matched/unmatched summary returned | spec imports.md |
| T-IMP-025 | Given Cancel on the resolver / then staging row deleted, no batch/entries created | spec imports.md |
| T-IMP-026 | Given a manager viewing another user's pending import / then not found; an owner can resolve anyone's | spec imports.md (scope() replaces RLS) |
| T-IMP-027 | Given view-as / then upload/commit attribute to the EFFECTIVE user; batch list scoped to them; out-of-scope batch delete refused | spec imports.md |
| T-IMP-028 | Given batch delete allowed / then entries and batch removed with no orphans (rebuild: FK/transaction, not delete-order convention) | port\* imports.md |
| T-IMP-029 | Given commit / then batch `row_count` = parsed-entry count, not raw CSV line count | spec imports.md |
| T-IMP-030 | Given a fully-ambiguous weekly CSV, true format DD/MM, form default MM/DD / then all entries land in one month (detector overrides selector) — the "26-01-08" incident | spec imports.md (regression) |
| T-IMP-031 | Given a staging row older than the expiry window / then it is purged/unusable (expiry actually enforced) | new (rpcs-and-authz §8 delta 10) |

### 2.3 CC-expense paste pipeline (pytest)

Parsing/matching runs server-side; exports too. Visual row-state tests
are in §7 (frontend).

| ID | Given / when / then | Source |
|---|---|---|
| T-CCE-D-001 | Given `'May 20, 2026'` / then `'2026-05-20'` | port CCE-D-001 |
| T-CCE-D-002 | Given ISO `'2026-05-20'` / then passthrough | port CCE-D-002 |
| T-CCE-D-003 | Given `'mAy 20 2026'` (mixed case, no comma) / then parses | port CCE-D-003 |
| T-CCE-D-004 | Given `'nonsense'`/`''`/`'Foobar 20, 2026'` / then None | port CCE-D-004 |
| T-CCE-D-005 | Given `'Jan 5, 2026'` / then `'2026-01-05'` (zero-padded) | port CCE-D-005 |
| T-CCE-D-006 | Given `'Sept. 3, 2026'` (abbrev + period) / then `'2026-09-03'` | spec cc-expenses.md |
| T-CCE-D-007 | Given `'Feb 31, 2026'` / then **None** (rebuild adds calendar validation; current app returns `'2026-02-31'` — documented wart) | port\* cc-expenses.md |
| T-CCE-C-001 | Given `'$3,856.56'` / then 3856.56 ($ and commas stripped) | port CCE-C-001 |
| T-CCE-C-002 | Given `''`/whitespace/`'abc'` / then None | port CCE-C-002 |
| T-CCE-C-003 | Given `'7.155'` / then 7.16 (2dp) | port CCE-C-003 |
| T-CCE-L-001 | Given a 5-column tab line (date⇥desc⇥debit⇥credit⇥balance, trailing tab OK) / then all fields parse | port CCE-L-001 |
| T-CCE-L-002 | Given blank debit + credit `'$25.00'` / then amount = 25.00 | port CCE-L-002 |
| T-CCE-L-003 | Given empty/whitespace line / then None | port CCE-L-003 |
| T-CCE-L-004 | Given any non-blank line / then verbatim `raw_line` preserved (audit) | port CCE-L-004 |
| T-CCE-L-005 | Given unparseable date, valid amount / then date None, other fields kept | port CCE-L-005 |
| T-CCE-L-006 | Given both debit and credit set / then debit wins | spec cc-expenses.md |
| T-CCE-P-001 | Given multi-line paste with blanks / then blanks skipped, order preserved | port CCE-P-001 |
| T-CCE-P-002 | Given CRLF endings / then identical to LF | port CCE-P-002 |
| T-CCE-H-001 | Given description containing a keyword / then that keyword returned | port CCE-H-001 |
| T-CCE-H-002 | Given case-mismatched keyword / then hit | port CCE-H-002 |
| T-CCE-H-003 | Given no match / null / empty description / then None | port CCE-H-003 |
| T-CCE-H-004 | Given multi-match / then first-in-list wins (rebuild: normalize ordering with auto-rules — extraction flag #6) | port CCE-H-004 |
| T-CCE-H-005 | Given `keyword: ''` in the list / then skipped | port CCE-H-005 |
| T-CCE-R-001 | Given a matching rule / then whole rule returned (project_id, assignment_description, category) | port CCE-R-001 |
| T-CCE-R-002 | Given case-mismatched keyword / then hit | port CCE-R-002 |
| T-CCE-R-003 | Given no matching rule / then None | port CCE-R-003 |
| T-CCE-R-004 | Given two matching rules / then first in caller's order wins (call sites pass created_at DESC — newest wins) | port CCE-R-004 |
| T-CCE-R-005 | Given null/empty description / then None | port CCE-R-005 |
| T-CCE-X-001 | Given `[]` / then `''` | port CCE-X-001 |
| T-CCE-X-002 | Given one assigned line / then 7 tab-separated columns: `'{client} : {project}'`, assignment_description, amount 2dp, date, `''`, tenant name, category | port\* CCE-X-002 (`'Bowden Works'` literal → tenant setting, 04-architecture) |
| T-CCE-X-003 | Given amount 750 / then `'750.00'` — 2dp, never `$` | port CCE-X-003 |
| T-CCE-X-004 | Given two lines / then joined `\n`, no trailing newline | port CCE-X-004 |
| T-CCE-A-001 | Given a 3-line batch / when a 2-line second paste / then line_index continues 3,4; display order = paste order | spec CCE-A-001 (🟡) |
| T-CCE-A-002 | Given two rows / when one Saved / then only that row changes | spec CCE-A-002 (🟡) |
| T-CCE-A-003 | Given a batch with lines / when batch deleted / then lines cascade | spec CCE-A-003 (🟡) |
| T-CCE-A-004 | Given a rule matching a pasted line / then line inserts pre-filled, auto_assigned=true, flash "Added N lines (K auto-assigned)." | spec CCE-A-004 (🟡) |
| T-CCE-A-005 | Given existing lines + newly created rule / then existing lines untouched (rules fire at paste/Re-apply only) | spec CCE-A-005 (🟡) |
| T-CCE-A-006 | Given unassigned lines + matching rule / when reapplyRules / then filled with auto_assigned=true, flash "Re-applied rules: N lines auto-assigned." | spec cc-expenses.md |
| T-CCE-A-007 | Given all lines assigned / when reapplyRules / then "No unassigned lines to match.", no change | spec cc-expenses.md |
| T-CCE-A-008 | Given zero rules / when reapplyRules / then "No auto-assign rules to apply." | spec cc-expenses.md |
| T-CCE-A-009 | Given an auto-assigned line / when reapplyRules again / then untouched (project already set) | spec cc-expenses.md |
| T-CCE-A-010 | Given an auto-assigned line / when updated (even unchanged values) / then auto_assigned=false | spec cc-expenses.md |
| T-CCE-A-011 | Given only blank/whitespace lines / then "No usable lines found in the paste." | spec cc-expenses.md |
| T-CCE-A-012 | Given empty paste field / then "Paste some lines first." | spec cc-expenses.md |
| T-CCE-A-013 | Given a duplicate keyword differing only in case / then unique-violation maps to the friendly "already exists" message | spec cc-expenses.md |
| T-CCE-A-014 | Given a project from another tenant / when createAutoRule / then "Selected project not found in this org." | spec cc-expenses.md |
| T-CCE-A-015 | Given a non-owner (incl. view-as) / then every cc-expenses page/action refuses owner-only | spec cc-expenses.md |
| T-CCE-B-002 | Given lines missing date/amount/project / when paste block builds / then excluded; remaining lines export | spec cc-expenses.md |

---

## 3. services/money — stamping, re-stamp, adjustments, invoice locks

### 3.1 Stamping math (lock-at-write; 02-domain-model §5 invariant 1)

| ID | Given / when / then | Source |
|---|---|---|
| T-CLK-022b | Given None seconds or None rate / when billout amount computed / then None | port CLK-022 (2nd use) |
| T-CLK-023 | Given (3600, 25)→25.00; (1800, 25)→12.50; (60, 25)→0.42 / then amount = seconds/3600 × rate, 2dp at computation time (input = SOURCE seconds in the rebuild) | port CLK-023 |
| T-CLK-021b | Given a worker with a billout compensation term / when an entry is written / then the term's rate in force is what stamps the entry | port\* CLK-021 (2nd use — rate source moves from team_members to compensation_terms) |
| T-LOCK-001 | Given an entry stamped at import / when the worker's rate later changes / then the stamps do NOT change | spec LOCK-001 (🟡; load-bearing invariant) |
| T-LOCK-002 | Given a duration edit / then stamps recompute at the CURRENT rates (explicit re-stamp on edit) | spec LOCK-002 (🟡) |
| T-LOCK-003 | Given a re-resolve to a different worker / then stamps recompute from that worker's current terms | spec\* LOCK-003 (🟡; minus proportion, ADR #034) |
| T-TEAM-02 | Given a member/engagement rate change / then historical entries' stamps unchanged (only future writes / explicit re-stamp) | spec TEAM-002 |
| T-ADJ-022 | Given a rate change via the team/engagement page / then ZERO entries are re-stamped | spec adjustments-and-rates.md |
| T-TEAM-07 | Given blank cost rate / then NULL stored; subsequent imports stamp NULL cost (margin shows cost-unknown) | spec organizations-and-team.md |
| T-TEAM-08 | Given Gary (cost 0, billout 25) / when 10h import / then cost = 0.00 (not NULL), billout = 250.00 — zero-cost billable worker | spec organizations-and-team.md (= T-ADJ-024; judgment call #12 "tested explicitly") |
| T-MNY-001 | Given no applicable compensation term / then stamp is **NULL** on EVERY path incl. re-stamps ("NULL = never priced, 0 = priced at zero") | new (judgment call #19; replaces the restamp `coalesce(rate,0)` divergence in rpcs §2.6/2.7) |

### 3.1b Currency (02 §3 "Currency"; judgment call #21)

| ID | Given / when / then | Source |
|---|---|---|
| T-CUR-001 | Given a compensation term in CAD / when an entry stamps / then cost_currency (or billout_currency) = 'CAD' alongside the amount — currency is part of the locked stamp | new |
| T-CUR-002 | Given an entry whose cost term is USD and billout term is CAD / then both stamps carry their own currency; nothing errors | new |
| T-CUR-003 | Given entries in USD and CAD under one filter / when totals computed / then per-currency subtotals returned; the aggregate helpers cannot produce a merged single number (type-level: per-currency map) | new |
| T-CUR-004 | Given an invoice in CAD / when attaching an entry whose billout_currency is USD / then rejected with a structured error — invoices are single-currency documents | new |
| T-CUR-005 | Given a CC batch in CAD / then every line inherits the batch currency; paste-block export shows amounts in the batch currency only | new |
| T-CUR-006 | Given income in CAD and cost in CAD / then margin computes; given income CAD but cost USD / then margin renders both figures with codes and NO computed margin number | new |
| T-CUR-007 | Given a re-stamp / then currency re-stamps together with the amount from the term in force — an amount never changes currency without its amount source changing | new |
| T-MIG-015 | Given the declared historical currency mapping / when migration runs / then every money row carries the declared currency; reconciliation report shows per-currency row counts; harness totals compare per currency | new (05 step 2.6) |

### 3.2 Effective billout rate resolution (rpcs-and-authz §3.8 semantics)

| ID | Given / when / then | Source |
|---|---|---|
| T-ADJ-001 | Given user-specific + project-wide overrides on one project / then user-specific wins (covers T-TEAM-11, T-PRJ-031) | spec ADJ-001 (🟡) |
| T-ADJ-002 | Given only a project-wide override / then it applies to every worker on the project | spec ADJ-002 (🟡) |
| T-ADJ-003 | Given no overrides / then the worker's default billout term | spec ADJ-003 (🟡) |
| T-ADJ-004 | Given default $25 + project-wide pct −20 / then effective $20.00 (pct applies to the default) | spec ADJ-004 (🟡) |
| T-ADJ-007 | Given user-specific PCT rule AND project-wide ABSOLUTE rule / then user-specific wins regardless of kind; effective = default × (1+pct/100); no chaining. Pin the rebuild's deliberate answer for the NULL-default case — rpcs §3.8 flags this precedence subtlety for explicit decision, do not blind-port | spec adjustments-and-rates.md |
| T-ADJ-008 | Given a pct override + NULL default rate / then effective NULL ("no rate available") | spec adjustments-and-rates.md |
| T-ADJ-006 | Given a row with BOTH absolute and pct set / then rejected (one-kind XOR constraint) | spec ADJ-006 (🟡) |
| T-ADJ-009 | Given pct −150 / then rejected "Percentage discount cannot be less than -100% (which is free)."; −20 stores −20 (covers T-PRJ-032) | spec adjustments-and-rates.md |
| T-ADJ-010 | Given a second save for the same (project, worker) scope / then upsert updates the existing row, no duplicate | spec adjustments-and-rates.md |

### 3.3 Re-stamp actions

| ID | Given / when / then | Source |
|---|---|---|
| T-ADJ-005 | Given stamped entries / when an override is saved or deleted / then affected entries re-stamp and the reported count equals rows **whose stamps changed** (honest counts — rpcs §2.7's doc-comment lie fixed) | spec\* ADJ-005 (🟡) |
| T-ADJ-011 | Given a project-wide rule change / then ALL project entries re-stamp (other workers included); a user-specific change re-stamps only that worker's rows (covers T-PRJ-033 scope) | spec adjustments-and-rates.md |
| T-ADJ-012 | Given entries on a paid invoice / when the project re-stamps / then invoiced rows are **skipped and reported separately** (current app re-stamps them — port the rebuild target, not the bug) | port\* adjustments-and-rates.md |
| T-ADJ-013 | Given a worker with no billout term / when re-stamped / then NULL (never 0.00) both write-path and re-stamp | port\* adjustments-and-rates.md (with T-MNY-001) |
| T-ADJ-014 | Given ids including rows with no worker resolution / when bulk re-stamp / then define explicitly: null their stamps and include in the report (current: silently skipped) | port\* adjustments-and-rates.md |
| T-ADJ-015 | Given N selected, only k changed / then reported count = k and the UI copy matches | port\* adjustments-and-rates.md (pin the chosen semantic) |
| T-ADJ-016 | Given a manager runs import/edit on a project with overrides / then rate resolution is IDENTICAL regardless of actor (current app stamps at default under invoker-RLS — rebuild fixes) | port\* adjustments-and-rates.md |
| T-ADJ-017 | Given a manager bulk-Recalculates locked rows / then the lock is respected (current app re-stamps them) | port\* adjustments-and-rates.md |
| T-ADJ-026 | Given an override save whose re-stamp fails / then the whole operation is atomic — no "saved but failed to re-stamp" half-state | port\* adjustments-and-rates.md |
| T-ADJ-025 | Given a non-owner / then override upsert/delete and income update each refuse with owner-only errors; override rows invisible to manager reads (covers T-PRJ-030) | spec adjustments-and-rates.md |
| T-ADJ-023 | Given a manager-rendered form with no billout field / when submitted / then existing billout term NOT nulled (payload-absent guard; covers T-TEAM-06, T-PRM-06) | spec adjustments-and-rates.md |
| T-ENT-21 | Given bulk Recalculate over selected ids / then cost+billout re-stamp from current terms incl. project overrides; worker attribution unchanged | spec entries.md |
| T-ENT-23 | Given bulk update with both worker AND project targets / then project applies first so the re-stamp uses the new project | spec entries.md |

### 3.4 Display-time project adjustments (ADR #040 — never stamped)

| ID | Given / when / then | Source |
|---|---|---|
| T-ADJ-018 | Given pct 10 + amount 50 on raw billout $1,000 / when project summary computes / then adjusted = 1,000 × 1.10 + 50 = $1,150; per-entry stamps unchanged | spec adjustments-and-rates.md (= T-PRJ-010) |
| T-ADJ-019 | Given a fixed adjustment / then EACH window (this/prev/all-time) includes the full fixed amount (ADR #036 — intentional) | spec adjustments-and-rates.md |
| T-ADJ-020 | Given an adjustment edit saved all-blank / then income/pct/amount all cleared to NULL | spec adjustments-and-rates.md |
| T-ADJ-021 | Given adjustments set / then /projects margin = income − cost (unaffected) and invoice totals/paste block use UNadjusted per-entry sums | spec adjustments-and-rates.md (ADR #040) |

### 3.5 Eligibility ("blocked") predicate — ONE implementation

| ID | Given / when / then | Source |
|---|---|---|
| T-TGL-009 | Given an otherwise-eligible entry with no worker resolution / then blocker names the missing worker first | port TGL-009 |
| T-TGL-011 | Given each required field nulled in turn (operator, client, project, description, end_at, source_user_email, start_at) / then each flags with its exact message; converted_* fields NOT required | port TGL-011 |
| T-TGL-012 | Given a fully-resolved entry / then blocker = None (not blocked) | port TGL-012 |
| T-PHX-009 | Given every eligibility check in the codebase / then exactly ONE implementation is invoked (no duplicated predicate — ADR #018/#021 regression guard; a grep/meta test) | new phase-outs.md |
| T-PHX-010 | Given the export surface / then the invoice paste block is the ONLY sheet-bound export; no code path emits the 14-column Toggl format | new phase-outs.md |

### 3.6 Invoices — lifecycle, attach/detach lock, paste block

| ID | Given / when / then | Source |
|---|---|---|
| T-INV-001 | Given invoice "ABC" / when creating "abc" / then duplicate-name error (unique on lower(name) per tenant) | spec invoices.md |
| T-INV-002 | Given entries applied to an invoice / then invoice line linkage + applied_at/applied_by (= REAL user) stamped; line snapshots display fields at attach time | spec\* invoices.md (+ snapshot per judgment call #9) |
| T-INV-003 | Given a detach / then linkage + applied stamps cleared and the row unlocks | spec invoices.md |
| T-INV-004 | Given open→sent stamps sent_at; →paid stamps paid_at; paid→open→paid round-trip preserves original timestamps | spec invoices.md |
| T-INV-005 | Given an invoice deleted / then attached entries detach and become editable by non-owners again | spec invoices.md |
| T-INV-006 | Given a manual total override / then displayed Billout + Margin change on list and detail | spec invoices.md |
| T-INV-007 | Given a breakdown / then sums equal Σ per-entry billout per (client, project) | spec invoices.md |
| T-INV-008 | Given an entry already on invoice A / when bulk-applying to B / then silently skipped, flash count excludes it | spec invoices.md |
| T-INV-009 | Given an entry with NULL client / when applied by explicit id / then skipped | spec invoices.md |
| T-INV-010 | Given NULL operator only / then blocked-status and explicit-id apply AGREE (rebuild fixes the current disagreement wart) | port\* invoices.md |
| T-INV-011 | Given create with status `paid` / then sent_at AND paid_at both stamped at create | spec invoices.md |
| T-INV-012 | Given create with missing/malformed invoice_date / then default (last day of previous month); update posting empty date → NULL | spec invoices.md |
| T-INV-013 | Given a non-owner (or super-admin in view-as) / when any invoice mutation / then owner-only refusal, no write | spec invoices.md |
| T-INV-014 | Given a locked entry / when a non-owner edits/deletes/re-resolves / then blocked with the lock message (covers T-RBAC-004, T-ENT-40, T-ENT-12 enforcement side) | spec invoices.md |
| T-INV-015 | Given detach posted with all-matching UI state / then refused; detach accepts explicit ids only (covers T-ENT-20) | spec invoices.md |
| T-INV-016 | Given detach ids including already-detached rows / then no-op on those, flash = actually-detached count | spec invoices.md |
| T-INV-017 | Given manual_total_usd set / then paste block amounts still equal per-row entry sums | spec invoices.md |
| T-INV-018 | Given NULL invoice_date / then paste block col 4 = default date; list date falls back to created_at date | spec invoices.md |
| T-INV-019 | Given `?invoice=<id>&status=applied` on /entries / then rows scoped to that invoice; footer link count = detail row_count | spec invoices.md |
| T-INV-020 | Given a `paid` invoice / then absent from the apply picker; flipping to `sent` restores it | spec invoices.md |
| T-INV-021 | Given the list page / then margin renders green `+` when ≥0, red when negative; "manual override" note only when override ≠ entry sum | spec invoices.md |
| T-INV-022 | Given >150 detach ids / then succeeds (set-based mutation; current app risks request failure) | port\* invoices.md |
| T-INV-023 | Given a viewer with partial visibility / then detail totals/breakdown sum only visible entries | spec invoices.md |
| T-INV-024 | Given all-matching apply with a filter / then only rows matching BOTH filter and eligibility attach; flash = rows actually stamped (343-vs-415 regression family) | spec invoices.md |
| T-ENT-18 | Given bulkApplyToInvoice / then stamps only unattached rows with required fields; attached rows silently skipped; redirect count = rows actually updated | spec entries.md |
| T-ENT-19 | Given an invoice from another tenant or missing / then "Invoice not found."; paid invoices never in the dropdown | spec entries.md |
| T-INV-P-001 | Given empty breakdown / then `''` | port INV-P-001 |
| T-INV-P-002 | Given one row / then one line, 7 tab-separated columns in exact order (col2 team label, col6 tenant name, col7 category — all tenant settings, not literals) | port\* INV-P-002 (`'Bowden Works Team'`/`'Bowden Works'`/`'Labour'` → tenant config) |
| T-INV-P-003 | Amount `toFixed(2)`-equivalent, no `$` | port INV-P-003 |
| T-INV-P-004 | Multiple rows join `\n`, caller's order preserved (no sort) | port INV-P-004 |
| T-INV-P-005 | NULL client/project → `(no client) : (no project)` | port INV-P-005 |
| T-INV-P-006 | Separator is `' : '` colon-with-spaces (same convention as the Clockify split) | port INV-P-006 |
| T-INV-P-007 | 5th column is the empty string | port INV-P-007 |

---

## 4. services/authz — the permission engine (ported truth tables FIRST)

Per 04-architecture: "the capability truth tables are data; tests
enumerate (actor-fixture × action × resource-fixture) grids. R1's grids
must reproduce the current app's permission functions exactly."

### 4.1 T-AZ-001 — the R1 parity grid (one parametrized test, 56 cases)

Carries T-PRM-01 (permissions-and-view-as.md) and T-ORG-03
(organizations-and-team.md). Actor fixtures (seed-graph names) ×
capabilities, expected values verbatim from rpcs-and-authz §5:

| actor fixture | canTransfer | canManageTeam | canManageImports | canEnterViewAs | canEditEntry (unlocked) | canEditEntry (locked) | canSeeIncome (=canTransfer wiring) |
|---|---|---|---|---|---|---|---|
| A0 no actor (`null`) | F | F | F | F | F | F | F |
| A1 rian: super-admin, NOT viewing-as | T | T | T | T | T | T | T |
| A2 owner (official_partnership/owner; not SA) | T | T | T | F | T | T | T |
| A3 manager (management-grade engagement) | F | F | T | F | T | F | F |
| A4 member/worker (subcontract, no manage grants) | F | F | F | F | F | F | F |
| A5 user with no engagement to the tenant | F | F | F | F | F | F | F |
| A6 rian viewing-as A2 / A3 / A4 / A5 | as target | as target | as target | **F** | as target | as target | as target |
| A7 super-admin viewing-as another super-admin | target role=='owner' | target role=='owner' | owner∥manager | **F** | per row | per row | = canTransfer |

Rules encoded: `canEditEntry(locked) = canTransfer`;
`canEditEntry(unlocked) = canManageImports`; the `canManageTeam`
column encodes the EFFECTIVE enforcement (team owner OR org owner OR
super admin) — the dead, never-called `canManageTeam` function's
broader any-manager rule is NOT the parity bar (02-domain-model §4);
a locked entry is one
attached to an invoice line (the legacy `transferred_at` branch
already lives on the per-org "Legacy transfers" invoices —
T-MIG-006/012 — so invoice attachment is the ONLY lock); there is **no separate canSeeIncome function** — the projects
income/billout gate reuses canTransfer; view-as never nests
(canEnterViewAs F while impersonating); super-admin power is stripped
while viewing-as anyone, including another super-admin.

| ID | Given / when / then | Source |
|---|---|---|
| T-AZ-001 | Given every (actor × capability × lock-state) cell above / when evaluated through `capabilities.py` / then exact match with the table — before ANY new semantics land | port rpcs §5 truth tables (spec T-PRM-01, T-ORG-03) |
| T-AZ-002 | Given the Adi parity fixture (subcontract/worker + `can_manage_imports` engagement capability) / then his outcomes across all five functions equal the current app's manager row wherever he had access today (02-domain-model §4 parity mapping) | new (R1 parity requirement) |
| T-PRM-02 | Given no resolvable actor / then every capability returns False | spec permissions-and-view-as.md |
| T-PRM-03 | Given a super-admin viewing-as another super-admin / then super-admin power stripped; only the target's derived role counts | spec permissions-and-view-as.md |
| T-PRM-05 | Given a manager / then income, margin, and billout dollars are ABSENT from the API payload (DTO shaping), not just the UI — fixes judgment call #20's income-leak regression structurally | spec\* permissions-and-view-as.md |
| T-PRM-07 | Given a non-owner / when entries are queried via `scope()` / then only rows from import batches THEY uploaded; rows with no import linkage are invisible to them (rpcs §3.7 branch 3) | spec permissions-and-view-as.md |
| T-PRM-08 | Given an owner or super-admin / then all tenant rows regardless of importer (still requires tenant membership) | spec permissions-and-view-as.md |
| T-AZ-003 | Given any `can()` decision / then it carries a reason chain naming the granting engagement (e.g. `worker_party(Adi) --subcontract#42--> project(Brentwood) => entries:read:own`) | new (04-architecture "Decisions carry reasons") |
| T-AZ-004 | Given any list/aggregate route and its row-level twin / then both use the same `scope()` filter — a meta-test asserting no router builds its own permission WHERE clause | new (04-architecture "one choke point") |

### 4.2 View-as (app-layer impersonation — judgment call #7)

| ID | Given / when / then | Source |
|---|---|---|
| T-AZ-030 | Given a super-admin entering view-as / then `resolve_actor` returns the target's context with `impersonated_by` set; the swap is app-layer only (no JWT, no DB identity) | new (replaces T-VAS-02/03; T-PHX-008) |
| T-AZ-031 | Given ANY view-as enter/exit / then an audit row records BOTH identities (current app has none) | new (T-VAS-14) |
| T-VAS-01 | Given a non-super-admin attempting to enter view-as / then refused; forged impersonation state is ignored | port\* VAS-01 (cookie mechanics → server-side check) |
| T-VAS-04 | Given view-as of a manager with no imports / then /entries returns zero rows and the Imported-by control is hidden | spec permissions-and-view-as.md |
| T-VAS-05 | Given view-as, a mutation targeting a row outside the target's scope / then "That row isn't visible to the user you're viewing as. Exit view-as to make this change." and no write | spec permissions-and-view-as.md |
| T-VAS-06 | Given a batch mutation where ANY id is out of the target's scope / then the WHOLE batch is denied (no partial application) | spec permissions-and-view-as.md |
| T-VAS-07 | Given view-as of a non-owner on the team/engagement page / then only their scope renders; cross-scope mutations denied | spec permissions-and-view-as.md (covers T-TEAM-13) |
| T-VAS-08 | Given view-as active / then credential/config actions are blocked while impersonating | port\* VAS-08/T-AUTH-008 (password change dies with id-auth; the guard generalizes) |
| T-VAS-09 | Given view-as active / when a CSV import or comment post/resolve happens / then attribution = the EFFECTIVE user | spec permissions-and-view-as.md (covers T-COM-108) |
| T-VAS-10 | Given "Exit view" / then next request is fully the real user | spec permissions-and-view-as.md |
| T-VAS-11 | Given already viewing-as / then canEnterViewAs = F and the switcher is not rendered (no nesting) | spec permissions-and-view-as.md |
| T-VAS-12 | Given a bogus/unknown view-as target / then rejected with an error (rebuild replaces the current silent fall-through) | port\* permissions-and-view-as.md |
| T-VAS-13 | Given sign-out during view-as / then impersonation state cannot survive into the next session (current app's cookie survives — inverted per T-AUTH-010) | port\* permissions-and-view-as.md |
| T-RBAC-002 | Given a super-admin not viewing-as, with ≥1 other user / then the "View as ▾" switcher renders | spec RBAC-002 (🟡; + Playwright touch in E2E-004) |
| T-RBAC-005 | Given canTransfer false / then no Invoices/Tools nav links (Transfer link gone for everyone) | spec\* RBAC-005 (page retired) |
| T-ENT-41 | Given view-as of a non-owner / then every entries list/total/option/action is scoped to the effective user's batches; an `imported_by` URL param cannot widen it | spec entries.md |
| T-SUM-017 | Given view-as of a non-owner / then summary queries are forced to the effective user's scope and billout is hidden; owner sessions pass unscoped | spec summary.md |
| T-PRJ-023 | Given a non-owner on /projects / then Billout/Income/Margin columns, income totals, importer dropdown and inline editors absent AND income NULL at the data layer | spec projects-and-entities.md |
| T-PRJ-024 | Given view-as with `imported_by` in the URL / then the effective filter is the impersonated user's id | spec projects-and-entities.md |
| T-TEAM-14 | Given an effective user with zero visible teams/engagements / then empty state, and the query short-circuits (no sentinel-uuid hack) | port\* organizations-and-team.md |

### 4.3 Auth middleware ("Sign in with BW" — Pattern B kit, judgment #31)

| ID | Given / when / then | Source |
|---|---|---|
| T-AUTH-016 | Given no app session on any non-allowlisted route / then 401/redirect into the BW sign-in flow; given a first-ever BW username completing sign-in / then a zero-capability `users` row is auto-provisioned and no data is accessible | new auth-and-login.md + app-auth kit |
| T-AZ-040 | Given the OAuth callback / then state+PKCE verified and the id_token's signature + audience checked per PATTERN-B.md; tampered/expired/wrong-audience tokens → rejected, no session | new (04-architecture; app-auth kit) |
| T-AZ-041 | Given `/healthz`, static assets, and `/auth/callback` / then served without a session; given ANY other route / then denied by default — the allowlist is explicit, no path-prefix exemptions | new (04-architecture) |
| T-AZ-042 | Given the app's route table / then none of the id-auth reserved routes (`/login`, `/logout`, `/authorize`, `/_grant`) are defined by the app — `/auth/callback` is the ONE app-defined auth route, exactly as the kit prescribes | new (04-architecture) |
| T-AZ-043 | Given `AUTH_DEV_USER` set in a production-flagged env / then the app REFUSES to start; in dev it logs loudly and resolves the stub identity | new (judgment call #13, local-test convenience) |
| T-AZ-045 | Given a request carrying identity-claiming headers (`X-Auth-User` etc.) without a valid app session / then never honored — identity comes only from the app session established via the verified id_token | new (04-architecture) |

*(T-AZ-044/T-AZ-046 — machine-token path — DEFERRED with the feature
per 04: no non-browser consumer exists in R1; the diff harness reads
Postgres directly. Tier C.)*

| T-AUTH-013 | Given each actor fixture / then the nav role label derives from their engagement grants ("super admin" / owner / manager / worker / "no role") | port\* auth-and-login.md (org_role → derived capability label) |

---

## 5. services/entities — resolve-or-create, merge, move

One implementation used by imports, entry edits, and bulk edits
(02-domain-model §5 invariant 3). IDs renamed from
projects-and-entities.md `T-ENT-00x` → `T-ENTITY-00x` (collision with
entries.md — see §1).

| ID | Given / when / then | Source |
|---|---|---|
| T-ENTITY-001 | Given a rename to an existing sibling name (case-insensitive) / then per-scope duplicate error, nothing changes; same-name rename → no-change info | spec projects-and-entities.md |
| T-ENTITY-002 | Given an operator rename / then every entry reachable via project FK displays the new text (FK display makes the current text-rewrite moot; entry counts unchanged — carries PRJ-011) | port\* projects-and-entities.md |
| T-ENTITY-003 | Given create operator/client/project with an in-scope duplicate / then each fails with its exact message; otherwise inserted under the right parent (uniques: operator per tenant, client per operator, project per client — all lower(name)) | spec projects-and-entities.md |
| T-ENTITY-004 | Given a worker-role user / then every manage action refuses with that action's message; a manager succeeds | spec projects-and-entities.md |
| T-ENTITY-005 | Given moveProject to a client already holding a same-lower(name) project / then refused with merge guidance; a concurrent unique-violation race maps to the same friendly error | spec projects-and-entities.md |
| T-ENTITY-006 | Given moveProject cross-operator / then entries re-point (display follows FK), the project keeps id/overrides/income/adjustments, invoices NOT reassigned, invoiced lines keep their snapshots | port\* projects-and-entities.md (snapshot per judgment call #9) |
| T-ENTITY-007 | Given deleteProject with entries or CC lines and no target / then fails listing exact blocking counts; with a target / then entries + CC lines re-point, source overrides deleted, source income/adjustments lost, source row gone | spec projects-and-entities.md |
| T-ENTITY-008 | Given the final delete removes zero rows / then the user sees the "no row was removed" error, never a false success | spec projects-and-entities.md |
| T-ENTITY-009 | Given deleteClient with a target under a different operator / then projects re-parent, entry display refreshes, invoice client linkage re-points | spec projects-and-entities.md |
| T-ENTITY-010 | Given deleteOperator with a target / then clients re-parent, entry display refreshes, invoice operator linkage re-points | spec projects-and-entities.md |
| T-ENTITY-011 | Given source == target / then refused ("can't merge into itself") for every scope | spec projects-and-entities.md |
| T-ENTITY-012 | Given an empty source / then delete needs no target ("Deleted empty …"); a project with 0 entries but >0 CC lines still REQUIRES a target | spec projects-and-entities.md |
| T-ENTITY-013 | Given two clients with same-named projects / when client A merges into B / then colliding projects merge recursively (entries re-pointed, husk deleted), non-colliding re-parent, invoiced rows keep snapshots — no unique-constraint error | new (judgment call #10; fixes the PlusROI/Palms incident) |
| T-ENTITY-014 | Given any multi-step merge/move failing midway / then NO partial state persists (transactional) | new projects-and-entities.md |
| T-ENTITY-015 | Given the manage page for a tenant with entries + CC lines / then per-entity counts equal true DB counts (aggregate query, immune to page caps) | port\* projects-and-entities.md |
| T-ENT-24 | Given bulk quick-create with only 2 of 3 fields / then "fill in all three" error; with all 3 / then chain resolve-or-created and it overrides any picked project | spec entries.md |
| T-ENT-22 | Given bulk edit with only a project target / then entries re-point to that project and display the canonical chain (single scoped update) | port\* entries.md (text-column rewrite → FK re-point) |
| T-ENT-29 | Given an entry edit with all three entity fields / then missing entities created under the right parents and the entry re-points; the "blank one field nulls the link" wart is replaced by explicit unlink | port\* entries.md |
| T-TEAM-01 | Given an existing worker email in scope X / when creating the same email in X / then unique-violation → "<email> is already in this team." | spec\* TEAM-001 (reworded to per-scope uniqueness) |
| T-TEAM-03 | Given the same email in scope Y / then succeeds (uniqueness is per-scope) | spec organizations-and-team.md |
| T-TEAM-04 | Given ` RIAN@Rian.CA ` / then stored `rian@rian.ca` (trim+lowercase) and import matching hits case-insensitively | spec organizations-and-team.md |
| T-TEAM-09 | Given a worker with entries / when their engagement/party link is removed / then entries keep stamped money, lose resolution (NULL worker), and their overrides/terms rows are gone | port\* organizations-and-team.md |
| T-TEAM-15 | Given an update form / then the scope (team/engagement counterparty) is never modified by edit | spec organizations-and-team.md |
| T-ORG-01 | Given a tenant member / when the tenant resolves / then exactly one tenant row for the app context | spec ORG-001 (single-org today; becomes tenant resolution) |
| T-ORG-02 | Given a user with no tenant engagement / then tenant resolution fails with the not-found state, no data leaks | spec ORG-002 |

---

## 6. services/reporting — RPC-equivalence tests

Each current SQL RPC becomes a `services/reporting/` function; semantics
pinned by these tests, then cross-checked against the live app by the
diff harness. Shared fixtures: the §1 filter contract of
rpcs-and-authz.md (12 params; naive timestamps; half-open windows).

| ID | Given / when / then | Source |
|---|---|---|
| T-ENT-01 | Given no filter params on /entries / then defaults date=all + status=pending, unbounded (no silent snap to this-month; carries ENT-008) | spec entries.md |
| T-ENT-02 | Given `status=transferred` / then identical results to `status=applied` (alias honored for old bookmarks) | spec entries.md |
| T-ENT-03 | Given status=pending / then every row unlocked with all eligibility fields present; blocked = complement; disjoint; union = all unlocked rows — the SQL-level predicate, one implementation (carries ENT-016) | spec entries.md |
| T-ENT-04 | Given a custom range ending day E / then rows on day E included, day E+1 excluded (inclusive-end-day → half-open bound) | spec entries.md |
| T-ENT-05 | Given `q` matching description only / then row matches; q searches exactly project, description, source_user_email, client (covers catalog ENT-012/013's list-narrowing) | spec entries.md |
| T-ENT-06 | Given `imported_by` for a user with zero imports / then zero rows (no sentinel leakage) | spec entries.md |
| T-ENT-07 | Given `?invoice=<id>` / then list AND totals scoped to that invoice (rebuild fixes the current totals wart) | port\* entries.md |
| T-ENT-08 | Given sort=cost&dir=asc / then server-side order by the cost stamp asc, NULLs last; every mapped sort key works | spec entries.md |
| T-ENT-09 | Given 250 matching rows, page=3 / then rows 201–250, totalPages=3; status-filtered rows pack from page 1 | spec entries.md |
| T-ENT-10 | Given any filter / then the totals card equals the aggregate over the WHOLE filter match, not the page; zero matches → one zero row; billout total absent for non-owners (carries ENT-011; `billout_seconds` duplicate column NOT ported) | spec entries.md + rpcs §2.1 |
| T-RPT-001 | Given project/user/source-email filters / then list AND totals narrow together, and bulk scope respects them (carries ENT-012) | spec tests-catalog |
| T-RPT-002 | Given a client filter / then same (carries ENT-013) | spec tests-catalog |
| T-RPT-005 | Given an operator pivot filter / then the entries list AND the totals card narrow together, and bulk scope respects it (carries ENT-017) | port\* tests-catalog ENT-017 (operator text column → FK-resolved party) |
| T-RPT-003 | Given the filtered set / then filter pivot options reflect exactly that set: distinct, ascending, NULLs excluded; empty → `[]` (normalized from SQL NULL; equivalence vs live app must accept NULL) (carries ENT-018) | spec rpcs §2.2 |
| T-PRJ-001 | Given entries with stamps / when project summary aggregates / then per-group count/seconds/cost/billout equal the sum of per-entry stamps | spec PRJ-001 |
| T-PRJ-010 | Given a project with adjustment pct/amount / then displayed billout = raw × (1+pct/100) + amount, stamps unchanged (= T-ADJ-018's read side) | spec PRJ-010 |
| T-PRJ-021 | Given rows with NULL income / when sorted by income or margin either direction / then NULL rows at the bottom | spec projects-and-entities.md |
| T-PRJ-022 | Given some rows with income / then headline margin = Σincome − Σ(cost over income-set rows only), labeled `(k/n with income)` | spec projects-and-entities.md |
| T-PRJ-025 | Given a group with NULL operator / when expanded / then the NULL-group breakdown returns that group's rows (`__null__` → SQL NULL mapping made robust) | spec projects-and-entities.md |
| T-RPT-004 | Given a project-row expansion by description / then the expansion rows SUM to the parent row (kills rpcs §2.4's stale predicate/hours divergence — one status predicate, one hours definition everywhere) | new (rpcs §2.4 binding decision) |
| T-SUM-001 | Given entries across two months, several projects / then one row per (operator, client, project) with correct this/prev/all seconds (grouping keys are FKs; carries SUM-001, SUM-004) | port\* summary.md (text-triple grouping → FK grouping, delta #4) |
| T-SUM-002 | Given stamped entries / then dollar columns equal sums of stamps — NO rate lookup at read time (carries SUM-002 rewritten) | spec summary.md |
| T-SUM-003 | Given `?month=2026-7` and `?month=2026-07` / then both July 2026; absent/malformed/month-13 → current month | spec summary.md |
| T-SUM-004 | Given current month July 2026 / then this = `['2026-07-01 00:00:00','2026-08-01 00:00:00')`, prev = June; December rolls to January year+1 | spec summary.md |
| T-SUM-006 | Given a project with this=0 and prev=0 but all-time>0 / then row hidden AND its all-time cost/billout still in the footer all-time total (carries SUM-003) | spec summary.md |
| T-SUM-007 | Given an entry with NULL duration / then it appears in no window and no total | spec summary.md |
| T-SUM-008 | Given entries with no resolved operator/client/project / then grouped under `(no operator)`/`(no client)`/`(no project)` labels — applied at the PRESENTATION layer in the rebuild — with zero adjustment effect | port\* summary.md (delta #4) |
| T-SUM-009 | Given pct −10 + amount 50 on a matched project / then each window's adjusted billout = raw × 0.9 + 50, including zero-raw windows | spec summary.md |
| T-SUM-013 | Given a pivot filter narrowing results / then headline, 140h-target tracker, AND table all reflect the filtered scope | spec summary.md |
| T-RPT-006 | Given the seeded 100x dataset / then the summary aggregates pass an EXPLAIN plan check (indexed, no full-scan regression) and stay inside the CI latency budget | new (04-architecture "Performance baseline") |

---

## 7. routers — API contract tests (pytest + httpx)

| ID | Given / when / then | Source |
|---|---|---|
| T-API-001 | Given any 4xx/5xx from any route / then body is `{error_code, summary, detail}` — a parametrized sweep over registered routes | new (04-architecture cross-cutting) |
| T-API-002 | Given `/api/meta` / then the deployed version string (matches the SPA footer build arg) | new (04-architecture) |
| T-PHX-002 | Given `/transfer` or `/transfer/download` / then 404 — no route exists | new phase-outs.md |
| T-ENT-42 | Given a failing action / then the API returns its structured error and the SPA maps it to a toast/inline message (replaces the current `?error=` redirect convention) | port\* entries.md |
| T-AUD-001 | Given any state-changing action / then an audit row (actor, impersonated_by, action, entity, before/after digest) is written | new (04-architecture "Audit") |
| T-AUD-002 | Given the route table / then every mutating route is covered by an audit assertion (meta-test — new mutations can't silently skip audit) | new |
| T-CFG-001 | Given a second tenant configured with different paste-block values / then exports/invoice numbering/default categories use tenant settings — no `'Bowden Works'` literals in code paths | new (04-architecture per-tenant config) |

### 7.1 Entries write paths

| ID | Given / when / then | Source |
|---|---|---|
| T-ENT-26 | Given an inline edit save / then persists, and the client returns to the exact filtered/paged state (carries ENT-022's server half) | spec entries.md |
| T-ENT-27 | Given an edit posting client-derived computed fields / then the server ignores them and derives from source duration (converted-* derivation retired) | port\* entries.md |
| T-ENT-28 | Given an edit picking a different worker / then cost re-stamps from that worker's cost term and billout from the effective billout rate for (worker, project) | spec entries.md |
| T-ENT-32 | Given re-resolve with an unknown source email / then "No team member with email … Add them on the Team page first." (message re-worded to engagement language, same behavior; carries ENT-002) | spec entries.md |
| T-ENT-33 | Given re-resolve with empty source email / then worker resolution AND all money stamps cleared, including billout amount (fixes the current partial-clear wart; carries ENT-003) | port\* entries.md |
| T-ENT-34 | Given re-resolve / then lookup runs against the batch UPLOADER's worker scope, not the current viewer's | spec entries.md |
| T-ENT-35 | Given delete / then row removed; locked-row delete by a manager fails owner-only (carries ENT-004) | spec entries.md |
| T-ENT-36 | Given createEntry / then date/duration/description/worker required with exact messages; start defaults 00:00:00; end = start + duration incl. midnight rollover | spec entries.md |
| T-ENT-37 | Given createEntry / then source identity stamped from the worker, billable=true, and the row lands in the per-(tenant,user) "Manual entries" batch — created once, reused; the batch link is REQUIRED (manual rows without it would be invisible to their creator under the scope rule) | spec entries.md + rpcs §3.7 consequence |
| T-ENT-39 | Given duration input `1:30:00`/`1:30`/`1.5` / then 5400s; negatives rejected (server-side twin of the client parser) | spec entries.md |

### 7.2 Bulk-mutation safety (judgment call #11 — architectural)

| ID | Given / when / then | Source |
|---|---|---|
| T-ENT-16 | Given a bulk action in all-matching mode / then the server derives scope from the SAME query object as the display, applying EVERY active filter — a dropped status/operator dimension must fail this test (the 3,274-rows incident; carries ENT-020) | spec entries.md |
| T-ENT-17 | Given the filter now matches 415 rows but expected_count=343 / then abort with the "Safety stop:" message, zero rows changed (carries ENT-021) | spec entries.md |
| T-ENT-44 | Given every all-matching mutation / then it carries an expected-count precondition AND is undoable post-action | new entries.md (judgment call #11) |
| T-ENT-25 | Given a manager bulk edit including locked rows / then locked rows skipped in both modes; owner includes them; zero-after-guard → the "No entries left…" error (covers T-PRM-04, catalog ENT-015) | spec entries.md |
| T-PRJ-034 | Given 2 selected project groups + only a project target / then EVERY entry in both groups (ignoring the active date filter — documented, deliberate) updates; confirm count = rows updated; unselected groups untouched | spec projects-and-entities.md |
| T-PRJ-035 | Given a manager bulk-editing a group with locked entries / then only unlocked rows update | spec\* projects-and-entities.md (transferred → invoiced) |
| T-ENT-14s | Given `set_team_member`-style bulk reassign / then attribution columns only, followed by an explicit re-stamp (two-step preserved: auditable scope), target must exist in tenant else zero rows | port\* rpcs §2.8 (minus converted-* stamping) |
| T-PRJ-028 | Given income input `'1,234.50'`, pct `'-10'`, amount `'5'` saved by an owner / then stored 1234.50 / −10 / 5; clearing all three writes NULLs | spec projects-and-entities.md |
| T-PRJ-029 | Given a group with any NULL tuple member / when income save attempted / then "Fix the entries first" error | spec projects-and-entities.md |

### 7.3 Comments

| ID | Given / when / then | Source |
|---|---|---|
| T-COM-101 | Given two tenant members / when one posts / then the other sees it in the bell (newest-first), badge increments | spec COM-101 (🟡) |
| T-COM-102 | Given an open comment / when any member resolves / then resolved_at/resolved_by set, badge decrements | spec COM-102 (🟡; + Playwright touch in E2E-005) |
| T-COM-103 | Given a comment / then author and super-admin can delete; others get an explicit denial (current silent no-op replaced) | port\* COM-103 (🟡; RLS denial → service-layer denial with error) |
| T-COM-104 | Given a resolved comment / when reopened by any member / then resolved fields nulled, back in Open tab and badge | spec comments.md |
| T-COM-105 | Given empty/whitespace body / then `Comment body is required.`, no row | spec comments.md |
| T-COM-106 | Given 51+ comments with an unresolved one beyond the page window / then the badge counts ALL unresolved (current undercount fixed) | port\* comments.md |
| T-COM-108 | Given view-as Adi / when posting / then author = Adi (effective user) | spec comments.md (enforced by T-VAS-09) |
| T-COM-109 | Given view-as active / then delete button visibility and enforcement use ONE consistent identity | port\* comments.md (current wart fixed) |
| T-COM-110 | Given a comment whose author's account is gone / then renders author "Unknown" ("Resolved by someone" when resolver nulled) | spec comments.md |
| T-COM-111 | Given open + resolved comments / then Open/Resolved tabs partition correctly with the documented empty states | spec comments.md |
| T-COM-112 | Given an unauthenticated comment action / then refused, nothing written | spec comments.md |
| T-COM-113 | Given a mutation on a nonexistent comment id / then no change and a not-found error (current silent success replaced) | port\* comments.md |
| T-COM-115 | Given an already-resolved comment / when resolved again / then resolved_at/resolved_by re-stamp to the new actor/time; reopening an OPEN comment is a no-op write (no state precondition) | spec comments.md (B9) |

---

## 8. frontend — Vitest

### 8.1 Filter serialization (`lib/filters` port; clock pinned 2026-05-15T12:00Z)

| ID | Given / when / then | Source |
|---|---|---|
| T-FLT-001 | Given no `date` param / then this-month default `'2026-05-01 00:00:00'`/`'2026-06-01 00:00:00'` | port FLT-001 |
| T-FLT-002 | Given `date=all` / then both bounds null | port FLT-002 |
| T-FLT-003 | Given `date=this-month` / then [first-of-month, first-of-next) | port FLT-003 |
| T-FLT-004 | Given `date=last-month` in January (clock re-pinned 2026-01-15) / then `'2025-12-01'`..`'2026-01-01'` | port FLT-004 |
| T-FLT-005 | Given `date=custom&start=2026-04-10&end=2026-04-15` / then endBound `'2026-04-16 00:00:00'` (inclusive-end-day) | port FLT-005 |
| T-FLT-006 | Given `status` / then passes through, defaults `'all'`; `blocked`/`pending`/`applied` survive, `transferred` accepted as alias only | port\* FLT-006 |
| T-FLT-007 | Given `batch=all` / then null; other strings preserved | port FLT-007 |
| T-FLT-008 | Given empty/whitespace `q` / then null; non-empty preserved | port FLT-008 |
| T-FLT-009 | Given `imported_by` / then string passthrough; empty/absent → null | port FLT-009 |
| T-FLT-010 | Given a `defaults` argument (`{date:'all', status:'pending'}`) / then it overrides built-ins when the URL is bare (how /entries gets its defaults) | port FLT-010 |
| T-ENT-23f | Given a fresh /entries / then date=all + status=pending applied to BOTH the query and the dropdown UI (carries ENT-023) | spec entries.md |
| T-ENT-24f | Given "Clear filters" / then every filter param stripped, defaults restored (carries ENT-024) | spec entries.md |
| T-SFLT-001 | Given the summary FilterBar presets / then this-month/last-month bounds incl. January → December year−1 | spec summary.md (was its T-FLT-001) |
| T-SFLT-002 | Given custom `2026-03-05..2026-03-10` / then end `'2026-03-11 00:00:00'`; month-end `2026-03-31` → `'2026-04-01 00:00:00'` | spec summary.md |
| T-SFLT-003 | Given `date=all` / unknown preset / `batch=all` / whitespace pivots / then nulls | spec summary.md |
| T-SFLT-004 | Given any FilterBar update / then the `page` param is deleted and empty values remove their keys | spec summary.md |
| T-SFLT-005 | Given "Clear filters" on `/?month=2026-03&operator=PlusROI` / then `month` is PRESERVED and pivots cleared (rebuild inverts the current month-reset wart) | port\* summary.md |
| T-SUM-005 | Given active pivots + `?month=` / when "← prev" / then pivots survive, month decrements; "This month" drops only `month` | spec summary.md |

### 8.2 Formatting (`lib/format` port — null/undefined always `'—'`)

| ID | Given / when / then | Source |
|---|---|---|
| T-FMT-001 | `fmtHoursDecimal`: 3600→`'1.00'`, 5400→`'1.50'`, null→`'—'` | port FMT-001 |
| T-FMT-002 | `fmtHoursMinutes`: 5400→`'1:30'`, 3600→`'1:00'`, 0→`'0:00'`; 409,860→`'113:51'` | port FMT-002 |
| T-FMT-003 | `fmtHoursMinutes` rounds to nearest minute: 5430→`'1:31'`, 5429→`'1:30'` | port FMT-003 |
| T-FMT-004 | `fmtHoursMinutes(null\|undefined)` → `'—'` | port FMT-004 |
| T-FMT-005 | `fmtUsd`: 1593.89→`'$1,593.89'`, 0→`'$0.00'`, null→`'—'` | port FMT-005 |
| T-FMT-006 | `fmtMoneyHours(5400, 14)` → `'$21.00 (1:30)'` | port FMT-006 |
| T-FMT-007 | `fmtMoneyHours` → `'—'` for 0/null/undefined seconds | port FMT-007 |
| T-FMT-008 | `fmtTimestampShort`: `'2026-05-20T09:30:00'` and space variant both → `'2026-05-20 09:30'`; null→`'—'` | port FMT-008 |
| T-FMT-009 | `fmtHoursMinutesSeconds` exact: 5400→`'1:30:00'`, 3661→`'1:01:01'`, 0→`'0:00:00'`, null→`'—'` | port FMT-009 |
| T-INV-D-001 | `defaultInvoiceDate(2026-05-15)` → `'2026-04-30'` (last day of previous month) | port INV-D-001 (pytest twin lives in T-INV-012) |
| T-INV-D-002 | On the 1st (Jun 1) → `'2026-05-31'` | port INV-D-002 |
| T-INV-D-003 | January → previous December 31 | port INV-D-003 |
| T-INV-D-004 | Leap-year Feb: Mar 5 2024 → `'2024-02-29'`; Mar 5 2026 → `'2026-02-28'` | port INV-D-004 |
| T-COM-114 | `timeAgo`: 30s/5m/3h/2d ages → `'30s ago'`/`'5m ago'`/`'3h ago'`/`'2d ago'` | spec comments.md |

### 8.3 Table interactions & component state

| ID | Given / when / then | Source |
|---|---|---|
| T-FE-001 | Given a sortable column header click / then `?sort=/?dir=` update and the query key changes (carries ENT-010's client half; server half = T-ENT-08) | spec tests-catalog |
| T-FE-002 | Given an API response whose version ≠ the client build / then the SPA shows the "Page is out of date → Refresh" treatment, never a generic error (stale-deploy recovery) | new (04-architecture Errors) |
| T-ENT-11f | Given entry states / then status icon precedence invoice > legacy > blocked > pending with exact titles; blockerReason shows the FIRST missing field | spec entries.md |
| T-ENT-12f | Given a locked row as manager / then 🔒 and no edit/delete affordances; owner sees them (display half of T-INV-014) | spec entries.md |
| T-ENT-13 | Given all page rows selected with more matches / then "Select all N matching" offered; clicking one checkbox in all-matching mode collapses to that single id | spec entries.md |
| T-ENT-14 | Given any filter change / then selection and all-matching flag cleared | spec entries.md |
| T-ENT-15 | Given all-matching submit / then payload carries `all_matching=1` + the filter snapshot; explicit submit carries ids | spec entries.md |
| T-ENT-30 | Given the entity cascade: operator blur clears client+project; client blur clears project and auto-fills a UNIQUE operator; project blur auto-fills a unique (operator, client); ambiguous parents never auto-fill | spec entries.md (EntityPicker) |
| T-ENT-31 | Given pickers / then client/project options strictly narrow to the picked parent chain — a same-named project under another client is never offered | spec entries.md (EntityPicker) |
| T-ENT-38 | Given the new-entry form / then the worker select defaults to the row matching the current user's email case-insensitively | spec entries.md |
| T-ENT-43 | Given each mutation / then its TanStack Query invalidation map refreshes the documented affected views (replaces Next.js revalidatePath lists) | port\* entries.md |
| T-PRJ-020 | Given /projects bare / then defaults date=all, status=all, sort=billout_hrs desc; invalid sort falls back | spec projects-and-entities.md |
| T-PRJ-026 | Given `?expand=<key>` / then the row is open AND its breakdown loaded | spec projects-and-entities.md |
| T-PRJ-027 | Given the missing-info chip / then URL becomes exactly `date=all&missing_info=1` (all other params cleared) | spec projects-and-entities.md |
| T-SUM-011 | Given owner / then green adjusted-billout headline + muted cost line with the raw-vs-adjusted tooltip variants; non-owner sees a single cost span | spec summary.md |
| T-SUM-012 | Given this-month 342,000s / then `-45.00h vs 140h target` class `under-target`; 522,000s → `+5.00h` `over-target` (the 140h target comes from the engagement setting, not a hardcode) | spec\* summary.md |
| T-SUM-014 | Given owner / then `this` column sorts by billout; non-owner by cost; default sort `this` desc | spec summary.md |
| T-SUM-015 | Given cost=1234.56, 45,240s / then `$1,234.56 (12:34)`; 0/0 → `—`; owner billout-0-cost-positive → muted `—` over the cost line | spec summary.md |
| T-SUM-016 | Given rows / then a Total footer with the same cell treatment; zero rows → empty state, no footer | spec summary.md |
| T-SUM-018 | Given the options query errors / then no error shown, pivots contain only "All" (+ current selection); a summary query error DOES render its message | spec summary.md |
| T-SUM-019 | Given a selected pivot value absent from the narrowed options / then prepended and kept selected | spec summary.md |
| T-CCE-V-001 | Given assigned + not auto / then row background green `#d1fae5` | spec cc-expenses.md |
| T-CCE-V-002 | Given assigned + auto / then orange `#fed7aa` even when a highlight keyword also matches (assigned beats highlight) | spec cc-expenses.md |
| T-CCE-V-003 | Given unassigned + highlight match / then yellow `#fef9c3` with title `Highlight match: "<keyword>"` | spec cc-expenses.md |
| T-CCE-V-004 | Given unassigned, no match / then no background | spec cc-expenses.md |
| T-CCE-V-005 | Given null expense_date / then red `?` in the Date cell | spec cc-expenses.md |
| T-CCE-B-001 | Given a 4-line batch, 2 assigned / then Assigned shows `2 (50%)` and Total sums ALL lines | spec cc-expenses.md |
| T-CCE-B-003 | Given zero exportable lines / then "Assign at least one line to a project to populate the paste block." | spec cc-expenses.md |
| T-COM-107 | Given a body containing `@Adi fix this` / then `@Adi` visually highlighted, no notification sent | spec comments.md |

---

## 9. E2E — Playwright (behavior assertions, not render assertions)

The three critical flows named in 04-architecture, plus two cheap
smokes that cover the pw-flagged 🟡 items:

| ID | Given / when / then | Source |
|---|---|---|
| T-E2E-001 | **Import→resolve→commit**: given a real Clockify CSV fixture (incl. one unknown email + one unknown project) / when uploaded, decisions applied, committed / then the batch summary shows matched/unmatched, the unknown-email rows show Blocked on /entries, and totals match the fixture (carries IMP-001/002 end-to-end) | new (04-architecture) + spec IMP-001 |
| T-E2E-002 | **Entry edit**: given a committed entry / when edited inline (duration + project) and saved / then the change persists, the filtered/paged URL state is preserved, and no silent nested-form failure occurs (carries ENT-022) | new (04-architecture) + spec ENT-022 |
| T-E2E-003 | **Invoice create + lock**: given eligible entries / when an invoice is created, entries applied, and a manager then tries to edit one / then the row is locked for the manager, totals match, and detaching unlocks | new (04-architecture) |
| T-E2E-004 | **View-as smoke**: given rian / when entering view-as Adi / then the amber banner shows, owner-only affordances disappear, an out-of-scope mutation is refused, and Exit restores (carries RBAC-002/003) | spec RBAC-003 (🟡, pw) |
| T-E2E-005 | **Comment smoke**: given Adi posts a comment / then rian's bell badge increments; resolve decrements (carries COM-102's pw half) | spec COM-102 (🟡, pw) |

---

## 10. Migration diff harness — its own checks as tests

The harness (05-migration-plan step 0, built FIRST in R0) is the
oracle for everything the unit suite can't pin: it compares canonical
JSON exports from the live Supabase app and the new Postgres. Its
checks are versioned and run in CI against the nightly migration
rehearsal.

| ID | Given / when / then | Source |
|---|---|---|
| T-MIG-001 | Given any month / then per-month per-project (entry count, source seconds, cost total, billout total with AND without adjustments) identical old vs new | new (05 step 0) |
| T-MIG-002 | Given every invoice / then line count, attached-entry id set, and totals identical | new (05 step 0) |
| T-MIG-003 | Given every CC batch / then line count, assigned totals by project, billed totals identical | new (05 step 0) |
| T-MIG-004 | Given entity inventories / then operators/clients/projects with entry counts, and workers with rates, identical | new (05 step 0) |
| T-MIG-005 | Given a difference NOT on the explicit allowlist (converted_* dropped, operator/client/project text renamed to `raw_operator`/`raw_client`/`raw_project`, `billout_seconds` duplicate dropped, 11-param options overload dropped) / then the run FAILS — allowlist self-test with a seeded unexpected diff | new (05 step 0) |
| T-MIG-006 | Given the pre-flight check / then `count(transferred_at NOT NULL AND invoice_id NULL)` is computed and REPORTED — expected 0, since the 2026-05-25 backfill already put every transferred row on a per-org "Legacy transfers" invoice; a nonzero count is NOT an abort gate — those rows take T-MIG-012's straggler path with a WARNING (05-migration-plan step 2; judgment call #22) | new phase-outs.md (T-PHX-003, resolved) |
| T-MIG-007 | Given migrated data / then every previously-locked row (invoice_id OR transferred_at) is locked via an invoice line in the new system, and NO previously-unlocked row became locked | new phase-outs.md (T-PHX-004) |
| T-MIG-008 | Given historical `source='toggl'`/`'backfill'` rows / then queryable plain entries with `source` carried VERBATIM — the new CHECK admits the historical values, no `legacy_source` remap exists; only the importer is restricted to writing clockify/manual (02-domain-model §3) — no behavior attached | new phase-outs.md (T-PHX-006) |
| T-MIG-009 | Given the rebuilt schema / then NO `transferred_at`, `transferred_by`, `transfer_batch_id`, `converted_user`, `converted_duration_seconds`, `rate_proportion`, `base_rate_usd`, `default_hourly_rate_usd`, `hourly_rate_usd`, or `must_change_password` columns exist; and the CARRIED columns are present — `invoices.manual_total_usd`, `time_entries.source_rate_usd`/`source_amount_usd` are in NO drop list, and the operator/client/project text triple carries RENAMED as `raw_operator`/`raw_client`/`raw_project` with values equal to the legacy text on ALL rows (immutable import payload, not a display cache — 02-domain-model §3) (05-migration-plan steps 6–7) | new phase-outs.md (T-PHX-001) |
| T-MIG-010 | Given a migration run / then every legacy table row is accounted for: migrated / dropped-allowlisted / error, and the run aborts on unexplained deltas | new (05 step 2) |
| T-MIG-011 | Given the migration run twice on the same snapshot / then the second run is a no-op (idempotent — the "cutover day is the hundredth run" property) | new (05 principles) |
| T-MIG-012 | Given T-MIG-006's count is NONZERO / then the straggler rows attach to the EXISTING per-org "Legacy transfers" invoice (the one the old backfill created — per ORG, not per operator; NO new invoices created) and the run report carries a WARNING for owner review; the run never aborts on this | new (05 step 2; judgment call #22) |
| T-MIG-013 | Given migrated entries / then cost/billout stamps are byte-identical to legacy values — copied verbatim, NEVER recomputed | new (05 step 2.6) |
| T-MIG-014 | Given legacy `invoice_applied_at`/`invoice_applied_by` values / then each survives verbatim as `invoice_lines.attached_at`/`attached_by` on the corresponding line — the audit VALUES carry, not just the columns | new (05 step 2.7) |
| T-PHX-005 | Given the new import UI / then no Toggl source option; a Clockify CSV imports unchanged | new phase-outs.md |
| T-PHX-007 | Given the rebuilt codebase / when grepped / then no `SUPABASE_*` env references, no RLS policy files, no JWT-minting code | new phase-outs.md |
| T-PHX-011 | Given the 2025-12-31 "Transfer baseline from Toggl…" manual entries / when migrated / then all 20 arrive as ordinary entries (date, description, source email intact) and the hourly chip's lifetime prepaid-block drawdown totals are unchanged — load-bearing opening balances, NOT Toggl residue (02-domain-model §2 `prepaid_block`; carries T-PRJ-036) | new phase-outs.md |

Not a CI test but a mandatory gate: the **restore drill** (05 backup
regime) — restore the nightly pg_dump into a scratch container, diff
harness green against production, once before cutover.

---

## 11. Coverage assertions — what has NO test, and why

Behaviors from the feature specs deliberately without automated
coverage (everything else above either ports, implements, or pins):

| Behavior | Why no test | Verdict |
|---|---|---|
| **Sign-in flow against LIVE id-auth** (OAuth+PKCE round trip, real id_token) | Unit tests cover the app-side verification (T-AZ-040…042); the live round trip needs the registered client + real auth.bowden.works. | **TODO** — the M1 gate IS this integration test (Pattern B kit shipped 2026-07-07; 09-build-plan M1) |
| Performance targets (<1s cold dashboard, instant filters, no full-page reloads) | Not unit-testable end-to-end; measured manually at the R1 gate per 00-vision success criteria. The CI-testable half (seeded 100x EXPLAIN/latency budget) is T-RPT-006. | Acceptable (manual gate + T-RPT-006) |
| Phone floor (390px) + zero inline styles | Frontend standards, not behaviors. | **TODO** — cheap CI guards: a grep for `style=` in `frontend/src` and one Playwright 390px viewport smoke; not yet in the catalog |
| Concurrent-writer races (two owners attaching the same entry to different invoices simultaneously) | No current-app equivalent test; the rebuild's DB constraint (one invoice line per entry) makes the race safe by construction, but no test exercises it under concurrency. | **TODO** — add a two-session pytest once the constraint lands |
| Caddy / id-auth gate behavior (redirect to `auth.bowden.works`, session revocation) | Owned by the id-auth service and server infra, tested there; the app's contract is the app-auth kit's flow (tested). | Acceptable (out of app scope) |
| Backup/restore | One-time operational drill (§10 note), not repeatable CI. | Acceptable (runbook gate) |
| Command palette, skeleton loaders, motion (04 "fun/fast extras") | Cosmetic/interaction polish; behavior-free by design. | Acceptable — revisit if the palette gains actions beyond navigation |
| Audit coverage of FUTURE mutations | T-AUD-002 (route-inventory meta-test) guards this structurally; a genuinely new route pattern could still evade it. | Acceptable with the meta-test |
| catalog IDs ENT-012/ENT-013 as distinct tests | Subsumed: list-narrowing by T-ENT-05, totals-narrowing by T-RPT-001/002, bulk-scope by T-ENT-16. | Acceptable (mapped, not lost) |
| spec T-SUM-010 (case-insensitive text-chain adjustment join) | Subsumed by T-SUM-001: the rebuild groups and joins by FK (delta #4), so a case-mismatched text join is structurally impossible. | Acceptable (subsumed) |
| spec T-TEAM-05 (proportion parsing) | Deliberately dropped: rate_proportion retired with TEAM-003/004 (ADR #034); T-MIG-009 asserts the column is gone. | Acceptable (deliberate) |
| spec T-TEAM-10 (consolidate_as → converted_user) | Deliberately dropped: consolidate_as/converted_* retired with CLK-019; T-MIG-009 asserts the columns are gone. | Acceptable (deliberate) |
| spec T-TEAM-12 (non-owner /team hides billout column) | Subsumed by T-PRM-05: billout is absent from non-owner API payloads structurally (DTO shaping), so no per-page column test. | Acceptable (subsumed) |
| spec T-TEAM-17 (unknown-email CSV row → NULL stamps + post-import listing) | Ported under different IDs: T-IMP-002 + T-CLK-018 (minus the retired converted_user clearing). | Acceptable (mapped, not lost) |
| spec T-PRJ-036 (the 20 sentinel baseline entries survive migration) | Subsumed: T-PHX-011 asserts all 20 arrive intact with drawdown totals unchanged; T-MIG-001/T-MIG-010 pin their per-month parity and row accounting. | Acceptable (subsumed) |
| spec T-PRJ-037 (hourly-chip lifetime drawdown pins date=all) | Subsumed: T-PHX-011's totals-unchanged check fails if any filter excludes a baseline row — the block balance would understate vs legacy. | Acceptable (subsumed) |
| Dropped Toggl/transfer/proportion behaviors (TGL-*, TRN-*, ENT-005/006/007, TEAM-003/004, CLK-019/020, AUTH-001/002, RBAC-001/006, VAS-02/03) | Phase-outs confirmed in 00-vision-and-scope; guarded negatively by T-PHX-* / T-MIG-009. | Acceptable (deliberate) |
| R2–R4 features (engagement visibility levels, sets/tasks, commissions, profit share, invoice generation from engagements) | Out of R1 scope (judgment call #6); the engagement/terms foundations exist but carry no behavior; sets/tasks tables deliberately do NOT exist until R3 (judgment #32). Each phase brings its own test-plan appendix. | Acceptable (future doc) |

### Count reconciliation vs the current suite

- Current suite: **116** vitest tests (verified count in tests.md;
  judgment call #14's "44-test" clockify figure is off by one — 43).
- Of those 116: **13 drop** with phase-outs (11 real TGL tests +
  CLK-019/CLK-020 — note TGL-007/TGL-008 are catalog ghosts that
  never existed in the vitest suite, distinct from real drops;
  tests.md quirk #5), **5 port with rewritten semantics** (CLK-018,
  CLK-021b, CCE-X-002, INV-P-002, FLT-006), **98 port mechanically**
  — all accounted for row-by-row above
  (CLK §2.1, CCE §2.3, TGL §3.5, INV §3.6/§8.2, FLT/FMT §8).
- This catalog totals **415 test IDs**, all unique (grep `'^| T-'`
  rows in this file for the exact number at any time): the 98+5
  ports, ~44
  previously-🟡 catalog behaviors, the feature-doc specs that were
  never automated, and the rebuild-only surface (authz grid, audit,
  middleware, migration harness). The R1 success criterion
  ("current 116 + ports of the RPC/permission semantics green") is
  satisfied by §2, §3, §4, §6 plus the §10 harness.

### Standing rule for new work

Every substantive feature added during the rebuild adds its tests to
THIS catalog (ID, one-line G/W/T, source `new`) in the same change —
the "running log of unit tests" requirement. A feature whose tests
aren't in this file isn't done (same Definition-of-Done discipline as
docs/features/).
