# Entries editing + bulk safety (M5)

The write half of /entries — cell-level editing as the primary
interaction, manual entry, re-resolve, delete — plus the bulk-mutation
safety architecture (judgment #11), the audit/undo mechanism, and the
Directory → Manage entity page with first-class merge (judgment #10).

Parity bar: `/srv/apps/work/docs/rebuild/01-current-system/features/entries.md`
(B44–B66 + bulk B26–B43) and `projects-and-entities.md` §manage
(B32–B47), as amended by `04-architecture.md` and judgment calls
#9/#10/#11. Deviations: ADR #006.

## Behavior

### Single-entry writes (`app/services/entries_write.py`)

- **PATCH `/api/entries/{id}`** — field-level: only the fields the
  client SET apply (a cell edit touches exactly one thing). Fields:
  `description`, `billable`, `start_at` (required non-null), `end_at`,
  `duration` (string, `H:MM[:SS]` | decimal hours — B51 parser) or
  `duration_seconds`, `worker_party_id` (null = unresolved),
  `project_id` (null = **explicit unlink**) XOR the full
  `operator`+`client`+`project` name trio (resolve-or-create through
  THE one services/entities implementation; partial trio →
  `PARTIAL_ENTITY_CHAIN` 422 — the legacy silent-null wart is dead).
- **Re-stamps** (through `services/money/restamp.py`, the only other
  money writer besides import commit): whenever worker, project, or
  duration changed — cost + billout from compensation terms in force
  TODAY + rate_overrides precedence. No worker → NULL stamps. NULL
  rate → NULL stamp, never 0 (judgment #19). `source_user_email/name`
  are frozen audit history — never patchable.
- **POST `/api/entries`** (manual entry): date/duration/description/
  worker required with the legacy messages verbatim; start time
  defaults 00:00:00; `end_at = start + duration` (midnight rollover);
  identity stamped FROM the worker party; `billable` always true;
  lands in the per-(tenant,user) **"Manual entries"** batch
  (`import_batches` row, `source='manual'`, created once, reused).
- **POST `/api/entries/{id}/reresolve`**: re-runs the worker lookup
  from the row's CURRENT `source_user_email` against the tenant-wide
  active worker engagements. Empty email → clears worker + ALL money
  stamps. Unknown email → "No team member with email …".
- **DELETE `/api/entries/{id}`**: hard delete; returns `undo_id` (the
  audit row) for the toast-with-undo pattern.

### Lock rule (B62/B63 carried)

Locked ⇔ `invoice_line_id IS NOT NULL`. Locked rows need
`can_transfer` (owner surgery allowed, exactly like the old app);
unlocked rows need `can_manage_imports`. Non-owners get 409
`ENTRY_LOCKED` with the legacy message. Every write path fetches the
row THROUGH `scope()` — view-as containment holds on writes
(guardEntry parity), and view-as mutations audit both identities.

### The bulk engine (`app/services/bulk.py`)

Selection = explicit `ids` **or** `all_matching` + the SAME filter
params the display used (the SPA posts its own query-param object;
the server parses them with the same `build_entry_filters` the GET
dependency uses). Scope materializes through
`scope() + reporting/filters.apply_filters` — ONE code path with the
display (T-ENT-16; the 3,274-row incident class is structural now).

- `expected_count` is REQUIRED in both modes; the server recomputes
  the scope FIRST and 409s with the exact "Safety stop: …" message on
  mismatch, before any write (T-ENT-17/44).
- One transaction per operation; the audit row carries COMPLETE
  before-images; responses carry verified counts
  (`matched` / `changed` / `skipped_locked`) + `undo_id`.
- **POST `/api/entries/bulk/edit`** — worker and/or project re-point
  (project first, then worker, then re-stamp — T-ENT-23), or the
  quick-create trio (all-three-or-nothing, overrides a picked
  project). Managers skip locked rows, owners include them; all
  locked → 409 "No entries left to reassign…".
- **POST `/api/entries/bulk/recalculate`** — re-stamp from current
  terms/overrides; locked rows skipped for EVERYONE and reported;
  no-worker rows get NULL stamps; `changed` counts rows whose values
  actually changed (honest counts).
- **POST `/api/entries/bulk/delete`** — locked rows always skipped;
  undo re-inserts verbatim (ids preserved).
- Invoice apply/detach are M6 (invoice_lines own the lock).

### Undo (`app/services/audit.py`)

**POST `/api/entries/undo/{audit_id}`** replays the before-images of
one undoable audit row (`entries.update/delete/reresolve/bulk_*`).
At most once (the `entries.undo` audit row is the marker); only the
acting user or an owner; rows whose lock state changed since are
skipped and reported exactly; missing rows re-insert. Undo never
touches `invoice_line_id`.

### Entity manage (`app/services/entities/manage.py`, `/api/entities/*`)

- **Rename** (operator/client/project): one row update — display
  follows the FK everywhere instantly (the legacy text-propagation
  layer B37/B38 is dead). Friendly per-scope duplicate pre-checks;
  same-name rename → no-change info.
- **Move project**: re-point the project's party FKs; entries follow;
  the project keeps id/overrides/income/adjustments; invoices NOT
  touched; name collision → merge guidance (+ 23505 race maps to it).
- **Merge** (project→project, client-edge→client-edge,
  operator→operator): move children, re-point entries/CC lines/
  auto-rules/project-scoped terms (source rate overrides dropped —
  target's win), recursive same-name child merges (T-ENTITY-013, the
  Palms incident), ONE transaction (T-ENTITY-014), invoiced rows keep
  their line snapshots (judgment #9), invoices re-point to the target
  when the source party retires, husk projects deleted / husk parties
  archived. The tenant's own party can't be merged away.
- **Delete**: empty-only (children → "merge instead"), with exact
  blocking counts.
- **GET `/api/entities/manage`**: three sections with TRUE aggregate
  counts (grouped SQL — immune to page caps, T-ENTITY-015); income
  column owner-only.

### Frontend

- **/entries cell editing** (primary): click a cell → editor
  (EntityPicker comboboxes with create-in-place for worker/operator/
  client/project; text/datetime/duration inputs; billable click-
  toggles). Tab/Shift+Tab move along cells wrapping rows; ↑/↓ move
  rows; Esc cancels. Locked rows don't open (🔒) unless owner. Money
  cells flash green when a save re-stamped them. Cosmetic edits
  (description/billable) apply optimistically; money-affecting edits
  are pessimistic from the verified server row (04 policy).
- **Side panel** (✎): the demoted full-row form — all fields,
  read-only "Logged as (audit)" identity, Re-resolve, Delete
  (toast-with-Undo).
- **"+ New entry"**: manual-entry strip; team member defaults to the
  signed-in user's worker row.
- **Bulk bar**: selection model (page checkbox → "Select all N
  matching the filter" → single click collapses; filter change
  clears), count-confirm strip quoting the EXACT expected count,
  pessimistic "Working — verifying counts…" state, verified-count
  toast ("matched N, changed K — verified", locked-skips named) with
  **Undo**, safety-stop errors surfaced verbatim.
- **/projects/manage**: three sortable tables with rename ✏️ /
  move ↪ / merge ⇄ (searchable pickers) / delete 🗑️; affordances
  gate on `can_manage_imports`.

## Permissions

- `can_manage_imports` (manager+): every entries write, every bulk
  operation, undo (own actions), every manage action.
- `can_transfer` (owner): editing/deleting/bulk-including LOCKED rows,
  undoing other users' actions.
- Workers/viewers: read-only — no affordances, and the server refuses
  independently.
- View-as: true impersonation — the effective user's capabilities
  gate everything; scope containment applies to reads AND writes;
  audit rows record actor + impersonated_by.

## Constraints / invariants

- Bulk scope ≡ display scope (one query-builder path) — property- and
  gate-tested against migrated data (tests/bulk).
- Stamps only ever written by services/money (import commit +
  restamp); locked-at-write otherwise.
- raw_* columns are immutable — no write path touches them.
- Undo restores byte-identically (snapshot equality + harness compare
  green after an edit+undo cycle — the M5 gate test).

## Changelog

- 2026-07-08 (0.6.0): shipped with M5 — entries write paths, bulk
  engine + safety stop + undo, entity manage with first-class merge,
  cell-level editing UI, bulk bar, side panel, manual entry,
  /projects/manage. Gate test green (incident replay + harness
  compare after undo).
