# Architecture decisions log

Append-only. New entries at the bottom. Format:

```
## NNN — short title
**Date:** YYYY-MM-DD
**Status:** accepted | superseded by NNN | reversed

**Context.** What problem we were trying to solve.
**Decision.** What we chose.
**Alternatives.** What we considered and didn't pick, and why.
**Consequences.** What this implies going forward.
```

---

## 001 — Supabase Cloud, not self-hosted
**Date:** 2026-05-20
**Status:** accepted

**Context.** Needed Auth + Postgres for the app. Self-hosting Supabase
is ~7 containers; project goal was to ship a working login quickly.

**Decision.** Supabase Cloud free tier.

**Alternatives.** Self-hosted Supabase (more infra ownership but heavy);
roll our own Postgres + auth backend (more code).

**Consequences.** External dependency. Easy to migrate to self-hosted
later because the JS SDK is the same. NocoDB can be pointed at the same
Postgres via the project's connection string when we add the
spreadsheet-view layer.

## 002 — Next.js App Router as the frontend
**Date:** 2026-05-20
**Status:** accepted

**Context.** Need to build a real app with forms, server-side data
access, and growing complexity (CRM, invoicing eventually).

**Decision.** Next.js 15 with the App Router, TypeScript, Server Actions,
React Server Components. Plain CSS for now (no Tailwind yet).

**Alternatives.** Plain HTML + vanilla JS (would be rewritten on the
first real CRUD screen); SvelteKit (smaller community, fewer reference
patterns for our stack).

**Consequences.** Most pages are server components with explicit small
client islands for selection / sorting / filters. Server Actions handle
mutations. The RSC boundary is something we cross intentionally — see
the `lib/filters.ts` split for an example.

## 003 — Naive timestamps for `time_entries`
**Date:** 2026-05-20
**Status:** accepted

**Context.** Adi tracks in Clockify (Indonesia local time); Rian imports
into Toggl (BC local time). The current spreadsheet does no timezone
conversion — a 9 AM Clockify entry becomes a 9 AM Toggl entry.

**Decision.** `time_entries.start_at` and `end_at` are
`timestamp` (no timezone), stored exactly as the source string. No
conversion on import or export.

**Alternatives.** Store as `timestamptz` in UTC and convert at the edges
(more correct, but a behavior change from the spreadsheet that might
confuse the team).

**Consequences.** If we ever want to display "real" times across timezones,
we'll need to know what TZ each entry's source was in — that data isn't
stored today. Acceptable because both producers and consumers treat the
times as local-anchor strings.

## 004 — `team_members` is a reference table, not auth users
**Date:** 2026-05-20
**Status:** accepted

**Context.** Adi has ~19 developers tracked in Clockify. They don't use
this app today. Putting them in Supabase Auth means 19 password resets,
profile rows, RLS policies for users who'll never log in.

**Decision.** `team_members` is a plain reference table keyed by email.
No auth accounts.

**Alternatives.** Create real Supabase Auth users (over-engineering for
now).

**Consequences.** When/if developers ever need access (track their own
time, view their own data), we'll add an optional FK
`team_members.profile_id` and treat the table as the source of truth
either way.

## 005 — Per-employee `rate_proportion` decided manually by Adi
**Date:** 2026-05-20
**Status:** accepted

**Context.** Conversion to "Adi hours" is `original × proportion`. The
proportion typically equals `effective_rate / adi_rate` but Adi can
override per-person (he had a few rows at 0.25 even though the implied
rate would suggest 0.5).

**Decision.** Store `rate_proportion` independently of
`hourly_rate_usd`. Only `rate_proportion` is used in the conversion math;
`hourly_rate_usd` is informational.

**Alternatives.** Compute proportion from `hourly_rate_usd / adi_rate`
(rigid; can't override).

**Consequences.** When Adi edits a rate, he should also revisit the
proportion. The Team UI shows both.

## 006 — Explicit "Mark as transferred", not auto-mark on export
**Date:** 2026-05-21
**Status:** accepted

**Context.** Initial version marked rows as transferred when the CSV
downloaded. But Toggl imports sometimes fail and the user has to undo and
correct. Implicit marking made the undo flow more error-prone.

**Decision.** Download CSV is a read-only action. A separate "Mark
selected as transferred" button writes to the DB. Each marking creates a
`transfer_batch_id` so a whole batch can be undone atomically.

**Alternatives.** Auto-mark + undo (less clicks, but rewards optimism);
no marking at all (need a way to know what's in Toggl already).

**Consequences.** Two clicks instead of one for the happy path. A clear
"Recent transfer batches" widget for re-download + undo.

## 007 — Only owners (and super admins) can mark as transferred
**Date:** 2026-05-21
**Status:** accepted

**Context.** Rian (Bowden Works owner) is the only person who actually
imports into Toggl. Adi (Bowden Works manager) shouldn't be able to
change transfer state.

**Decision.** `canTransfer(eu)` returns true only for super admins not in
view-as mode, or org owners. Server actions enforce; UI hides the button.
Other actions (team CRUD, imports, edits) are allowed for managers too.

**Alternatives.** Single role per user (simpler but couples privileges).

**Consequences.** Adding new actions means a new `canX(eu)` helper and
both UI + server checks.

## 008 — `members_co_member_select` policy must use a SECURITY DEFINER helper
**Date:** 2026-05-21
**Status:** accepted (after a near-miss)

**Context.** The first attempt at letting members see each other in an
org used a recursive subquery directly in the RLS policy:
`organization_id in (select organization_id from organization_members where user_id = auth.uid())`.
Postgres silently returned zero rows for some queries because of the
recursive RLS evaluation, which manifested as "too many redirects" on
the live app.

**Decision.** Any RLS policy that needs to query the same table (or a
table whose RLS would recurse) must go through a `security definer` SQL
function. We have `current_user_is_super_admin()` and
`current_user_org_ids()` for this purpose.

**Alternatives.** Drop the policy and accept narrower visibility (would
work but blocks future co-member features).

**Consequences.** Any new "I can see X if I'm a member of Y"-style
policy gets a helper function. The function bypasses RLS for the inner
query while still scoping to the calling `auth.uid()`.

## 009 — Postgres-aggregated Summary, not client-paginated
**Date:** 2026-05-21
**Status:** accepted

**Context.** The Summary page initially paginated `time_entries` from
the client (1000 at a time) and summed in JS. Three-month + all-time
view = 7+ round-trips. ~700ms at 3,300 rows, projected to scale very
badly to 100x the data.

**Decision.** `summary_by_project(org, this_start, this_end, prev_start,
prev_end)` Postgres function returns per-project this/prev/all sums in a
single call. Supporting index
`(org_id, project, start_at) WHERE converted_duration_seconds IS NOT NULL`.

**Alternatives.** Materialized view (refresh staleness); separate
per-month tables (write amplification, complexity).

**Consequences.** Future report pages should follow the same pattern —
write a Postgres function, expose via `supabase.rpc(...)`. `/transfer`
and `/entries` may need similar functions at scale.

## 012 — Base hourly rate moved from `team_members` to `organizations`
**Date:** 2026-05-21
**Status:** accepted

**Context.** Each team member previously had an editable `hourly_rate_usd`.
In practice every member's rate is derived: `base_rate × rate_proportion`.
There's a single base rate per org; editing per-member was misleading.

**Decision.** Added `organizations.default_hourly_rate_usd`, seeded from
Adi's old per-member rate. Removed the hourly-rate input from the team
form. The team page now has a "Base hourly rate" panel at the top that
edits the org column; each member's effective rate is shown read-only
as `base × proportion`. The same value drives the Cost column on
Entries / Transfer and the dollar figures on Summary.

**Alternatives.** Keep the per-member field for the rare case where
someone's pay diverges from `base × proportion` (currently not the
case for any active team member; we can re-add later if needed).

**Consequences.**
- `team_members.hourly_rate_usd` is left in the schema for backward
  compat but ignored by all code paths. A future migration can drop it.
- Summary's `getAdiRate` helper is gone; everything reads
  `org.default_hourly_rate_usd`.
- One source of truth for the base rate.

## 013 — Back-computed `time_entries.duration_seconds` for backfilled rows
**Date:** 2026-05-21
**Status:** accepted

**Context.** The initial 2026 backfill only stored each row's
*already-converted* duration (the sheet's "Converted Entries"
Duration column). The original tracked time was on a different tab
(Tingang Entries) and never made it into the database. As a result,
the Source-hrs column on /entries was blank for every historical row,
making it look like conversion hadn't happened.

**Decision.** A one-time SQL migration computed
`duration_seconds = converted_duration_seconds / team_member.rate_proportion`
for every backfilled row where the team member was resolved. Future
imports populate the column natively from Clockify, so this is a
one-shot fix.

**Alternatives.** Leave it blank and add a UI note. Rejected — the
user reasonably interpreted the blank column as "the math is wrong".

**Consequences.** Source-hrs is now a derived estimate for the
historical 3,274 rows. If a team member's proportion changes
retroactively, those historical Source values will look stale relative
to the new proportion. Acceptable because (a) proportions don't
retroactively change for past work, and (b) `converted_duration_seconds`
is the source of truth for billing math anyway.

## 014 — Lock billout cost at import time in `time_entries.billout_cost_usd`
**Date:** 2026-05-21
**Status:** accepted

**Context.** A team member's `rate_proportion` or the org's
`default_hourly_rate_usd` can change at any time. Before this decision,
the Cost column on Entries / Transfer / Summary was computed dynamically
from `converted_duration_seconds × current_base_rate`. That meant
historical entries' cost would silently shift whenever the base rate
changed, breaking the invariant that an already-billed entry's invoice
amount is fixed.

**Decision.** Added `time_entries.billout_cost_usd` (numeric(10, 4)).
Cost is captured at import time and stored on the row. The Cost column
reads the stored value, not a derived calculation. Updating the base
rate on /team or a proportion on a team member affects only future
imports and explicit per-entry "Re-resolve". The Summary RPC
(`summary_by_project`) now sums the stored column too.

`converted_duration_seconds` was already locked at import time —
nothing changed there.

**Alternatives.** Snapshot the base rate per row instead
(`base_rate_at_import`), and re-compute on display. More flexible but
the math is the same and storing the result avoids a multiplication per
row.

**Consequences.**
- New invariant: `billout_cost_usd` never changes implicitly. The only
  writes are at import, on explicit per-entry edit (Save), or on
  explicit Re-resolve.
- Bulk transfer-mark/unmark do NOT touch billout_cost_usd. Marking a
  row as transferred doesn't re-cost it.
- The previous "back-computed source duration" migration (#013) used
  current proportion against historical converted values, so for any
  team member whose proportion changed since import, the computed
  source duration is wrong by a known factor. Source-hrs is the only
  field that suffers; billout and cost stay correct because they're
  stored.

## 015 — Adi can't access /transfer and can't edit transferred entries
**Date:** 2026-05-21
**Status:** accepted

**Context.** Adi is a Bowden Works manager. He shouldn't be approving
work for transfer to Toggl (that's Rian's job), and he shouldn't be
able to alter entries that have already been billed out.

**Decision.** Two layers of restriction:

1. **Nav + route gating.** The Transfer link in the nav is hidden for
   users where `canTransfer(eu)` is false. The `/transfer` page itself
   redirects to `/` if the user can't transfer. Server actions
   (`markSelectedTransferred`, `unmarkBatch`, `bulkMarkTransferred`,
   `bulkUnmarkTransferred`) already gate on `canTransfer`.
2. **Per-entry edit permission.** New `canEditEntry(eu, entry)` helper:
   transferred entries can only be edited or deleted by owners /
   super admins. UI shows a "Locked" badge in the actions column for
   managers. Server actions (`updateEntry`, `resolveEntry`,
   `deleteEntry`) reject with a clear error if a manager tries to
   touch a transferred row.

**Alternatives.** Single restriction at the page level (would still
let a crafted request bypass it via the API).

**Consequences.**
- Each new entry-modifying action must use `canEditEntry`, not just
  `canManageImports`.
- View-as Adi now correctly hides the Transfer nav and locks
  transferred rows, so it's a faithful preview of what Adi sees.

## 016 — Split end-client out of the embedded `{Client} : {Project}` string
**Date:** 2026-05-21
**Status:** accepted

**Context.** The legacy convention encoded the end client inside the
`project` field as `"{Client} : {Project}"`. The actual `client` column
held the operator agency (Bowden Works / PlusROI). Filtering / reporting
by end client was awkward; the naming was misleading.

**Decision.**
- Rename `time_entries.client` → `operator`.
- Add a new `client` column holding the parsed end-client name.
- Migration splits the existing `project` strings on " : " (literal
  space-colon-space) into `client` + `project`. Rows without that
  delimiter keep their `project` and get `client = NULL` (then surface
  as Blocked).
- `lib/clockify.ts#splitClientProject` does the same split on every
  CSV import going forward.
- `blockerReason` now requires both `operator` and `client`.
- `lib/toggl.ts#buildTogglCsv` recombines on export: Toggl's Client
  column = `operator`, Toggl's Project column = `"{client} : {project}"`
  (so it still matches Toggl's existing project names exactly).

**Alternatives.** Leave the legacy string and parse on display
(rejected — losses on filtering, sorting, and any future first-class
client modeling).

**Consequences.**
- Future first-class `clients` table (see `docs/domain/`) has a clear
  home — the `client` column becomes an FK reference.
- Some rows surfaced as Blocked after the migration because their
  project field had no " : " delimiter. Those need manual fix-up on
  /entries.
- The `description` field on /transfer is unchanged.

## 017 — Bulk-edit fields on /entries
**Date:** 2026-05-21
**Status:** accepted

**Context.** When a client or project name gets renamed (or was
misnamed from the start), fixing it row-by-row is painful. The split
of project → client+project surfaces many rows that need bulk fixes.

**Decision.** Added a `bulkUpdateFields` server action. UI sits inside
the existing bulk-action bar on /entries: an "Edit fields ▾" toggle
opens a sub-row with Client / Project / User inputs and an Apply
button. Empty inputs = don't change that field. Works on either an
explicit selection or the "all matching filter" selection.

For non-owners (Adi), the update is constrained to non-transferred
rows. For owners (Rian), it can touch transferred rows too — same
locking rule as per-row edit (decisions.md #015).

**Alternatives.** A separate "find and replace" page (more scope).
SQL access (not exposed to the app surface).

**Consequences.**
- Three fields covered today; can extend trivially. The action is
  open-ended via the `bulk_*` form-data convention.
- No undo for bulk-update; the user is asked to confirm with a count.

## 018 — Status filter pushed to SQL so pagination stays consistent
**Date:** 2026-05-22
**Status:** accepted

**Context.** `/entries` previously filtered to `transferred_at IS NULL` in
SQL and then narrowed to "pending" vs "blocked" in JS by re-evaluating
`blockerReason()`. With 100/page pagination, a page might fetch 100 rows
but only show 10 — the user saw blocked entries scattered across pages
when there were really only 91 total.

**Decision.** The full eligibility predicate (the same one
`blockerReason` enforces) is now inlined at the SQL level for both the
data query and the count query. `pending` requires every column to be
non-NULL; `blocked` is the OR of any required column being NULL.
Identical predicate lives in three places: the `applyStatus` helper on
the entries page, the `entries_filter_totals` RPC, and the
`entries_filter_options` RPC. They must stay in sync.

**Alternatives.** Move blockerReason into Postgres as a generated
column or function and reference it from RLS / queries (more elegant
but a bigger schema change). Worth doing later.

**Consequences.** Pagination is now correct for all status values.
Anyone adding a new "blocker" requirement must update all three sites
(JS blockerReason, applyStatus in page, both RPCs).

## 019 — Filter dropdowns driven by `entries_filter_options` RPC
**Date:** 2026-05-22
**Status:** accepted

**Context.** Free-text "contains…" inputs for the pivot filters
(operator / client / project / user / source email) made it hard to
discover what values are available. The user wanted dropdowns that
reflect the currently-filtered result set.

**Decision.** New RPC `entries_filter_options(org_id, …same args as
totals…)` returns the distinct non-null values per column for the
current filter. The page calls it alongside `entries_filter_totals` in
one round-trip pair. The FilterBar component renders a `<select>` when
an option list is provided and falls back to a text input otherwise
(so `/transfer` etc. can still use the bar without dropdowns).

**Alternatives.** Compute distinct values client-side from the visible
page (only shows current page's values — not what the user wanted).

**Consequences.** Adding a new pivot dimension means extending the
RPC + the FilterBar component + the page query.

## 020 — Comments on time entries
**Date:** 2026-05-22
**Status:** reversed by #022

(Removed: per-entry comments were the wrong abstraction. See #022 for
the global-board replacement.)

## 021 — applyBulkFilter must mirror EVERY filter the entries page applies
**Date:** 2026-05-22
**Status:** accepted

**Context.** The bulk action bar's "Select all matching filter" mode
caused a catastrophe: a user with filter `status=blocked AND
operator=Bowden Works` selected "all matching" and bulk-edited
operator/client/project, expecting ~353 rows. The action updated 3,274
rows (every row in the org). Root cause: `applyBulkFilter` in
`app/(app)/entries/actions.ts` only re-applied date / batch / project /
user / source_email / client / q — it silently dropped `status` and
`operator`. With those missing the WHERE clause widened.

**Decision.** `applyBulkFilter` now mirrors every filter the page
applies, including `status` (with the same eligibility predicate
documented in #018) and `operator`. The page, the totals RPC, the
options RPC, and `applyBulkFilter` all carry the same `status` logic.
A single `ELIGIBILITY_COLUMNS` constant lists the columns the
predicate uses, used by both the page's `applyStatus` helper and
`applyBulkFilter` to stay in sync.

**Alternatives.** Move the filter logic into a single Postgres
function called from every site (cleaner, more refactor). Worth doing
once we add more dimensions.

**Consequences.**
- Data was reverted via `scripts/restore_client_project_operator.py`,
  matching each row by `start_at + source_user_email + description`
  against the original spreadsheet and rewriting operator / client /
  project to the original values.
- Adding a new filter dimension means updating FOUR places (FilterBar,
  page query, both RPCs, applyBulkFilter). The catalog test
  ENT-016/017/018 cover the read paths; need a regression test for
  bulk write at scale.
- Catalog entry **ENT-020** captures the regression.

## 022 — Replaced per-entry comments with an org-wide comment board
**Date:** 2026-05-22
**Status:** accepted (supersedes #020)

**Context.** #020 added comments to individual time entries with a
per-row `💬 N` column. The user wanted a simpler "notes for the team"
affordance — a single board visible from the top nav where anyone can
drop a comment, @-mention a teammate, and resolve when handled.

**Decision.** Dropped the `time_entry_comments` table and the
per-entry UI entirely. New `comments` table — org-scoped, free-text
body, `resolved_at` / `resolved_by`. A 💬 bell in the nav shows the
unresolved count; clicking opens a dropdown panel with the comment
list (Open / Resolved tabs), a post form, and per-comment Resolve /
Reopen / Delete buttons. `@mentions` are highlighted visually but not
routed yet (no notifications).

**Alternatives.** Keep the per-entry table AND add a global one
(noise, two concepts to maintain).

**Consequences.**
- Anyone in the org can resolve any comment — it's a shared board.
- No threading / replies; if needed later, add a `parent_id`.
- @mention notifications (email / Slack) are future work.

## 023 — Per-page filter defaults via FilterBar props
**Date:** 2026-05-22
**Status:** accepted

**Context.** `readFiltersFromSearchParams` had a hard-coded
`date='this-month'` default. The user wanted `/entries` to default to
`date=all + status=pending` while `/transfer` keeps `date=this-month`.

**Decision.** `readFiltersFromSearchParams` accepts a second
`defaults: { date?, status? }` arg. Each page calls it with its own
preferred defaults. `FilterBar` takes matching `defaultDate` and
`defaultStatus` props so the UI dropdowns reflect the same defaults
when the URL has no param.

**Alternatives.** Redirect-to-defaults on first visit (would dirty the
URL). Hard-code one default and special-case the other in each page
(messier).

**Consequences.** Adding a new page that uses `FilterBar` requires
deciding its defaults. The `defaultDate` / `defaultStatus` props must
match the values passed to `readFiltersFromSearchParams`, or the UI
will lie about what the page is filtering.

## 024 — Bulk-edit safety net: `expected_count` confirmation
**Date:** 2026-05-22
**Status:** accepted

**Context.** A bulk "edit fields" with `all_matching=1 + status=blocked
+ operator=Bowden Works` updated more rows than the user expected.
Most likely cause was that the user inferred the filter scope from
page 1 alone (sorted by date desc, happened to be 100% Prompt
Victoria). The fix is to refuse to write if the action's filter
result diverges from what was shown in the confirmation dialog.

**Decision.** `bulkUpdateFields` now does a `SELECT count(*)` with
the same filter BEFORE the `UPDATE`. The form ships an
`expected_count` hidden input (the same number shown in the confirm
dialog). If `preCount !== expected_count`, the action aborts with a
"safety stop" error and writes nothing. Also writes the active
filter + counts to container logs for post-mortem if the gap is ever
caused by a real query divergence.

**Alternatives.** Strict row-level: the action returns the row IDs
that match and the client confirms again. Heavier UX. The pre-count
check covers the common case (filter is stable between page render
and action submit).

**Consequences.** Concurrent imports that add rows between the user's
confirmation and the bulk action would trigger a false safety stop.
Acceptable — the user can refresh and retry.

## 025 — Don't nest forms (per-row Edit was silently broken)
**Date:** 2026-05-22
**Status:** accepted

**Context.** Per-row Edit Save did nothing because the table was
wrapped in the bulk-action `<form>`, and `EditRow` rendered its own
`<form>` inside. HTML 5 forbids nested forms; browsers flatten them,
so the inner Save button submitted the (empty) outer form.

**Decision.** The bulk-action form is rendered above the table, not
around it. `EditRow`'s form sits in the tbody but is no longer
nested. State (selection, bulk edit fields) lives in EntriesTable's
React state; the bulk form is reconstructed on each render from that
state.

**Consequences.** Any future table-level interactive form (checkbox
selection, bulk actions) MUST stay outside any nested `<form>` —
including any future per-row edit, modal forms, etc. If we ever need
to add inputs INSIDE table rows that submit to a server action, they
need to be in a separate form that's a sibling of, not a descendant
of, any other form.

## 026 — Summary by-project regrouped on (operator, client, project)
**Date:** 2026-05-22
**Status:** accepted (supersedes the project-only grouping introduced in #009)

**Context.** After the operator/client split (#016), grouping by just
`project` collapses rows that share a project name across different
operators or clients. Each (operator, client, project) tuple is a
distinct billing surface.

**Decision.** `summary_by_project` now returns one row per
(operator, client, project) triple. The Summary page shows three
sortable columns instead of one combined string. Footer row spans the
first three cells.

**Consequences.** Row count grows (one row per triple). Acceptable —
the per-project table now mirrors what billing actually segregates.
Existing "hide rows with 0 prev + 0 this" filter still applies.

## 011 — Captured the long-term business domain in `docs/domain/`
**Date:** 2026-05-21
**Status:** accepted

**Context.** The current `time_entries` schema uses free-text fields for
client / project / source user and folds the operator agency
("PlusROI" or "Bowden Works") into the Client column. This is fine for
the Clockify-to-Toggl bridge but does not survive the eventual rebuild
into a full operations platform — the real business has multi-party
projects, typed engagements (freelancer / subcontractor / partnership /
referrer), commissions, markups, and per-party visibility levels.

**Decision.** Captured the full domain model — parties, engagements,
compensation models, visibility levels, project/phase structure, plus a
roster of the real-world people and companies — in `docs/domain/`. No
code or schema changes. Future feature designs reference this folder
when modeling new entities.

**Alternatives.** Wait until each feature lands to spec the model
piece-by-piece (would risk inconsistent vocabulary across features).

**Consequences.**
- Proposed standard terms: `party` (person | org), `engagement`,
  `engagement type`, `compensation model`, `visibility level`. The
  free word "relationship" should be retired in code/schema.
- Recommended renaming the user's "Set" concept to "Phase". (Same model,
  clearer term.)
- The current `time_entries` "Client" column actually names the
  operator agency, not the end client — that ambiguity will need a
  migration when the entities become first-class.

## 010 — Supabase CLI for migrations, not the SQL editor
**Date:** 2026-05-21
**Status:** accepted

**Context.** Every schema change required pasting SQL into Supabase's
dashboard SQL editor. Friction, hard to track what's applied where.

**Decision.** Use Supabase CLI. Migrations live in
`supabase/migrations/<14-digit-timestamp>_<name>.sql`. Push with
`npm run db:push`. The first three migrations (already applied via SQL
editor) were marked applied via `supabase migration repair`.

**Alternatives.** Custom migration runner (more code); direct `psql`
calls (no tracking).

**Consequences.** SUPABASE_ACCESS_TOKEN and SUPABASE_DB_PASSWORD live in
`.env` (chmod 660, same sensitivity tier as the existing service key).

## 027 — Time entries are scoped by importer (RLS)
**Date:** 2026-05-23
**Status:** accepted

**Context.** Before this app, Adi imported his Clockify timelogs into
the bridging spreadsheet and Rian imported his Toggl logs separately,
then merged them in the invoicing sheet. Now both flows live here:
Adi uploads his Clockify CSVs, Rian uploads his Toggl CSVs. We needed
a rule for who sees what.

**Decision.** Time entries are visible to (a) any owner / super_admin
in the org, or (b) the member who uploaded the batch. Enforced via
RLS on `time_entries` joined to `clockify_imports.imported_by`.
The `clockify_imports` table itself is filtered the same way.
Migration `20260523000001_import_scoping.sql`.

**Alternatives.** Per-row ownership of `time_entries.created_by` —
rejected because rows come from CSV imports, not direct creation,
so the natural unit of ownership is the import batch.

**Consequences.**
- Adi sees only his own imports on /entries, /transfer, /summary,
  /imports. He cannot see Rian's Toggl batches at all.
- Owner / super_admin sees everything (and can filter — see ADR 028).
- A new column `time_entries.source` distinguishes 'clockify' vs
  'toggl' for the export pipeline; CSV parser sets it.
- Rian was added to `team_members` so his rows have a team_member_id
  for the summary and transfer flows.

## 028 — "Imported by" filter on the entries page
**Date:** 2026-05-23
**Status:** accepted

**Context.** Now that Rian sees all imports (owner), he needs a way
to look at just Adi's data or just his own.

**Decision.** Added an "Imported by" select to the FilterBar that
appears only when there are multiple importers visible to the user
(i.e. only for owner / super_admin). The `imported_by` query param
joins `time_entries` → `clockify_imports.imported_by`. The RPCs
`entries_filter_totals` and `summary_by_op_client_project` accept a
new `p_imported_by` argument so totals and summary respect the filter.

**Alternatives.** A separate /entries route per importer — rejected
because the rest of the filter state (date, status, operator, etc.)
must compose with it.

**Consequences.** `applyBulkFilter` had to become async to look up
import IDs by importer before constraining `time_entries.import_id IN
(...)`. All four call sites in `actions.ts` were updated to `await`.

## 029 — Toggl Detailed Report shape for export
**Date:** 2026-05-23
**Status:** accepted

**Context.** Rian's invoicing sheet expects the Toggl "Detailed Report"
CSV shape. The earlier export was the bridging-spreadsheet shape
(Billable, Task, etc.) which doesn't match Toggl's native export.

**Decision.** `lib/toggl.ts` `TOGGL_COLUMNS` is now Member, Email,
Client, Project, Project end, Description, Teams, Start date, Stop
date, Project start, Start time, Stop time, Duration, Tags — matching
a Toggl Detailed Report CSV. Tests TGL-007 and TGL-008 (Billable/Task)
were removed.

**Alternatives.** Add a "format" dropdown — rejected because we no
longer have a second downstream consumer that wants the legacy shape.

**Consequences.** The transfer flow now produces a CSV that drops
straight into the invoicing sheet's import. `tests-catalog.md` lost
two entries and gained no new ones (shape is asserted by TGL-001..006).

## 030 — View-as mode must enforce importer scoping at the app layer
**Date:** 2026-05-23
**Status:** accepted

**Context.** ADR #027 introduced RLS scoping: managers only see batches
they uploaded. But RLS keys off `auth.uid()` — the real JWT user — and
view-as is a server-side overlay that swaps the *effective* user for
permission checks without changing the JWT. So when Rian (super admin)
switched into "view as Adi," he could still see all data, because the
underlying queries still ran as Rian and RLS happily returned
everything.

**Decision.** Added `getViewAsImportScope(supabase, orgId, eu)` in
`lib/effective-user.ts`. It returns:
- `null` if view-as is off (RLS already does the right thing), or
  if the effective user is an owner / super_admin (they'd see all
  data under RLS anyway).
- Otherwise, the list of `clockify_imports.id`s where
  `imported_by = effective_user_id`. Callers `.in('import_id', ids)`
  on `time_entries` (or `.in('id', ids)` on `clockify_imports`).

Applied in:
- `/entries` page — data query, count query, batch options dropdown,
  totals RPC (`p_imported_by` forced to the effective user when scope
  is on), options RPC, importer-options dropdown (hidden under scope).
- `/` (Summary) page — `summary_by_project` RPC. Required a new
  `p_imported_by` arg, added in migration `20260523020001`.
- `/import` page — batches list.
- `app/(app)/entries/actions.ts` — `applyBulkFilter` calls the helper
  and `.in('import_id', scope)` on the bulk write, so a view-as bulk
  edit can't sweep across batches the effective user can't see.

**Alternatives.** Use Supabase's PostgREST RLS impersonation (e.g.
sign a short-lived JWT for the target user) — rejected for now: too
much auth-state surface to manage, and view-as is super-admin only,
so app-layer enforcement is sufficient.

**Consequences.**
- The /entries Imported-by dropdown is hidden when view-as is
  scoping us — there's nothing to switch between.
- The view-as banner remains as the visible cue that scoping is on.
- /transfer continues to redirect non-transfer users to `/` (existing
  behavior), so no scope work was needed there.
- Mutation handlers that already permission-check at the entry level
  (`updateEntry`, `deleteEntry`, `resolveEntry`) still query by `id`
  alone within the org. That's a hardening hole (knowing an ID lets
  you mutate it under view-as) but not exploitable through the UI;
  noted for a follow-up.

## 031 — Source picker on the upload form
**Date:** 2026-05-23
**Status:** accepted

**Context.** Adi uploads Clockify CSVs, Rian uploads Toggl CSVs, but
both used the same upload form with Clockify-flavored defaults
(MM/DD/YYYY + 12h). The header parser already accepts either vendor's
column names (ADR #027 / migration `20260523000001`), but the date/time
format dropdowns don't auto-detect — and Toggl's Detailed Report uses
YYYY-MM-DD + 24h, so an unaltered upload would fail every row with
"invalid start date/time."

**Decision.** Added a **Source: Clockify / Toggl** radio at the top of
the upload form (`app/(app)/import/upload-form.tsx`, a small client
component). Flipping the radio swaps the date/time format `defaultValue`s
on the selects (Clockify → MM/DD/YYYY + 12h; Toggl → YYYY-MM-DD + 24h),
and the server action writes the picked source into
`clockify_imports.source` so future export logic can branch on it.
Server-side defaults also key off the radio so the right defaults
apply even if JS is disabled. The expected-columns hint at the bottom
of the form updates to reflect the selected source.

**Alternatives.**
1. Auto-detect from headers + first-row format parsing — more robust
   but more code to test, and headers don't disambiguate date format.
2. Two separate routes (`/import/clockify` and `/import/toggl`) —
   most explicit, but two pages of duplicated form state.
3. Leave it — eat the footgun. Rejected: silent "invalid start
   date/time" on every row is a poor failure mode.

**Consequences.**
- `clockify_imports.source` is now set on every new upload (previously
  NULL on UI uploads; the Python one-shot already set it).
- The form's header note ("Expected columns: …") branches on source.
- No new migration needed — the column existed already.

## 032 — Teams per user (one team per `(org, user)`)
**Date:** 2026-05-23
**Status:** accepted

**Context.** The app started as Adi's bridge: one org-wide team, one
org-wide hourly rate (`organizations.default_hourly_rate_usd`). When
Rian started importing his own Toggl data (ADR #027), this fell over:
his collaborators aren't on Adi's roster, and he bills at $50/hr, not
Adi's $14/hr. The user explicitly noted that the *real* model is
complex (one person can be on many teams with different compensation
models per engagement) but asked for the minimum-viable simplification
first: one team per user, per org.

**Decision.** New `teams` table — `(id, org_id, owner_user_id, name,
base_rate_usd, …)` with `unique (org_id, owner_user_id)`. Added
`team_members.team_id` (NOT NULL). Replaced the org-wide
`(org_id, email)` uniqueness with per-team `(team_id, email)` so the
same email (e.g. `rian@rian.ca`) can appear in two teams.
Migration `20260523030001_teams.sql`.

**Backfill in the same migration:**
- Created **Adi's team** (owner = Adi, rate = inherited legacy
  `organizations.default_hourly_rate_usd` = $14).
- Created **Rian's team** (owner = Rian, rate = $50).
- All legacy `team_members` rows → Adi's team initially.
- Moved the `rian@rian.ca` row (added in `20260523000001` so Rian's
  own Toggl imports could resolve) → Rian's team.
- Inserted `matmoncoo@gmail.com` (Matthew Monti-Cooper, 0.7) into
  Rian's team.

**RLS:**
- `teams`: visible to team owner; insert / delete by org owner or
  super_admin; team owners can update their own team's name + rate.
- `team_members`: flows through `teams` visibility — you see members
  of teams you can see.

**Code:**
- `lib/teams.ts` — `getTeamForUser`, `getRateForTeamMember`,
  `listVisibleTeams`.
- Import action: looks up the importer's team in the org, scopes the
  email→member lookup to that team, computes `billout_cost_usd` at
  that team's `base_rate_usd`.
- `entries/actions.ts` `updateEntry`: re-locks cost at the entry's
  team_member → team rate (was `org.default_hourly_rate_usd`).
- `entries/actions.ts` `resolveEntry`: looks up the team by the
  *import's* uploader, scopes the email→member lookup to that team,
  re-locks cost at that team's rate.
- `/team` page: one panel per visible team (Adi sees Adi's team
  only; Rian sees both stacked). Each panel has its own base-rate
  field, add-member form, and members table.
- `/team` actions: `updateTeamBaseRate(team_id)` replaces the
  org-wide `updateBaseRate`; `createTeamMember` takes `team_id`.

**Backwards compat:**
- `organizations.default_hourly_rate_usd` column stays in place but
  nothing reads it. Historical `billout_cost_usd` was already locked
  per-row at import time, so nothing changes for existing entries.
- The legacy `members_co_member_select` RLS helper
  (`current_user_org_ids`) is untouched.

**Alternatives considered.**
1. A general "engagements" model with per-engagement compensation —
   the right long-term model but a much bigger change. Deferred per
   the user.
2. Keep org-wide rate, add per-user override — leaks the rate to
   anyone who can see the org. Rejected.

**Consequences.**
- A future importer (a third user) needs a team created for them
  before they can upload. The import action fails with a clear
  message; no provisioning UI yet.
- The 21 previously-blocked `matmoncoo@gmail.com` entries in Rian's
  Toggl batch are now resolvable — Rian can Re-resolve them on
  /entries.
- One footgun: the same email in two teams means `getTeamForUser` →
  scoped lookup is the only correct path. The OLD pattern of
  `team_members where email = X` is now ambiguous.

## 033 — View-as is true impersonation, not a UX overlay
**Date:** 2026-05-23
**Status:** accepted

**Context.** ADRs #030 (entries / summary / import) and the later /team
fix (view-as visibility) closed the data-leak side of view-as. But the
broader contract — "what does view-as mean?" — was still ambiguous.
Some writes attributed to the real user, some bulk paths weren't
scope-checked, and the import action used the real user's team and
attribution rather than the effective user's. The user clarified that
view-as is **a fidelity test mode**: when Rian switches into "view as
Adi," the experience must be **exactly like logging in as Adi** — both
for reads and writes.

**Decision.** Adopt one unified rule across the app:

1. **Reads** — already covered by ADR #030 + the /team patch.
2. **Writes** — must target rows in the effective user's would-be RLS
   scope; if the target is out of scope, the action fails loudly.
3. **Attribution** — write actions populate `imported_by`,
   `author_id`, `resolved_by`, etc. with the **effective** user id,
   not the real user id. This is the trade-off that buys fidelity:
   forensic audit is murkier, but the test workflow ("upload as Adi;
   does Adi see the batch afterwards?") works correctly.
4. **Permission gates** — already use the effective user via
   `canTransfer` / `canManageImports` / `canEditEntry`. Unchanged.

**Implementation.** New `lib/view-as-guards.ts`:
- `guardEntry(supabase, eu, orgId, entryId)`
- `guardEntryIds(supabase, eu, orgId, ids)` — for bulk paths
- `guardImport(supabase, eu, orgId, importId)`
- `guardTeam(supabase, eu, orgId, teamId)`
- `guardTeamMember(supabase, eu, orgId, memberId)`

Each returns an error message string or `null`. Callers pair it with
their action-specific `fail(msg)`. Applied at every per-row mutation in
`entries/actions.ts`, `import/actions.ts`, `team/actions.ts`.

For attribution: `uploadClockifyCsv` now uses `eu.effective_user_id`
for both team lookup and `clockify_imports.imported_by`.
`comments.postComment` / `resolveComment` use `eu.effective_user_id`
for `author_id` / `resolved_by`.

**Alternatives considered.**
- Block writes entirely in view-as. Rejected — defeats the testing
  workflow ("can Adi successfully save a team member?").
- Attribute writes to the real user. Rejected — uploads in view-as
  Adi would be invisible to Adi afterwards (since `imported_by` is
  the visibility key), breaking the "exactly like logging in" intent.

**Consequences.**
- Comments posted in view-as appear as the effective user.
- Batches uploaded in view-as appear in the effective user's history.
- Forensic audit needs a separate column eventually (e.g.
  `clockify_imports.imported_by_real` to record both); deferred until
  there's an actual audit requirement.
- The `eu?.real_user_id` reference in `bulkMarkTransferred`'s
  `transferred_by` is left as-is because that path is unreachable
  from view-as (`canTransfer` returns false for the manager Adi).

## 034 — Eliminate rate_proportion from the cost/billout pipeline
**Date:** 2026-05-25
**Status:** decided + migrated

**Context.** Cost was computed as `team.base_rate × member.rate_proportion`
and billout as `member.billout_rate × member.rate_proportion`. The
"proportion" abstraction was a leftover from the old spreadsheet
("Adi's devs work at 50% of his rate"). It produced two confusing
side effects:
1. The displayed "Billout hrs" column was source × proportion — a
   number that looked like hours but only existed for the Toggl
   export's "converted hours" semantic.
2. Setting an absolute rate override on a project required mentally
   pre-multiplying by proportion to figure out the effective dollar
   amount per source hour.

**Decision.** Move to per-source-hour rates on each team_member:
- `team_members.cost_rate_usd` — what Rian pays this person per
  source hour. New canonical cost basis.
- `team_members.billout_rate_usd` — what Rian charges the client
  per source hour. Existing column rescaled in-place by the
  member's proportion so the migration was dollar-neutral for
  already-stamped entries.
- `team_members.rate_proportion` — retained as input (default 1)
  but ONLY used by the Toggl CSV export's
  `converted_duration_seconds` column.

**Migration (20260524000013_eliminate_proportion.sql).**
1. Add `cost_rate_usd` column.
2. Backfill `cost_rate_usd = team.base_rate × rate_proportion`
   (rounded to 2dp). Special-case: Gary's cost set to $0 (AI, per
   owner direction).
3. Scale `billout_rate_usd` in place by proportion so the formula
   `source × new_billout_rate` matches the old `converted ×
   old_billout_rate`. Dollar-neutral for non-override entries.
4. Re-stamp every entry's `billout_cost_usd` + `billout_amount_usd`
   using the new per-source-hour rates.
5. Update the entries / summary / project_summary RPCs to sum
   `duration_seconds` as the "billout hours" aggregate.

**Alternatives considered.**
- Keep proportion, fold it into a derived `effective_cost_rate` at
  read time. Rejected — keeps the displayed "Billout hrs" column
  confusing and doesn't simplify rate overrides.
- Drop proportion entirely (including from Toggl export). Deferred
  — Rian explicitly wants the Toggl export to keep proportioned
  hours.

**Consequences.**
- `teams.base_rate_usd` column is vestigial. The /team UI removed
  the "Base hourly rate" panel and the derived "Effective rate"
  cell. The DB column is retained until a future cleanup.
- The /entries display dropped the "Billout hrs" column.
- `restamp_billout_for_project` SQL function rewritten to use
  `duration_seconds × effective_billout_rate`. New companion
  `restamp_billout_for_entry_ids(org_id, ids[])` powers the bulk
  Recalculate action.
- Re-resolve + manual-entry actions stamp from
  `team_members.cost_rate_usd` directly via a new
  `getCostRateForTeamMember` helper.

## 035 — Entities (operators / clients / projects) as first-class tables
**Date:** 2026-05-22 (decided + migrated in 4 phases through 2026-05-23)
**Status:** decided + migrated

**Context.** Originally `time_entries.operator` / `client` /
`project` were free-text columns. Aggregations grouped on those text
columns (with `lower()` to normalize). This blocked anything that
needed identity:
- "Rename a client" required updating every entry row.
- "Set income on a project" had nowhere to live.
- Rate overrides per (project, member) had nowhere to live.

**Decision.** Promote to three first-class tables linked in a chain:
- `operators(id, org_id, name)`
- `clients(id, org_id, operator_id, name)`
- `projects(id, org_id, client_id, name, income_usd?, billout_adjustment_*)`

Add `time_entries.project_id` FK. Keep the legacy text columns as
a denormalized cache so the existing filter + display queries
don't need rewriting. Renames update both the entity row AND the
text columns.

**Phases (each landed as its own migration + UI iteration):**
1. **Schema + backfill** — create tables, backfill from existing
   text columns (case-insensitive dedupe), wire `project_id` FK.
2. **/projects/manage rename UI** — 3-column page for renaming
   operators / clients / projects. Renames cascade to
   `time_entries.{operator,client,project}` text columns.
3. **Import resolver** — Clockify CSV import now creates entity
   rows when it encounters a new (operator, client, project)
   tuple, and stamps `project_id` on each imported entry.
4. **EditRow combobox + cascading inheritance** — typing a known
   client auto-fills its operator; typing a known project
   auto-fills both, when the match is unique.

**Consequences.**
- Rate overrides (`project_rate_overrides`), project income
  (`projects.income_usd`), and project billout adjustments
  (`projects.billout_adjustment_*`) hang off `projects`.
- /projects summary RPC returns `project_entity_id` for inline-edit
  deep-links.
- Invoices can scope to `operator_id` / `client_id` cleanly.
- Text-column drift is possible if anyone bypasses the rename UI
  and does a manual SQL update. The /projects/manage flow keeps
  them in sync; out-of-band edits could create inconsistency.

## 036 — Project rate overrides with pct variant
**Date:** 2026-05-24 (absolute) / 2026-05-25 (pct added)
**Status:** decided + shipped

**Context.** "Prompt Victoria gets all work at -20%" needs to apply
to every entry on that project without overriding rates one user at
a time. Two flavors needed: an absolute rate (e.g. "$20/hr regardless
of who logged it") and a percentage tweak (e.g. "−20% off the
member's normal billout rate").

**Decision.** Single table `project_rate_overrides(project_id,
team_member_id?, override_rate_usd?, override_pct?)`. CHECK
constraint enforces exactly one of `override_rate_usd` /
`override_pct` is set. NULL team_member_id = project-wide; non-NULL
= user-specific.

**Precedence** (in the `effective_billout_rate` SQL function):
1. User-specific override on this project
2. Project-wide override (`team_member_id IS NULL`)
3. `team_members.billout_rate_usd` default

For pct overrides, multiply the NEXT-FALLBACK rate by `(1 + pct/100)`.
So a project-wide -20% applied to a member with $25/hr default →
$20/hr effective.

**Alternatives considered.**
- Stack overrides multiplicatively (user pct stacks on project pct).
  Rejected for v1 — adds complexity; "which one wins" is more
  intuitive.
- Overrides as direct columns on `projects`. Rejected — can't
  express per-user overrides without separate columns per user.

**Consequences.**
- Re-stamp on save: editing an override re-stamps existing entries
  on that (project, member?) tuple via `restamp_billout_for_project`
  SQL function — single UPDATE, not N HTTP PATCHes.
- A SubmitButton component (`useFormStatus`) provides visible
  "Re-stamping entries…" feedback while the action runs.

## 037 — Team-member dropdown replaces source_user_email + re-resolve
**Date:** 2026-05-25
**Status:** decided + shipped

**Context.** Three fields on every entry — `source_user_email`,
`source_user_name`, `converted_user` — all editable as free text,
plus an invisible `team_member_id` FK that actually drove cost +
billout. Users editing the source email to change billing
discovered the change had no effect until they explicitly clicked
the 🔄 Re-resolve button. The model leaked.

**Decision.** Reshape the entry edit form:
- Add a **Team member** dropdown (grouped by team) — the canonical
  picker. Stamps `team_member_id`, `converted_user`,
  `converted_duration_seconds`, cost, billout on save.
- Demote `source_user_email` / `source_user_name` to a read-only
  "Logged as (audit)" display. Frozen at import time.
- Drop `converted_user` from the editable form. Derived from
  `picked_member.consolidate_as ?? display_name` every save.

For manual entry creation (NewEntryRow), the dropdown is required.
Source identity is stamped from the picked member.

**Alternatives considered.**
- Auto-re-resolve on every source_user_email change. Rejected —
  surprising override of manually-set converted values.
- Keep source fields editable as an "Advanced" toggle. Deferred.

**Consequences.**
- The 🔄 Re-resolve button is still useful for legacy entries that
  failed to resolve at import time. It looks up team_member by
  `source_user_email` against the importer's team.
- Tests for `applyConversion` updated to include `cost_rate_usd`.

## 038 — Transfers → Invoices as the canonical lock signal
**Date:** 2026-05-25
**Status:** decided + migrated

**Context.** "Mark as transferred" + `transferred_at` was a coarse
"this entry has left the building" flag. It conflated two things:
(a) "I've exported this to Toggl for invoicing" and (b) "this is
billed to a client". With Toggl going away (replaced by in-app
invoicing), (a) loses meaning; (b) needs a richer model — an
invoice is an entity, not just a timestamp.

**Decision.** Add `invoices` table and `time_entries.invoice_id` FK.
Attaching an entry to an invoice locks it (same legal effect as
`transferred_at` had). Detaching unlocks. Invoices carry status
(open / sent / paid), optional operator/client scope, optional
manual_total override, notes.

Backfill: every entry with `transferred_at IS NOT NULL` is linked
to a per-org "Legacy transfers" invoice (status=paid). The legacy
columns are preserved as historical timestamps; new writes don't
touch them.

`canEditEntry` locks on `invoice_id IS NOT NULL OR transferred_at
IS NOT NULL`. The OR with the legacy column is defensive —
post-backfill the two should always agree.

**Alternatives considered.**
- Keep `transferred_at` AND add invoice_id. Rejected — two locks
  to keep in sync, two filter predicates to maintain.
- Drop `transferred_at` immediately. Deferred — the column is
  preserved as audit history; a future cleanup can drop it.

**Consequences.**
- `/entries` bulk actions: "Mark transferred" / "Unmark transferred"
  replaced by **Apply to invoice** (dropdown + button) / **Detach**.
  Recalculate keeps the same shape.
- Status filter relabeled — URL param value `transferred` kept for
  back-compat; new alias `applied` added.
- Filter predicate in `entries_filter_totals` / `entries_filter_options`
  / `project_summary` checks `invoice_id IS NOT NULL OR
  transferred_at IS NOT NULL`. Eligibility columns trimmed:
  `converted_*` removed (Toggl-only).
- `bulkMarkTransferred` / `bulkUnmarkTransferred` server actions
  remain as orphan exports until the /transfer page is dropped in a
  follow-up.
- /transfer page kept routable (off main nav) so Rian can pull a
  final Toggl CSV backup before that follow-up cleanup.

## 039 — Billout rate as a per-member concept distinct from cost
**Date:** 2026-05-24 (back-filled ADR 2026-05-25)
**Status:** decided + shipped

**Context.** Until 2026-05-24 the only money-per-hour on the model
was **cost** — what Rian pays the worker. That's adequate for the
margin question on /summary (revenue − cost), but Rian also charges
the *end-client* a different rate per worker, and the difference
between those two is the margin signal that actually matters. Cost
and "what we charge" had been collapsed into the same number, which
was wrong.

**Decision.** Introduce a new owner-only concept: **billout rate**.
- `team_members.billout_rate_usd numeric(10, 2)` — USD per billout
  hour (later rescaled to per source hour by ADR #034). NULL = "not
  set, no billout amount computed for entries pointing here".
- `time_entries.billout_amount_usd numeric(12, 2)` — locked at write
  time, same way `billout_cost_usd` is locked (ADR #014). Changing
  the rate later doesn't retroactively re-stamp historical rows
  (Recalculate or a per-project re-stamp action handles those
  intentionally).
- Owner-only at the **app layer**: the column is visible per RLS to
  any org member, but the /team UI gates the input behind
  `canSeeBillout(eu)` and the /entries display gates the Billout
  column behind `canTransfer(eu)`. Adi (manager) doesn't see it.

**Alternatives considered.**
- Org-wide default billout rate + per-member proportion. Rejected —
  the actual rates per worker / project tier aren't strictly
  proportional, and pretending they are creates exactly the
  pre-ADR-#034 mess.
- Column-level RLS via Postgres views. Rejected for v1 — app-layer
  gating is simpler and the threat model is "Adi might look", not
  "Adi might run SQL". Could revisit if Adi gets DB-level access.
- Stamp `billout_rate_usd` directly on the entry (not just the
  amount). Rejected — only the resolved amount matters at read
  time; the rate is reconstructible from member + project overrides
  if needed.

**Consequences.**
- /summary cards split into Cost + Billout. /projects gains a
  Billout column. /entries gains a Billout column (owner-only).
- The conceptual ground for project rate overrides (ADR #036) and
  invoices (ADR #038) — both reference "billout" as an established
  per-member fact, not a derived org-level number.
- ADR #034 was able to "rescale `billout_rate_usd` in place by
  proportion" specifically because billout_rate already lived per
  member; if it had been org-wide, eliminate-proportion would have
  been a much bigger surgery.

## 040 — Project billout adjustments are display-only, not stamped
**Date:** 2026-05-24 (back-filled ADR 2026-05-25)
**Status:** decided + shipped

**Context.** Project billout adjustments (a pct or fixed-amount
tweak applied to a project's total billout) were introduced as the
first of the "adjustments" Phase 1. They look superficially similar
to rate overrides (ADR #036) but they're a different shape: rate
overrides change per-entry rates; project billout adjustments are a
post-hoc bump on the project's TOTAL billout.

Two implementation options were on the table:
- **(A) Stamp:** when the adjustment changes, re-stamp every entry's
  `billout_amount_usd` to a per-entry pro-rated share of the
  adjusted total.
- **(B) Display-only:** store the adjustment values on
  `projects.billout_adjustment_*`, apply them at read time in the
  summary RPCs (`project_summary`, `summary_by_project`). Never
  alter `time_entries.billout_amount_usd`.

**Decision.** Option B. Display-only.

**Rationale.**
- Preserves ADR #014's "locked at write time" invariant.
  Per-entry stamps stay honest — they represent the rate that
  actually applied to that work; the adjustment is the
  organization-level summary fudge.
- Pro-rating a fixed-amount adjustment back to per-entry is lossy
  and arbitrary (do you weight by hours? by cost? by source rate?).
- Reversing or changing an adjustment doesn't require re-stamping,
  so the action is instant. Owner can experiment with
  pct-vs-fixed shapes without rewriting history.
- An adjusted total **cannot** be reconstructed from per-entry
  sums alone — but the adjusted total is what the *invoice* shows,
  and the unadjusted sum is still on the entries for audit. Both
  views are valid in their context.

**Migration `20260524000008_project_billout_adjustments.sql`.**
The migration comment refers to "ADR #036" — that's an editing
error; #036 is rate overrides (which DO stamp). The correct
reference is this ADR (#040).

**Consequences.**
- /projects displays the adjusted total in the Billout column.
  Hovering reveals the raw sum + the applied adjustment string.
- /projects/[expand] sub-table also reflects adjusted totals.
- /invoices doesn't display the adjustment by default — invoices
  are a separate aggregation. If a client sees an adjusted total on
  /projects but an unadjusted total on the invoice, that's a
  modeling gap worth revisiting; for now the use case is "owner-only
  internal margin tracking" and the invoice totals come from
  per-entry stamps directly.
- adjustments.md is the feature doc covering both adjustments and
  the per-entry rate overrides (ADR #036).

## 041 — Two-step CSV import with pending_imports resolver
**Date:** 2026-05-24 (back-filled ADR 2026-05-25)
**Status:** decided + shipped

**Context.** The original import flow was one-step: parse CSV →
implicitly create entity rows for any new operator / client /
project names → insert time_entries. With the entity migration (ADR
#035) operators and clients became first-class entities. The
implicit-create behavior had two failure modes:
1. **Typo amplification.** A misspelled "Bowden Wroks" silently
   created a new operator row, partitioning entries across two
   operators that should have been one. Renaming after the fact
   required the /projects/manage rename UI — fixable but not
   discoverable.
2. **No preview.** The user couldn't tell whether their CSV would
   create 0 new operators or 5. Subtle data drift snuck in.

**Decision.** Two-step flow:
1. **Upload** parses the CSV server-side and stashes the parsed rows
   in `pending_imports(id, parsed_rows JSONB, expires_at)` with a
   24h TTL. Redirects to `/import/resolve/[id]`.
2. **Resolve** walks the parsed rows, lists every distinct
   (operator, client, project) and forces the user to confirm
   "Create new" or "Map to existing &lt;foo&gt;" for any name that
   doesn't already exist. Required before the Confirm button enables.
3. **Confirm** writes `clockify_imports` + entities + `time_entries`
   in one go, then deletes the `pending_imports` row.

**Alternatives considered.**
- Keep the one-step flow + add a post-import "review changes" page.
  Rejected — by the time the user reviews, the entities exist; undo
  requires deleting + reuploading.
- Use a Postgres transaction across upload + resolve. Rejected —
  Next.js Server Actions don't hold a connection across requests;
  the `pending_imports` table is the explicit cross-request stash.

**Consequences.**
- New table: `pending_imports`. 24h TTL via `expires_at` column.
  Cleanup of expired rows is currently manual; a Supabase cron job
  is a follow-up.
- New route: `/import/resolve/[id]` (RSC + server action).
- The previous "implicitly create entity if missing" behavior is
  gone from the main /import flow. The `resolveOrCreateOperator` /
  `resolveOrCreateClient` / `resolveOrCreateProject` helpers are
  still used by `entries/actions.ts` (manual entry creation + edit)
  where the user explicitly types a new name — those paths are
  intentional creates, not import-time drift.
- Re-uploading a CSV that's mid-resolve creates a SECOND
  `pending_imports` row. Idempotency / dedupe at upload-time is a
  follow-up.
- The resolver respects view-as (ADR #033): entities + team-member
  lookups happen under the effective user's scope, and the
  resulting batch is attributed to the effective user.

## 042 — Bulk Edit fields reassigns to existing entities only (no free-text)
**Date:** 2026-05-25
**Status:** decided + shipped

**Context.** The bulk **Edit fields** action on /entries originally
accepted three free-text inputs (`operator`, `client`, `project`)
and rewrote the corresponding text columns on selected `time_entries`
rows. Two failure modes hit at once on 2026-05-25:

1. **Drift between text columns and FK.** Bulk edit only touched the
   denormalized text columns; it didn't update `client_id` /
   `project_id`. A row's text could say `"AI Smart Marketing"` while
   its FK still pointed at the `AISV` entity. `/projects/manage`
   renames don't surface this; the next rename via the proper UI
   would overwrite the manually-set text back to whatever the entity
   said.
2. **Status-filter exclusion.** The action ran under the active
   /entries filter — which defaults to `status=pending`. Pending
   excludes invoice-attached rows, so a bulk "rename" attempt
   silently skipped every row already on an invoice. User assumed
   "all my AISV entries are now AI Smart Marketing" because /entries
   (still filtered to pending) showed the new name everywhere — but
   the invoice detail page still rendered "AISV" from the
   invoice-attached rows that the bulk edit had skipped.

**Decision.** Reshape bulk Edit fields to be a **reassignment**
action, not a free-text rewrite. Two pickers only:

- **Project picker** — searchable combobox; options are existing
  `projects` rows labeled `{client} : {project}`. Picking sets
  `project_id` AND the text-column cache (`operator` / `client` /
  `project`) from the resolved entity chain. Drift impossible
  because the text is derived from the entity, not user-typed.
- **Team member picker** — searchable combobox; options are existing
  `team_members` rows labeled `{display_name} : {email}`. Picking
  stamps `team_member_id`, `converted_user`,
  `converted_duration_seconds` (via new SQL function
  `set_team_member_for_entry_ids`) AND re-stamps cost + billout
  (via existing `restamp_billout_for_entry_ids`).

At least one of the two pickers must be set. Both may be set in one
submission. The Operator field is gone — to change operator,
reassign to a project under the desired operator (which carries
its operator).

**Renames live on /projects/manage.** That UI updates the entity
row AND every `time_entries` text cell referencing it (regardless
of pending / applied / locked status). Bulk Edit fields explicitly
cannot rename; it can only re-point entries to entities that
already exist.

**Alternatives considered.**
- Auto-resolve free text to FK: if the user types a name that
  matches an entity exactly, also update the FK. Rejected — still
  allows the typo-create failure mode; the "create new" path on
  bulk edit doesn't compose with the resolver UI on imports.
- Combine free-text + filter widening: change the bulk-edit
  default to `status=all`. Rejected — fixes one symptom but not
  the drift problem. Free-text bulk on locked rows is also a
  scary default.

**Consequences.**
- New SQL function `set_team_member_for_entry_ids(org_id,
  team_member_id, entry_ids)` parallels `restamp_billout_for_entry_ids`.
- New client component `components/searchable-combobox.tsx` —
  small type-filter picker, keyboard nav (↑↓/Enter/Esc), no extra
  dependencies. Reusable elsewhere if other bulk-pickers need it.
- The /entries page now fetches a `projectOptions` list (labeled
  `{client} : {project}`) alongside the existing `teamMembers`
  list, and passes both to `EntriesTable`.
- The form's hidden inputs (`bulk_project_id`, `bulk_team_member_id`)
  are read at confirm-time directly from the DOM, not mirrored
  into React state. The SearchableCombobox owns its hidden input;
  the parent doesn't need to duplicate state.
- Existing drift from the 2026-05-25 incident is fixed by running
  `/projects/manage` rename on the affected clients ("AISV" →
  "AI Smart Marketing"; "Second Wind" → "Second Wind Consultants").
  The rename action propagates to `time_entries.client` text on
  invoice-attached rows too.
