# Phase-outs — current-system spec (parity bar for the rebuild)

> This doc is the exact-behavior record of everything the rebuild **deliberately drops**: the
> `/transfer` page and Toggl CSV export, `lib/toggl.ts`, the orphaned `bulkMarkTransferred` /
> `bulkUnmarkTransferred` actions, the `transferred_at` lock semantics, the Toggl CSV *import*
> dialect, the `rate_proportion` remnants, `converted_user` / `converted_duration_seconds`,
> the `must_change_password` first-login flow, and the Supabase-specific machinery (RLS
> policies, JWT-swap view-as). Unlike the other feature specs, the parity bar here is
> **inverted**: the rebuild must NOT contain these behaviors, and the data migration must
> retire their columns knowingly, not accidentally. Each subsection records what the thing
> did (precisely enough to reproduce one final export if ever needed), why dropping it is
> safe (cited to the transcript or an ADR), and what — if anything — absorbs its
> responsibility.

## User workflows

All of these are workflows rian has **explicitly ended**:

1. **The Toggl round-trip (dead 2026-07-07).** Originally: clean up imported time in the app →
   select pending entries on `/transfer` → download a Toggl-Detailed-Report-shaped CSV →
   import into Toggl (later: paste directly into the PlusROI invoicing Google Sheet) → return
   and "Mark as transferred" only after the import succeeded → undo the batch if it didn't.
   Rian, final session: *"I no longer have any need for toggl"* (transcript §16). The invoice
   workflow replaced it as the lock mechanism on 2026-05-25: *"the effect will be the same as
   'transfer' where it locks the entries. In fact, It can replace transfer all together"*
   (transcript §6, ADR #038). Since #038 the page survives off-nav only so rian could pull
   one final CSV backup.
2. **Importing rian's own Toggl exports.** The upload form's Source=Toggl radio existed because
   rian consolidated his Toggl history into the app (transcript §4). With Toggl gone there is
   no future Toggl CSV to import; historical rows migrate as plain data (judgment call #8).
   **⚠️ Caution for any Toggl-era cleanup:** the 20 manual entries dated 2025-12-31
   ("Transfer baseline from Toggl, totals as of March 31, 2026") are NOT Toggl residue —
   they carry the opening balances of the prepaid support blocks and MUST migrate
   (02-domain-model §2, `prepaid_block`).
3. **"Adi hours" proportion conversion.** Eliminated from the money pipeline 2026-05-25
   (*"we need to eliminate the 'proportion' and just have a rate"*, transcript §8, ADR #034);
   its one surviving consumer was the Toggl export's proportioned-hours Duration column —
   an exception rian requested (*"Keep the old behavior (export proportioned hours)"*,
   compaction summary) that is **moot now that Toggl is dead** (transcript §8/§19).
4. **First login with a temporary password.** Rian: *"I want users to create a password upon
   first login. in the meantime they get a temporary password"* (transcript §2). Replaced by
   id-auth accounts — the rebuild holds no passwords at all (00-vision-and-scope phase-out
   table; architecture doc "Auth middleware").
5. **Debugging as Adi via database-level impersonation.** The JWT-swap view-as (ADR #043)
   existed only because RLS made the *database* permission-aware. The rebuild has no RLS, so
   app-layer impersonation is faithful by construction (judgment call #7).

## Complete behavior

The behaviors below are the **as-built record**. B-numbers are for the migration checklist
and the final-backup procedure, not for re-implementation.

### /transfer page (`app/(app)/transfer/page.tsx`)

- B1. Access is hard owner-gated: `if (!canTransfer(eu)) redirect('/')`. Not linked from the
  nav (`components/nav.tsx` `LINKS` has no `/transfer` entry) — routable-off-nav per ADR #038.
- B2. Pending list = `time_entries` where `org_id` = Bowden Works AND `transferred_at IS NULL`,
  **limit 5000** (silent cap), date-filtered by the shared `FilterBar` /
  `readFiltersFromSearchParams` (default `date=this-month` — the global default in
  `lib/filters.ts`; ADR #023) applied as `gte('start_at', startBound)` / `lt('start_at', endBound)`.
- B3. Pending splits into **eligible** (`blockerReason(e) === null`) and **blocked** (first
  failing check's message shown per row). `blockerReason` checks, in order:
  `team_member_id` → "Unknown person — pick a Team member." · `operator` → "Missing operator
  (BW / PlusROI)." · `client` → "Missing client." · `project` → "Missing project." ·
  `description` → "Missing description." · `start_at` → "Missing start time." · `end_at` →
  "Missing end time." · `source_user_email` → "Missing source email."
  `converted_user`/`converted_duration_seconds` are deliberately NOT required (post-#038
  the invoice flow doesn't read them).
- B4. Sort: `?sort`/`?dir`, default `date asc` (`nullsFirst: false`). `SORT_COLUMNS`:
  `date→start_at`, `user→converted_user`, `source_email→source_user_email`,
  `project→project`, `description→description`, `source_hrs→duration_seconds`,
  `billout_hrs→converted_duration_seconds`, `cost→converted_duration_seconds` (proxy).
  Known wart: a `client` sort header renders but `client` is absent from `SORT_COLUMNS`, so
  clicking it silently sorts by `start_at`.
- B5. **Relevant month**: text input `relevant_month`, `pattern="\d{4}-\d{2}"`, defaults to the
  **previous calendar month** `YYYY-MM` (server-local time, explicit January wrap → month 12
  of the previous year). It becomes **column G ("Teams") of the CSV** — the invoicing sheet's
  billing-month bucket (rian, 2026-05-23, transcript §6).
- B6. One `<form>`, two submits: **"Download CSV (N)"** → `POST /transfer/download`
  (pure export, **no DB write** — ADR #006's explicit-mark principle) and **"Mark N as
  transferred"** → `markSelectedTransferred` with `window.confirm("Mark N entries (Xh Ym, $Z)
  as transferred? You can undo from the batch list below.")`. Selection tallies sum
  `converted_duration_seconds` (hours) and `billout_cost_usd` (cost).
- B7. **Recent transfer batches**: scans up to **2000** `time_entries` rows with
  `transfer_batch_id NOT NULL` ordered `transferred_at` desc, groups client-side, shows the
  top **10** batches (When · first-8-chars batch id · row count) with per-batch
  **Re-download** (`GET /transfer/download?batch_id=<uuid>`) and owner-only **Undo**
  (`unmarkBatch`, confirm "Undo this batch of N entries? They'll go back to pending.").
  Batch counts beyond the 2000-row window are undercounted.
- B8. Blocked-entries table (when any): Reason badge · Source email · Date · Client · Project ·
  Description · Source hrs, plus a warning banner linking `/entries?status=blocked`.

### Transfer server actions (`app/(app)/transfer/actions.ts`)

- B9. `markSelectedTransferred`: `canTransfer` gate ("Only an organization owner can mark
  entries as transferred."); `batchId = crypto.randomUUID()`; updates in **chunks of 150 ids**
  (`chunkIds`, `lib/db-chunks.ts` — PostgREST `.in()` URL-length limit) setting
  `transferred_at = now`, `transferred_by = eu?.real_user_id ?? null`,
  `transfer_batch_id = batchId`; guarded by `transferred_at IS NULL` (idempotence) plus a
  server-side eligibility re-check requiring NOT NULL: `team_member_id`,
  `converted_duration_seconds`, `operator`, `client`, `project`, `description`, `end_at`,
  `source_user_email`, `converted_user` (STRICTER than `blockerReason` — adds the two
  `converted_*` columns). Ineligible/already-marked ids are **silently skipped**. Redirect
  `/transfer?marked={batchId}`.
- B10. `unmarkBatch`: owner gate; single update setting `transferred_at/transferred_by/
  transfer_batch_id = NULL` for the whole `transfer_batch_id` (no partial undo). Redirect
  `/transfer?unmarked=1`.

### CSV download route (`app/(app)/transfer/download/route.ts`)

- B11. `POST` (fresh export): form-encoded repeated `id` fields + `relevant_month`. Loads
  entries per 150-id chunk, merges, sorts globally by `start_at` (string compare). **No
  eligibility re-check** (trusts the UI) and — known gap — **no `canTransfer` check**: any
  authenticated org member who hits the URL directly gets the CSV (session + org + RLS only).
  Errors: 400 "no ids", 404 "no matching entries", 500 with message (JSON).
- B12. `GET ?batch_id=<uuid>` (re-download): same columns where `transfer_batch_id = batchId`
  ordered `start_at` asc; optional `?relevant_month=` honored. 400 "batch_id required",
  404 "no entries in batch".
- B13. `normalizeRelevantMonth(raw)`: accepts `YYYY-M` or `YYYY-MM`, month 1–12, year
  2000–2100, zero-pads; else null. Filename:
  `toggl-import-{today YYYY-MM-DD}{-YYYY-MM if month}-{suffix}.csv` where suffix is `{N}rows`
  (POST) or the batch id's first 8 chars (GET). Headers: `Content-Type: text/csv;
  charset=utf-8`, `Content-Disposition: attachment`, `Cache-Control: no-store`. Leftover
  `[transfer/download GET|POST]` `console.log` debugging ships in production.

### The Toggl CSV format (`lib/toggl.ts`) — for the record

- B14. **14 columns, exact order** (`TOGGL_COLUMNS`, ADR #029 — Toggl "Detailed Report"
  shape): `Member, Email, Client, Project, Project end, Description, Teams, Start date,
  Stop date, Project start, Start time, Stop time, Duration, Tags`. Serialized with
  `Papa.unparse(rows, { columns: TOGGL_COLUMNS, header })`.
- B15. **Header row removed by default** (`options.header` defaults `false` — rian 2026-05-23:
  "remove the header row from the export"; the invoicing sheet's import doesn't use one).
- B16. Per-row mapping: `Member = converted_user`; `Email = ADI_EMAIL =
  'info@adipramono.com'` (hard-coded — every row, regardless of who worked; the Toggl account
  everything was imported under); `Client = operator ?? ''` (the app's **operator** —
  Bowden Works/PlusROI — travels in Toggl's "Client" field, transcript §6 quirk, ADR #016);
  `Project = joinClientProject(client, project)` = **`` `${client} : {project}` `` with exact
  spaces around the colon** when both set, else `project ?? client ?? ''` (matches Toggl's
  project naming, ADR #029, same convention as the invoice paste block); `Description =
  description ?? ''`; `Teams` = column G = relevant month or literal `-`; `Project end` /
  `Project start` = literal `-` always; Start/Stop date `YYYY-MM-DD` + time `HH:MM:SS` 24h
  from `splitNaiveTimestamp` (naive wall-clock, ADR #003; missing `end_at` → stop = start);
  `Duration = formatDurationSeconds(converted_duration_seconds)` `HH:MM:SS`, hours may
  exceed 24 — **the proportioned-hours exception**: this is source duration ×
  `rate_proportion`, NOT `duration_seconds` (ADR #034 exception, transcript §8);
  `Tags = source_user_email` (original tracker identity for traceability).
- B17. `EligibleEntry.billable` is selected and typed but **never emitted** — the current CSV
  has no Billable/Task columns (tests TGL-007/TGL-008 removed per ADR #029; see
  Contradictions in Test specs).
- B18. `blockerReason` also lives in `lib/toggl.ts` but is **NOT phased out as a concept** —
  it is the app-wide eligibility predicate mirrored in `entries_filter_totals`,
  `project_summary`, `applyStatus`, and `ELIGIBILITY_COLUMNS` (ADR #018/#021 "update all
  sites together"). The rebuild centralizes it (one implementation in the entries service);
  only its file location and the Toggl-specific callers die.

### Orphaned bulk actions (`app/(app)/entries/actions.ts`)

- B19. `bulkMarkTransferred` and `bulkUnmarkTransferred` are exported server actions with
  **no UI caller** since ADR #038 replaced the /entries bulk buttons with Apply-to-invoice /
  Detach. Both support explicit-ids and `all_matching=1` (via `readBulkFilter` +
  `applyBulkFilter`, the post-#021 same-predicate machinery). `bulkMarkTransferred` requires
  NOT NULL: `team_member_id`, `converted_duration_seconds`, `converted_user`, `client`,
  `project`, `description`, `end_at`, `source_user_email` — **note: it does NOT check
  `operator`, unlike `markSelectedTransferred` (B9)**. Redirects
  `/entries?marked={batchId}&count={N}` / `/entries?unmarked=1&count={N}`. Dead code; do not
  port.

### `transferred_at` semantics (the legacy lock)

- B20. Columns on `time_entries`: `transferred_at timestamptz`, `transferred_by uuid
  references auth.users(id) on delete set null`, `transfer_batch_id uuid`. Partial index
  `(org_id, transferred_at) WHERE transferred_at IS NULL` supports the pending query.
- B21. **Lock rule** (`lib/permissions.ts` `canEditEntry`): an entry with `invoice_id OR
  transferred_at` set is editable/deletable only when `canTransfer(eu)`; otherwise
  `canManageImports(eu)` suffices. The `transferred_at` branch is explicitly documented as
  a defensive leftover — post-#038-backfill every transferred entry also has `invoice_id`
  (backfilled onto a per-org "Legacy transfers" invoice, status=paid).
- B22. Status-filter predicate everywhere (`applied` status, `entries_filter_totals`,
  `entries_filter_options`, `project_summary`): locked = `invoice_id IS NOT NULL OR
  transferred_at IS NOT NULL`. URL back-compat: `status=transferred` is a kept alias of
  `status=applied` (`lib/filters.ts`).
- B23. Marking transferred never re-costs — `billout_cost_usd`/`billout_amount_usd` untouched
  (ADR #014 invariant).

### Toggl import dialect (`lib/clockify.ts` + `/import`)

- B24. The upload form (`app/(app)/import/upload-form.tsx`) has a **Source: Clockify / Toggl**
  radio (ADR #031). Toggl selection flips the format defaults to date `YYYY-MM-DD` + time
  `24h` (Clockify: `MM/DD/YYYY` + `12h`), branches the expected-columns hint, and the server
  action writes `clockify_imports.source = 'toggl'` (default `'clockify'`); parsed rows get
  `time_entries.source = 'toggl'`.
- B25. The header parser accepts both vendors' synonyms in one `HEADERS` map: user =
  `user|member`; endDate = `end date|enddate|stop date|stopdate`; endTime = `end time|…|stop
  time|stoptime`; the `billable` column is **optional** (Toggl Detailed Report often omits
  it). This synonym handling stays (harmless); the Toggl radio/defaults path is what dies.

### `rate_proportion` remnants (post-ADR #034)

- B26. `team_members.rate_proportion numeric(6,5) NOT NULL CHECK (0 ≤ x ≤ 1)`, default 1.
  Since #034 it is **only** an input to `converted_duration_seconds` — cost is
  `duration_seconds × cost_rate_usd`, billout is `duration_seconds ×
  effective_billout_rate`; proportion touches neither.
- B27. Live consumers today (all Toggl-export plumbing): `lib/clockify.ts#applyConversion`
  (`converted_duration_seconds = Math.round(duration_seconds * rate_proportion)`);
  `entries/actions.ts` re-derives it in `updateEntry`, `resolveEntry`, and manual entry
  create; `import/resolve/actions.ts` stamps it at import commit; SQL function
  `set_team_member_for_entry_ids` (migration `20260525000030`) re-stamps it on bulk
  reassignment; `/team` page still shows a proportion column and a `rate_proportion` form
  input. `teams.base_rate_usd` and `organizations.default_hourly_rate_usd` are the related
  dead columns (unread since #034/#032; `lib/org.ts` still coerces the org column with
  `FALLBACK_BASE_RATE = 14`).

### `converted_user` / `converted_duration_seconds`

- B28. Stamped at every entry-write from the picked team member: `converted_user =
  member.consolidate_as ?? member.display_name` (ADR #037); `converted_duration_seconds`
  per B27. NULL when the source email resolves to no member.
- B29. Current readers beyond the Toggl CSV — these are the **absorption points** the rebuild
  must re-home: the `/entries` and `/transfer` "User" display column and the user filter
  (`ilike('converted_user', …)`; RPC arg `p_user`); `/transfer`'s "Billout hrs" column and
  eligible-hours tally; the `summary_by_project` lineage originally summed
  `converted_duration_seconds` (migration `20260521140001`) before #034 switched aggregates
  to `duration_seconds`. In the rebuild the worker's display name resolves through
  `worker_party_id` (FK), `consolidate_as` becomes import-mapping config on the engagement
  (02-domain-model §7), and "billout hours" ≡ source hours (already true post-#034).

### `must_change_password` (first-login flow)

- B30. `profiles.must_change_password boolean NOT NULL DEFAULT true` (migration
  `20260520120001_init.sql`). `scripts/seed.ts` sets it `true` for newly created users only
  (re-runs don't re-arm it) and prints a one-time temp password.
- B31. `lib/supabase/middleware.ts#updateSession` queries
  `profiles.must_change_password` on **every non-static request** and redirects any path
  except `/change-password` to `/change-password` while true.
- B32. `changePassword` action: refuses while ANY `view_as_user_id` cookie exists ("Exit
  view-as before changing your password." — under view-as `auth.updateUser` would change the
  impersonated user's password); validates ≥8 chars + match server-side; on success clears
  the flag and redirects `/`.

### Supabase-specific machinery (RLS, JWT view-as)

- B33. **RLS** enforces org membership, importer-scoped entry visibility (ADR #027 — entries
  visible to owner/super-admin or the batch uploader), and team visibility, via SECURITY
  DEFINER helpers (`current_user_is_super_admin()`, `current_user_org_ids()`,
  `is_org_member`, `is_org_owner`, `is_super_admin_caller` — ADR #008's recursion rule).
  Billout/income confidentiality is **app-layer only** (ADR #039; judgment call #20 notes
  `projects.income_usd` became org-member-writable when RLS was relaxed 2026-06-09).
- B34. **JWT-swap view-as** (ADR #043): `view_as_user_id` cookie (httpOnly, lax, 8h, unsigned);
  `createClient()` verifies the real user is super-admin then mints a 5-minute HS256 JWT for
  the target via `mintJwtForUser` signed with **`SUPABASE_JWT_SECRET`** (the legacy secret —
  service-role-equivalent sensitivity), so RLS evaluates as the target.
  `getEffectiveUser` recovers the real identity by decoding the chunked
  `sb-*-auth-token` cookie **without verification** (`readRealUserIdFromCookie`).
  `lib/view-as-guards.ts` (`guardEntry`, `guardEntryIds`, `guardImport`, `guardTeam`,
  `guardTeamMember`) survives as pre-#043 defense-in-depth with stale header comments.
  Known warts: sign-out doesn't clear the view-as cookie; change-password blocks on mere
  cookie presence; no audit trail of impersonation.
- B35. **PostgREST RPCs** (`summary_by_project`, `entries_filter_totals`,
  `entries_filter_options`, `project_summary`, `effective_billout_rate`,
  `restamp_billout_for_project`, `restamp_billout_for_entry_ids`,
  `set_team_member_for_entry_ids`) carry the aggregate/eligibility semantics. The
  *semantics* are pinned in `01-current-system/rpcs-and-authz.md` and port to
  `services/reporting/` + `services/money/`; the RPC mechanism itself is dropped.

## Data model

Everything here is **retired** by the rebuild's schema (02-domain-model §3/§7):

| current column / object | type & semantics | fate |
|---|---|---|
| `time_entries.transferred_at` | `timestamptz`; legacy lock timestamp | dropped after the migration asserts zero `transferred_at IS NOT NULL AND invoice_id IS NULL` rows (expected post-`20260525000010` backfill); stragglers attach to the existing per-org "Legacy transfers" invoice with a WARNING — never an abort (05-migration-plan; judgment call #22) |
| `time_entries.transferred_by` | `uuid → auth.users`, ON DELETE SET NULL; always the REAL user (`eu.real_user_id`) | dropped with the above |
| `time_entries.transfer_batch_id` | `uuid`; undo/re-download batch key | dropped; batch history not migrated |
| partial index `(org_id, transferred_at) WHERE transferred_at IS NULL` | pending-query support | dropped |
| `time_entries.converted_user` | text; `consolidate_as ?? display_name` at stamp time | dropped; display resolves via `worker_party_id` |
| `time_entries.converted_duration_seconds` | integer; `round(duration_seconds × rate_proportion)` | dropped; archived only if the diff harness needs it (00 phase-out table) |
| `time_entries.source = 'toggl'` enum value | import provenance | carried verbatim — the new `source` CHECK admits the historical `toggl`/`backfill` values (02 §3); only the importer is restricted to WRITING `clockify`/`manual` |
| `team_members.rate_proportion` | `numeric(6,5)` 0–1 default 1; Toggl-export-only input | dropped (ADR #034 + Toggl death) |
| `teams.base_rate_usd`, `organizations.default_hourly_rate_usd`, `team_members.hourly_rate_usd` | dead rate columns (#032/#034) | dropped; `FALLBACK_BASE_RATE = 14` magic value dies with them |
| `profiles.must_change_password` | boolean, default true | dropped; no app-held credentials (id-auth) |
| `profiles`, `organizations`, `organization_members`, Supabase `auth.users` | Supabase auth/org scaffolding | replaced by `users` + `parties` + `tenants` (02 §1) |
| All RLS policies + SECURITY DEFINER helpers | DB-level authorization | deleted; `services/authz/` + default-deny middleware (02 §7) |
| Env vars `SUPABASE_JWT_SECRET`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_SECRET_KEY`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_DB_PASSWORD` | Supabase plumbing (names only) | retired with the platform |

Not dropped (shared with surviving features): `time_entries.duration_seconds`,
`source_user_email` / `source_user_name` (immutable import audit, ADR #037),
`billout_cost_usd` / `billout_amount_usd` stamps, `source_rate_usd` / `source_amount_usd`,
`invoices.manual_total_usd`, `invoice_id` lock, `clockify_imports` (renamed
`import_batches`), `team_members.consolidate_as` (becomes engagement import-mapping
config).

**⚠️ Not Toggl residue:** any cleanup pass over Toggl-era rows (this table's
`source='toggl'` row, the final-backup procedure, ad-hoc pruning) must explicitly EXCLUDE
the 2025-12-31 "Transfer baseline from Toggl…" manual entries — they carry the prepaid
support-block opening balances and MUST migrate (02-domain-model §2 `prepaid_block`;
05-migration-plan carries them).

## Permissions

- `canTransfer(eu)` — true for real super-admin (`is_super_admin && !is_viewing_as`) or
  `org_role === 'owner'` — gates `/transfer` page access, `markSelectedTransferred`,
  `unmarkBatch`, `bulkMarkTransferred`, `bulkUnmarkTransferred`. **The function name retires
  with transfer, but its truth table survives** as the owner/money-tier capability (it also
  gates invoices, billout visibility, income columns; 02-domain-model §4 R1 parity mapping
  keeps identical truth tables).
- `canEditEntry(eu, entry)` — the `transferred_at` half of its lock condition retires; the
  `invoice_id` half is the surviving rule (ADR #038).
- **Known gap for the record:** `/transfer/download` (GET and POST) checks only
  session + org + RLS — NOT `canTransfer`. Any org member could fetch the CSV (including
  per-entry cost-adjacent data) by direct URL. Moot after deletion; the rebuild's
  default-deny router makes the class impossible.
- `must_change_password` enforcement is unauthenticated-adjacent middleware (real user only,
  never the view-as target).

## Edge cases & error behavior

- E1. `markSelectedTransferred` silently skips ineligible/already-marked ids — the success
  banner reports the batch id, not a skipped-count. (Contrast: the invoice-era actions
  report real counts via `.select('id')`.)
- E2. The two eligibility re-checks disagree: `markSelectedTransferred` requires `operator`
  NOT NULL; `bulkMarkTransferred` doesn't (B9 vs B19). A fourth and fifth variant of the
  predicate — living proof of the ADR #018/#021 duplication hazard the rebuild centralizes.
- E3. `POST /transfer/download` applies **no eligibility filter**: an ineligible id (smuggled
  past the UI) yields a row with nulls where `EligibleEntry` promises non-null — e.g. a
  `Duration` computed from null seconds.
- E4. Dead code on the page: a `!userCanTransfer && eu?.is_viewing_as` notice block is
  unreachable (the redirect fires first).
- E5. Batch undo is all-or-nothing (`transfer_batch_id` match); there is no partial unmark.
  Undoing does not touch money stamps (B23).
- E6. Pending capped at 5000 rows and batch scan at 2000 rows — both silent truncations.
- E7. The `relevant_month` input rides along on BOTH submit buttons (one form); only the
  download route reads it. Malformed values fall back to no month → Teams column `-`.
- E8. Toggl-vs-Clockify date ambiguity (12h/24h, DD/MM vs MM/DD) is handled by the Source
  radio defaults + `detectDateFormat`'s tightest-span tiebreaker — the Toggl-specific
  branch retires; the Clockify detection survives (see imports feature doc).
- E9. `must_change_password` costs one `profiles` query on every matched request; a user
  with no `profiles` row skips the redirect but bounces at the app layout.
- E10. View-as JWT machinery failure modes: missing `SUPABASE_JWT_SECRET` throws
  `'SUPABASE_JWT_SECRET missing — view-as impersonation requires the legacy JWT secret in
  .env'`; a forged cookie is silently ignored for non-super-admins; a supabase-ssr cookie
  format change silently breaks real-user detection (appears logged out); a stale 8h cookie
  survives sign-out and blocks password changes with a confusing message.
- E11. Migration guard (05-migration-plan "Transferred-but-not-invoiced rows"; judgment
  call #22): post-backfill (migration `20260525000010`, ADR #038) the expected count of
  `transferred_at IS NOT NULL AND invoice_id IS NULL` rows is **zero**, and the migration
  asserts it. If it's nonzero (something used the orphaned B19 actions since), the
  stragglers attach to the **existing** per-org "Legacy transfers" invoice — per ORG,
  matching the original backfill's granularity, NOT per operator; no new sentinel
  invoices — with a WARNING in the run report for owner review. The migration never
  aborts for this, and never carries `transferred_at` forward as a second lock mechanism.

## UI notes

- The transfer page is a functional owner-only console: stats trio ("In filter, pending" /
  "Ready" + hours / "Blocked" with red under-target styling), blocked-triage table with
  per-row reason badges, selection table, recent-batches list. Subtitle instructs the
  two-step ritual: *"Download the CSV, import in Toggl, then come back and mark the rows as
  transferred only after that import succeeded."*
- Taste-profile signals from the transcript (§17) that this feature area generated:
  - **Sensible defaults**: relevant month defaults to the previous month — a pattern rian
    asked for repeatedly (invoice date, filters). Keep the *pattern*, not the feature.
  - **Paste blocks over file downloads**: the Toggl CSV was the app's only file-download
    export; both later exports (invoice lines, CC expenses) were explicitly requested as
    tab-separated paste blocks ("I dont need an export, just a block of text"). The rebuild
    treats paste blocks as the standard sheet-bound output (judgment call #15).
  - **Redundant identity fields annoyed him**: "there is source email, source user, and
    converted user... I wonder if this is just somethign taht's gotten messy and redundant?"
    (§5). Dropping `converted_user` resolves the exact confusion he flagged.
  - Explicit mark-after-verify + batch undo (ADR #006) is a trust pattern he valued —
    it survives in invoice attach/detach, not here.
- The forced-password screen ("Set a new password" / "Choose a password you'll use from now
  on.") and the amber view-as banner ("Viewing as **{name}**. Some actions are restricted to
  match their role." + Exit view) are the two auth-UI pieces retiring; the view-as *banner
  UX* carries over to the rebuild's context-swap impersonation.

## Rebuild deltas

Everything in this doc IS the delta — dropped per the 00-vision-and-scope phase-out table.
Absorption map:

- **Lock + export**: invoice attachment is the only lock (ADR #038; 02-domain-model §3
  `invoice_line_id`); the PlusROI sheet is fed by the invoice paste block until the R4 money
  engine kills the sheet itself. **Nothing absorbs the per-entry-detail CSV export** (dates/
  times/durations per row) — accepted loss; if it's ever missed, it's a small invoice-detail
  export, not a Toggl format.
- **Eligibility**: `blockerReason`'s predicate (minus `converted_*`) becomes ONE
  implementation in the entries service (04-architecture "Reporting"; fixes ADR #018/#021's
  4-way duplication).
- **Identity/display**: `converted_user` → FK-resolved worker party name;
  `consolidate_as` → engagement import-mapping config; "billout hours" ≡ source hours
  (02 §3/§7).
- **Rates**: `rate_proportion`/`base_rate` columns die; compensation_terms carry the member cost/billout rates (rate_overrides stays its own table — judgment call #25)
  (02 §2; judgment call #12 — Gary's $0 is a real rate; #19 pins NULL = never priced).
- **Auth**: id-auth Pattern B "Sign in with BW" (kit shipped 2026-07-07); no passwords,
  no `must_change_password` (04-architecture "Auth"; judgment calls #31/#13).
- **Authorization**: RLS + RPCs → `services/authz/` `can()`/`scope()` + reporting services
  (04-architecture "authz engine"); billout/income secrecy becomes enforceable server-side,
  closing ADR #039's app-layer-only gap and judgment call #20's income exposure.
- **View-as**: JWT minting → app-layer context swap with dual-identity audit logging
  (judgment call #7); guard-rail behaviors (no credential actions while impersonating,
  banner) carry over.
- **Import**: the importer WRITES `clockify`/`manual` only; Toggl dialect radio retired
  (judgment call #8 — escape hatch: if Toggl CSVs ever reappear, the parser keeps both
  dialects cheaply). Historical `toggl`/`backfill` source values remain valid data under
  the new `source` CHECK (02 §3).

## Test specs

Existing suite (for the record; `tests/toggl.test.ts` current IDs). The Toggl-export-specific
tests are deleted at cutover, not ported — EXCEPT the three eligibility-predicate assertions
(T-TGL-009/011/012), which survive as ports in 06-test-plan §3.5:

- T-TGL-001: given 0 seconds / when `formatDurationSeconds` / then `'00:00:00'`. (carries TGL-001)
- T-TGL-002: given >1h values / then correct zero-padded `HH:MM:SS`, hours may exceed 24. (TGL-002)
- T-TGL-003: given `'YYYY-MM-DD HH:MM:SS'` or T-separated / when `splitNaiveTimestamp` / then same `{date, time}`. (TGL-003)
- T-TGL-004: given default options / when `buildTogglCsv` / then NO header row. (TGL-004 — note the tests-catalog.md description of TGL-004 is stale, see contradictions)
- T-TGL-005: source email lands in `Tags`, not `Member`. (TGL-005)
- T-TGL-006: `Email` is always `info@adipramono.com` regardless of source. (TGL-006)
- T-TGL-009: `blockerReason` flags missing `team_member_id` first. (TGL-009)
- T-TGL-011: `blockerReason` flags each of operator/client/project/description/start/end/source-email with its exact message. (TGL-011)
- T-TGL-012: fully-resolved entry → `blockerReason` null; `converted_*` NOT required. (TGL-012)
- T-TGL-013: Project column = `"{client} : {project}"` colon-with-spaces; operator → Client column. (TGL-013)
- T-TGL-014: null client → Project column is just the project name. (TGL-014)
- T-TGL-015: `header: true` re-enables the header matching `TOGGL_COLUMNS`. (TGL-015)
- T-TGL-016: `teams` option populates column G (7th column). (TGL-016)
- T-TGL-017: omitted/empty `teams` → literal `-`. (TGL-017)
- T-CLK-020: `applyConversion` multiplies duration by `rate_proportion` — retires with the column. (CLK-020)

New specs seeding 06-test-plan.md (the rebuild-side contract):

- T-PHX-001: given the rebuilt schema / when inspected / 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. (Carried money columns — `manual_total_usd`,
  `source_rate_usd`, `source_amount_usd` — are deliberately NOT in this list; they migrate,
  05-migration-plan.)
- T-PHX-002: given the rebuilt app / when requesting `/transfer` or `/transfer/download` /
  then 404 (no route).
- T-PHX-003: given the migration runs against a copy of production / when a `time_entries`
  row has `transferred_at NOT NULL` and `invoice_id NULL` / then the row attaches to that
  org's EXISTING "Legacy transfers" invoice and the run report carries a WARNING naming it;
  the migration does NOT abort (E11 guard; judgment call #22).
- T-PHX-004: given migrated data / when comparing lock state / then every previously-locked
  row (invoice_id OR transferred_at) is locked in the new system via its invoice line, and
  no previously-unlocked row became locked.
- T-PHX-005: given the new import UI / then no Toggl source option; given a Clockify CSV /
  then import succeeds unchanged (CLK-* suite ports intact).
- T-PHX-006: given historical rows with `source='toggl'` (or `'backfill'`) / when migrated /
  then the value carries verbatim under the new `source` CHECK (02 §3) and the rows are
  queryable plain entries (provenance preserved as data, no behavior attached); the
  importer can still only WRITE `clockify`/`manual`.
- T-PHX-007: given the rebuilt codebase / when grepped / then no `SUPABASE_*` env var
  references, no RLS policy files, no JWT-minting code.
- T-PHX-008: given a super-admin entering view-as / then authz resolves as the target with
  `impersonated_by` recorded in the audit log; credential/config actions are blocked while
  impersonating (carries T-AUTH-008/T-AUTH-010's intent without cookies or JWTs).
- T-PHX-009: given eligibility ("blocked") checks anywhere in the rebuild / then exactly one
  implementation is invoked (no duplicated predicate — regression guard on ADR #018/#021).
- T-PHX-010: given the invoice paste block / then it remains the only sheet-bound export;
  no code path emits the 14-column Toggl format.
- T-PHX-011: given the 2025-12-31 "Transfer baseline from Toggl…" manual entries / when
  migrated / then all 20 are present as ordinary entries and lifetime prepaid-block
  drawdown totals are unchanged (load-bearing opening balances, NOT Toggl residue —
  02-domain-model §2 `prepaid_block`).
