sacral
Sacral 2.0

Embedded banking intelligence.

Sacral carries customer work from the first interaction to completion, with controls and evidence built into every step.

Correct, not convincing.

Every claim points to working code, test evidence or a clearly marked product definition.

CaptureStart with the customer interaction.
CompleteMove the case through the bank's checks and systems.
RecordKeep the evidence for what happened and what comes next.
Products
One system across the customer journey.

Start with onboarding. Extend the same controls, evidence and case record across deposits, field banking, lending, cards and service.

Runnable synthetic demo

Intelligent assisted onboarding

Run a synthetic customer case through consent, cited fact capture, policy checks, exception routing and a simulated core write. No real customer data or bank system is connected.

Run the demo →

What works now

Four synthetic customer cases; consent and required-field checks; review and decline routing; a simulated, idempotent core write; a cited product recommendation; automated contract and cohort tests.

Not connected yet

Production identity or KYC providers; bureau or core-banking systems; persistent storage; live model inference; production monitoring, security and compliance controls.

Repository

Working code, tests and boundaries.

Correct, not convincing. Browse the code and evidence behind each product. Every runnable product states what works, what is simulated and what is not connected yet. All customer records are synthetic.

Runnable codeProduct engines and browser demos
Automated testsContract, edge-case and cohort coverage
Clear boundariesSynthetic data and simulated bank adapters
READMEREADME.mdView file
README.md
# Sacral banking demonstrations

Runnable code and synthetic test evidence for Sacral's banking product line. The implemented product is **intelligent assisted onboarding**. The other six products are defined as product directions and are not represented as working demos.

## Run it

Requires Node.js 20 or newer and no third-party packages.

```bash
npm test
npm run demo
# open http://localhost:4173
```

## What is implemented

- a deterministic onboarding workflow engine;
- explicit consent blocking;
- cited facts with confidence scores;
- market selection isolated from the common workflow;
- missing-field, low-confidence and prohibited-feature controls;
- idempotent simulated core writeback;
- evidence-backed next action;
- ordered audit events;
- an interactive browser demo driven by four synthetic fixtures;
- adaptation of the GIFT One behavioral Indian cohort generator under its synthetic-only restrictions;
- a deterministic 10,000-customer cohort runner;
- 14 executable tests covering fixtures, consent, KYC gating, source provenance, missing fields, resume, idempotency, market isolation and audit sequencing;
- a Locust workload for the real onboarding HTTP endpoint.

The core adapter is an in-memory simulator. No production bank, bureau, identity provider or core banking system is connected. No real customer data is included.

## Repository

- `apps/onboarding/` runnable browser demonstration and local server
- `packages/workflow/` workflow engine
- `fixtures/` synthetic scenarios
- `tests/` executable tests
- `load/` Locust workload
- `evidence/` generated synthetic-cohort and load results
- `docs/` architecture, boundaries and evidence

## Product universe

| Product | Current evidence |
| --- | --- |
| Intelligent assisted onboarding | Runnable demo and executable tests |
| Deposit onboarding | Product definition only |
| Correspondent banking | Product definition only |
| Lending | Product definition only |
| Card onboarding | Product definition only |
| Customer intelligence | Product definition only |
| Community bank platform | Product definition only |
Workflow enginepackages/workflow/engine.mjsView file
packages/workflow/engine.mjs
const requiredByMarket={IN:['fullName','dateOfBirth','taxId','consent'],US:['fullName','dateOfBirth','taxId','consent']};
const allowedMarkets=new Set(Object.keys(requiredByMarket));
const copy=value=>JSON.parse(JSON.stringify(value));

export class MemoryCoreAdapter {
  #records=new Map();
  write(caseId,payload,idempotencyKey){
    const prior=this.#records.get(idempotencyKey);
    if(prior) return {created:false,record:copy(prior)};
    const record={coreRecordId:`core-${caseId}`,caseId,payload:copy(payload)};
    this.#records.set(idempotencyKey,record);
    return {created:true,record:copy(record)};
  }
  count(){return this.#records.size}
}

export function createCase(input){
  if(!allowedMarkets.has(input.market)) throw new Error(`Unsupported market: ${input.market}`);
  if(!input.id||!input.channel) throw new Error('id and channel are required');
  return {id:input.id,market:input.market,channel:input.channel,status:'captured',facts:{},exceptions:[],audit:[event('case.captured','system',{channel:input.channel})],coreWrite:null,nextAction:null};
}

export function captureConsent(state,{granted,sourceRef}){
  const next=copy(state);
  next.facts.consent=fact(Boolean(granted),sourceRef,1);
  next.status=granted?'consented':'blocked';
  if(!granted) next.exceptions=upsert(next.exceptions,{code:'MISSING_CONSENT',owner:'banker',reason:'Consent is required before interaction data can be used.'});
  next.audit.push(event(granted?'consent.granted':'consent.denied','customer',{sourceRef}));
  return next;
}

export function addFacts(state,facts){
  const next=copy(state);
  if(next.facts.consent?.value!==true) return block(next,'MISSING_CONSENT','banker','Consent is required before facts can be captured.');
  for(const [key,item] of Object.entries(facts)){
    if(!item.sourceRef) throw new Error(`${key} requires sourceRef`);
    const confidence=Number(item.confidence);
    if(!Number.isFinite(confidence)||confidence<0||confidence>1) throw new Error(`${key} confidence must be between 0 and 1`);
    next.facts[key]=fact(item.value,item.sourceRef,confidence);
  }
  next.status='facts_captured';
  next.audit.push(event('facts.captured','assistant',{fields:Object.keys(facts)}));
  return next;
}

export function evaluate(state){
  let next=copy(state); next.exceptions=[];
  if(next.facts.consent?.value!==true) return block(next,'MISSING_CONSENT','banker','Consent is required before evaluation.');
  for(const field of requiredByMarket[next.market]) if(next.facts[field]?.value===undefined) next=block(next,'MISSING_FIELD','banker',`${field} is required.`);
  for(const [field,item] of Object.entries(next.facts)) if(item.confidence<0.8) next=block(next,'LOW_CONFIDENCE','reviewer',`${field} requires review.`,{field,confidence:item.confidence});
  const prohibited=['race','religion','gender'];
  for(const field of prohibited) if(next.facts[field]!==undefined) next=block(next,'PROHIBITED_FEATURE','compliance',`${field} cannot be used for eligibility.`,{field});
  const kyc=next.facts.kycOutcome?.value;
  if(!kyc) next=block(next,'MISSING_KYC_OUTCOME','reviewer','A cited KYC outcome is required.');
  else if(kyc==='review') next=block(next,'KYC_MANUAL_REVIEW','reviewer','KYC provider returned manual review.');
  else if(kyc==='decline') next=block(next,'KYC_DECLINE','compliance','KYC provider returned decline.');
  else if(kyc!=='pass') next=block(next,'KYC_INDETERMINATE','reviewer','Unknown KYC outcome cannot proceed.');
  if(next.facts.suspicious?.value===true) next=block(next,'DOWNSTREAM_RISK_REVIEW','compliance','KYC passed, but downstream risk control requires review.');
  next.status=next.exceptions.length?'needs_review':'ready_to_complete';
  next.audit.push(event('policy.evaluated','policy-engine',{market:next.market,outcome:next.status,exceptionCodes:next.exceptions.map(x=>x.code)}));
  return next;
}

export function complete(state,adapter){
  let next=evaluate(state);
  if(next.status!=='ready_to_complete') return next;
  const payload=Object.fromEntries(Object.entries(next.facts).filter(([key])=>!['consent','race','religion','gender'].includes(key)).map(([key,item])=>[key,item.value]));
  const write=adapter.write(next.id,payload,`onboarding:${next.id}`);
  next.coreWrite=write.record;
  next.status='completed';
  next.nextAction=recommend(next);
  next.audit.push(event('core.write','system',{created:write.created,recordId:write.record.coreRecordId,idempotencyKey:`onboarding:${next.id}`}));
  if(next.nextAction) next.audit.push(event('next_action.recommended','assistant',next.nextAction));
  return next;
}

export function runScenario(scenario,adapter=new MemoryCoreAdapter()){
  let state=createCase(scenario);
  state=captureConsent(state,{granted:scenario.consent,sourceRef:'interaction:consent'});
  if(scenario.consent) state=addFacts(state,scenario.facts||{});
  return complete(state,adapter);
}

function recommend(state){
  const need=state.facts.statedNeed?.value;
  if(need==='everyday_spend') return {code:'CARD_DISCOVERY',reason:'Customer stated a need for everyday spending during the interaction.',sourceRefs:[state.facts.statedNeed.sourceRef]};
  if(need==='initial_funding') return {code:'FUND_ACCOUNT',reason:'The opened account requires an initial funding step.',sourceRefs:[state.facts.statedNeed.sourceRef]};
  return null;
}
function fact(value,sourceRef,confidence){return {value,sourceRef,confidence}}
function block(state,code,owner,reason,details={}){state.exceptions=upsert(state.exceptions,{code,owner,reason,...details});state.status='blocked';state.audit.push(event('exception.raised','policy-engine',{code,owner,reason,...details}));return state}
function upsert(items,item){return [...items.filter(x=>!(x.code===item.code&&x.field===item.field)),item]}
function event(type,actor,details){return {sequence:null,type,actor,details}}
export function finalizeAudit(state){const next=copy(state);next.audit=next.audit.map((x,i)=>({...x,sequence:i+1}));return next}
Executable teststests/workflow.test.mjsView file
tests/workflow.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {MemoryCoreAdapter,createCase,captureConsent,addFacts,evaluate,complete,runScenario,finalizeAudit} from '../packages/workflow/engine.mjs';
const scenarios=JSON.parse(fs.readFileSync(new URL('../fixtures/onboarding-cases.json',import.meta.url)));
for(const scenario of scenarios)test(`scenario ${scenario.id}`,()=>{const result=runScenario(scenario);assert.equal(result.status,scenario.expectedStatus);if(scenario.expectedException)assert.ok(result.exceptions.some(x=>x.code===scenario.expectedException));if(scenario.expectedNextAction)assert.equal(result.nextAction?.code,scenario.expectedNextAction)});
test('source references are mandatory',()=>{let state=createCase({id:'source',market:'IN',channel:'video'});state=captureConsent(state,{granted:true,sourceRef:'interaction:1'});assert.throws(()=>addFacts(state,{fullName:{value:'Test',confidence:1}}),/sourceRef/)});
test('missing fields are explicit',()=>{let state=createCase({id:'missing',market:'US',channel:'branch'});state=captureConsent(state,{granted:true,sourceRef:'interaction:1'});state=evaluate(state);assert.equal(state.status,'needs_review');assert.ok(state.exceptions.some(x=>x.code==='MISSING_FIELD'))});
test('completion is idempotent',()=>{const adapter=new MemoryCoreAdapter();const first=runScenario(scenarios[0],adapter);const second=complete(first,adapter);assert.equal(first.coreWrite.coreRecordId,second.coreWrite.coreRecordId);assert.equal(adapter.count(),1);assert.equal(second.audit.at(-2).details.created,false)});
test('paused state resumes without losing facts',()=>{let state=createCase({id:'resume',market:'IN',channel:'field'});state=captureConsent(state,{granted:true,sourceRef:'interaction:1'});state=addFacts(state,{fullName:{value:'Test Person',sourceRef:'interaction:2',confidence:.99}});const serialized=JSON.stringify(state);const resumed=JSON.parse(serialized);assert.equal(resumed.facts.fullName.value,'Test Person');assert.equal(evaluate(resumed).status,'needs_review')});
test('market rules reject unknown market',()=>assert.throws(()=>createCase({id:'x',market:'GB',channel:'phone'}),/Unsupported market/));
test('audit sequence is deterministic',()=>{const result=finalizeAudit(runScenario(scenarios[0]));assert.deepEqual(result.audit.map(x=>x.sequence),result.audit.map((_,i)=>i+1));assert.ok(result.audit.every(x=>x.type&&x.actor))});
Cohort adapterpackages/cohort/adapter.mjsView file
packages/cohort/adapter.mjs
import {generatePopulation} from './gift-one-synthetic.js';
import {MemoryCoreAdapter,runScenario} from '../workflow/engine.mjs';
export function cohortScenario(customer,{consent=true,consentVersion='sacral-demo-v1'}={}){
  const source=`provider:synthetic-kyc:${customer.id}`;
  return {id:customer.id,market:'IN',channel:'synthetic-cohort',consent,facts:{
    fullName:{value:`Synthetic Customer ${customer.id}`,sourceRef:`synthetic:${customer.id}:name`,confidence:1},
    dateOfBirth:{value:'1990-01-01',sourceRef:`synthetic:${customer.id}:dob`,confidence:1},
    taxId:{value:`SYNTH-${customer.id}`,sourceRef:`synthetic:${customer.id}:tax`,confidence:1},
    consentVersion:{value:consentVersion,sourceRef:`consent:${customer.id}`,confidence:1},
    kycOutcome:{value:customer.kyc,sourceRef:source,confidence:1},
    suspicious:{value:customer.suspicious,sourceRef:`risk:synthetic:${customer.id}`,confidence:1}
  }};
}
export function runCohort({count=10000,seed=20260915}={}){
 const customers=generatePopulation(count,seed),adapter=new MemoryCoreAdapter(),results=[];
 for(const customer of customers){const result=runScenario(cohortScenario(customer),adapter);results.push({id:customer.id,kyc:customer.kyc,suspicious:customer.suspicious,status:result.status,exceptionCodes:result.exceptions.map(x=>x.code),coreRecordId:result.coreWrite?.coreRecordId||null});}
 const summary={seed,count,completed:results.filter(x=>x.status==='completed').length,review:results.filter(x=>x.status==='needs_review').length,blocked:results.filter(x=>x.status==='blocked').length,coreWrites:adapter.count(),kycPass:customers.filter(x=>x.kyc==='pass').length,kycReview:customers.filter(x=>x.kyc==='review').length,kycDecline:customers.filter(x=>x.kyc==='decline').length,suspiciousPass:customers.filter(x=>x.kyc==='pass'&&x.suspicious).length};
 return {summary,results};
}
Locust workloadload/locustfile.pyView file
load/locustfile.py
from locust import HttpUser, task, between
import random
class SacralSyntheticCustomer(HttpUser):
    wait_time=between(0.01,0.05)
    abstract=False
    def on_start(self): self.r=random.Random(20260915+self.environment.runner.user_count)
    @task
    def onboard(self):
        risk=self.r.randrange(100); kyc='decline' if risk>=98 else 'review' if risk>=94 else 'pass'; suspicious=self.r.random()<.027; cid=f'LOC-{self.r.randrange(10**9):09d}'
        scenario={'id':cid,'market':'IN','channel':'locust-synthetic','consent':True,'facts':{
          'fullName':{'value':f'Synthetic {cid}','sourceRef':f'synthetic:{cid}:name','confidence':1},'dateOfBirth':{'value':'1990-01-01','sourceRef':f'synthetic:{cid}:dob','confidence':1},'taxId':{'value':f'SYNTH-{cid}','sourceRef':f'synthetic:{cid}:tax','confidence':1},'kycOutcome':{'value':kyc,'sourceRef':f'provider:synthetic:{cid}','confidence':1},'suspicious':{'value':suspicious,'sourceRef':f'risk:synthetic:{cid}','confidence':1}}}
        with self.client.post('/api/onboarding',json=scenario,name='/api/onboarding',catch_response=True) as response:
            if response.status_code!=200: response.failure(f'HTTP {response.status_code}'); return
            data=response.json(); expected='completed' if kyc=='pass' and not suspicious else 'needs_review'
            if data.get('status')!=expected: response.failure(f"expected {expected}, got {data.get('status')}")
Synthetic evidenceevidence/README.mdView file
evidence/README.md
# Synthetic evidence

Generated September 20, 2026. This is local demonstration evidence, not production, bank or customer evidence.

## Contract tests

`npm test` passed 14 of 14 tests. The suite covers four hand-built edge fixtures plus the deterministic 10,000-customer Indian behavioral cohort, fail-closed consent and KYC handling, cited facts, missing fields, low confidence, prohibited features, resume, idempotent writeback, market isolation and ordered audit events.

## Cohort run

Command: `npm run cohort` using count 10,000 and seed 20260915.

- 9,366 KYC pass
- 409 KYC manual review
- 225 KYC decline
- 258 KYC-pass records also flagged by the separate synthetic downstream risk control
- 9,108 completed onboarding workflows and simulated core writes
- 892 routed to owned review or decline handling
- 0 review, decline or suspicious records created a simulated core record

These are deterministic synthetic results, not business or production metrics.

## Locust load run

Locust 2.31.6 drove the implemented `POST /api/onboarding` endpoint locally with 25 concurrent synthetic users, ramped at 5 users/second, for 20 seconds.

The persisted CSV interval recorded 12,967 requests, 0 failures, 2 ms median, 5 ms p95 and 29 ms maximum. The terminal shutdown summary recorded 13,432 total requests and 0 failures. The difference is the final partial CSV sampling interval; both raw outputs are retained. These local timings do not predict production performance.
Cohort summaryevidence/cohort-summary.jsonView file
evidence/cohort-summary.json
{
  "seed": 20260915,
  "count": 10000,
  "completed": 9108,
  "review": 892,
  "blocked": 0,
  "coreWrites": 9108,
  "kycPass": 9366,
  "kycReview": 409,
  "kycDecline": 225,
  "suspiciousPass": 258,
  "durationMs": 261.29
}
Locust statisticsevidence/locust_stats.csvView file
evidence/locust_stats.csv
Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%
POST,/api/onboarding,12967,0,2,2.3989150230611727,0.8243360002779809,28.85895999997956,955.9868897971775,681.8648709118384,0.0,2,3,3,3,4,5,6,7,21,28,29
,Aggregated,12967,0,2,2.3989150230611727,0.8243360002779809,28.85895999997956,955.9868897971775,681.8648709118384,0.0,2,3,3,3,4,5,6,7,21,28,29
How it works

One case from interaction to completion.

Sacral keeps the customer conversation, captured facts, policy checks, exceptions, system actions and case record together. Market adapters handle India and US requirements without splitting the product into separate codebases.

India and the United States

Start with proven banking work. Build a repeatable product.

Decimal brings 20 banking relationships in India, roughly ₹70 crore in revenue, 16 years of delivery history and repeat customers. Sacral adds a common product architecture for India and the United States.

India

Expand within trusted bank relationships.

Turn proven delivery into common products that can serve more teams and more banks.

Product

Build once. Improve across deployments.

Keep the interaction, application, checks, exceptions, completion and case record in one product.

United States

Serve community banks with the same core.

Adapt identity, compliance and core connections for the US while keeping one product and one codebase.

Product path

Assisted onboarding: Extend deployed video banking across video, phone, branch and field channels.

Deposits: Turn account-opening work into one configurable onboarding and service product.

Lending: Add cited document checks, policy decisions and clear review routing around existing loan systems.

Cards: Connect consent, identity, eligibility and application routing.

Customer intelligence: Keep a cited record of needs, actions and outcomes across every journey.

Community bank platform: Connect the product line behind the bank's own brand.

Product model

AI inside the banking workflow, not beside it.

Sacral starts with the customer interaction, carries the case through checks and exceptions, writes to bank systems and keeps the evidence for what happened.

One case. One record. Clear responsibility.

Each fact points to its source. Each exception has an owner. Each system action is recorded. People keep the approvals and judgment that regulation requires.

Finish the work, not one task.

A faster employee tool does not help when the case still waits at every handoff. Sacral keeps the work moving across the full case.

A new bank should not require a new codebase. The same core should handle the workflow, with clear market and bank adapters around it.

Synthetic test evidence · local environment

Reproducible tests. Limited claims.

A result is shown only when it can be reproduced. Everything else is labeled as a product definition or proof of concept.

Cohort evidence

A fixed-seed run used 10,000 synthetic customer records. 9,108 completed the simulated workflow. 892 were routed to review or decline. No review, decline or downstream-risk case created a simulated core record.

Local load test

Locust ran 25 concurrent synthetic users against the local onboarding endpoint for 20 seconds. The recorded interval contained 12,967 requests with no failures, a 2 ms median and a 5 ms p95. These are local test results, not production benchmarks.

The source, test contract, synthetic cohort and raw results are available in Repository.

Lead product

Intelligent video onboarding

Workflow, controls and evidence.