Outcomes & reason codes
How a submission resolves (committed, duplicate, or rejected) and the reason codes that explain a decline.
Source src/contract.ts#L309Outcomesrc/errors.ts#L27RejectionCodesrc/errors.ts#L103ERROR_CODES
The shape of an answer
Every call to submit resolves to one Outcome. The
status field tells you which of three things happened, and the rest of the object carries exactly
what that status implies.
type Outcome =
| { status: 'committed'; transaction: Transaction }
| { status: 'duplicate'; transaction: Transaction }
| { status: 'rejected'; detail: RejectionDetail };
A request lands one of three ways: it went through, it repeated an operation already done, or it was valid but declined for a business reason.
committed: it went through
A committed outcome means money moved. It carries the Transaction
that posted: its id, the time it committed, and the balanced debit and credit legs that recorded
the movement.
const result = await economy.submit(
topUp({
idempotencyKey: 'ord_1',
actor: systemActor('payments'),
userId: 'usr_buyer',
amount: toAmount('CREDIT', 5_000n),
source: 'card',
}),
);
if (result.status === 'committed') {
console.log(result.transaction.id); // → "txn_…"
}
Its legs are the lines that posted, and its links record how
each touched account’s hash chain advanced: the per-account integrity trail described under
the chain.
duplicate: you already did this
A duplicate outcome means you submitted an operation whose idempotencyKey matched one already
processed, so the economy returned the earlier result instead of running it again. This is the
visible side of idempotency: a retried request runs at most once.
It carries the same Transaction the original commit did, so a duplicate counts as success.
A network retry, a double-clicked button, or an at-least-once delivery queue all land here,
and the caller can treat duplicate exactly like committed.
rejected: a declined-but-valid request
A rejected outcome means the request was well-formed and the system was healthy, but the answer is
no. It carries a RejectionDetail in detail: a discriminated union whose detail.reason names
the RejectionCode, and whose remaining fields are exactly the
specifics that decline needs — the account that was short, the order that wasn’t found, when writes
resume.
const result = await economy.submit(
spend({
idempotencyKey: 'idem_0',
actor: userActor('usr_buyer'),
orderId: 'ord_1',
buyerId: 'usr_buyer',
sku: 'wrld_pass',
price: toAmount('CREDIT', 400n),
recipients: [{ sellerId: 'usr_seller', shareBps: 10_000 }],
}),
);
if (result.status === 'rejected' && result.detail.reason === 'INSUFFICIENT_FUNDS') {
// show the buyer a "not enough funds" message
}
A rejection comes back as data, so you handle it in an ordinary if branch; see the throw-versus-decline rule below.
The money fields in detail — need, have, minimum, amount — are branded Amounts, so a caller compares them directly (detail.have.minor < detail.need.minor) and formats them with encodeAmount. Over the HTTP service a rejection travels as { status, reason, detail }: the top-level reason is a convenience copy derived from detail.reason — in TypeScript, detail.reason is the sole discriminant — and the money fields travel as the same decimal strings every other wire amount uses (bigint fields such as limitMinor become decimal strings too).
The RejectionCode catalog
A RejectionCode names one expected reason a valid request gets declined on a healthy system. Each
one is a stable string you can branch on, and each is raised by a specific set of operations.
The table lists every code, what it means, and which operation kinds return it. The operation names map to the pages under operations.
| Code | What it means | Raised by |
|---|---|---|
INSUFFICIENT_FUNDS | The account can’t cover the amount the request needs. | spend, subscribe, requestPayout |
FUNDS_IMMATURE | The funds exist but are still in their holding period, so they aren’t usable yet. | spend, subscribe, requestPayout (renewals defer on it) |
RISK_DENIED | The velocity / abuse check declined this request. | spend, subscribe, topUp, grantPromo, requestPayout (every risk-screened write) |
DUPLICATE_ORDER | A spend reused an orderId that already has a completed sale, but carried a different idempotencyKey. | spend |
UNKNOWN_ORDER | No sale was found for the orderId the request refers to. | refund |
NOT_ENTITLED | The user doesn’t own the item or feature the request needs. | revokeEntitlement |
UNKNOWN_SUBSCRIPTION | No subscription matched the request. | cancelSubscription |
ALREADY_SUBSCRIBED | The user already has an active subscription to this sku / seller; a second would double-bill. | subscribe |
BELOW_MINIMUM | A payout was requested for less than the configured minimum. | requestPayout |
PAYOUT_TOO_SOON | A payout was requested before the configured minimum gap since the user’s last request. | requestPayout |
PAYEE_UNVERIFIED | The configured payee directory hasn’t cleared the seller’s identity verification. | requestPayout |
ECONOMY_PAUSED | A maintenance window is in effect, so an end user’s discretionary write is declined. | Any paused user write |
A few of these reward a closer look.
FUNDS_IMMATURE is a timing problem: the money is there, but it hasn’t cleared its
maturity window yet, a concept that lives with credit maturity.
The detail names the source whose window is still open and availableAt — when the funds
will have cleared, assuming no further activity — so a caller can tell the user when to try again.
RISK_DENIED names its trigger the same way: its detail carries which window tripped
(window — 'inflow' for top-ups and promo grants, 'outflow' for spends, subscriptions,
and payout requests) and the limitMinor ceiling of that
velocity window.
DUPLICATE_ORDER catches a specific client mistake: a retried spend that lost its original
idempotencyKey. The orderId identifies a unique purchase, so a second charge for the same order
is a declined “no” rather than a thrown fault.
ECONOMY_PAUSED only ever stops end-user writes. A system actor’s settlement and an operator’s
manual fix are never paused, per the rules on actors and authorization.
The decline carries resumesAt in its detail so the caller can tell the user when to come back.
Rejected versus thrown
A rejected outcome is the economy’s considered
“no” to a question it understood, while a thrown fault means the question itself was broken.
They never overlap: the core decides up front which path a problem takes, so a given failure
arrives either as a RejectionCode in the outcome or as a thrown exception, never as both.
Thrown faults carry their own codes, kept deliberately separate from the rejection codes in
ERROR_CODES. Six of them are the ones you’ll meet when a request is malformed or misdirected:
| Thrown code | Constant | When it’s thrown |
|---|---|---|
OP.MALFORMED | MALFORMED_OPERATION | The request was structurally wrong: a missing or invalid field, or a topUp amount outside the configured purchase catalog. |
MONEY.INVALID_AMOUNT | INVALID_AMOUNT | A money amount was invalid, such as negative or not a whole minor unit. |
AUTH.UNAUTHORIZED | UNAUTHORIZED | The actor isn’t permitted to perform this action. |
SAGA.INVALID_TRANSITION | INVALID_TRANSITION | A saga was told to move to a state it can’t reach from its current one. |
SESSION.SETTLED | SESSION_SETTLED | A settled netting session refuses further movements; rotate to a new session id (epoch). |
SESSION.MISROUTED | SESSION_MISROUTED | A scope’s traffic reached a cluster node that doesn’t own it; detail.owner names the node that does. |
The line between the two follows cause rather than severity. INSUFFICIENT_FUNDS is a healthy system
giving an expected answer — a rejection you show the user. INVALID_AMOUNT means the caller sent a
number that can’t be money — a fault to fix in code.
That separation has a practical payoff: ordinary “no” answers stay off the thrown-error path, so they never light up error dashboards or page an on-call engineer.
ERROR_CODES also holds deeper safety faults you won’t normally trigger from a well-formed request:
LEDGER.OVERDRAFT, CHAIN.BROKEN, LEDGER.COMMINGLING, and others. Those are last-resort
backstops deep in the posting and integrity paths; reaching one means an invariant was about to
break.
Troubleshooting
I get duplicate but I only submitted once
The key was minted from something two different actions share — a user id, a SKU, a template string that never varies. The first action claimed it; every later action replays that recorded outcome. Mint the key from the action’s own identity:
const idempotencyKey = `ord_${order.id}`; // unique per order, stable per retry
Idempotency & retries owns the key discipline.
My code throws instead of getting a rejected outcome
Then it wasn’t a decline — the request itself was broken before any business question was asked. The two usual suspects: the actor is a user acting on an account they don’t own (AUTH.UNAUTHORIZED), or a malformed payload (OP.MALFORMED, e.g. a blank key). Rejections are reserved for valid requests the economy declines; see rejected versus thrown above and actors & authorization.
The catalog, live
Every code in the table can be produced on demand, and each block below prints the reason
verbatim plus the exact RejectionDetail it carries. Run them as they are, or edit the figures
and run again.
The classic first: an underfunded spend, declined with the funds gate’s own numbers.
import { credits, encodeAmount, spend, userActor } from '@pwngh/economy-lab';
import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// A rejection is data, not an exception: the reason code verbatim, plus the typed
// figures it carries — here the funds gate reporting what the spend needs against what
// the wallet has.
export async function run(economy: Economy): Promise<SnippetReport> {
const orderId = `ord_${crypto.randomUUID().slice(0, 8)}`;
const outcome = await economy.submit(
spend({
idempotencyKey: orderId,
actor: userActor('usr_newcomer'),
orderId,
buyerId: 'usr_newcomer', // an empty wallet
sku: 'First Purchase',
price: credits(250),
recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
}),
);
if (outcome.status !== 'rejected' || outcome.detail.reason !== 'INSUFFICIENT_FUNDS') {
return {
lines: [`status: ${outcome.status} — the newcomer has funds now; reset the economy to rerun`],
consolePath: '/market',
};
}
return {
lines: [
`status: ${outcome.status}`,
`reason: ${outcome.detail.reason}`,
`need: ${encodeAmount(outcome.detail.need)} · have: ${encodeAmount(outcome.detail.have)}`,
],
consolePath: '/market',
};
}The record-keyed declines — DUPLICATE_ORDER, UNKNOWN_ORDER, UNKNOWN_SUBSCRIPTION,
ALREADY_SUBSCRIBED, and NOT_ENTITLED — each name the record they couldn’t find or refuse to
double-book:
import {
cancelSubscription,
createEconomy,
credits,
memoryPorts,
refund,
revokeEntitlement,
spend,
subscribe,
systemActor,
topUp,
userActor,
} from '@pwngh/economy-lab';
import type { Outcome } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// The record-keyed declines, produced in one sitting against a private little economy: each
// answer is the code plus the record it couldn't find or refuses to double-book.
export async function run(): Promise<SnippetReport> {
const economy = createEconomy(memoryPorts({ signingKey: 'docs-signing-key' }));
const buyer = userActor('usr_r');
await economy.submit(
topUp({
idempotencyKey: 'idem_fund',
actor: systemActor('docs'),
userId: 'usr_r',
amount: credits(1_000),
source: 'card',
}),
);
const order = (idempotencyKey: string) =>
spend({
idempotencyKey,
actor: buyer,
orderId: 'ord_r1',
buyerId: 'usr_r',
sku: 'Poster',
price: credits(100),
recipients: [{ sellerId: 'usr_s', shareBps: 10_000 }],
});
await economy.submit(order('idem_first')); // commits and records the sale
const dupOrder = await economy.submit(order('idem_lost_the_key'));
const ghostRefund = await economy.submit(
refund({ idempotencyKey: 'idem_gr', actor: systemActor('docs'), orderId: 'ord_ghost' }),
);
const ghostCancel = await economy.submit(
cancelSubscription({ idempotencyKey: 'idem_gc', actor: buyer, subscriptionId: 'sub_ghost' }),
);
const club = (idempotencyKey: string) =>
subscribe({
idempotencyKey,
actor: buyer,
userId: 'usr_r',
sellerId: 'usr_s',
sku: 'Club',
price: credits(300),
periodMs: 2_592_000_000,
});
await economy.submit(club('idem_join')); // the one active subscription
const twice = await economy.submit(club('idem_join_again'));
const unowned = await economy.submit(
revokeEntitlement({
idempotencyKey: 'idem_rv',
actor: systemActor('docs'),
userId: 'usr_r',
sku: 'sku_never_granted',
}),
);
await economy.close();
const reason = (o: Outcome) => (o.status === 'rejected' ? o.detail.reason : o.status);
const detail = (o: Outcome) => JSON.stringify(o.status === 'rejected' ? o.detail : {});
return {
lines: [
`same order, new key: ${reason(dupOrder)} — detail ${detail(dupOrder)}`,
`refund of no sale: ${reason(ghostRefund)} — detail ${detail(ghostRefund)}`,
`cancel of no sub: ${reason(ghostCancel)} — detail ${detail(ghostCancel)}`,
`subscribe twice: ${reason(twice)} — detail ${detail(twice)}`,
`revoke unowned sku: ${reason(unowned)} — detail ${detail(unowned)}`,
],
consolePath: '/market',
};
}The three payout gates in the order requestPayout checks them — BELOW_MINIMUM,
PAYOUT_TOO_SOON, PAYEE_UNVERIFIED — on an economy built with a 100-credit minimum, a
seven-day gap, and a payee directory that has cleared only one seller:
import {
adjust,
createEconomy,
credits,
earned,
memoryPorts,
operatorActor,
requestPayout,
userActor,
} from '@pwngh/economy-lab';
import { encodeAmounts } from '@pwngh/economy-lab/store-kit';
import type { Outcome } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// The three payout gates, tripped in order on a purpose-built economy: a 100-credit minimum, a
// 7-day gap between requests, and a payee directory that has cleared only one seller.
export async function run(): Promise<SnippetReport> {
const economy = createEconomy({
...memoryPorts({
signingKey: 'docs-signing-key',
config: {
payoutMinimumEarnedMinor: 10_000n, // 100 credits
payoutMinIntervalMs: 7 * 86_400_000,
},
}),
payees: {
status: async (userId) => ({ state: userId === 'usr_cleared' ? 'CLEARED' : 'PENDING' }),
},
});
await economy.submit(
adjust({
idempotencyKey: 'idem_seed',
actor: operatorActor('op_docs'),
account: earned('usr_cleared'),
amount: credits(200),
reason: 'docs: seed earnings',
}),
);
const request = (userId: string, amount: number, key: string) =>
economy.submit(
requestPayout({
idempotencyKey: key,
actor: userActor(userId),
userId,
amount: credits(amount),
}),
);
const tooSmall = await request('usr_cleared', 50, 'idem_small'); // under the 100 minimum
await request('usr_cleared', 150, 'idem_ok'); // 150 clears every gate
const tooSoon = await request('usr_cleared', 150, 'idem_again'); // clears the minimum, inside the 7-day gap
const unverified = await request('usr_pending', 150, 'idem_pending');
await economy.close();
const report = (label: string, o: Outcome) =>
o.status === 'rejected'
? `${label}: ${o.detail.reason} — detail ${JSON.stringify(encodeAmounts(o.detail))}`
: `${label}: ${o.status}`;
return {
lines: [
report('50 against the 100 minimum', tooSmall),
report('a second ask, day one ', tooSoon),
report('an uncleared payee ', unverified),
],
consolePath: '/payouts',
};
}FUNDS_IMMATURE, from giving card credit a three-day
holding window — the money is in the wallet, and the
detail says when it clears:
import {
createEconomy,
credits,
memoryPorts,
spend,
systemActor,
topUp,
userActor,
} from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// FUNDS_IMMATURE, produced by giving card credit a three-day holding window: the money is
// there, it just hasn't cleared, and the detail says when it will have.
export async function run(): Promise<SnippetReport> {
const economy = createEconomy(
memoryPorts({
signingKey: 'docs-signing-key',
config: { maturityHorizonMs: { card: 3 * 86_400_000 } },
}),
);
await economy.submit(
topUp({
idempotencyKey: 'idem_fund',
actor: systemActor('docs'),
userId: 'usr_m',
amount: credits(100), // in the wallet, but held for three days
source: 'card',
}),
);
const outcome = await economy.submit(
spend({
idempotencyKey: 'idem_try',
actor: userActor('usr_m'),
orderId: 'ord_m1',
buyerId: 'usr_m',
sku: 'Poster',
price: credits(50),
recipients: [{ sellerId: 'usr_s', shareBps: 10_000 }],
}),
);
await economy.close();
if (outcome.status !== 'rejected' || outcome.detail.reason !== 'FUNDS_IMMATURE') {
return { lines: [`status: ${outcome.status}`], consolePath: '/market' };
}
return {
lines: [
`status: rejected (${outcome.detail.reason})`,
`detail: ${JSON.stringify(outcome.detail)} — clears ${new Date(outcome.detail.availableAt).toISOString()}`,
],
consolePath: '/market',
};
}ECONOMY_PAUSED, inside a live maintenance window — the user’s write waits, the system’s
settlement does not:
import {
createEconomy,
credits,
memoryPorts,
spend,
systemActor,
topUp,
userActor,
} from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// ECONOMY_PAUSED, inside a live maintenance window: the user's discretionary write waits, the
// system's settlement does not, and the detail says when writes resume.
export async function run(): Promise<SnippetReport> {
const resumesAt = Date.now() + 3_600_000; // the window closes in an hour
const economy = createEconomy(
memoryPorts({
signingKey: 'docs-signing-key',
config: { pauseStartMs: Date.now() - 1_000, pauseEndMs: resumesAt },
}),
);
const funded = await economy.submit(
topUp({
idempotencyKey: 'idem_fund',
actor: systemActor('payments'),
userId: 'usr_p',
amount: credits(100),
source: 'card',
}),
);
const paused = await economy.submit(
spend({
idempotencyKey: 'idem_try',
actor: userActor('usr_p'),
orderId: 'ord_p1',
buyerId: 'usr_p',
sku: 'Poster',
price: credits(50),
recipients: [{ sellerId: 'usr_s', shareBps: 10_000 }],
}),
);
await economy.close();
const minutes =
paused.status === 'rejected' &&
paused.detail.reason === 'ECONOMY_PAUSED' &&
paused.detail.resumesAt !== null
? Math.round((paused.detail.resumesAt - Date.now()) / 60_000)
: null;
return {
lines: [
`system top-up in the window: ${funded.status} — settlement is never paused`,
paused.status === 'rejected'
? `user spend in the window: rejected (${paused.detail.reason})${minutes === null ? '' : ` — resumes in ~${minutes} min`}`
: `user spend in the window: ${paused.status}`,
],
consolePath: '/controls',
};
}That leaves RISK_DENIED, which runs live where the velocity window is explained: on the
spend velocity page.