# Acceptance — rian's parity checklist

This is the living checklist of milestone gates **you (rian) can run
yourself**, without reading code. One section per milestone from the
build ladder (`/srv/apps/work/docs/rebuild/09-build-plan.md`). Gates
are checklists, never vibes: a milestone is done when every step in
its section passes.

**How to use:** when a session reports a milestone landed, open its
section and run the steps top to bottom. Any step failing means the
milestone is NOT done — say so, and the next session fixes it before
anything new starts. Sections marked ⏳ get their concrete steps when
that milestone's build starts; until then the one-liner says what the
gate will prove.

## M0 — Walking skeleton

- [ ] Visit https://with.bowden.works — the app shell renders (nav +
      footer), not the old static placeholder.
- [ ] The version in the footer matches the top entry of
      [CHANGELOG.md](../CHANGELOG.md).
- [ ] Visit https://with.bowden.works/dev/ui — the design-system
      styleguide renders: tokens, and the first primitives (DataTable,
      FilterBar, MoneyCell) in their states.
- [ ] https://with.bowden.works/api/meta returns JSON showing that
      same version and the database as connected.

Two further M0 gate items are session-verified rather than clickable —
`make check` green, and the nightly snapshot job having run 7
consecutive nights. BUILD-STATE.md tracks both.

## M2 — Schema + migration transforms (current milestone)

The gate here is mostly scripted; these are the parts you can run and
read yourself (from `/srv/apps/with`, or ask a session to run them):

- [ ] `srv-gw logs --project with` after 03:30 — the nightly line shows
      the FULL rehearsal: dump, load_scrub, extract, transform,
      extract_new, compare — and "RESULT: GREEN".
- [ ] `.venv/bin/python -m scripts.migrate_legacy` prints a report
      ending `RESULT: OK`, with `legacy.time_entries: 4593 ->
      migrated=4593` (the count grows as Adi uploads) and every legacy
      table showing no `!! delta`.
- [ ] The same report's straggler line: ONE known straggler
      (entry adeb5a3f…, CAD 3.49, Villa del Mar) is expected until you
      resolve it in the old app — see docs/features/migration.md
      "Owner review". Anything MORE than that needs a look.
- [ ] Restore drill (run with the orchestrator session): restore the
      latest sidecar backup into a scratch container, run the
      transforms + harness there, compare GREEN.

## M1 — Sign in with BW

- [ ] Visit https://with.bowden.works in a fresh/incognito window —
      you are redirected to the BW login at auth.bowden.works (never a
      password form on the app itself).
- [ ] Sign in as **rian** — you land back on the app, and the header's
      right side shows `rian` with a "Sign out" link.
- [ ] **Adi repeats the same** with his BW account (real login, not a
      shared password) — header shows `adi`.
- [ ] Click "Sign out" — you're signed out of BW too: revisiting the
      site asks you to log in again (no silent re-entry).
- [ ] https://with.bowden.works/healthz answers `{"status":"ok"}`
      WITHOUT being signed in; https://with.bowden.works/api/meta
      WITHOUT being signed in returns a 401 error, not data.

Session-verified (not clickable): forged/expired/wrong-audience tokens
rejected — T-AZ-040/041/042/043/045 plus the auto-provision tests, all
green in `make check` (40 backend tests).

## M3 — The read-only mirror (side-by-side months)

Open BOTH apps next to each other, signed in as rian:
**old** = https://work.bowden.works · **new** = https://with.bowden.works

**A. /summary, the 3 biggest months (numbers must match to the cent):**

For each of **June 2026, March 2026, January 2026**:

- [ ] Old: open `work.bowden.works/?month=YYYY-MM` (2026-06, 2026-03,
      2026-01). New: open `with.bowden.works/summary?month=YYYY-MM`.
- [ ] The headline **green billout number matches** exactly, and the
      muted "cost" line under it matches exactly.
- [ ] Total **hours** match (same h:mm figure; the ±140h line agrees).
- [ ] Pick the 3 biggest rows in the by-project table — for each, the
      This-month cell matches: same billout (bold line), same
      `$cost (h:mm)` small print. (Row ORDER may differ only where two
      rows tie; values may not.)
- [ ] The **Total footer row** matches column for column.

**B. A bookmarked filter view on /entries:**

- [ ] Old: `work.bowden.works/entries?date=last-month&status=all&operator=PlusROI`
      (the "Last month's PlusROI" chip). New: same path+query on
      `with.bowden.works/entries` — or click the same chip.
- [ ] The summary card matches: same **entry count**, same **hours
      (h:mm:ss)**, same **Cost**, same green **Billout**.
- [ ] Default view sanity: bare `/entries` on both shows the pending
      backlog (all dates + status=pending) with the same entry count.
- [ ] Pivot dropdowns on the new side narrow as you filter (pick
      Operator=PlusROI → the Client list shrinks to PlusROI's clients).

**C. Adi's view (or rian via View-as → Adi):**

- [ ] New side shows the amber "Viewing as Adi Pramono" banner.
- [ ] /entries shows ONLY Adi's uploaded batches' entries and **no
      Billout column / no Billout stat**; /summary shows cost-only
      (no green number); /projects has no Income/Margin/Billout
      columns.
- [ ] Old side as real Adi (or view-as): the same counts.
- [ ] Exit view — the banner disappears, owner columns return.

Session-verified (already green in `make check`): the same numbers are
asserted programmatically for ALL 8 months against the M2 harness
baseline (tests/reporting/test_rpt_equivalence.py), and the
capability/scoping grids (T-AZ-001 + equivalence property) pass.
Perf note for the deploy gate: /api/summary and /api/entries answer in
well under 500ms on the full 4,593-row dataset (in-process timings
16–71ms warm; re-measure over HTTPS after deploy).

## M4 — CSV re-upload (the real-CSV gate)

Proves: a real weekly Clockify CSV re-uploaded through staging →
resolver → commit produces a week the diff harness confirms identical
to the old app's, and the two parsers agree byte-for-byte on the file.

**You need:** the SAME weekly Clockify CSV that was (or will be)
uploaded to the old app that week. Save a copy into
`/srv/apps/with/harness/csv-archive/` named
`YYYY-MM-DD_<original-name>.csv` (see the README there).

**A. Parser cross-validation (scripted, from `/srv/apps/with`):**

```bash
.venv/bin/python -m scripts.cross_validate_csv harness/csv-archive/*.csv
```

- [ ] Every file prints `✓ … identical (N entries, …)`. Any `✗` is a
      port-fidelity bug — stop and report it.

**B. The re-upload, in the browser (as rian or Adi):**

- [ ] Open https://with.bowden.works/import — the upload panel says
      formats are auto-detected (no format pickers anywhere).
- [ ] Upload the weekly CSV with the batch name left BLANK — after
      "Parsing CSV…" you land on the resolver, and the batch is
      auto-named from the CSV's date range (e.g. "Jun 2 – Jun 8,
      2026"), matching what the old app named it.
- [ ] The resolver shows "✓ All names already exist" (a routine weekly
      CSV has no new names). If it shows unknowns/buckets instead,
      resolve them the same way you did (or would) in the old app.
- [ ] Click Import ("Importing…") — you land back on /import with the
      green "Import complete" card; matched/unmatched counts equal the
      old app's for the same file.
- [ ] The new batch row shows the right entry count; its name links to
      /entries filtered to the batch; spot-check a few rows (times,
      hours, cost — and billout as rian).

**C. The harness check for that week (scripted):**

The nightly rehearsal wipes and rebuilds `time_entries` from the old
app's data at 03:30 — so run this the SAME day as step B, AFTER the
old app also has the week (Adi's normal upload):

```bash
cd /srv/apps/with
.venv/bin/python -m harness.extract_legacy --env-file .supabase-export.env > harness/out/gate-legacy.json
.venv/bin/python -m harness.extract_new > harness/out/gate-new.json
.venv/bin/python -m harness.compare harness/out/gate-legacy.json harness/out/gate-new.json
```

- [ ] Compare is GREEN for the re-uploaded week's month (the shadow
      upload plus the old app's copy of the same week agree
      cent-for-cent). NOTE: the new side will show the week TWICE
      (migrated + shadow-uploaded) until the nightly rebuild wipes the
      shadow copy — delete the shadow batch on /import after the
      check, or run the check against a scratch restore. If the counts
      confuse you, ask a session to run this step.

**D. Delete guard sanity (30 seconds):**

- [ ] On /import, the shadow batch's Delete asks "Delete batch … and
      all N entries…?" with the REAL count; deleting it removes the
      batch and its entries.
- [ ] Any batch with invoice-applied entries shows "Locked" instead of
      Delete (hover it for the count).

Session-verified (already green in `make check`): the 42-test CLK
parser port, staging/resolver/commit/batch suites (T-IMP-001…031
minus the UI-only rows), stamping precedence (Gary 0/25, override
precedence, NULL-no-term), chunked-insert rollback, and the vitest
resolver-state tests.

## M5 — Entries editing + the bulk-safety replay

Everything below runs in your browser at
https://with.bowden.works/entries, signed in as rian. The scripted
twin of this section is already green in `make check`
(tests/bulk/test_gate_incident.py — it replays the same shape against
migrated data and re-runs the harness compare after the undo).

**A. Cell editing (the "Google Sheet" model):**

- [ ] Click any Description cell → it becomes an input. Type, press
      Enter — it saves without the page moving. Press Esc instead —
      nothing changes.
- [ ] Tab walks the editable cells left-to-right and wraps to the next
      row; ↑/↓ move up/down a row in the same column.
- [ ] Click a User cell → a searchable team-member picker opens. Pick
      someone else — the **Cost and Billout cells re-stamp and flash
      green** (they recompute from that person's current rates).
- [ ] Click a Project cell → a searchable "Client : Project" picker.
      Picking a project also fixes the Operator + Client cells (they
      follow the project). Type a brand-new name → "Create …" adds it
      in place.
- [ ] Click a Client cell → picking a different client CLEARS the
      project and asks you to pick/create one inside it (an entry
      can't keep a project that isn't under its client). Operator cell
      works the same, one level higher.
- [ ] A row with the ✓ (applied to invoice) shows 🔒 and its cells
      don't open for Adi; as rian (owner) you can still open them.
- [ ] The ✎ at the row end opens the full-row side panel (all fields +
      Re-resolve + Delete). Delete shows an **Undo** toast — click it
      and the row comes back exactly as it was.

**B. Manual entry:**

- [ ] "+ New entry" (top right) → date defaults today, team member
      defaults to you. Fill duration `1:30` + a description → Create.
      The row appears; its batch is your "Manual entries" bucket
      (Batch filter dropdown shows it).

**C. THE INCIDENT REPLAY (the reason M5 exists):**

The 2026-05-22 incident: a bulk edit meant for 353 filtered rows
changed all 3,274. The same move now:

- [ ] Filter to a subset — e.g. the "Last month's PlusROI" chip, or
      Status=Pending + Operator=PlusROI. Note the **Entries in
      filter** count on the summary card (call it N; on today's data
      Status=Pending + Operator=PlusROI ≈ 216 of 4,593).
- [ ] Tick the header checkbox, then click **"Select all N matching
      the filter"** in the dark bulk bar.
- [ ] Edit fields ▾ → pick a Team member → **Reassign N** → the
      confirm strip quotes exactly N — confirm.
- [ ] The result toast reads "**matched N, changed N — verified**" —
      the numbers must equal the count you confirmed. Rows outside
      your filter are untouched (clear the filter and spot-check).
- [ ] **The safety stop:** repeat the selection, but before
      confirming, delete one matching entry in a second tab (or
      change its status). Confirm in the first tab → a red toast:
      "Safety stop: the filter currently matches … but you confirmed
      …. Nothing was changed." — and truly nothing changed.
- [ ] Click **Undo** on the success toast from the real run → "Undo:
      restored N entries." Every edited row is back byte-for-byte
      (worker, money stamps, everything).
- [ ] Locked rows: include an invoice-applied row in a selection →
      the result reports "… locked rows skipped" with the exact count.

**D. Manage entities (Directory → Manage):**

- [ ] Rename an operator/client/project → every entry displays the
      new name instantly (no propagation step — display follows the
      entity).
- [ ] Merge two projects (⇄): entries re-point, the husk disappears;
      an INVOICED entry keeps its invoice snapshot untouched.
- [ ] The Palms case: merge a client into a same-named client under a
      different operator — same-named projects merge recursively, no
      constraint error (the legacy B47 landmine).
- [ ] Delete only works on empty entities; anything with children
      tells you to merge instead.

## M6 — Invoices + the paste-block byte-diff

Everything below runs in your browser, signed in as rian. The scripted
twins are already green in `make check`:
`tests/invoices/test_paste_block_gate.py` (all 3 real migrated
invoices byte-diffed against the old app's actual
`lib/invoices.ts` code run through its own tsx) and
`tests/invoices/test_lifecycle_gate.py` (the full
create→apply→locked→detach→delete cycle on real data, harness green
after).

**A. Create an invoice (Billing → Invoices):**

- [ ] https://with.bowden.works/invoices shows the three migrated
      invoices ('Legacy transfers', '2026 April PlusROI', '2026 May
      PlusROI') with status pills, entries/hours/cost/billout/margin —
      the same numbers work.bowden.works/invoices shows for them.
- [ ] "+ New invoice" → the date pre-fills the **last day of last
      month**; name it like you always do (e.g. `2026-06 PlusROI`) →
      Create lands on its detail page.
- [ ] Try creating it again with different casing → "An invoice named
      … already exists."

**B. Apply last month's PlusROI (the month-end loop):**

- [ ] /entries → the "Last month's PlusROI" quick chip (or Date=Last
      month + Operator=PlusROI + Status=Pending). Note the count N.
- [ ] Header checkbox → "Select all N matching the filter" → **Apply
      to invoice ▾** → pick your new invoice → the confirm quotes
      exactly N and warns they'll be locked → Confirm.
- [ ] The toast reads "**Applied N to "…" — verified.**" and offers
      **Undo (detach)**. Don't click it — but know it's there.
- [ ] The applied rows now show ✓/🔒; re-filter Status=Pending → they're
      gone from the pending lane.

**C. The paste block — the actual gate:**

- [ ] Open the invoice detail → "Paste block for the PlusROI sheet" →
      **Copy to clipboard** ("Copied N lines — verified").
- [ ] In work.bowden.works, open the SAME month's invoice (or build it
      the old way) and copy ITS paste block.
- [ ] Paste both into two columns of a scratch sheet (or two files and
      diff) → **byte-identical**: same rows, same order, same amounts,
      same `Bowden Works Team` / `Bowden Works` / `Labour` columns.
      (The in-tests gate already proves this for the three migrated
      invoices; this is your eyes-on version for a fresh month.)

**D. Locks, detach, status:**

- [ ] As Adi (or view-as): a locked row's cells don't open; edit
      attempts are refused with "attached to an invoice and locked".
- [ ] Bulk Recalculate over locked rows: "N locked rows skipped",
      changed 0 — invoiced money never silently re-stamps.
- [ ] On /entries with the invoice's rows (the detail page's "→ View
      the N attached entries" link): pick a few rows explicitly →
      **Detach** → confirm → "Detached … — verified", rows back to
      Pending. "Select all matching" + Detach is refused — detach is
      explicit-selection only, by design.
- [ ] Detail page: Mark sent stamps the Sent date; Mark paid stamps
      Paid; flipping back and forth never changes the original stamps.
- [ ] Delete is disabled while entries are attached ("Detach all
      entries first") — no more silent unlock-everything deletes.

## M7 — CC statement ⏳ lands with M7

Will prove: one real credit-card statement pasted into both systems
yields identical assignments and identical billing export text.

### M7 — CC pass-through expenses (slice A) ✅ machine-verified 2026-07-08

Proves: the statement parser and the PlusROI billing export are
byte-identical to the old app, and the whole paste → auto-rules →
assign → export loop works on the real migrated data — the "CC
statement" gate above, discharged by machine.

Scripted checks (all green — `tests/cc/`):
- **PARSER byte-diff:** the 102 REAL migrated statement lines
  (`cc_expense_lines.raw_line`) run through the new Python
  `parse_cc_line` are field-identical to the legacy `parseCcLine`,
  executed through the old app's `tsx` (`scripts/ts_cc_parse.mjs`) —
  0 divergences (`test_cc_parser_gate.py`).
- **EXPORT byte-diff:** every real batch's paste block is byte-identical
  to the legacy `formatCcPasteBlock` (`scripts/ts_cc_export.mjs`) fed the
  same assigned lines — same 7-column shape, same `Bowden Works` literal
  (now a tenant setting), same amounts to 2dp.
- **End-to-end on a clone of the REAL migrated data:** create a batch →
  paste real lines → a rule auto-fills the recurring charge (orange) →
  confirm it (green) + assign one by hand → the export emits exactly the
  assigned lines, skipping the unbilled one (`test_cc_e2e_gate.py`).
- The CCE-* parity suite (dates/currency/parse/paste/highlight/rules/
  export) and the CCE-A/B service behaviors, plus owner-only enforcement
  at the API (`test_cc_service.py` / `test_cc_router.py`).

rian walkthrough (leisure): BILLING → CC expenses → new batch → paste a
real statement → work the yellow rows → copy the paste block and diff it
against what work.bowden.works produces for the same statement.

### M7 — Comments board + bell (slice B) ✅ machine-verified 2026-07-08

Will prove: rian and Adi can leave each other notes from the 💬 bell,
resolve/reopen them, delete their own, and the red unresolved badge is
always accurate — matching the old app's tiny comment feature exactly,
minus its warts.

Scripted checks (all green in the API suite `tests/comments/`):
- Post a note as rian → Adi sees it newest-first and the badge is +1
  (T-COM-101); Adi resolves it → badge −1; reopen → back on the badge
  (T-COM-102 / T-COM-104).
- Only the author (or you, as super admin) can delete a comment; another
  member's delete is refused with a message, not a silent no-op
  (T-COM-103).
- Posting while "viewing as Adi" authors the note AS Adi, and the Delete
  affordance follows that same one identity (T-COM-108 / T-COM-109).
- With 51+ comments the badge still counts every open one, even past the
  50-row panel window (T-COM-106).

rian walkthrough (leisure): open the bell on any page, post "@Adi test",
switch to Adi, resolve it, and confirm the badge clears on both sides.

### M7 — Team & engagement admin (slice C) ✅ machine-verified 2026-07-08

Will prove: rian manages the worker roster from ADMIN → Team — add a
person, set their cost/billout rates, change a rate later WITHOUT ever
rewriting the money already stamped on past entries, deactivate someone
who left, and set project-specific billout overrides — matching the old
Team page's economics exactly, minus the hard-delete orphaning wart.

Scripted checks (all green in `tests/team_admin/`):
- Add a worker with a cost + billout rate → they appear active with both
  terms and now resolve for imports (T-TEAM add row); a duplicate active
  email is rejected (T-TEAM-01); email is stored trimmed + lowercased and
  matches imports case-insensitively (T-TEAM-04).
- Gary (cost 0 / billout 25) stamps 10h as cost **$0.00** (not NULL) and
  billout **$250.00** — a zero-cost billable worker (T-TEAM-08); a blank
  cost stamps NULL, never $0 (T-TEAM-07).
- Editing a worker without touching billout leaves the billout rate
  untouched (T-TEAM-06); the `consolidate_as` label round-trips
  (T-TEAM-10); a worker-specific billout override beats a project-wide
  percentage which beats the term default (T-TEAM-11).
- Deactivate ends the engagement — history + stamps intact, the worker
  stops resolving on new imports, Gary's 0/25 stays representable;
  reactivate restores it (R1 replacement for T-TEAM-09).
- **THE GATE (on a clone of the REAL migrated data, worker = Adi):** a
  cost 14 → 22 change opens a NEW term row, closes the old one
  (`effective_to` = the day before), leaves every existing entry's stamp
  **byte-for-byte unchanged**, keeps the OLD 14 rate resolvable for a
  March date, and stamps the NEW 22 rate on the next import. Clearing a
  rate ends the term and the next stamp is NULL.

rian walkthrough (leisure): open ADMIN → Team, add a test worker, give a
rate, then change it and confirm past entries on /entries are unchanged
while a fresh manual entry stamps the new rate; deactivate the worker.

## M8 — Hardening + saved views + UX debt ✅ machine-verified 2026-07-08

Proves: your recurring views save/pin/reopen; the app stays fast at 100×
your data with no seq-scan regressions; the R1 UX-fixes checklist is
signed off; and the cutover-gate (tier-A) inventory is on the record.

### Saved-views walkthrough (do this in the browser)

1. Open **/entries**. The old "Quick:" chip row is gone — there's a
   **Views ▾** picker instead. Open it: you should see your seeded
   pinned views — *Last month's PlusROI*, *Pending backlog*, *Blocked
   triage* — and they also appear under a **Pinned** group in the nav.
2. Apply *Last month's PlusROI* → the list + summary card scope to last
   month, PlusROI. The picker shows it as the active view.
3. Set some filters by hand, then **＋ Save current view…**, name it,
   tick **Pin to nav**, Save. It appears in the picker and the nav.
   Reload the page → it's still there (it's per-user, server-stored).
4. **Rename** and **Delete** it from the picker's inline controls. Try
   saving a second view with the same name → friendly "already exists".
5. Open **/projects** → the *Hourly blocks* / *Missing info* / *Last
   month admin* views replace the old chips (Hourly keeps `date=all` —
   the support-block drawdown math). **/cc-expenses** has the picker too.
6. Sanity: these are YOUR views. They don't change when you use
   "View as" (they follow you, like browser bookmarks).

### Performance at 100× (the numbers, for the record)

Measured on a synthetic **459,305-entry** scratch DB (100× production's
~4,593; `scripts/seed_100x.py`), medians of 8 warm calls through the
full app stack. Full detail + methodology in `docs/PERF.md`.

| endpoint | 1× warm | 100× cold | 100× warm | budget |
|---|--:|--:|--:|---|
| Dashboard (`/api/summary` this month) | 29 ms | **859 ms** | 807 ms | cold **< 1 s** ✅ |
| Entries list — month-scoped (interactive) | 26 ms | 65 ms | **57 ms** | warm **< 300 ms** ✅ |
| Entries list — default (`date=all`, all pending) | 34 ms | 408 ms | 362 ms | (unbounded backlog COUNT) |
| Entries totals (default) | 16 ms | 455 ms | 446 ms | (unbounded aggregate) |
| Projects rollup (`date=all`) | 25 ms | 759 ms | 788 ms | (unbounded aggregate) |

Reading it: the **cold dashboard is under 1 s** and the **interactive
(scoped) list is 57 ms** — both budgets met, and the scoped list barely
moves from 1× because the date/tenant index carries it. The three
unbounded `date=all` aggregates land 360–810 ms at 100× — they add up
the *entire* backlog (O(N), the correct plan, **no seq-scan or
algorithmic regression**), at a scale ~decades beyond the real growth
rate. The **no-seq-scan gate** (`tests/perf/`) proves the indexed hot
paths stay indexed and runs in `make check`.

### UX + cutover inventory sign-off

- `docs/UX-CHECKLIST.md` — all **7** flagged UX warts FIXED (filter-
  context preservation, `?invoice=` scope, debounce, no-op save, month
  paging + rollover, progress feedback, Clear-keeps-month) + the quick-
  chips retired for saved views. Residual nits listed, none blocking.
- `docs/TIER-A.md` — the cutover-gate inventory: **253/282 tier-A IDs
  mapped**, the money/authz/import/migration cores fully green. Two GAPs
  are one genuinely-unbuilt feature (project income/adjustment editing)
  logged for the orchestrator; the rest are present-but-untested or
  re-scoped.
- **390px pass** — the mobile-first pages (Summary, project drill-down,
  comments, dashboard) don't overflow at a 390px viewport; wide tables
  scroll inside their own container (`styles/responsive.css`; a vitest
  guards no inline styles).
- **Playwright smokes** — the 3 critical flows (import→resolve→commit,
  entry cell-edit + bulk-with-undo, invoice create→apply→paste→detach)
  are wired as `make e2e` (separate from `make check`; needs
  `playwright install` + a seeded e2e DB — see `docs/E2E.md`).
