# Shared Operation Layer — Design

Date: 2026-05-16
Status: Implemented

## Problem

CLI commands (`apps/cli`) and MCP tools (`apps/mcp`) are parallel
reimplementations, not thin adapters over a shared core. They share data
*primitives* (`@firma/db` schema/repositories, `@firma/domain` types) but
each re-derives the same *operations*: input validation, ticker
normalisation, the DB query/filter, native→USD conversion, the write
flow.

Consequence: every behavioural rule lives in N places. When a rule
changes, each copy must be found and updated by hand; some are missed.
The manual-assets feature alone produced four CLI/MCP divergences
(non-stock ticker casing, `show txns`/`show snapshot` lookup, MCP
`edit_snapshot`/`show_snapshot`). Parity is held by discipline plus a
spot-check test harness with holes.

Proof of the cause: extracted operations never diverge —
`buildManualPriceRow` (`@firma/db`) makes `add price`/`add_price` write
byte-identical rows; `aggregateHoldings` is shared. Only the still-duplicated
logic drifts.

## Principle

A command/tool is a **thin adapter**: collect input (CLI prompts / MCP
params) → call **one shared operation** → render output (CLI table / MCP
JSON). Input collection and output rendering legitimately differ per
surface. Everything between — validation, normalisation, queries,
conversion, writes — is shared, so it *cannot* diverge.

## Extractions

### A. Ticker normalisation → `@firma/domain`

`normalizeTicker(assetType, raw)`: stock → `raw.trim().toUpperCase()`,
non-stock → `raw.trim()`. Currently duplicated in `apps/cli/.../add.ts`
and `apps/mcp/.../mutate.ts`. Single home in the symbol module.

### B. Query operations → `@firma/db` repositories

DB queries for a domain concept move out of commands/tools into
repository methods. Commands/tools must not write `select().where()`:

- `transactions.findByTicker(ticker)` — case-insensitive ticker match.
- `snapshots` — `findBySymbol` / `updateBySymbol(date, symbol, fields)`.
  MCP `edit_snapshot` / `show_snapshot` currently bypass the repo with
  raw `db.update/select`; route them through it.

### C. Native→USD conversion → `@firma/db`

`convertTxnPriceToUsd` (CLI) and `nativeToUsdPrice` (MCP) unify into one
`nativeToUsd({ amount, currency, date, fxRepo, liveRates? })`.

**Decision: no `FALLBACK_RATES` fallback.** If neither the `fx_rates`
cache nor live rates resolve a rate, it fails (no value stored) — a cost
basis or manual price must never be guessed from a hardcoded rate. Both
surfaces error. (Live-rate *fetching* stays per-app I/O wiring; the
conversion, cache lookup, and no-fallback rule are shared.)

Scope: write-side conversion only (cost basis, manual price). The
display-side `usdToDisplayAtDate` is a different direction and untouched.

### D. Input operations → `@firma/portfolio` use-case

`addTransaction(input)`, `editTransaction(input)`, `setManualPrice(input)`
(and the snapshot edit) become use-case functions owning the full flow:
semantic validation → normalisation (A) → conversion (C) → repository
write (B). They take the repository and optional `liveRates` as injected
params (the pattern `syncPrices` already uses).

`add txn` / `add_txn` / `add_txns` / `edit_txn` / `add price` /
`add_price` become adapters: gather input, call the use-case, render the
result. CLI keeps thin live-format validation for prompt UX; the
use-case owns authoritative validation.

## Component changes

| Area | Change |
|---|---|
| `@firma/domain` | `normalizeTicker` |
| `@firma/db` repositories | `transactions.findByTicker`, snapshot `findBySymbol`/`updateBySymbol`; `nativeToUsd` |
| `@firma/portfolio` | `addTransaction` / `editTransaction` / `setManualPrice` use-cases |
| `apps/cli` commands | `add txn` / `edit txn` / `add price` / `show txns` / `show snapshot` / `edit snapshot` → thin adapters |
| `apps/mcp` tools | `add_txn` / `edit_txn` / `add_txns` / `add_price` / `show_txns` / `show_snapshot` / `edit_snapshot` → thin adapters |

## Behaviour

This is a refactor — behaviour is preserved, with one deliberate change:
C drops the `FALLBACK_RATES` fallback on the CLI write path (the CLI
previously could store a value from a hardcoded rate; it will now error,
matching MCP). Per the refactor checklist, characterisation tests are
added *before* each extraction where coverage is thin, so behaviour
preservation is proven by a green suite.

## Testing

- Characterisation tests front-loaded per extraction.
- `normalizeTicker`, `findByTicker`, `nativeToUsd`, the use-cases — unit
  tested directly (pure/injectable — far more testable than the old
  in-command logic).
- The CLI/MCP parity harness must stay green throughout; both surfaces
  now route through the same operation, so parity is structural.

## Branch

`refactor/shared-operation-layer` off `main` (PR #14 merged).
