# Target domain model — parties, engagements, and the permission engine

The data model for the rebuilt system. Derived from `docs/domain/`
(the business-reality blueprint written early in this app's life) plus
everything learned building the current app (docs/decisions.md
#001–#044). The current app's model is a deliberately-flattened subset
of this; the mapping from current tables is at the end.

Guiding principle (from docs/domain/README.md): **roles live on
relationships, not on parties.** "Agency," "client," "referrer" are not
properties of a company — they're the role a party plays in one
specific engagement. Model the engagement as the unit and every
"complex org structure" requirement falls out naturally.

---

## 1. Identity and actors

### `parties`
Any actor: a human or an organization.

| column | notes |
|---|---|
| id | uuid pk |
| kind | `person` \| `org` |
| name | display name ("Adi Pramono", "PlusROI") |
| slug | stable short handle, unique |
| email / phone / address / notes | contact metadata, nullable |
| is_active | soft archive (parties are never hard-deleted — history references them) |

Examples: Rian, Rob, Adi, Heather, Summer, Yulia, Zeina (person);
Bowden Works, PlusROI, Tingang, McAllister, AISV, Agent Noir, Hartling
Group, The Palms (org). End clients and agencies are the SAME table —
what makes AISV "a referrer" or Copernic "a client" is the engagement,
not the row.

**Parties carry shared identity ONLY** (name, kind, contact). Every
commercial default — currency, terms, credit limit, commission %,
categories, paid-by — is an attribute of a tenant-scoped engagement
or registry row, never the global party (otherwise two tenants fight
over one row, and R5 external tenants could read each other's
commercial terms and private notes about shared parties).

### `users`
App login identities. **No password column — authentication is
id-auth's job (Pattern B).** A user row exists so the app can attach
preferences, super-admin status, and a party link to an id-auth
identity.

| column | notes |
|---|---|
| id | uuid pk |
| idauth_username | unique — the BW username from the Pattern B id_token (`app-auth` kit; judgment #31) |
| email | for matching + display |
| person_party_id | fk → parties (kind=person), unique. Every user IS a person. |
| is_super_admin | rian only. Unlocks view-as + cross-tenant admin. |
| preferences | jsonb (UI state worth persisting) |

Not every person has a user (Zeina and the ~18 Tingang workers are
parties with no login). Every user has exactly one person party.

### `tenants` (the businesses running their books here)
The system runs Bowden Works today; PlusROI, Heather's business,
Summer's business tomorrow. A **tenant** is a party (org) whose books
live in this system — the anchor for "whose data am I looking at."

| column | notes |
|---|---|
| id | uuid pk |
| party_id | fk → parties (kind=org), unique |
| settings | jsonb (invoice numbering, default currency, fiscal year…) |

Every operational row (project, time entry, invoice, expense batch)
carries a `tenant_id`. A party can be a tenant AND appear in other
tenants' engagements (PlusROI is a tenant of its own books and the
partner/client in Bowden Works' books). Tenancy controls *whose ledger
a row belongs to*; engagements control *who can see it*.

> Replaces the current `organizations` + `organization_members`. The
> current app has exactly one org ("Bowden Works") and role enum
> owner/manager/member; those roles become engagement-derived grants
> (§4).

---

## 2. Engagements — the relationship graph

### `engagements`
A directional, typed working relationship between two parties.

| column | notes |
|---|---|
| id | uuid pk |
| tenant_id | whose books this engagement is recorded in |
| type | `subcontract` \| `freelance` \| `partnership` \| `referral` \| `referral_pass_through` \| `direct_client` \| `employment` \| `official_partnership` |
| party_a_id / party_b_id | the two parties |
| role_a / role_b | the role each side plays, constrained by type (prime/subcontractor, referrer/operator, partner/partner, engager/freelancer, client/operator, employer/employee, owner/owner) |
| transparency | `transparent` \| `opaque` (the NewGrowth pattern — subcontractor poses as prime) |
| started_on / ended_on | validity period; **ended engagements are never deleted** (history is queryable) |
| notes | free text |

Two parties can hold **multiple concurrent engagements** (McAllister is
Prime on project X and Referrer on project Y with PlusROI — two rows).
State changes append (end one row, start another), never mutate history.

**The client→operator hierarchy lives here too.** Today's
`clients.operator_id` ("Brentwood is a Bowden Works client") becomes a
`direct_client` engagement between the client party and the operator
party. Everything R1 needs from the old hierarchy derives from these
rows: the cascading pickers ("clients under operator X" = parties with
a direct_client engagement to X), client-name uniqueness per operator
(unique index on (operator side, lower(client party name)) across
active direct_client engagements, enforced in the entities service),
and the import resolver's operator→client→project chain. The
migration seeds one direct_client engagement per legacy `clients` row
(05-migration-plan step 2.2 — after the tenant exists; engagements
are tenant-scoped).

### `compensation_terms`
Comp is separate from engagement type (same type, different comp).
One engagement can have several terms over time or per scope.

| column | notes |
|---|---|
| id | uuid pk |
| engagement_id | fk |
| kind | `cost` \| `billout` — **the R1 worker-terms model**: both sides of a worker's economics live as two term rows on the worker's OWN engagement (Adi's engagement carries his cost term AND his billout term; Gary: cost 0 + billout 25). The two-hop purity story (billout on the BW→PlusROI partnership engagement) is the R4 aspiration — R1's diff harness only needs today's per-worker numbers reproduced, and a per-worker billout cannot hang on the single partnership engagement. Resolved per the 2026-07-07 plan critique; 05 step 3 seeds accordingly. |
| model | `hourly` \| `per_unit` \| `fixed_price` \| `prepaid_block` \| `profit_share` \| `commission` \| `gross_percentage` \| `salary` |
| rate_amount + currency | hourly rate / unit value / fixed price (meaning depends on model; currency per §3) |
| percentage | for profit_share / commission / gross_percentage |
| project_id | nullable — null = default for the engagement, set = project-specific override |
| effective_from / effective_to | rate history is append-only |

This replaces and generalizes: `team_members.cost_rate_usd`,
`team_members.billout_rate_usd`, and the org-level base rate. In R1
both kinds live on the worker's engagement (the `kind` column above).
**The two-hop chain remains the R4 direction:** cost = what BW pays
Adi (his subcontract terms); billout = what BW bills PlusROI (the
partnership's terms) — making each hop explicit is what lets the same
engine later compute PlusROI's own books, Danielle's gross-percentage
cut, and the 50/50 split. R4's design pass decides how per-worker
billout composes with partnership-level terms; R1 does not guess.

Nuances the terms model must record from day one:
- **Per-unit comp uses an agency-global unit value by default** (rian:
  "a global unit value" per agency) — a `project_id`-NULL term on the
  engagement carries it; project-specific terms override.
- **Commission entitlements can outlive the engagement that created
  them** (Community Financials: the referrer was fired; PlusROI still
  pays the commission). `ended_on` on an engagement ends the *working
  relationship*, never the money obligations derived from it — the R4
  commission engine must read terms through ended engagements.
- **`prepaid_block` is the client-side retail pattern** — customers
  pre-purchase 5/10-hour support blocks that are drawn down by time
  entries over months ("10 Hour General Support Block" projects, the
  "hourly" quick-filter's whole purpose). In the current app the only
  representation is 20 **sentinel baseline entries dated 2025-12-31**
  (description "Transfer baseline from Toggl, totals as of March 31,
  2026") that make lifetime drawdown totals correct — those rows are
  **load-bearing data, not Toggl residue** (05-migration-plan carries
  them; do not confuse them with the phase-outs). R3 models blocks as
  real objects (purchase + drawdown + balance); until then the
  sentinel-row convention continues.

### `rate_overrides` (R1 — kept as its own table, deliberately)
Today's `project_rate_overrides` semantics do NOT flatten into
compensation_terms cleanly: a project-**wide** override
(team_member_id NULL) applies to every member *including ones added
later*, and a **pct** override is a signed modifier computed against
each member's own default rate at resolution time — both are
resolution-time rules, not per-engagement static terms. R1 therefore
carries the table near-verbatim: (tenant_id, project_id,
worker_party_id nullable, absolute-rate XOR pct, notes), with the
same specificity precedence (worker-specific > project-wide > member
default). R4's money-engine design pass decides whether terms can
subsume it. Judgment call #25.

### `project_engagements`
Which engagements touch a project, with what visibility.

| column | notes |
|---|---|
| id | uuid pk |
| project_id / engagement_id | fks |
| visibility | `operator` \| `subcontractor_deep` \| `subcontractor_shallow` \| `referrer_billing` \| `referrer_blind` \| `client` |
| is_primary_contact | flags the account-manager human on the engagement |

Visibility is **per-engagement per-project** — the same party can be
operator on one project and referrer-blind on another. This table is
the permission engine's backbone (§4).

---

## 3. Work and money

### Currency (first-class from R1 — rian, 2026-07-07: "most of my
clients are in CAD")

Money is never a bare number: every money-bearing row carries an
ISO-4217 `currency` code alongside its amount. The `_usd` column
suffix dies with the migration.

- **Where currency lives** (one code per scope that can't mix):
  `compensation_terms.currency` (a rate is quoted in one currency);
  `rate_overrides.currency`; `invoices.currency` (single-currency
  documents — every line inherits it, enforced);
  `cc_expense_batches.currency` (a statement is one card, one
  currency); `projects.income_currency` alongside income;
  `tenants.settings.default_currency` (seeds new terms/invoices).
- **`time_entries` carries TWO**: `cost_currency` and
  `billout_currency`, stamped from the terms that priced each side —
  because the two hops legitimately differ (Adi may invoice Bowden
  Works in USD while BW bills PlusROI in CAD). Stamps remain locked
  at write time; the currency is part of the stamp.
- **The segregation rule (load-bearing):** amounts in different
  currencies are NEVER summed, netted, or compared without an
  explicit FX step — and R1 has no FX step. Every aggregate
  (summary cells, entries totals card, project rollups, invoice
  totals) GROUPS BY currency and renders per-currency subtotals.
  Margin (income vs cost, billout vs cost) is computed only when
  both sides share a currency; mixed cases render side-by-side with
  both codes visible, never a fake single number.
- **FX conversion is an R4 money-engine concern** (profit-share
  across a USD-cost/CAD-revenue chain needs real rate-source and
  timing decisions — as-of-invoice-date vs as-of-payment). R1 keeps
  the books honest per-currency; R4 makes them convertible.
- Display: currency code always visible where amounts from more than
  one currency can co-occur; the common single-currency case renders
  clean (`$1,201.54` with the code in the column header or cell
  badge per the design system's MoneyCell).
- **R1 scope trim (07 #21 amendment):** R1 data is all-CAD, so the
  mixed-currency RENDERING paths (badges, side-by-side no-margin
  views, per-currency subtotal UI) are deferred until a second
  currency exists; R1 aggregates assert all-one-currency and error
  loudly if that ever fails. The columns, stamps, and single-currency
  document constraints all ship in R1.

### `projects`
| column | notes |
|---|---|
| id | uuid pk |
| tenant_id | whose books |
| operator_party_id | the org running the project (today's "operator": Bowden Works or PlusROI) |
| client_party_id | the end client (today's "client" entity) |
| name | unique per **(tenant, client party)** (case-insensitive) — the current constraint is per-client; tenant scoping added per judgment #33 so R3's PlusROI-tenant migration can't collide with BW-tenant rows for the same client+name. Canonical-project rule for R3: when a project's operator party is itself a tenant, that tenant's row is canonical. |
| status | `active` \| `archived` |
| income_usd / billout adjustments | carried over from current projects table (pct + fixed adjustment, display-only per ADR #040) until the invoice engine (R4) supersedes them |

### `sets` and `tasks` — created by R3, not R1 (judgment #32)
Per docs/domain/projects-and-time-logs.md, sets and tasks are core
to the PM future — but their REAL column set lives in the Airtable
"Production" decode (08 §8: units, percentage allocations, duty
flags, 11-state workflow, autocreation params), which superseded the
domain doc's sketch. R1 ships neither the tables nor a `task_id`
column; R3 creates them from the live spec via ordinary Alembic
migrations (additive, metadata-only — nothing is protected by
shipping guessed versions early).

### `time_entries`
The heart of today's app; carries over with party-based attribution.

| column | notes |
|---|---|
| id, tenant_id | |
| project_id | fk, nullable (NULL = unassigned → "Blocked" status, same semantics as today) |
| source_timezone | nullable — stamped at import where known (07 #17: naive wall-clock stays, but the tz it was recorded in is unrecoverable later; capture it now, use it never until R2+) |
| worker_party_id | who did the work (person party; replaces team_member_id) |
| via_engagement_id | the engagement the work flows through (Adi's BW subcontract; nullable for tenant-internal work) |
| source | `clockify` \| `toggl` \| `manual` \| `backfill` — the CHECK keeps the historical values (`toggl`/`backfill` rows migrate with provenance intact); the importer only ever *writes* `clockify`/`manual` |
| import_id | fk → import_batches, nullable |
| source_user_email / source_user_name | as imported, for re-resolution |
| raw_operator / raw_client / raw_project | the as-imported text triple — an **immutable import payload**, not a display cache. For resolved rows it's provenance; for unassigned (Blocked) rows it is the ONLY identity the resolver and /entries have to show, exactly as today. Display for resolved rows always comes from FKs. |
| source_rate_usd / source_amount_usd | the rate/amount columns as parsed from the source CSV — **carried** (live import data, billing provenance) |
| description, billable, start_at, end_at, duration_seconds | as today |
| cost_usd / billout_usd | **locked-at-write-time stamps** (ADR #014 invariant carries over verbatim: stamped from compensation_terms at write; rate changes never silently rewrite history; explicit re-stamp actions only) |
| invoice_line_id | nullable fk — **the lock**: attached to an invoice ⇒ immutable (ADR #038 semantics) |

Dropped: the text triple's **display-cache role** (the denormalized
lookups that caused every rename-propagation bug and the "Halo
Programs in summary but not manage" ghost). The columns survive as
the immutable `raw_*` import payload above, but nothing ever *reads*
them for a resolved row's display, grouping, or joins — FKs, full
stop. Renames touch one entity row and zero entries. Also dropped:
`rate_proportion`, `converted_user`, `converted_duration_seconds`, and
`transferred_at` (Toggl-era concepts; ADR #034 + Toggl phase-out).

### `import_batches`
Carries over `clockify_imports` + `pending_imports` two-step flow
(ADR #041): upload → parse → staged pending → resolver (map/create/skip
per unknown entity and per incomplete bucket) → commit. Auto-naming
from CSV date range. Source: Clockify CSV + manual; Toggl import
retired.

### `invoices` + `invoice_lines`
Today an invoice is a bag of time entries (invoice_id stamped on
entries). The rebuild introduces **lines** so one invoice can carry
time, CC expense pass-throughs, adjustments, and manual items — which
is how real invoices in this business actually look.

**R1 keeps the current invoice shape** (the parity tests pin it; the
migration carries it verbatim): `name` (unique per tenant,
case-insensitive), `status` `open`/`sent`/`paid` with independent
`sent_at`/`paid_at` stamps, `invoice_date` (defaults to last day of
previous month — the accounting date), `created_by`, `notes`,
`manual_total_usd` (manual total override, kept in R1). R1 *adds*:
`from_party_id`, `to_party_id` (seeded from the operator chain), and
the `invoice_lines` table:

- `invoice_lines`: invoice_id, kind (`time` \| `expense` \| `adjustment`
  \| `manual`), description, quantity, rate, amount, snapshot display
  fields (client/project names as of attach — judgment call #9),
  **attached_at / attached_by** (carries the current
  `invoice_applied_at`/`invoice_applied_by` audit), and back-refs
  (time entries reference their line; expense lines likewise).

The richer vocabulary sketched for the money engine (invoice `number`,
`draft`/`issued`/`void` states, `issued_on`/`due_on`, engagement-driven
generation) is **R4's evolution**, designed in its own pass — R1 does
not rename or re-state what works today.

**The lock invariant carries over:** an entry/expense attached to an
invoice is immutable and excluded from re-stamps. "Applied to
invoice" replaces the retired "transferred" status everywhere.

### `cc_expense_batches` + `cc_expense_lines`
Carries over as-is (statement paste → parse → assign to
client/project → auto-rules → keyword highlights → billed onward).
Auto-rules and highlight keywords carry over. Lines gain
`invoice_line_id` (same lock as time).

### `commissions` and profit-share (R4 — schema reserved, not built in R1)
Derived money: a `revenue_events` ledger (invoice paid) feeding
commission entitlements (from referral engagements' percentage terms)
and profit-share computations (partnership terms; Danielle's
gross-percentage comes off before the Rian↔Rob 50/50 — worked example
in docs/domain/). R1 reserves the enum values and the engagement/comp
foundations; the computation engine is its own phase with its own
design doc when we get there. **Do not build speculative tables for it
in R1** — the foundations (engagements + terms + invoice lines) are
what R4 needs; the ledger design deserves its own pass with real
requirements.

### `comments`
Carries over as the global board + unread bell (parity — the current
table has NO row anchors; anchored comments would be a new feature in
parity clothes). Schema is future-proofed cheaply: tenant_id NOT NULL
+ nullable anchor (entity_type, entity_id) columns, unused by R1 UI.

---

## 4. The permission engine

The core question the current app cannot answer and the new one must:
**"What can this user see and do, on this specific resource, given how
their party is connected to it?"**

### Resolution pipeline (every request)

```
app session (Pattern B, judgment #31) → users row → person party
  → active engagements (direct, or via orgs the person holds an
    official_partnership/employment engagement with)
  → per-resource: the project_engagements rows connecting those
    engagements to the resource's project
  → visibility level + engagement role → capability set
```

Implemented as one Python module (`services/authz/`) exposing:

- `can(actor, action, resource) -> Decision` — Decision carries
  allowed/denied + the *reason chain* (which engagement granted it).
  The reason chain is surfaced in a debug view (super-admin) — this is
  how we keep "many many changes" to the permission system debuggable.
- `scope(actor, action, resource_type) -> SQLAlchemy filter` — the
  query-side twin, so lists and aggregates are filtered identically to
  row checks. One implementation, used by every route.

### Capability derivation (R1 rules)

Tenant-level grants (derived, not stored as role enums):

| engagement connecting person → tenant party | grants |
|---|---|
| official_partnership (owner role) | **owner**: everything in the tenant, incl. money views |
| partnership (partner role) | owner-equivalent on shared projects; money views on those |
| employment / long-term freelance with management flag | **manager**: operational everything, no cost/income views |
| subcontract / freelance (worker role) | **worker**: own time entries, assigned projects at engagement visibility, own imports |
| referral (referrer role) | per project_engagements visibility (billing or blind) |
| direct_client (client role) | client portal view of their own projects (future) |

Super-admin (rian) bypasses scoping but every privileged action is
audit-logged. **View-as becomes an app-layer context swap** (pick any
user; authz resolves as them; banner + guard rails as today) — no JWT
minting, no auth-provider involvement. ADR #043's machinery retires.

### R1 parity mapping (so nothing regresses at cutover)

Current `organization_members.role` → seed engagements: rian =
official_partnership/owner on Bowden Works; Adi = subcontract/worker
(+ manager-grade import permission carried via a `can_manage_imports`
capability on his engagement, matching today's `canManageImports`).
The current permission functions (`canTransfer`, `canManageTeam`,
`canManageImports`, `canEditEntry`, `canEnterViewAs` — plus the
income/billout visibility rule, which today is page-local wiring of
`canTransfer`, not a separate function) become named capabilities
whose truth tables reproduce the current app's **effective
enforcement** — which is not always what the old docs (or even the
old functions) claim. The rebuild names income visibility as its own
capability (`can_see_income`) even though the old app never did. Known divergences the port
must resolve deliberately rather than copy blindly:
- `canManageTeam` exists but is never called — actual team-management
  enforcement is RLS (team owner OR org owner OR super admin). Port
  the effective rule; the dead function's broader rule (any manager)
  is NOT the parity bar.
- Entry-eligibility ("blocked") logic exists in four divergent copies
  in the current code (display filter, invoice-apply guard, legacy
  transfer guard, per-row blockerReason). The rebuild has exactly ONE
  eligibility predicate, used everywhere; the parity bar is the
  display filter's version (`ELIGIBILITY_COLUMNS`), with the
  divergences recorded in 01-current-system/features/entries.md.
  (blockerReason's extra `start_at` check is subsumed, not lost —
  start_at is NOT NULL by schema, so the one predicate is equivalent
  to the superset.)
Parity is verified by porting the truth-table tests before any new
semantics land.

---

## 5. Invariants carried over (binding on the rebuild)

From the ADR review — these survived every iteration of the current
app and are load-bearing:

1. **Locked-at-write-time money stamps** (ADR #014, #034): cost and
   billout are stamped onto entries when written, from the rates in
   force; rate edits require explicit re-stamp actions that skip
   invoiced rows and report counts.
2. **Invoice attachment is the lock** (ADR #038): invoiced rows are
   immutable; un-invoicing is explicit and audited.
3. **Entities are first-class, no free-text references** (ADR #035,
   #042): every assignment picks or explicitly creates an entity;
   bulk edits and imports resolve through the same
   resolve-or-create service.
4. **Two-step imports with human resolution** (ADR #041): parse →
   stage → resolve unknowns (map/create/skip) → commit atomically,
   with rollback on failure.
5. **Display-only project adjustments** (ADR #040): pct/fixed billout
   adjustments are arithmetic at read time, never stamped — until the
   invoice engine replaces them with real invoice lines.
6. **Documentation as definition-of-done**: the rebuild keeps the
   docs/features + decisions.md + tests-catalog discipline from day
   one (it's how this plan was possible at all).

---

## 6. Worked example — the model under load

The Copernic chain (docs/domain/relationships.md) exercised end-to-end:
Zeina (person, no user) works via Tingang; Tingang→Adi handled inside
Tingang's books (out of scope); Adi (person+user) has a
subcontract engagement → Bowden Works carrying his cost term AND his
billout term (the R1 `kind` model, §2). Bowden Works (tenant) has a
partnership engagement → PlusROI — in R4 that hop carries the
partnership billing terms; R1 leaves it term-less. PlusROI invoices McAllister
(subcontract, PlusROI = subcontractor); McAllister invoices Copernic
(direct_client). AISV holds a referral engagement (commission %) on
the client.

- Adi's June time entry: worker_party=Adi, via_engagement=Adi→BW,
  cost stamped from Adi's cost-kind term, billout from his
  billout-kind term (R1; R4 may recompose billout onto the
  partnership hop).
- BW's June invoice to PlusROI: lines generated from billable entries
  where via-engagement's billing counterparty = PlusROI. Locks them.
- Permission checks: Adi sees his entries + assigned projects
  (subcontractor_deep), never BW's margin. Rob (PlusROI owner —
  partnership engagement with BW) sees shared-project billout, not
  Adi's cost rate. McAllister post-flip: engagement ends, new referral
  engagement starts (referrer_blind) — their user loses project detail
  visibility the moment the engagement rows change, with history intact.
- Mid-project the Hartling "split in thirds" case: one work stream,
  three client parties (The Palms, The Sands, Shore Club) — three
  invoices from one project's entries, which invoice_lines support and
  today's model cannot express.

---

## 7. Mapping from the current schema (entity level)

| current | becomes |
|---|---|
| organizations / organization_members | tenants + seeded engagements (§4 parity mapping) |
| profiles + auth.users (Supabase) | users (idauth_username-keyed) + person parties |
| operators | parties (kind=org) — BW, PlusROI |
| clients | parties (kind=org); operator_id link → engagement or project.operator_party_id (see 05-migration-plan) |
| projects | projects (operator_party_id + client_party_id from the entity chain) |
| team_members | person parties + subcontract/freelance engagements + compensation_terms (cost + billout as two `kind` rows on the worker's engagement — §2); `consolidate_as` → import-mapping config on the engagement |
| teams | dissolved — team scoping is engagement scoping |
| time_entries | time_entries (text-cache role dropped — columns carry as raw_*; team_member→worker_party; transferred_at dropped after backfill check) |
| clockify_imports + pending_imports | import_batches (+ staged payloads) |
| invoices | invoices + generated invoice_lines wrapping their current entries |
| project income / adjustments | projects columns (carried per ADR #040) |
| project_rate_overrides | the new `rate_overrides` table, near-verbatim (§2; judgment call #25) |
| cc_expense_batches / lines / cc_auto_rules | same names, tenant-scoped, line→invoice_line link |
| comments (table + global) | comments, re-anchored |
| RPCs (project_summary, entries_filter_totals, …) | Python service functions / SQL views (same aggregates; the RPC extraction doc pins the exact semantics) |
| RLS policies | deleted — authz service + default-deny middleware |

Column-level mapping with backfill scripts: 05-migration-plan.md.
