A customer has 97600 USD minor units posted to their deposit account. An outgoing authorization reserves 30000. After 12000 is captured, how much remains available?
The answer is 67600. Posted funds fall to 85600, while the uncaptured reservation falls to 18000. Subtracting the original 30000 again would report 55600 and count the captured 12000 twice.
This tutorial makes that bridge explicit, then implements the reservation lifecycle with durable operation identity and atomic journal posting. Operations staff can explain the remaining reservation; engineers can verify what concurrent requests and failed captures leave behind. Every example concerns fictional Cedar Teaching Bank, not a real customer's payment.
A reservation is a promise of capacity
The Available/Ledger Balance Calculation tutorial distinguishes posted deposit balance from spendable capacity. This topic owns the write decisions that keep that distinction coherent.
Let L be a credit-normal customer deposit liability balance, H effective remaining outgoing holds, and A available capacity. Cedar's selected teaching product uses A=L−H, with credit limit zero and pending incoming contributing nothing. A new reserve q must satisfy q<=A. Exact equality is permitted; one minor unit above is rejected.
A hold does not itself debit the financial ledger. Capture does: debit the customer's deposit liability and credit a trusted settlement-payable liability. Actual cash settlement comes later. This bank perspective follows Double-Entry Posting Engine; debit does not universally mean the same direction for every account type.
Real providers separate authorization and capture, but supported methods, deadlines and capabilities vary. Stripe's documentation provides one bounded comparison. Cedar's explicit lifecycle is a teaching choice, with no adopted card-network window or claimed provider integration.
Keep the full hold decomposition
Each hold retains scoped entity/ledger/account/currency identity, original amount, cumulative captured amount, cumulative released amount, remaining amount, created/updated/expiry timestamps, version and stored status.
original = captured + released + remaining
All amounts are exact decimal strings. Original is positive through 30 digits; the other components are nonnegative through 30 digits. USD-only writes use exponent 2. Python integers, TypeScript BigInt and SQL numeric sums avoid binary floating-point money.
An active hold has positive remaining and no released amount; earlier partial captures may exist. Captured, released and expired are terminal states with remaining zero. A final partial capture can close as captured while also releasing unused remainder. Released/expired rows may retain earlier captured value.
Terminal state is not the whole time rule. An active stored row may remain after its deadline because a worker has not yet persisted expiry. Reads count its remaining amount only while as_of<expires_at. At exact equality it contributes zero and reports effective_expired. A late worker cannot keep the customer's capacity reserved indefinitely in this variant.
Capture and release require trusted processing strictly before expiry. Expire requires processing at or after it. Supplying an earlier economic date cannot rescue a request that reaches the control after the deadline.
Follow the independently calculated bridge
The corrected Stage 1 Ada opening is 97600. Reserve 30000 and availability becomes 67600. Capture 12000 with final_capture=false: posted becomes 85600, remaining becomes 18000, and availability stays 67600.
Open the full-size capture bridge.
The unchanged availability is an identity:
(97600−12000) − (30000−12000) = 67600
Now capture 10000 with final_capture=true. Posted becomes 75600. The remaining 8000 is released without a financial debit, so effective holds become zero and available funds become 75600. The hold preserves 30000 = 22000 captured + 8000 released + 0 remaining.
The settlement payable increases 22000 while cash is unchanged. Ada 75600 plus Cora 2400 plus settlement payable 22000 still equals synthetic cash 100000. These are author-derived checks from synthetic inputs, not a fee tariff or settlement observation. Independent arithmetic fixture.
The selected partial/final distinction must be explicit. A provider may require special capability to retain uncaptured funds; Stripe's capture API documents its own optional behavior. Cedar does not silently inherit a provider default.
Make commands versioned and retryable
Every command includes its full scope, hold ID, durable hold-operation ID, action and versioned schema. Reserve adds amount and expiry. Capture adds expected version, amount, an actual boolean final_capture, journal identities and economic/source/booking fields. Release and expire add expected version and release all remaining capacity.
For a new capture, the expected version must match, the hold must still be active before expiry, amount must not exceed remaining, and currently posted funds must cover that amount. Unknown keys, numeric money coercion, string booleans and malformed timestamps reject the whole candidate.
The operation identity is retained separately from the hold identity and capture journal identity. An exact accepted retry returns the original complete result, even after the hold later closes or current policy changes. A changed command under that identity conflicts. A failed attempt leaves no accepted identity, so its corrected retry can proceed. Idempotent Journal Posting explains that recovery boundary in detail.
Scope authorization still precedes replay; stable identity is not permission to retrieve another ledger's result. A newly identified capture also cannot adopt an independently posted journal as its own new side effect.
Commit the hold and journal together
In the PostgreSQL profile, all state-changing paths lock the same ledger-control row. The function checks trusted processing time after obtaining the lock, so a waiting capture cannot rely on when its client originally sent the request.
For capture, the function stages the new hold projection/version, builds the exact two-leg journal, invokes the reviewed posting core, appends an immutable hold event and accepted-operation result, then commits every effect together. If the journal's period validation fails, even the staged hold update rolls back. PostgreSQL's transaction boundary provides the platform mechanism; the acceptance test verifies this concrete implementation. Transactions, locking.
Reserve/release/expiry produce hold events but no financial journal. A separate hold-event counter therefore advances on these operations, while the journal sequence advances only when capture posts. Exact retries advance neither. The mutable hold projection is distinct from the immutable accepted event and journal history.
The hold-service login can execute the reviewed API and authorized current snapshot export. It cannot directly modify tables, switch to owner or call raw posting. Owner/bootstrap and unrelated posting authorities remain an explicit boundary: a hold guard cannot force an external debit path to obey a policy it did not invoke.
The current snapshot exporter also refuses to label a later projection with an earlier clock. Under the ledger lock it checks actual time against both journal and hold recording watermarks. It never reconstructs old hold state by filtering a current terminal row. Recording timestamps inside transactions are not exact physical COMMIT timestamps.
Verify the behavior on both surfaces
The portable Python/TypeScript models pass 54 scenarios / 98 complete actions with exact state/result/reason/nullability parity. Cases include partial/final/full capture, exact exhaustion, duplicate commands, release, expiry equality and one millisecond before, malformed money/booleans, huge integers, stale versions and invalid-journal rollback.
Those copied snapshots do not establish actual database contention or durability. The separate PostgreSQL 18.1 run uses its own task-owned loopback cluster and restricted authenticated sessions. Its 23 named groups verify competing reservations, duplicate captures producing one journal, capture/release in both orders, time checked after a real lock wait, staged hold/journal rollback, original replay after changed policy, direct-write denial, durable restart and the recording-watermark guards. SQL evidence, portable evidence, validation record.
The SQL test observes real processing before/after expiry; exact equality is deterministic in the portable cases. Controlled restart is not a hardware power-loss experiment. Local durability settings and role restrictions do not establish independent retention or production scalability.
Open the guided reservation lab. It bundles the actual TypeScript reference, records its source hash and lets you switch retained versus final first capture. Step the delayed-expiry worker, then compare releasing after a partial capture and an invalid journal. The static bridge above preserves the explanation without scripts.
The base SQL and reservation extension reproduce the isolated profile. The supplied runner initializes its own cluster rather than accepting a production DSN. The two actual current snapshots exported after partial and final capture are available separately for integration with the availability reader; the final snapshot closes the hold exactly.
The complete observed journal export also contains an independently read ledger-head sequence and a trusted first archive-observation receipt after acknowledged writes. It supports replay of the posted balance without inventing earlier archive availability or treating an in-transaction recording timestamp as physical COMMIT time.
Use the reservation for the decision it owns
This product reserves outgoing capacity and records exactly what was captured or released. It excludes incremental authorizations, hold extensions, partial releases, FX, overdraft facilities and card-network rule replication. Negative availability from unrelated external posting remains visible instead of being hidden by a clamp.
Historical example: not useful. Reproducible synthetic clocks, amounts and failure states teach the contract without invented customer incidents. Continue to Daily Simple Interest Accrual for a different calculation on trustworthy posted balances; a pending hold is not silently converted into a posted debit or interest rule.
Rendered from the canonical Mermaid sources linked by this article.
Capture and hold update are one committed operation
The stage between the hold update and journal validation is not a visible committed state. The PostgreSQL test deliberately fails the journal there and verifies complete rollback.
Reservation lifecycle
Stored terminal state and time effectiveness are deliberately separate.
Every new transition needs the expected version and a fresh durable operation identity. Exact retries replay the prior result instead of taking this state machine a second time.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
- Accessed: 25 October 2026.
- Supports: source-specific technical behavior below; Cedar's selected policy remains an implementation choice.
- Limitations: no private customer data, universal network policy, legal recognition advice, production throughput or physical power-loss claim.
| ID | Organization, type and version | Primary source | Supports and applicability | Limitations |
|---|---|---|---|---|
| R01 | PostgreSQL Global Development Group; official technical docs; 18 | Transactions | Atomic grouped changes, rollback and COMMIT acknowledgement | Does not prove this implementation passed |
| R02 | Same; 18 | Explicit Locking, Isolation | Real-session row-lock lifetime and snapshot behavior | Per-ledger serialization is our chosen design |
| R03 | Same; 18 | Privileges, Safe Definer Functions | Separate owner/runtime scope and trusted search path | Owner/infrastructure authority excluded |
| R04 | Same; 18 | WAL Configuration, Date/Time Functions | Local durability settings and timestamp-evaluation semantics | Recording time is not physical COMMIT; no replica/power-loss proof |
| R05 | Stripe; official provider documentation; accessed snapshot | Separate authorization/capture | Bounded practical distinction between reservation and later capture | Cedar adopts no published authorization-window values or network rules |
| R06 | Stripe; official API reference; accessed snapshot | Capture API | Provider-specific optional retained remainder/final-capture comparison | Eligibility/capabilities are provider-specific; not a Cedar API integration |
Technical platform sources have no regulatory jurisdiction claim. Stripe material is a provider comparison, not a universal banking accounting method. USD exponent 2 is retained from Stage 0's researched currency contract. Historical case: not useful; transactions are fictional synthetic inputs with independently specified arithmetic. Public artifacts contain no real account or credential data.
Local reproducible results are in tests/parity-results.json and tests/postgresql-results.json; the actual PostgreSQL run is 18.1 without a latest-patch claim. SQL/service settings and trust boundaries apply only to that isolated teaching profile/run. Stage 1's core is copied locally and versioned in this package, with no runtime sibling imports or edits to the original package.
Full dependency-light reference implementations in both supported languages.
/** Portable committed-snapshot model. Actual DB permissions/durability need PostgreSQL tests. */
export type Account = {account_id:string;ledger_id:string;entity_id:string;currency:string;account_type:string;normal_side:string;status:string};
export type Leg = {account_id:string;side:'debit'|'credit';amount_minor:string};
export type Journal = {schema_version:string;ledger_id:string;entity_id:string;journal_id:string;business_operation_id:string;currency:string;effective_at:string;source_available_at:string;booking_period:string;legs:Leg[]};
export type PostingResult = {status:'committed';journal_id:string;business_operation_id:string;sequence:string;recorded_at:string;debit_minor:string;credit_minor:string;residual_minor:string;movements:{account_id:string;raw_delta_minor:string;normal_delta_minor:string}[];adjustment:{authorized:boolean;reason:string};command:Journal};
export type State = {ledger_id:string;entity_id:string;accounts:Account[];last_sequence:string;last_recorded_at:string;journals:{command:Journal;result:PostingResult}[]};
export type Context = {recorded_at:string;open_periods:string[];role?:string;authorized_scopes?:string[][];allow_current_period_adjustment?:boolean;adjustment_reason?:string};
export type Transition = {status:'committed'|'replayed'|'rejected';reason:string|null;result:PostingResult|null;state:State};
type ObjectValue = Record<string,unknown>;
const FIELDS=['schema_version','ledger_id','entity_id','journal_id','business_operation_id','currency','effective_at','source_available_at','booking_period','legs'];
const IDENTIFIER=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const AMOUNT=/^[1-9][0-9]{0,29}$/;
// Keep grammar acceptance explicit by comparing the complete matched input.
const fullmatch=(pattern:RegExp,value:string)=>pattern.exec(value)?.[0]===value;
// Match Python str.strip exactly: ECMAScript trim omits NEL/control separators and strips BOM.
const pythonStrip=(value:string)=>value.replace(/^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g,'');
const clone=<T>(value:T):T=>JSON.parse(JSON.stringify(value));
const object=(value:unknown):value is ObjectValue=>typeof value==='object' && value!==null && !Array.isArray(value);
const keys=(value:ObjectValue,expected:string[])=>Object.keys(value).sort().join('|')===[...expected].sort().join('|');
// Property order is irrelevant; array order is preserved. This is not cryptographic canonicalization.
function same(a:unknown,b:unknown):boolean{
if(Array.isArray(a)||Array.isArray(b))return Array.isArray(a)&&Array.isArray(b)&&a.length===b.length&&a.every((v,i)=>same(v,b[i]));
if(object(a)||object(b))return object(a)&&object(b)&&Object.keys(a).length===Object.keys(b).length&&Object.keys(a).every(k=>Object.hasOwn(b,k)&&same(a[k],b[k]));
return Object.is(a,b);
}
function stamp(value:unknown):value is string {
if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)||value.startsWith('0000'))return false;
const date=new Date(value);
return Number.isFinite(date.getTime())&&date.toISOString()===value;
}
function reject(reason:string):never{throw new Error(reason)}
export function initialState(accounts:Account[],ledger_id='CB-GENERAL',entity_id='CB-A'):State{
return {ledger_id,entity_id,accounts:clone(accounts),last_sequence:'0',last_recorded_at:'0001-01-01T00:00:00.000Z',journals:[]};
}
function validate(raw:unknown,state:State,context:Context){
if(!object(raw)||!keys(raw,FIELDS))reject('INVALID_SCHEMA');
for(const k of FIELDS.filter(k=>k!=='legs'))if(typeof raw[k]!=='string')reject('INVALID_SCHEMA');
const command=raw as unknown as Journal;
if(command.schema_version!=='d30-journal-v1')reject('UNSUPPORTED_SCHEMA');
for(const value of [command.ledger_id,command.entity_id,command.journal_id,command.business_operation_id])if(!fullmatch(IDENTIFIER,value))reject('INVALID_IDENTIFIER');
if(!stamp(command.effective_at)||!stamp(command.source_available_at)||!stamp(context.recorded_at))reject('INVALID_TIMESTAMP');
if(context.recorded_at<state.last_recorded_at)reject('RECORDED_TIME_REGRESSION');
if(command.effective_at>context.recorded_at)reject('FUTURE_EFFECTIVE_TIME');
if(command.source_available_at>context.recorded_at)reject('FUTURE_SOURCE_AVAILABILITY');
if(!fullmatch(/^\d{4}-(0[1-9]|1[0-2])$/,command.booking_period)||command.booking_period.startsWith('0000'))reject('INVALID_SCHEMA');
if(!context.open_periods.includes(command.booking_period))reject('PERIOD_CLOSED');
const adjusted=command.booking_period!==command.effective_at.slice(0,7);
if(adjusted&&!(context.allow_current_period_adjustment===true&&typeof context.adjustment_reason==='string'&&pythonStrip(context.adjustment_reason)))reject('UNAUTHORIZED_PERIOD_ADJUSTMENT');
if(!['USD','EUR'].includes(command.currency))reject('UNSUPPORTED_CURRENCY');
if(!Array.isArray(command.legs)||command.legs.length<2||command.legs.length>32)reject('INVALID_LEGS');
let debit=0n,credit=0n;
const movements=new Map<string,{raw:bigint;normal:string}>();
for(const rawLeg of command.legs as unknown[]){
if(!object(rawLeg)||!keys(rawLeg,['account_id','side','amount_minor']))reject('INVALID_SCHEMA');
if(typeof rawLeg.account_id!=='string'||!fullmatch(IDENTIFIER,rawLeg.account_id))reject('INVALID_IDENTIFIER');
if(typeof rawLeg.side!=='string'||!['debit','credit'].includes(rawLeg.side))reject('INVALID_SIDE');
if(typeof rawLeg.amount_minor!=='string'||!fullmatch(AMOUNT,rawLeg.amount_minor))reject('INVALID_AMOUNT');
const leg=rawLeg as unknown as Leg;
const accounts=state.accounts.filter(a=>a.account_id===leg.account_id);
const account=accounts.find(a=>a.ledger_id===command.ledger_id&&a.entity_id===command.entity_id);
if(!account)reject(accounts.length?'ACCOUNT_SCOPE_MISMATCH':'UNKNOWN_ACCOUNT');
if(account.status!=='active')reject('ACCOUNT_CLOSED');
if(account.currency!==command.currency)reject('CURRENCY_MISMATCH');
const amount=BigInt(leg.amount_minor);
if(leg.side==='debit')debit+=amount;else credit+=amount;
const delta=leg.side==='debit'?amount:-amount;
movements.set(leg.account_id,{raw:(movements.get(leg.account_id)?.raw??0n)+delta,normal:account.normal_side});
}
if(movements.size<2)reject('DISTINCT_ACCOUNTS_REQUIRED');
if(debit!==credit)reject('UNBALANCED_JOURNAL');
return {debit_minor:debit.toString(),credit_minor:credit.toString(),residual_minor:'0',
movements:[...movements].map(([account_id,v])=>({account_id,raw_delta_minor:v.raw.toString(),normal_delta_minor:(v.normal==='debit'?v.raw:-v.raw).toString()})),
adjustment:{authorized:adjusted,reason:adjusted?(context.adjustment_reason??''):''}};
}
export function transition(state:State,request:unknown,context:Context):Transition{
const denied=(reason:string):Transition=>({status:'rejected',reason,result:null,state});
const role=context.role??'writer';
if(role==='owner')return denied('ADMINISTRATOR_BOUNDARY');
if(role!=='writer')return denied('ROLE_FORBIDDEN');
if(!object(request)||typeof request.action!=='string')return denied('INVALID_SCHEMA');
if(request.action!=='append')return denied('APPEND_ONLY_VIOLATION');
if(!keys(request,['action','command'])||!object(request.command))return denied('INVALID_SCHEMA');
const command=request.command;
const allowed=context.authorized_scopes??[[state.ledger_id,state.entity_id]];
if(!allowed.some(s=>s[0]===command.ledger_id&&s[1]===command.entity_id)||state.ledger_id!==command.ledger_id||state.entity_id!==command.entity_id)return denied('SCOPE_FORBIDDEN');
const old=state.journals.find(j=>j.command.business_operation_id===command.business_operation_id);
if(old){
if(!same(old.command,command))return denied('IDEMPOTENCY_CONFLICT');
return {status:'replayed',reason:null,result:clone(old.result),state};
}
if(state.journals.some(j=>j.command.journal_id===command.journal_id))return denied('JOURNAL_ID_CONFLICT');
try{
const detail=validate(command,state,context);
const updated=clone(state);
const sequence=(BigInt(state.last_sequence)+1n).toString();
const result:PostingResult={status:'committed',journal_id:String(command.journal_id),business_operation_id:String(command.business_operation_id),sequence,recorded_at:context.recorded_at,...detail,command:clone(command) as unknown as Journal};
updated.last_sequence=sequence;updated.last_recorded_at=context.recorded_at;
updated.journals.push({command:clone(command) as unknown as Journal,result:clone(result)});
return {status:'committed',reason:null,result,state:updated};
}catch(error){return denied((error as Error).message)}
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
