D30-F05-A01 / Released engineering topic

Append-Only Journal Enforcement with PostgreSQL Roles and a Complete-Journal API

Use PostgreSQL roles and a complete-journal API to preserve accepted ledger history with an explicit owner trust boundary.

Append-Only Journal Enforcement with PostgreSQL Roles and a Complete-Journal APID30 / D30-F05

A useful ledger lets an operator inspect what the system accepted before deciding how to correct it. That becomes difficult when the same service account can quietly update a journal's amount, delete its inconvenient predecessor, or truncate a table.

Append-only journal enforcement gives that service a narrower capability: submit a complete new journal through a reviewed entry point. Accepted records stay available for inspection, and mistakes receive new linked records. The practical result is a clearer history for operations staff and a testable write boundary for engineers.

This tutorial builds a bounded PostgreSQL 18 teaching profile and compares it with portable Python/TypeScript models. All accounts and amounts belong to fictional Cedar Teaching Bank. There are no real customer records or claims of legal retention compliance.

Start with the actor

“The journal is immutable” hides a material question: immutable to whom?

Our runtime writer cannot directly insert, update, delete or truncate protected journal tables. It can execute a function that validates and appends a complete journal. A separate no-login owner controls schema objects and grants. That owner remains capable of changing the controls. PostgreSQL's privilege model makes this distinction explicit: ordinary grants and object ownership are different kinds of authority. Official privileges documentation.

The diagram places that owner outside the routine path. Notice that the new fee becomes a new journal; it does not replace the original funding.

Runtime append API and explicit owner boundary

Open this diagram at full size.

The operational promise is precise: the named service role's granted capabilities cannot rewrite previously accepted records. Independently retained commitments, backup policy, and administrator investigations require additional controls.

Preserve a prefix, then extend it

Let H be the ordered accepted history and j one complete new journal. An allowed append produces H followed by j; all old records retain their original contents. A denied mutation produces the same H. A retry of an already accepted instruction also leaves H unchanged while returning the original result.

This is a prefix invariant, not a claim that any accepted business instruction was correct. A journal can balance perfectly and still name the wrong beneficiary. The Double-Entry Posting Engine explains the account/sign checks required before a journal may be accepted.

The command contract is strict:

  • Entity/ledger, journal ID and durable business-operation ID are explicit.
  • The journal contains one supported currency and 2..32 ordered debit/credit legs, covering at least two distinct accounts.
  • Each amount is a positive canonical decimal string up to 10^30-1 minor units. Python uses integers, TypeScript bigint, and SQL exact numeric sums.
  • Accounts are trusted active records in that same scope and currency.
  • Economic time, source availability and open booking period remain distinct from server recording time.

Unknown fields, malformed timestamps, future information and unbalanced totals reject the whole candidate. A current-period adjustment to an older effective month requires explicit trusted authority and a retained reason. That is an educational policy, not a universal accounting recognition rule.

Route all runtime writes through one gate

The SQL profile stores each complete command, including its leg array, and accepted result in one journal row. A header is never committed first while later requests fill in missing legs. Non-null unique constraints protect business identity, journal identity and per-ledger sequence.

The runtime cannot bypass the gate with a direct table insert. Its allowed route resembles:

SQL
REVOKE ALL ON ALL TABLES IN SCHEMA d30
FROM PUBLIC, d30_writer, d30_reader;

GRANT USAGE ON SCHEMA d30 TO d30_writer;
GRANT EXECUTE ON FUNCTION d30.post_journal(jsonb)
TO d30_writer;

These lines are only the privilege portion. The complete migration additionally separates owner/runtime roles, removes default public function execution, restricts helper functions, and checks the authenticated session's authorized ledger scope. A caller cannot gain authority by adding "actor":"owner" to a command.

The append function uses SECURITY DEFINER with a fixed search_path of pg_catalog,d30,pg_temp. Temporary schema comes last, and protected objects are schema-qualified. Without that discipline, an attacker-writable lookalike object could change which object a privileged function reaches. The vendor documents this concern and the selective execution-grant pattern. Safe definer functions.

Enabled rejection triggers provide another guard for update/delete/truncate. They help catch accidental owner DML, but the owner can remove them. The acceptance suite deliberately demonstrates that boundary instead of treating the presence of a trigger as permanent retention.

Make acceptance a transaction result

The following flow shows the ordering of decisions. An accepted old identity is resolved before changed current account or period policy; a new command must satisfy the current rules.

All writers for one ledger lock its control row through transaction completion. That row contains a transactional counter. A rollback undoes both the counter increment and journal append. The next writer waits for commit or rollback before deciding what history it sees. PostgreSQL explicit locking.

The mutable counter is internal control state. Financial journal history remains append-only. A PostgreSQL sequence would be a different mechanism: sequence allocations can leave gaps after aborts and do not alone prove committed order. Sequence functions.

An assigned recording timestamp describes when it was evaluated inside the transaction. It is not a precise physical commit instant. A function result returned before an enclosing rollback is not accepted history. The final success boundary is acknowledged COMMIT. Transaction semantics.

Walk through an attempted rewrite

Begin with this synthetic funding journal, in USD minor units:

SideAccountAmount
DebitBank cash100000
CreditAda deposit liability100000

The bank owns cash as an asset and owes Ada a deposit liability. Both normal-side movements increase by USD1000.00. Debit does not mean “money left” in every account type.

Now the runtime tries to rewrite the original credit as 120000. The actual SQL role lacks UPDATE privilege and receives SQLSTATE 42501. In the portable teaching model, the requested mutation reports APPEND_ONLY_VIOLATION. Both representations preserve the accepted 100000 amounts; their wire error formats are intentionally distinct.

Next, append a new teaching fee journal: debit Ada's deposit liability 250, credit fee income 250. The original funding remains sequence1 and the new journal becomes sequence2. Author-derived normal movements are:

Plain text
Cash: 100000
Ada: 100000 - 250 = 99750
Fee income: 250
Accumulated debit totals = credit totals = 100250

The fee is a fictional posting example, with no claim about a bank tariff or whether a customer should be charged. If the original funding was wrong, use an authorized exact reversal and replacement, preserving its original observation. Correcting and Reversing Entry Algorithm.

Open the guided lab to step through ten attempts, then compare a large-money boundary and invalid requests. The role selector simulates trusted context; it is not a client-controlled permission field. The lab never connects to SQL. Its static fallback is the diagram and arithmetic above.

Verify the actual boundary

A browser animation cannot establish database permissions. An in-memory array cannot establish durable identity after a process restart. This package therefore keeps two evidence streams separate.

The portable Python and TypeScript suites compare 86 complete transition outputs, including reasons, nullability, original results and resulting state. Independent expected cases include 9007199254740000 + 993 = 9007199254740993, invalid amounts, denied mutations, retry after policy change and 32 maximum-value legs. Caller snapshots must remain unchanged.

The PostgreSQL runner initializes its own disposable cluster, uses actual restricted login sessions and SCRAM credentials generated in process, and does not accept an existing database DSN. Its 24 named evidence groups cover:

  • Direct runtime mutation, DDL and owner-role switch denial.
  • Two independent sessions, an observed blocking barrier, duplicate/conflicting commands, rollback and per-ledger commit order.
  • A lost response followed by unchanged retry; original replay after current account/period closure.
  • Invalid replacement rolling back its staged reversal and counter; competing corrections allowing one full reversal.
  • Committed identity surviving controlled process restart.
  • A sacrificial owner removing the trigger and changing a test record, making the trust boundary observable.

See the raw SQL evidence and parity evidence. SQL returns the stored original result with its original committed status. The portable transition wrapper can label the delivery replayed; no SQL/reducer wire-format equivalence is asserted.

The SQL run enables local fsync, full_page_writes and synchronous_commit. A process restart is still not a power-loss experiment, replica-retention test or disaster-recovery guarantee. WAL settings.

Continue from preserved history

The useful result is inspectable accepted history under a named service boundary. The owner and infrastructure remain trusted, and an accepted instruction can still be mistaken or unauthorized in the business sense. Those are explicit limits, not reasons to leave routine write privileges undefined.

Historical example: not useful for this control. Official platform documentation, reproducible synthetic transactions and observed permission tests supply the necessary evidence without invented customer incidents. The detailed validation record states the exact acceptance evidence and its limits.

Next, Idempotent Journal Posting examines the durable identity that lets a caller recover the original journal when a successful response disappears.

One controlled append path

This flow separates runtime authority, complete-journal validation and the final commit boundary.

An old accepted result is resolved before changed current policy. A returned function value remains provisional until the surrounding transaction commits. The administrator boundary is outside this runtime flow.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

Organization: PostgreSQL Global Development Group. Source type: official maintained technical documentation. Version: PostgreSQL 18. Accessed 24–25 October 2026. Jurisdiction: not applicable, technical platform scope. Actual tested local binary: 18.1, without a latest-patch claim. Documentation is platform evidence; the test outputs establish this implementation's observed behavior.

  • Accessed: 2026-10-24 through 2026-10-25.
  • Supports: platform claims identified for each source in the table below.
  • Limitations: implementation-specific choices and acceptance results require the named local evidence; no regulatory retention or hardware reliability inference.
IDSourceSupportsLimitations
R01PrivilegesSeparate grants and owner authorityNo administrator-proof retention
R02CREATE FUNCTIONSafe definer search path and selective execution grantsFunction logic and grants still need review/testing
R03TransactionsAtomic visibility, rollback and COMMIT boundaryNo hardware power-loss or replication evidence
R04Explicit LockingRow-lock lifetimePer-ledger serialization is our design
R05Sequence FunctionsSequence allocations differ from rollback-safe committed countersOur counter is separately implemented
R06Constraints, Uniqueness ChecksNon-null/unique scope; cross-row check limitations; concurrent uniquenessConstraints alone do not prove business legitimacy
R07CREATE TRIGGERUpdate/delete/truncate trigger mechanicsOwners control their trigger objects
R08WAL ConfigurationLocal durability settings and asynchronous acknowledgement limitsNot a storage-hardware experiment
R09Date/Time Functions, Transaction IsolationRecording-time evaluation and snapshotsRecording timestamp is not physical commit instant
R10initdb, pg_ctlSeparate cluster initialization/start/stopBinary discovery alone is not acceptance

Dataset and example rights

The Stage 0 independent accounting oracle contains fictional accounts and directly specified expected arithmetic; it is copied into this package for reproduction. No customer records, market-data redistribution or confidential infrastructure settings are required. Currency snapshot source context is retained in the oracle. Historical-example decision: not useful. A real incident would need reproducible permissions, transaction chronology, retained evidence and publication rights; none is invented.

Observed artifacts

tests/parity-results.json records complete portable output comparison. tests/postgresql-results.json records independent authenticated sessions against a disposable PostgreSQL 18.1 instance, including settings and cleanup. These artifacts support only the concrete implementation/run they name. They do not establish legal retention compliance, production throughput, independent completeness, power-loss safety or remote publication.

algorithm.ts
/** 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)}
}
Full-height labguided labOpen full screen
Written by

Fintech engineer building market-data and financial systems, and the author of every article, glossary record, and reference implementation on The Fintech Builder.