# Team & engagement admin

**Status:** in production
**Lives at:** `/team` (SPA `frontend/src/features/team/TeamPage.tsx`) ·
`app/routers/team.py` · `app/services/team_admin.py`
**Spec:** `/srv/apps/work/docs/rebuild/01-current-system/features/organizations-and-team.md`
(rebuild deltas) + `docs/rebuild/02-domain-model.md` §1–2, §4

## Summary

The owner-facing Team page: manage the people whose hours the system
stamps. It is the R1 rebuild of the legacy `team_members` CRUD, mapped
onto the target model — each worker is a **person party** + a
**subcontract engagement** (tenant → person) carrying the
`can_manage_imports` flag and the `consolidate_as` import-mapping label,
plus **compensation_terms** (`kind` = cost / billout) that price both
sides of the worker's economics. Rate history is **append-only**.

## Why

Ported from the current app (Rian + Adi maintained a roster of workers
whose Clockify email matched at import time and whose cost/billout rates
stamped onto every entry). The parity bar is the legacy behavior spec;
the R1 model dissolves `organizations`/`teams`/`team_members` into
parties + engagements + terms (02 §7) so "who is this hour by, what does
it cost, what do we bill for it" has one home.

## Behavior

- **List workers** — one row per worker engagement in the tenant: name,
  email, active status (the engagement's `ended_on IS NULL`), the current
  in-force cost term and billout term (rate + currency + since-date),
  `consolidate_as`, a "can manage imports" tag, and the worker's
  time-entry count.
- **Add worker** — creates a person party (or reuses the existing party
  for that email — parties are never duplicated), a subcontract worker
  engagement, and cost/billout terms. Email is stored trimmed +
  lowercased so import matching is case-insensitive end to end. A blank
  rate creates **no** term (NULL = never priced); `0` creates a real
  zero-rate term (Gary).
- **Edit worker** — name, email, `consolidate_as`, `can_manage_imports`,
  notes update directly. Cost/billout rates, **when present in the
  patch**, go through the append-only term machinery; omitting a rate
  field never clobbers the stored rate.
- **Append-only rate change** — a rate change ENDS the current term
  (stamps `effective_to` = the day before the change) and OPENS a new
  term effective today. Historical resolution still returns the old
  rate; **existing time-entry stamps are never touched here**. Re-stamping
  existing entries is the explicit action on `/entries` (M5 recalculate),
  deliberately not offered on this page. A same-value change is a no-op;
  a same-day correction (the open term started today, so no history
  resolves through it) amends the rate in place — still append-only with
  respect to history, which is the actual invariant.
- **Deactivate / reactivate** — deactivate ends the worker engagement
  (`ended_on` = today); the person party, its terms (incl. Gary's 0/25),
  and every historical entry stay put. An ended worker no longer resolves
  on import. Reactivate clears `ended_on`.
- **Project billout overrides** — the project-scoped `rate_overrides`
  rules (judgment #25), kept as their own table. A worker-specific rule
  beats a project-wide rule (worker = NULL, applies to everyone incl.
  workers added later); an absolute rate replaces the billout, a signed
  percentage adjusts the worker's own billout rate. Owner-only. Precedence
  is enforced at stamp time by `services/money/stamping` — this page only
  authors the rows.
- **`consolidate_as`** — the export/summary grouping label, stored on the
  engagement (import-mapping config, 02 §7). Blank renders "(own name)".

## Constraints & edge cases

- **NULL ≠ 0.** A blank cost rate means "cost unknown" → NULL stamp;
  `0` is a real zero-cost billable worker (Gary, cost 0 / billout 25).
  Never coalesced (judgment #19; T-TEAM-07, T-TEAM-08).
- **Uniqueness is per tenant, not per team.** Teams dissolved (02 §7),
  so a person can hold at most one active worker engagement in the
  tenant; adding an email that already has one is rejected. The legacy
  per-team uniqueness (T-TEAM-03) has no R1 analogue.
- **Deactivate stops import resolution.** In the legacy app inactive
  members still resolved on import; the R1 rule is that ending the
  engagement removes it from the resolver (a departed worker's new hours
  shouldn't auto-stamp). History is fully preserved — this replaces the
  legacy hard-delete SET-NULL orphaning wart (T-TEAM-09 → the R1
  deactivate test).
- **Worker notes live on the engagement,** matching where the migration
  places them, not on the party — a deliberate divergence from the
  rebuild-delta doc's `notes → parties.notes` line (recorded in ADR).
- **Existing stamps are frozen.** No path on this page rewrites
  `time_entries` money. That is the whole point of the append-only gate.

## Permissions

- Every read and write gates on **`can_manage_team`** — the EFFECTIVE
  rule (02 §4): owner / super-admin (not while viewing-as). Managers and
  workers are denied (`NOT_ALLOWED`). This resolves the legacy
  `canManageTeam`-vs-RLS mismatch: the dead any-manager grant is NOT the
  parity bar; per-team-owner grants are resource-scoped and land in R2.
- **Billout** rate fields (and the entire override list) are additionally
  gated on **`can_transfer`** — the owner's private number. In R1 the two
  capabilities coincide, but the payload honors the finer rule so the
  page is correct once R2 introduces team-scoped managers (T-TEAM-12).

## Open considerations

- R2: per-team-owner management grants (Adi maintaining his Tingang
  roster directly) — needs resource-scoped `can_manage_team` + read
  scoping so a manager can't see other workers' cost rates.
- Rate changes are always effective *today*; a backdated/scheduled
  effective date is a plausible future affordance (the service already
  takes an `effective_from` for testability).
- `rate_overrides` is R1's kept-as-is table; R4's money-engine design
  pass decides whether terms can subsume it (judgment #25).

## Tests

`tests/team_admin/` — unit rows on a SQLite graph slice + the append-only
GATE on the sidecar-Postgres migrated clone (a real worker, Adi):

- T-TEAM-01 — duplicate active email rejected (R1 tenant-wide uniqueness).
- T-TEAM-04 — email trimmed + lowercased; case-insensitive import match.
- T-TEAM-06 — a patch omitting `billout_rate` never clobbers it.
- T-TEAM-07 — blank cost → no term → NULL cost stamp.
- T-TEAM-08 — Gary (0 / 25) → 10h stamps cost 0.00, billout 250.00.
- T-TEAM-10 — `consolidate_as` round-trip on the engagement.
- T-TEAM-11 — override precedence (worker-specific > project-wide pct >
  term) via `stamping.resolve_rates`.
- T-TEAM-12 — a can-manage-team-but-not-can-transfer actor gets no
  billout fields and an empty override list.
- Add / edit / deactivate / reactivate happy paths; rate-override XOR +
  duplicate-scope validation; the manage-gate denial for manager/worker.
- **GATE (`test_append_only_gate.py`, real migrated Adi):** a cost 14→22
  change opens a new term row, closes the old one (`effective_to` = the
  day before), leaves every existing entry stamp byte-for-byte unchanged,
  keeps the old rate resolvable for a historical date, and stamps the new
  rate on the next import; clearing a rate ends the term and the next
  stamp is NULL.

## Changelog

- 2026-07-08 — Team page shipped (M7). New `services/team_admin.py`,
  `routers/team.py`, `features/team/`; append-only rate history; project
  billout overrides moved onto the Team page; `can_manage_team` gate.
