#Build Guide — for coding agents building on the Controlled Funding API

Give this whole file to your coding agent as the system/context prompt. It is written to be executable guidance: the model, the exact call sequences, the invariants you must not break, the UI patterns per surface, and the scenarios your build must pass before you call it done.

Companion docs: overview.md (the model), reference.md (every endpoint, real traffic), SAMPLE_PROMPTS.md (three product briefs to paste into an agentic platform), GET /api/openapi (machine-readable).


#0. Prompt preamble (copy this into your agent)

You are building a client application on the mCards Controlled Funding API. The API is the source of truth for every funding decision. Your application presents decisions; it never makes them.

Non-negotiable rules:

  1. Never reimplement policy, routing, sufficiency, cap arithmetic or ledgering in the client. If your code contains "if amount > cap" to decide an outcome, you have built a lie that will disagree with the API. Ask the API — POST /policies/{id}/simulate for previews, POST /authorizations for the real thing.
  2. Money is integer minor units. 4000 means $40.00. Never store money in a float, never round in the client, format only at the display edge.
  3. Every POST under /api/v1 carries a fresh idempotency-key — one per logical user intent, reused verbatim on retry.
  4. ETags are opaque. Keep the last one you saw per resource, send it as if-match, and on 409 RESOURCE_VERSION_CONFLICT refetch and retry once.
  5. A decline is a 201, not an error. Distinguish gate_failed: "POLICY" from "FUNDING" in the UI; they are different sentences to a human.
  6. Never show a recipient anything about a sponsor's money — no balance, no instrument, no decision_detail.gate_2, no candidates.
  7. Never claim money moved unless the response says so. A decline has no allocations, no pulls and no ledger entries. Do not write "payment failed, you may see a pending charge".

#1. The mental model in 90 seconds

A sponsor (parent, employer, friend) links a real bank account or card (linked_payment_source) and creates a commitment to fund a recipient's card: one instrument, one policy, one cap, one priority. Money stays with the sponsor until a tap happens.

A tap is an authorization. It passes two gates:

  • Gate 1 — POLICY: do the sponsor's rules and cap allow this? No bank is contacted. Simulatable with zero side effects.
  • Gate 2 — FUNDING: can the sponsor's instrument cover it, and does the pull settle?

Many commitments can fund one card. They are ranked by priority (ascending) and filled as a waterfall; each funded slice becomes an allocation, which is what refunds, reversals and sponsor-scoped views key off. The recipient's own money is used only with explicit consent and only after every sponsor.

Two facts that shape UI copy: an instrument must hold 1.5× the amount (interim buffer) — a $40 tap needs $60 available — and there is no partial approval.


#2. Setup sequence (the one you will implement first)

Run in this order. Every step is a real call; † needs if-match from the previous step's ETag.

 1  POST /api/v1/parties                      → sponsor party
 2  POST /api/v1/parties                      → recipient party
 3  POST /api/v1/linked_payment_sources       → sponsor's instrument   (must become `active`)
 4  POST /api/v1/cards                        → recipient's card
 5  POST /api/v1/commitments                  → status: draft
 6  POST /api/v1/commitments/{id}/actions †   → { "action": "invite" }
 7  POST /api/v1/commitments/{id}/actions †   → { "action": "accept" }
 8  POST /api/v1/commitments/{id}/actions †   → { "action": "activate" }
 9  POST /api/v1/budget_caps                  → e.g. 5000 weekly
10  POST /api/v1/policies                     → scope_type: "commitment", scope_id: {commitment}
11  POST /api/v1/policies/{id}/versions       → the rule tree (version 1)
12  POST /api/v1/policies/{id}/actions †      → { "action": "activate", "version_number": 1 }
        (the ETag here is the POLICY's, from step 10)
13  POST /api/v1/authorizations               → the first tap

Pitfalls that will bite you at exactly these steps:

  • Step 3: a pending source funds nothing, and there is no public endpoint to activate it. Assume your integration receives already-active sources.
  • Steps 6–8: each action bumps version. Using the ETag from step 5 at step 7 is a 409. Always thread the ETag from the response you just got.
  • Step 12: if-match is the policy's ETag, not the version's.
  • Step 11: give every predicate a stable id — it is the name the decision trace uses, and therefore the name your UI renders.

TypeScript client sketch:

type Money = { amount: number; currency: string }; // integer minor units

async function call<T>(
  method: "GET" | "POST" | "PATCH",
  path: string,
  opts: { body?: unknown; ifMatch?: string; sponsor?: string } = {}
): Promise<{ status: number; etag: string | null; body: T }> {
  const headers: Record<string, string> = { "x-tenant-id": TENANT };
  if (opts.body !== undefined) headers["content-type"] = "application/json";
  if (method !== "GET") headers["idempotency-key"] = crypto.randomUUID();
  if (opts.ifMatch) headers["if-match"] = opts.ifMatch;      // send back verbatim
  if (opts.sponsor) headers["x-sponsor-party-id"] = opts.sponsor;
  const res = await fetch(BASE + path, {
    method,
    headers,
    body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
  });
  return { status: res.status, etag: res.headers.get("etag"), body: await res.json() };
}

// lifecycle helper: thread the ETag through every action
async function act(kind: "commitments" | "policies" | "cards" | "authorizations",
                   id: string, body: unknown, etag: string) {
  const r = await call<{ version: number }>("POST", `/api/v1/${kind}/${id}/actions`,
                                            { body, ifMatch: etag });
  if (r.status === 409) throw new Error("stale ETag — refetch and retry");
  return r; // r.etag is the next If-Match
}

#3. Surfaces to build, and what each one may see

Surface Reads Writes Must never see
Sponsor (parent/employer) app own commitments, own caps, own policies, GET /authorizations with x-sponsor-party-id create/activate/suspend/revoke commitments, caps, policy versions, set_priority other sponsors' commitments, allocations, instruments
Recipient (child/employee) app own card, the rules in plain language, own authorization history taps (POST /authorizations) any sponsor balance, decision_detail.gate_2, candidates, pulls, ledger_entries, which instrument paid
Merchant / POS simulation — POST /authorizations, then capture / refund / reverse everything else
Admin / support console everything unscoped, including decision_detail and candidates lifecycle actions —

#3.1 Sponsor: "set up an allowance"

One form → the 8-call sequence above. Design notes:

  • Collect amount in major units, convert once at submit: Math.round(parseFloat(input) * 100). Never keep a formatted string in state and reformat on every render — that mangles typing.
  • Model rules as chips the sponsor picks (categories, per-transaction max, hours, countries) and compile them into one when tree with op: "and".
  • Show the cap as remaining_amount / cap_amount from GET /budget_caps/{id} — never a number you computed yourself.
  • Changing rules = new policy version + activate, not an edit. Show version history; every past authorization names the version that judged it.
  • "Pause" is suspend, "cancel" is revoke. Both return in_flight — surface it: "Stopped. $16.67 already funded on 1 transaction can still be captured or refunded." Do not imply the money is clawed back.

#3.2 Recipient: "what can I spend?"

Read the active policy version and render the rule tree in plain language — do not echo the sponsor's form input, and never invent limits:

mcc_in ["5812","5814","5499"]            → "Food and drink only"
amount_max 5000 USD                      → "Up to $50.00 per purchase"
time_window 06:00–20:00 mon–fri          → "Weekdays, 6am to 8pm"
cap_remaining + cap {5000 weekly, 1200 used} → "$38.00 left this week"
country_in ["US"]                        → "In the United States"

Show remaining_amount, never a sponsor balance. If several commitments fund the card, show the recipient the union of what they can do, not a per-sponsor breakdown.

#3.3 Recipient: the tap screen

amount entry → (optional) preview → POST /api/v1/authorizations → render outcome

Preview (POST /policies/{id}/simulate) is free of side effects and needs no idempotency key, so you may call it on debounce. Two rules:

  • pass cap_remaining explicitly, or a cap_remaining predicate evaluates unknown and denies under missing_data: "DENY";
  • simulation is Gate 1 only. Never present it as "this will work" — funding is only known at the real tap.

Rendering the outcome, driven entirely by the response:

Response Recipient copy Sponsor/admin copy
decision: "approved" "Approved — $12.00 at Joe's Diner" + which allocations funded it
gate_failed: "POLICY", reason_code: "POLICY_MCC_DENIED" "This store isn't covered by your allowance" + the failing rule_id
POLICY_AMOUNT_LIMIT "That's over your $50.00 per-purchase limit"
COMMITMENT_CAP_EXCEEDED "You've used this week's allowance" + remaining_amount
NO_ELIGIBLE_COMMITMENT / COMMITMENT_INACTIVE "No one is funding this card right now" + which commitment stopped
gate_failed: "FUNDING" (SPONSOR_SOURCE_*) "Your sponsor's payment method couldn't cover this right now" + sufficiency.message

Map the failing predicate to its human sentence via rule_id — that is exactly why rule ids exist:

const failed = auth.decision_detail?.gate_1?.evaluated?.find(r => r.passed === false);
const text = RULE_COPY[failed?.rule_id ?? ""] ?? DECLINE_COPY[auth.reason_code];

For a Gate 1 decline, state plainly that no payment was attempted. For a Gate 2 decline, do not leak the amount required or the balance to a recipient — that is the sponsor's private financial data.

#3.4 Merchant / lifecycle

  • capture with final_amount when the merchant clears (tip, fuel adjustment). Above the approved amount, the difference pulls from the primary allocation's instrument.
  • refund with amount — split proportionally back to the instruments that actually paid; bounded by the outstanding funded amount, so an over-refund is rejected rather than silently truncated.
  • reverse releases the entire authorization.
  • All three need if-match and are refused (403) to a sponsor-scoped caller.

The most persuasive UI on this API is one that shows its own traffic. Log { method, path, requestHeaders, requestBody, status, responseBody, correlation_id } per call and render request/response side by side with the two-gate trace.

Redact before you store or display: authorization headers, cookies, and anything from a scoped session. Label non-/api/v1 calls (e.g. simulator world controls) distinctly so nobody mistakes them for platform API.


#4. Protocol implementation checklist

// Idempotency: one key per user intent, reused on retry — not per HTTP attempt.
const key = intentKey ?? (intentKey = crypto.randomUUID());

// Concurrency: keep the last ETag per resource id.
etags.set(res.body.id, res.headers.get("etag"));

// 409 handling: refetch, then retry once with the fresh validator.
if (res.status === 409 && res.body.error.code === "RESOURCE_VERSION_CONFLICT") {
  const fresh = await call("GET", `/api/v1/commitments/${id}`);
  return act("commitments", id, body, fresh.etag!);
}
  • Set x-tenant-id on every call; a wrong tenant yields 404, not 403.
  • Paginate with next_cursor while has_more; cursors and ETags are opaque.
  • Log correlation_id from every response; it ties a user complaint to an authorization trace.
  • Retry only on 429/5xx, with backoff and the same idempotency key.
  • POST /authorizations is not a "try it and see" call: it may move real money. Preview with simulate.

#5. Privacy rules that are part of "working correctly"

  1. Sponsor-scoped reads use x-sponsor-party-id and return only that sponsor's allocations, with a distinct ETag and Vary: x-sponsor-party-id. Do not cache scoped and unscoped bodies under one key.
  2. That header is caller-asserted and only accepted when the deployment sets ALLOW_HEADER_SPONSOR_SCOPE=true. It is a dev/demo switch — in production the scope comes from a scope-bearing token. Never point a client that relies on the header at real financial data.
  3. Recipients see rules and outcomes; sponsors see their own funding; nobody sees another sponsor exists. candidates and gate_2 are admin-only fields.
  4. A sponsor who funded no slice of an authorization gets 404 — treat that as "not yours", not as an error to surface.

#6. Acceptance scenarios — your build is not done until these pass

Drive each one through your UI and assert on the API response, not your own state:

# Scenario Expected
1 Full setup, then an in-policy tap within cap and balance 201 approved, cap remaining_amount drops by the amount
2 Tap at a category the policy excludes declined, POLICY, POLICY_MCC_DENIED, no gate_2 in the trace, no pulls/ledger entries
3 Tap above the per-transaction max declined, POLICY, POLICY_AMOUNT_LIMIT
4 Tap that exceeds the remaining cap declined, POLICY, COMMITMENT_CAP_EXCEEDED
5 Tap with the sponsor's balance below 1.5× the amount declined, FUNDING, SPONSOR_SOURCE_INSUFFICIENT; UI says nothing about the balance
6 Two commitments, first balance-limited approved with 2 allocations summing exactly to the amount
7 Capture, then partial refund captured_amount decreases; allocations' refunded_amount rises proportionally
8 Reverse authorization reversed, caps released
9 Revoke a commitment, then tap again new tap NO_ELIGIBLE_COMMITMENT; the earlier authorization still refundable, and in_flight was surfaced at revoke
10 Personal-funds consent, then a tap no sponsor can cover personal allocation appears last; withdraw consent and it disappears from routing
11 Replay an idempotency key same resource id, idempotent-replay: true, no duplicate
12 Reuse a stale ETag 409; your UI refetches and succeeds
13 Sponsor-scoped read of a split authorization only that sponsor's allocation; no total amount, no trace
14 Same id under a different x-tenant-id 404

Test tenants must be isolated (x-tenant-id: sim-… or similar) and cleaned up. Never point acceptance tests at a tenant holding real funding data.


#7. Anti-patterns (each has bitten a real implementation)

Don't Do
Compute "will this be approved?" client-side POST /policies/{id}/simulate, then the real authorization
Store money as dollars/floats, or reformat a controlled input on every render Integer minor units in state; format at render, normalise on blur
Reuse an ETag across actions in a lifecycle chain Thread the ETag from the response you just received
Show "declined" with no reason Branch on gate_failed + reason_code + failing rule_id
Say "your card was charged" on a decline A decline has no pulls and no ledger entries — say nothing moved
Index pulls[2] Match by allocation_id or amount; slices share a timestamp and have no defined order
Edit a policy version in place Create a new version and activate it
Show a recipient sufficiency.message or candidates Keep sponsor financials out of the recipient surface
Enable ALLOW_HEADER_SPONSOR_SCOPE in production Use scope-bearing tokens
Treat a 404 on a sponsor-scoped read as a bug It means the sponsor funded no slice

#8. Reference material for the agent

  • overview.md — domain model, gates, routing, privacy, limits.
  • reference.md — every endpoint with captured request/response pairs.
  • SAMPLE_PROMPTS.md — three complete product briefs you can paste straight into an agentic development platform.
  • GET /api/openapi — generate a typed client from this rather than hand-writing one.
  • /admin/demo — a working two-persona reference implementation (parent sets up, child taps, API console shows the literal traffic) that imports no funding or policy code: the decisions on screen are the API's.
  • /admin/simulator — declarative scenarios executed as real HTTP calls with runner-enforced ledger invariants; a good source of expected outcomes.

Both admin surfaces require ENABLE_SIMULATOR=true and sign-in, and they run real authorizations against whatever database the deployment points at.