## Audit outcome ### Preview → Confirm → Audit **Canonical hardened pattern: portfolio policy** - Routes: `src/jarvis_finance/api/routers/overview.py` - `POST /api/portfolio/policy/preview` - `POST /api/portfolio/policy/confirm` - read-only active/history/detail/evaluation GETs. - Schemas: `src/jarvis_finance/api/schemas/portfolio_policy.py` - Preview and confirm share the complete business payload. - Confirm additionally requires `preview_id`, `confirmation_id`, and `confirm`. - Service: `src/jarvis_finance/services/portfolio_policy.py` - Canonical JSON-stable payload and SHA-256 fingerprint. - Preview is pure: `del conn`, deterministic content-bound `preview_id`, fresh `confirmation_id`, no storage writes. - Confirm revalidates the full payload, recomputes preview identity, requires `confirm is True`, and uses `BEGIN IMMEDIATE`. - Same `confirmation_id` + same payload is idempotent. - Reused `confirmation_id` + changed payload fails closed. - Audit and domain writes commit atomically; rollback on errors/races. - Audit detail is allowlisted to `version` and `previous_policy_id`; sensitive payload/hash/IDs are not copied into audit details. - Frontend: `frontend/src/components/policy/PortfolioPolicyManager.vue` - Keeps an immutable clone of the previewed payload. - Deep-watch invalidates preview after any edit. - Explicit PrimeVue `ConfirmDialog`. - Blocks duplicate confirm while pending. - Does not show preview, confirmation, or audit IDs. - Tests: - `tests/unit/test_portfolio_policy_foundation.py` - `frontend/src/components/policy/PortfolioPolicyManager.test.ts` - Cover read-only preview, payload binding, idempotency, race handling, audit allowlist, immutable history, stale-preview invalidation, duplicate-click suppression, OpenAPI and write security. **Do not copy weaker legacy variants** - `src/jarvis_finance/api/schemas/positions.py` contains older contracts where `ContainerConfirmRequest.preview_id` is optional and `GenericConfirmRequest` has no preview identity. - Some budget routes accept raw `dict` payloads and use endpoint names such as `confirm-candidates` without a strict shared contract. - Sprint 6 should follow the hardened policy implementation, not these legacy patterns. **Existing ingestion primitive** - `src/jarvis_finance/imports/execution.py::execute_initial_snapshot_plan` - Requires a non-empty note and a `ready` execution plan. - Deduplicates by execution-plan/source IDs and row hash. - Writes transaction, plan/review state, and two audit events. - Weakness for HTTP reuse: it commits internally without an explicit `BEGIN IMMEDIATE`/request-idempotency contract. Wrap/refactor rather than exposing it directly as a new endpoint. ### Auth and write security - `src/jarvis_finance/api/security.py` - Modes: `disabled`, `local_only`, `test`; unknown values resolve to `disabled`. - Default is `disabled`. - `local_only` permits only loopback source IPs. - `test` permits only TestClient’s `testclient` source. - All `POST`, `PUT`, `PATCH`, and `DELETE` requests pass through this guard. - `src/jarvis_finance/api/main.py` - Middleware rejects disallowed writes with `403 {"detail":"write_operations_disabled"}` before route handling. - Disabled mode exposes only GET in CORS; enabled modes expose all write verbs. - Runtime status redacts local paths; tested in `tests/unit/test_api_write_security.py`. - **No authentication/session layer exists.** - No user login, bearer token, browser session, CSRF token, Origin/Host enforcement, or authenticated audit identity. - Frontend `api/client.ts` sends no credentials or auth headers. - Remote GETs are unauthenticated. - Tailscale writes are explicitly forbidden until a separate session/CSRF/Origin/audit-identity decision (`docs/finance-manager-2.0/sprint-0-baseline.md:38,44`). - Preview endpoints are POSTs, so remote clients cannot even preview while writes are disabled. - CORS is not authentication: - Allowed HTTP origins include localhost, any hostname beginning `100.`, and `.ts.net`, with explicit ports. - `allow_credentials=False`, `allow_headers=["*"]`. - Sprint 6 must remain loopback-only for confirm/write operations. Do not enable Tailnet writes as part of ingestion. ### API/schema/router conventions - FastAPI app factory includes routers under `/api`; DB access is injected with `Depends(get_db)`. - Strong newer endpoints use: - dedicated Pydantic request/response models; - `response_model=...`; - `Literal` enums and `Field` length/cardinality constraints; - Decimal values represented as strings and validated in services; - explicit 400/404 OpenAPI responses; - service `ValueError` translated to bounded `HTTPException` messages. - Pydantic models do not generally set `extra="forbid"`; new ingestion models should consider doing so to fail closed on unknown fields. - Portfolio routes currently live in `routers/overview.py` under tag `overview`; Sprint 6 is large enough to justify a dedicated ingestion router/schema instead of further expanding that file. - `GET /api/portfolio/reconciliation-snapshot` is intentionally read-only and preserves: - missing values as `null`, never invented zero; - separate reconciliation and freshness status; - reason codes and source provenance; - redacted/opaque account identity; - no import, correction, provider request, or persistence. ### Pagination There is **no consistent server-side pagination convention**: - Portfolio policy history and `/api/audit` return unbounded lists. - Budget history/session endpoints accept raw `limit: int = 30`; audit timeline uses `limit: int = 25`. - Limits lack `Query(ge=..., le=...)`, offset/cursor metadata, and a shared response envelope. - No `offset`, `page_size`, or opaque cursor implementation exists. - `frontend/src/components/ui/PrimeDataTable.vue` enables PrimeVue’s client-side paginator and receives the complete array. For Sprint 6, define pagination explicitly rather than implying an existing standard. Minimal safe contract: ```text GET ...?limit=25&cursor= → { items: [...], next_cursor: string|null } ``` Use `Query(default=25, ge=1, le=100)`, deterministic ordering with a unique tie-breaker, and SQL-level limit. If offset pagination is chosen instead, return `{items,total,limit,offset}` consistently. ### Portfolio frontend/test conventions - Vue 3 `