KIFF Cards

Every other check in KIFF asks whether one action is allowed: is the entity in the right state, does this actor have the permission, are the parameters valid, does this need an approval. A card carries the answer to the one question no single action can answer, what has this agent already done, and is that enough?

An action can be correct in every way and still be the one that should not run, because of the twenty that came before it. That is what a card catches.

Why cards exist

As agents make consequential work cheap, approving each action by hand becomes the limit on how much of it they can do. A card is how an owner delegates instead of supervising: defined actions, the conditions the domain already enforces, and a bounded total, which the owner can widen as the record earns it or withdraw at once.

It is closer to a licence than to a payment card or an access badge. It counts any consequence you can count, not only money, and a limit bounds exposure without making an invalid action valid. That stays the domain’s job.

What a card is

A KIFF Card, a mandate in the API, the package and the schema, is a bounded, revocable instrument issued to one named agent:

refund-agent may run REFUND_ORDER, only while the order is PAID, up to a total of 500000 summed over the amount_cents parameter, each calendar day, until it is revoked.

What it says:

  • Who holds it: one agent by default. It must match the actor_id the agent already sends when it proposes an action. Two agents doing the same work usually hold two cards, each with its own balance. A card can also be shared with up to 20 agents: every holder draws on the same totals, and each decision still names the agent that asked. One agent may hold several cards, see below, where the tightest binds.
  • What it may do: the actions it covers. An action you leave out is still governed by the contract; it just does not draw on this card.
  • Under what conditions: the entity states the grant is confined to. Leave them unset and the action’s own states apply, which is every state the domain already allows it in. A card narrows; it never widens.
  • How much, over what period: a quantity, either count (how many times those actions are authorized) or sum(<parameter>) (the total of one numeric parameter the actions declare), with a ceiling and a window: each calendar day, any rolling 24 hours, or any rolling hour.
  • What each limit measures: attempts or effects. The default is attempts for count, effects for sum(<parameter>). Each limit may override its default; its statement shows the effective measure.
  • When it stops: an optional expiry, or the moment an owner revokes it.

When a card is required

By default a card constrains without conferring: an agent with no card covering an action is unaffected, and the check is a no-op. That is what lets you adopt cards one agent at a time without the first one breaking something already running.

An action opts out of that by declaring requires_authority in the domain:

actions:
  - name: REFUND_ORDER
    allowed_states: [PAID, PARTIALLY_REFUNDED]
    requires_authority: true

For that action, no card is a refusal rather than a no-op:

{
  "outcome": "blocked",
  "reasons": ["no_mandate_issued"]
}

That reason is its own, deliberately. The first integrator to meet it has wired everything correctly, and a generic blocked would send them to audit their domain, their permissions and their state machine before discovering that nothing has been issued yet.

It is declared per action, not per tenant: the author of the domain knows which consequences are worth an instrument, and a tenant-wide switch would make that judgement for every action at once.

Which key can use a card

A card names its holder, and the proposal names its actor in actor_id. So that one agent cannot name another and spend its card, the API key has to be issued for the holder:

  • A key issued for an agent acts only as that agent. A proposal from it naming anyone else is refused with actor_not_key_holder, on every action.
  • Drawing on a card needs such a key. A key not issued for any agent is refused with key_not_bound_to_agent on an action that requires a card, and on any action where the named agent holds a card that covers it, even if the card is optional there. It keeps working for actions no card covers.

“Create a key for this agent” on a card’s page issues the right key: the role the job needs, bound to the card’s holder. For a card scoped to an environment, the key is also bound to that environment. Its requests are decided there whether or not they name it, and a request naming another environment is refused, so the key cannot draw on a card for a different environment by asking for it.

If the agent already has its own key, for example the one issued when it was added to watch in observe, the card’s page offers to give that key the job’s role instead of issuing a second one. The agent keeps the key it has. Roles are only ever added to a key: to take one away, revoke the key and issue another. Through the API this is PATCH /v1/keys/{id} with {"add_roles": [...]}, under the same rules as creating a key: only an owner or admin may do it, and never a role the caller does not hold.

A test request sent from the card’s page while you are signed in is not held to any of this; it is you, the owner, exercising the card you issued.

Budget warnings

Warnings help you plan without changing the Card’s authority. The initial Card form stays the same. On an existing Card, an admin can open Settings → Budget warnings to change the defaults or switch notices off. Runtime agent keys cannot change these settings.

  • Day totals: a heads-up when estimated exhaustion is within 25% of the window’s duration, then a notice when the total is spent. For a rolling day, the default runway horizon is six hours.
  • Hourly totals: the forecast goes to the agent through kiff_card. People get spent, unusual-usage and applicable new-Card burst notices, without an hourly runway email.
  • Unusual usage: at least three times the rate in equivalent intervals over the previous seven days. With insufficient baseline history, no ratio is invented.
  • New Cards: more than 50% used in the first 10% of a window during the first week. A rolling window’s burst observation starts with the first draw in a usage episode; it does not have a daily reset.

Notices appear on Needs you, in the bell and in History → Statement. The Overview shows one short active notice beside the balance. Warning settings open on their own one-step screen from the Settings row. Emails go to the same people who receive held-call emails. Supported, configured webhooks whose filters match Card warnings also receive them: explicitly opt in with action card_budget or outcome card_warning. Targets with no filters continue receiving decision alerts only. To register a warning-only target, use POST /v1/me/alert-targets with {"url":"https://example.com/kiff-warnings","action":"card_budget","outcomes":["card_warning"]}. Generic warning payloads use schema_version: "kiff.card-warning.v1" with id, card_id, kind, reason, recorded_at and card_url. Slack targets need the same explicit opt-in and receive a text message instead. A warning links back to the Card: review extra room for this window, change its terms, or review the agent. It takes none of those actions.

kiff_card includes a forecast on each cumulative limit: its state, observation time and interval, and runs_out_at when an estimate can be made. resets_at exists only for calendar-day limits. Rolling forecasts account for each draw aging out and for fixed extra-room expiries. Shared Cards use all holders’ usage. Estimates use the same counted amounts as authorization, including unknown outcomes; eligible trusted no-effect releases follow their original accounting terms.

The recent observation is five minutes for an hourly total and sixty minutes for a day total. Forecasts need three draws spanning at least half that interval. Large or incomplete histories return an unavailable or insufficient-history state instead of a misleading estimate.

Warnings are recorded after accounting commits, once per episode; reads never send them. Delivery retries survive restarts. One failing account backs off without stopping other accounts. Each destination’s result is recorded independently; external sends do not hold a database transaction. Resend email retries use a stable provider idempotency key. Generic webhooks carry a stable event id that receivers should deduplicate; Slack can repeat a notice if its delivery acknowledgement is lost. Recovered or superseded notices leave the active queue and remain on the Card’s statement as history. Warnings never answer a hold, block a call or add budget. Stop or Ask still follows the Card’s limits.

What happens at the ceiling

Every Card says what happens to a call outside it, and both routes let calls inside the Card run:

  • Stop (on_exceed: refuse): the call is refused. Nothing is sent to the tool, and the agent is told why.
  • Ask (on_exceed: owner, the default): the call is held for an authorized approver. Nothing is sent until someone who may approve says yes. A hold nobody answers before it expires is refused; silence never approves. An approval is not the tool’s result: the tool can still refuse or fail, and Needs you shows what it reported.

At a cumulative ceiling (a total over a window) the action cannot run on either route: approval alone cannot waive it.

“At the ceiling” means the proposed action would exceed what the card has left for the window, not that the card is empty. A €2,000 refund against €1,000 of remaining authority is refused, and a €500 one right after it is allowed, same card, same window, same reason code for the first.

The owner must revise the budget explicitly to add room. Approve once waives only a reviewed per-action threshold. It still draws the full quantity against every applicable cumulative limit.

With Stop, a proposal the Card cannot cover comes back as:

{
  "outcome": "blocked",
  "reasons": ["mandate_limit_reached"],
  "message": "this action exceeds the remaining authority under mandate refund-agent-card: sum(amount_cents) limit 500000 per calendar_day reached: 300000 used, this action draws 250000"
}

The engine’s own words in that message are the precise rule: 300000 used of 500000, and an action drawing 250000 does not fit, €2,000 of authority is still on the card for something smaller.

If an agent needs more room, you raise the ceiling: one deliberate act, recorded with who made it, rather than approving past it one action at a time. That is the same decision, made visible and attributable instead of ambient.

Some properties worth knowing

A card narrows; it never confers. It selects from what the domain and its permissions already allow and caps how much of it happens. It cannot grant an action the domain does not have, or legalise one in a state it forbids. This is the opposite of a capability system.

Several cards can cover one action, and the tightest binds. Each is checked; the first that refuses decides. There is no precedence rule to reason about, and no way to widen an agent’s authority by adding another card.

A card that cannot be read refuses. If the ledger is unreachable, the action is blocked rather than allowed, because allowing means acting on authority nobody could confirm. Cards marks such a balance unknown rather than showing a comforting zero, an unused card and an unreadable one are very different facts.

A revoked card refuses; it does not disappear. Revoking does not return the agent to “unbounded” for an action that requires one, and re-issuing the same name does not quietly bring it back: a proposal under a revoked card is blocked with mandate_expired, and its statement stays readable.

Draws are recorded when an action is authorized. A retry of the same operation does not draw twice. An attempt limit retains that draw even when the tool refuses. An effect limit can release it once, only when a trusted executor proves a final no-effect outcome for that exact operation. A generic error, timeout, partial effect or absence from a lookup is not that proof. Without trusted outcome reporting, the draw stays charged.

The original draw remains unchanged. A separate release record names the operation, evidence, source, reason and original limit terms. Release cannot reopen an operation, restore a revoked Card, or create credit in another window or timebase. See the executor outcome contract.

A limit binds the approved path too. When an operator grants an approval, the re-decision runs the same check. Approval does not buy authority.

The domain’s observe posture

A card is checked even when the domain is in observe. In that posture the decision is advisory and the caller continues its existing flow; the authorization still draws on the card, whether or not the caller ultimately executes it. Without a trusted executor outcome, the statement records authorized authority rather than confirmed spend.

Counting it anyway is exactly what lets you see what your agent would have drawn before you enforce anything. That is the recommended on-ramp: issue a card deliberately high, run for a week, read the statement, and set the real ceiling from your own traffic instead of from a guess.

(This is the domain’s observe posture, which still calls the decide API. Guard’s own observe mode is a different thing: it watches an agent’s tool calls without asking KIFF at all, so nothing is decided and no card is drawn.)

The statement

Cards in the dashboard shows, for each card: the ceiling, how much has been drawn in the current window, what remains, and every movement that got it there.

A movement names what it was for, REFUND_ORDER on ord_8812: carries how much it drew, and says how much of that limit’s window had been consumed once it landed. Where a card caps the same quantity over more than one window, each subtotal names its own: five an hour and twenty a day are both true of the same movement.

Two things the statement deliberately does not do:

  • It never claims execution. Every movement is an authorization. The original draw records KIFF’s decision. A tool outcome and an effect release are separate evidence, not a rewrite of that decision.
  • It never lists refusals. A refusal moved no balance, so it is not a movement. Refusals live in the domain’s activity, which the card links to, filtered to its holder.

Below the movements, Issued and changed is the card’s history: what its ceiling was at each issuance, and who changed it. Re-issuing a card overwrites the row it lives in, so this is the only record of what the authority used to be.

Changing the ceiling

Change ceiling on the card opens the issuance form with its current terms, because a change is a re-issue under the same name. Draws already recorded still count, so raising a ceiling adds only the difference rather than opening a fresh window, and the revision records who made the change.

For temporary room, Review extra room first shows the quantity, measure, base ceiling and fixed expiry. This window only is the default: the next local midnight for a calendar day, or one recorded window duration for a rolling limit. Confirmation preserves that end; retrying cannot renew it. You may explicitly choose an end date or a permanent base increase. Expiry removes the extra allowance and leaves recorded usage in place.

The statement separates gross authorized quantities, trusted releases and net counted quantities. Base ceilings and active extra room are shown separately. Current approved exceptions are already included in the draws; historical approvals made under the earlier accounting model remain labelled outside-budget entries. Old balances are not rewritten, and an unused legacy approval requires fresh consent.

Over HTTP, a management identity can confirm the same revision:

{
  "revision_id": "room-refunds-2026-10-09",
  "quantity": "sum(amount_cents)",
  "measure": "effects",
  "window": "calendar_day",
  "amount": 10000,
  "duration": "this_window"
}

Send it to POST /v1/me/mandates/{id}/extra-room. Reuse the same revision id only for a transport retry of the same confirmation. Setup tooling acts under the administrator’s management authority; the agent’s runtime key cannot grant room or approve work.

Setting one

Cards → Issue a card in the dashboard. The form offers the actions your domain declares, the entity states each of them allows, and only the parameters it types as a number, so a quantity that cannot be summed is not offered in the first place.

Or over HTTP, with a management credential:

curl -sS -X POST https://api.kiff.dev/v1/me/mandates \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "id": "refund-agent-card",
        "subject": "refund-agent",
        "domain": "card-refund",
        "grants": [{"action": "REFUND_ORDER", "entity_states": ["PAID"]}],
        "aggregates": [
          {"quantity": "sum(amount_cents)", "limit": 500000, "window": "calendar_day"}
        ],
        "valid_until": "2027-01-01T00:00:00Z"
      }'

Issuing and revoking require a management identity, the signed-in account owner or an admin. An agent’s own runtime key is refused. A credential that could grant itself authority would make every card advisory.

What this is not

  • Not a payment card. No money moves through KIFF. A payment card knows a merchant and an amount; a KIFF Card knows that this order is refundable, and counts whatever the consequence is, three DELETE_CUSTOMER an hour is the same instrument as €5,000 a day.
  • Not a rate limit. A rate limit protects your infrastructure from volume. A card bounds consequence: what an agent is permitted to do, in the units the action is measured in.
  • Not a spend tracker. Nothing here reads your ledger or your bank. It counts what KIFF authorized, and says so on the statement.
  • Not portable. A card is enforced by your KIFF tenant. Signing one so a third party could verify it independently is a later question.

See also

  • Decisions, the proposal flow a limit is checked in, and the outcomes it can produce.
  • Govern, the evidence view per action, and the observe/enforce posture a limit is checked under either way.
  • Held calls and approvals, what happens to a call outside the card, and how the owner answers.