# ADR-110: Custodian Voucher Booking Writes `creditcard.ad_hoc_charges` via SECURITY DEFINER RPC, Not Client-Side Fetch

## Status

Proposed, 2026-07-09.

## Status History

```yaml
status_history:
  - date: 2026-07-09
    status: Proposed
    changed_by: Harish (via Sonnet 5 session, Opus 5 architecture review + advisor-reviewed plan)
    reason: |
      Custodian Voucher feature design (MDD_custodian_voucher.md) requires one genuine
      cross-schema write — a voucher's credit-card deduction line must become a real row in
      creditcard.ad_hoc_charges, not a free-text note. Characterized against ADR-106's existing
      cross-schema-write framework before deciding how to wire it.
    changed_via: manual (adr-kit conventions followed by hand)
```

## Context

The Custodian Voucher feature (see `finance/custodian/MDD_custodian_voucher.md`) lets a voucher's
deduction lines draw from three sources: an existing employee/party advance, a company credit-card
charge, or a direct custodian payment. All three are confirmed to require a real recorded
transaction, not a free-text note. For the credit-card case, the target table is
`creditcard.ad_hoc_charges` — checked directly (not assumed) to be the right table: it already
exists purpose-built for "log a known charge now, reconcile against the statement later"
(`vendor`, `amount`, `txn_date`, `gl_category`, `reconciled=false`), and `vehicle.cc_transactions`
was checked and ruled out as too narrowly scoped (company-owned-fleet fuel events only).

This is a cross-schema write: `custodian` (the Voucher feature's home schema) needs to insert into
a table owned by `creditcard`. ADR-106 (Accepted, 2026-07-03) already established the framework for
evaluating exactly this class of decision:

> Does this write bypass an invariant-enforcing RPC or business-logic path that the owning schema
> expects all writes to go through?

Checked directly against `finance/credit-card/index.html` (the credit-card PWA's own UI code, not
inferred): both of its own call sites that create an `ad_hoc_charges` row (`markAdHoc()` around
line 1314, and the manual ad-hoc-entry form handler around line 1490) do a **bare client-side
`fetch()` POST** with `Content-Profile: creditcard` — there is no owning RPC for this table at all.
Per ADR-106 §4's worked examples (`sales`→`installation.campaigns`, `hub`→`finance.upi_config`), a
foreign write to a table with no competing owning-RPC path to bypass is ratifiable as-is.

That answers "is this write allowed" — but the Voucher feature has an additional requirement
ADR-106's audited call sites did not: **atomicity**. Booking a voucher fires several real
transactions together (deduction settlements, possibly an ad-hoc charge, possibly new advances) in
one all-or-nothing action (see MDD §4). A raw client-side POST to `creditcard.ad_hoc_charges`,
made as a separate network call from the booking RPC, could succeed while the booking RPC itself
fails (or vice versa) — reintroducing exactly the partial-state risk ADR-033 exists to prevent for
expense-sheet approval, just in a new spot.

## Decision

The `creditcard.ad_hoc_charges` INSERT for a voucher's credit-card deduction line happens **inside
the same `SECURITY DEFINER` PL/pgSQL function that books the voucher** (`custodian`-schema RPC,
same technique already used by `custodian.apply_structural_edit` this session, and the same
underlying pattern ADR-007 establishes for `SECURITY DEFINER` writes to privileged tables) — not as
a separate client-side `fetch()` call the way `credit-card/index.html` does it today for its own
UI. The booking RPC reaches across the schema boundary in one PL/pgSQL statement, inside the same
transaction as every other side effect of booking, so it rolls back together with the rest if
anything fails.

This does **not** change how `credit-card/index.html`'s own UI creates ad-hoc charges — that
remains its existing, already-ratified bare-POST pattern for its own manual-entry use case. This
ADR only governs the new call site the Voucher feature introduces.

## Alternatives Considered

- **Client-side `fetch()` from the custodian PWA, mirroring `credit-card/index.html`'s own
  pattern.** Rejected: technically ratifiable per ADR-106, but breaks atomicity with the rest of
  the booking transaction — the exact partial-state failure mode ADR-033 was written to prevent,
  just relocated to a new feature.
- **A dedicated `creditcard`-owned RPC (e.g. `creditcard.create_ad_hoc_charge()`) that `custodian`
  calls, instead of a direct cross-schema INSERT inside `custodian`'s own RPC.** Considered
  reasonable and not wrong, but rejected as the v1 choice: `creditcard.ad_hoc_charges` has no
  owning-RPC precedent to begin with (§Context), so inventing one now is speculative
  infrastructure for a single call site rather than following ADR-010's own "acceptable for simple
  cases" allowance for a cross-schema SQL function in the calling PWA's schema. Revisit if
  `creditcard` later grows real business logic around ad-hoc-charge creation that this write should
  route through.
- **Leave the credit-card deduction line as free text for v1, defer the real write.** Rejected —
  Harish explicitly confirmed (2026-07-09) that credit-card and custodian-direct deduction lines
  must lead to actual recorded transactions, same as employee advances; this was a direct answer,
  not an assumption.

## Consequences

**Positive:**
- Voucher booking stays a single atomic operation even though it spans two schemas — no
  partial-booked state where a voucher's other effects landed but its ad-hoc charge didn't (or the
  reverse).
- Consistent with the `apply_delete`/`apply_structural_edit`/ADR-007 pattern already proven this
  session, rather than introducing a second cross-schema-write style for the same feature.
- Matches ADR-106's own stated preference (§2) that new code should still favor going through a
  purpose-built path where reasonable, even when a bare table write is technically ratifiable.

**Negative / Trade-offs:**
- `custodian`'s booking RPC now has a direct compile-time/runtime dependency on
  `creditcard.ad_hoc_charges`'s exact column shape — if `creditcard` changes that table
  structurally, the booking RPC must be updated too. Same trade-off ADR-033 already accepts for
  `approve_sheet` and `finance.impress_accounts`.
- Slightly widens `custodian`'s effective footprint beyond its own schema, continuing the pattern
  ADR-106 documents as already-pervasive on this platform (most PWAs already reach into at least
  one foreign schema).

**Risks and mitigations:**

| Risk | Mitigation |
|---|---|
| `creditcard.ad_hoc_charges` gains a trigger or NOT NULL column later that the voucher-booking RPC doesn't know to populate | Same class of risk ADR-106 already flags for `expense.employees`; when `creditcard` changes this table, its owner should grep for cross-schema writers (this RPC included) before shipping the change |
| Someone later "fixes" this by moving the write to client-side `fetch()` for consistency with `credit-card/index.html`'s own style | This ADR exists specifically to record why that would reintroduce a partial-booking failure mode — cite this ADR if that refactor is proposed |

## Related Decisions

- **ADR-106** — supplies the underlying cross-schema-write risk framework this ADR applies; this
  ADR is the "future call site characterized using that framework" ADR-106's own Implementation
  Notes anticipated.
- **ADR-007** — `SECURITY DEFINER` pattern for privileged-table writes; used here for a
  cross-schema variant of the same technique.
- **ADR-033** — the precedent for why atomicity matters more than which side of a schema boundary a
  write happens to be on; this ADR's core rationale is the same one ADR-033 already established for
  `approve_sheet`.
- **ADR-010** — "cross-schema SQL function in the calling PWA's own schema... acceptable for simple
  cases" is the exact allowance this ADR uses instead of inventing a new `creditcard`-owned RPC.

## References

- `finance/custodian/MDD_custodian_voucher.md` §3.2, §4, §7 — the feature design this ADR supports
- `finance/credit-card/index.html:1314`, `:1490` — the existing (unchanged) client-side POST pattern for the credit-card PWA's own UI
- `docs/adr/ADR-106-cross-schema-access-is-grant-gated-not-rls.md` — the framework applied here
- `custodian/migrate_custodian_v16.sql` — the `apply_structural_edit` precedent for this session's `SECURITY DEFINER` cancel-and-recreate pattern
