# The read-only mirror — /entries, /summary, /projects (M3)

The first user-facing milestone: the three read surfaces of the old
app, rebuilt over the migrated party/engagement data with the authz
engine underneath. Read-only — no mutation exists anywhere in the app
yet (they arrive M4+). Parity bar:
`/srv/apps/work/docs/rebuild/01-current-system/{entries,summary,
projects-and-entities,permissions-and-view-as}.md` +
`rpcs-and-authz.md`; deltas recorded in [ADR #004](../decisions.md).

## The authz engine (`app/services/authz/`)

The load-bearing wall (04-architecture). One choke point:

- **`capabilities.py`** — the R1 truth tables AS DATA. Capabilities:
  `can_transfer`, `can_see_income` (same values as can_transfer in R1,
  its own name by design), `can_manage_imports`, `can_manage_team`
  (the EFFECTIVE legacy rule: owner/super-admin — not the dead
  function's any-manager grant), `can_enter_view_as` (super-admin
  only, never while viewing-as). Lock rule: editing a locked
  (invoice-attached) entry needs `can_transfer`, unlocked needs
  `can_manage_imports` — pinned now, used by writes from M4.
- **`context.py`** — `resolve_actor(request, session) -> Actor`:
  session identity → users row → person party → engagements → role.
  Roles: `owner` (active `official_partnership`), `manager` (active
  `subcontract` with `can_manage_imports`), `worker` (plain
  subcontract), `none`. The Actor carries BOTH identities explicitly
  (`real_*` + effective fields) — the legacy single-flag foot-gun does
  not exist here. Tenant context = the tenant party with slug
  `APP_TENANT_SLUG` (setting, default `bowden-works`).
- **`policy.py`** — `can(actor, action, resource) -> Decision` with a
  reason chain naming the granting engagement (e.g.
  `user(adi) --subcontract#ab12cd34(can_manage_imports)--> tenant(
  Bowden Works) => entries:read:own-imports`).
- **`scope.py`** — `scope(actor, action, stmt) -> Select`, the query
  twin. R1 rules: super-admin/owner → tenant-wide; manager/worker →
  only entries whose import batch THEY uploaded (batch-less rows
  invisible to them — rpcs §3.7); no role → nothing. Batch metadata is
  tenant-visible (legacy parity) except view-as narrows it. The
  can()/scope() equivalence property test guarantees the two never
  drift (tests/authz/test_scope_equivalence.py).
- **`view_as.py`** — app-layer impersonation (judgment #7): cookie
  `with_view_as` (8h) holds the target username; honored only when the
  REAL user's DB row is super admin and the target exists. Enter is
  strict (403/404/409/400) and both directions write audit rows with
  both identities. Logout clears it.

## Reporting (`app/services/reporting/`)

Ports of the SQL RPCs, all aggregation IN Postgres via scope()-
transformed SQLAlchemy Core (the one justified exception: totals rows
folded from the GROUP BY result — ADR #004 §6).

- **`filters.py`** — the §1 filter contract as ONE builder
  (`EntryFilters` + `apply_filters`) over the FK join graph
  (`EntryJoins`: entries → project → operator/client parties → worker
  party). Display expressions: resolved rows read entity names,
  unassigned rows read the immutable `raw_*` triple. ILIKE-contains
  with live `%`/`_` wildcards; NULL display columns never match; `q`
  searches exactly {project, description, source_user_email, client}.
  THE ONE status predicate: `applied`/`transferred` (alias) =
  invoice-line lock; `pending` = unlocked + worker + project +
  description + end + source email present; `blocked` = the
  complement; unknown values match nothing. Partition property
  (pending+blocked+applied = all) test-pinned on fixture AND real data.
- **`entries.py`** — list (server-side sort map + 100/page
  pagination), totals (§2.1: one aggregate row over the whole match,
  zero rows → zeros), options (§2.2: cascading distinct arrays,
  NULL→[], plus batches + owner-only importer options).
- **`summary.py`** — summary_by_project (§2.5): three windows
  (this/prev/all) via CASE-in-SUM on `start_at`, half-open bounds,
  NULL-duration rows excluded, per-window adjusted billout
  (`raw × (1+pct/100) + amount`, fixed amount in full per window —
  ADR #036 parity); month parsing (`2026-7` or `2026-07`, silent
  fallback to the current month).
- **`projects.py`** — project_summary (§2.3, FK-grouped) + the
  per-description expansion (fixed per rpcs §2.4: sums to its parent).
  Headline margin = Σincome − Σ(cost of income-carrying rows only).
- **`money.py`** — per-currency aggregation: every SUM travels with
  `count(DISTINCT currency)`; >1 → `MixedCurrencyError` → 500
  `MIXED_CURRENCY`. DTO money is `{amount: "12.34", currency: "CAD"}`
  strings end to end.

## API

| Route | What |
|---|---|
| `GET /api/entries` | list; §1 filters + `sort`/`dir`/`page` |
| `GET /api/entries/totals` | whole-filter aggregate card |
| `GET /api/entries/options` | cascading pivots + batches + importers |
| `GET /api/summary` | month dashboard (`month`, 4 pivots) |
| `GET /api/projects` | rollup + headline summary |
| `GET /api/projects/{id\|unassigned}/descriptions` | expansion (raw_* pins for the unassigned group) |
| `GET /api/me` | real identity + capabilities + view-as state + candidates |
| `POST /api/view-as` / `DELETE /api/view-as` | enter/exit impersonation |

Non-`can_see_income` payloads have billout/income/margin ABSENT (data
layer, not UI). Under view-as of a non-owner, reads are scoped to the
target's imports and `imported_by` in the URL cannot widen the scope.
Errors are `{error_code, summary, detail}` everywhere.

## Frontend

- **/entries** — FilterBar (date presets incl. custom-inclusive-end,
  status, batch, debounced q, five cascading pivot dropdowns with
  keep-current-value, owner-only Imported-by), `Last month's {op}`
  quick chips, whole-filter summary card (entries · h:mm:ss hours ·
  cost · owner-green billout), server-sorted DataTable (✓/○/⚠ status
  icons with hover reasons, locked/blocked row states), pagination.
  Page defaults: all dates + status=pending. Every filter lives in the
  URL (bookmarkable).
- **/summary** — month picker (← prev · This month · next →; label is
  the h1), pivot dropdowns (all-time options scope), owner headline
  (big green adjusted billout over muted cost) or cost-only, hours +
  `±h vs 140h target` tracker (140 stays a constant until engagement
  config), by-project table with `$X.XX (h:mm)` cells (owner: bold
  billout stacked over muted cost+hours), hide-null-rows (no activity
  in either window; all-time totals still include them), totals
  footer. Clear-filters preserves the month.
- **/projects** — quick chips (Hourly = the prepaid support-block
  drawdown view, date=all load-bearing; Missing info; Last month
  admin), filter bar (+ missing-info checkbox), headline (cost, Hh MMm,
  N projects · N entries, owner income/margin `(k/n with income)`),
  client-sorted rollup (default billout-hrs desc; income/margin sort
  NULLs last), expandable per-description breakdown rows (lazy,
  works for the unassigned group), owner-only Billout (adjusted with
  small-print raw · ±pct · ±$ detail), Income, Margin columns.
- **Shell** — Entries/Summary/Projects are live nav links; view-as
  switcher (super-admin only, from /api/me candidates) + the amber
  ViewAsBanner with Exit; the header user chip shows `username (role)`.

## Tests

- `tests/authz/` — T-AZ-001 grid (9 actor fixtures × 5 capabilities ×
  lock states, expected values verbatim from rpcs §5), resolve_actor
  (roles, view-as swap, forged-cookie fail-safes), can()/scope()
  equivalence property over the seeded mini-graph, T-PRM-02/03/07/08.
- `tests/reporting/` — filter-contract suite (ILIKE-contains, NULL
  exclusion, q column set, half-open windows, batch/imported_by/
  invoice/missing_info, partition, alias, sort keys) on a scratch
  Postgres migrated from the fixture legacy dataset; summary + project
  service suites (windows, adjustments incl. zero-raw window,
  hide-nothing data rules, expansion-sums-to-parent, money shaping);
  **T-RPT oracle equivalence**: services over the REAL sidecar equal
  `harness/out/e2e-new.json` for ALL 8 months, cent-exact, plus totals
  and summary-window equivalence and the real-data partition test.
- `tests/routers/` — API contract + money shaping + structured errors
  + the full view-as flow (audit rows, scoping, clamped imported_by,
  no nesting, logout clearing).
- Frontend vitest — filter↔URL serialization (pinned clock) and the
  formatting ports ($X.XX (h:mm) cells, h:mm:ss, date cells).

## Changelog

- 2026-07-07 — v0.4.0: shipped (M3). Oracle equivalence green across
  2025-12…2026-07; hot endpoints 16–71ms warm on the full 4,593-row
  dataset (in-process; the deploy gate re-measures over HTTP).
