sentinel
portfolio / Sentinel
// a governed agentic data-science platform

Sentinel.

Pick a banking question and hit Run. A model writes the analysis code, a static gate reads that code before the machine does, a sandbox executes it, and a disclosure screen strips small cells out of the result before anything narrates it. Every step emits an audit event, several of the controls can refuse, and on a normal run at least two of them do.

Sentinel is a public, clickable demo of one idea: in a regulated bank, the blocker on agentic AI is not capability, it is governance.

A model that a regulator cannot audit, that reads a column it should not, that promotes itself without a human signature, or that ships without a fairness review and a model-risk document, does not go to production in a bank. It stalls at the demo. The gap is not a better model. It is the harness around the model: the controls that make an autonomous analysis defensible to a second line of defence, an internal auditor, and an examiner.

So Sentinel treats governance as the product rather than the paperwork. The machine learning underneath is deliberately boring, a logistic-regression baseline on a public credit dataset. What is not boring is that the same run produces the evidence a bank would need to trust it: an append-only audit log, a column-level access decision, a PII redaction, a human approval gate that the author of the run cannot clear themselves, an eval gate, a fairness disparity number, and a generated model card sized like an SR 11-7 document.

The analysis is always real. Metrics move with the dataset and the seed. The narration is scripted by default, so the public link costs about zero and cannot be run up by a stranger, and the app says so on screen rather than implying live reasoning it is not doing. A live-model path exists behind a hard spend cap.

9stages, one vocabulary for reading a run
26controls in one catalogue: 23 enforced, 3 declared
676tests against roughly 27,000 lines
$0.0000per run in the default scripted mode

I wanted an artifact, not a slide. The argument I care about is that agentic AI in a bank is a controls problem, and the only convincing way to make that argument is to build the controls and let someone click them.

The walkthrough

A recorded run, start to signature: a purpose refusal before any tokens are spent, a denied column, the gate reading the generated code, a small-cell suppression, and a signature blocked because the approver was the author.

Also on YouTube. Alongside it: sentinel.sandip.dev runs the same pipeline in a browser, free, with no login.

The architecture

A run is one vocabulary, used everywhere: Ask, Plan, Access, Generate, Gate, Execute, Screen, Interpret, Attest. The stages are how the app is laid out, how the audit log is indexed, and how the evidence pack is organised, because a single reading order is the thing that makes a run explicable to someone who did not write it.

Sentinel system architecture A request enters through a nine-stage governed pipeline (Ask, Plan, Access, Generate, Gate, Execute, Screen, Interpret, Attest). Generation runs through a model gateway; containment is a static code gate plus a subprocess sandbox. Every stage emits into a control plane and reads through a policy-scoped data plane. The run emits an evidence pack. The whole app runs on a single Elastic Beanstalk instance behind CloudFront. 1 · request plane Prebuilt request no free-text prompt box on the public link 6 personas role + identity, not role alone 8 datasets registry + data contract per table declared purpose checked against the purpose matrix 4 autonomy tiers L0 read-only through L3 open codegen 2 · the nine-stage governed run Ask persona, dataset, purpose, request CTL-PURP-01 Plan binds a certified analysis agent 4 cert gates Access column grants, dataset SHA pinned denied columns never exist CTL-CONTRACT-01 Generate the model writes the analysis code spend cap Gate static read before the machine reads 9 checks, 4 verdicts each regenerate loop Execute fresh subprocess, 15s, 1024MB, no net CTL-TIME-01 Screen small cells removed not masked (n < 10) proxy discovery flags, never refuses disclosure floor Interpret narration uses only the screened numbers faithfulness ≥ 0.9 Attest evidence pack, human signature approver cannot be the author CTL-SOD-01 a refusal at Gate regenerates, up to a cap, then hands to a human 3 · generation and containment Model gateway templated | anthropic | openai, behind one interface two routing tiers, response cache, per-call ledger process-global cumulative spend cap ($50/mo in prod) default is scripted: 0 tokens, $0.0000, labelled honestly Static code gate stdlib ast walk: never imports, evals or runs the code sqlglot parses any SQL literal and rewrites row filters import allowlist: 14 at L2, 29 at L3; egress denied at all tiers no eval, no exec, no dunder escapes, must parse Sandbox a fresh subprocess per run, spawned by the parent wall clock 15s, RLIMIT_AS 1024MB, RLIMIT_CPU backstop no network, no filesystem, no process control warmed bytecode caches at boot so the cap is honest 4 · control plane · every stage emits here, and every control can refuse Audit log append-only, one JSONL per run RBAC column grants from rbac.yaml PII redaction before any model sees text Guardrails tool allowlist, query ceiling Eval gate golden checks as a promotion gate Cost tokens, dollars, cycle time per run Identity segregation of duties by identity, not role 26 controls in one catalogue · 23 enforced, 3 declared and drawn dashed · each names the regime it answers to (SR 11-7, ECOA/Reg B, GDPR Art. 5, GLBA, BCBS 239, SOX change control) 5 · data plane Policy-scoped frame Access builds the table from granted columns only, so a denied column is not on the object the generated code receives. Enforcement by construction, not by a runtime check. DuckDB runs gated SQL · dataset SHA pinned · 8 datasets, 1k to 1.1M rows 6 · what a run leaves behind Evidence pack the finding, its provenance chain, the controls attested, and an explicit statement of what the analysis does not say. Model card PDF (SR 11-7 shaped) · OpenLineage START/COMPLETE marimo notebook · Quarto document · the cross-run audit ledger 7 · runtime Route 53 A + AAAA alias sentinel.sandip.dev CloudFront + ACM default behaviour: caching off, all headers forwarded (the WebSocket needs them) · /static/*: cached and compressed Elastic Beanstalk one t3.small, no load balancer nginx :80 → Streamlit :8501 Retrieval RDS pgvector + Bedrock Titan v2 falls back to a local TF-IDF index Two idempotent CloudFormation stacks own the whole footprint. Scripted narration is the default, so the public link costs about zero and cannot be run up.
// the request plane, the nine stages, the control plane, and the runtime open full size ↗

The stages, and what is armed on each

StageWhat happensThe control armed on it
AskPersona, dataset, declared purpose, and a prebuilt request. No free-text prompt box on the public link.The purpose matrix refuses a wrong-purpose request here, before a single token is spent.
PlanBinds a certified analysis agent. Only certified entries are selectable.Four certification gates: draft, candidate, certified.
AccessBuilds the working table from granted columns and pins the dataset by SHA.Column grants, the data contract, the fingerprint pin. OpenLineage START.
GenerateThe model writes the analysis code.The gateway ledger and the cumulative spend cap, because this is where money is spent.
GateA static read of the generated code before any machine reads it. Nine checks, four verdicts each.Import allowlist, no dynamic code, no egress, must parse. A refusal feeds a regenerate loop up to a cap, then hands to a human.
ExecuteA fresh subprocess per run. Fifteen second wall clock, 1024MB, no network, no filesystem.The time and resource caps.
ScreenSmall cells are removed from the result, and proxy discovery runs.The disclosure floor at n < 10, applied upstream of the narration.
InterpretThe narration is built only from what survived the screen.Faithfulness, measured, with a floor of 0.90.
AttestThe evidence pack is assembled and signed.Segregation of duties. The approver cannot be the author.

Four decisions that make it governance rather than logging

Most governed-AI demos are a pipeline with a log next to it. These four are the difference.

Access enforces by construction, not by a check. A denied column is not filtered at read time. It is never put on the object the generated code receives. There is no attribute to reach for, so there is no check to bypass, no rule to get the ordering wrong on, and nothing for a clever prompt to argue with.

The gate reads the code before the machine does. It is a standard-library ast walk. It never imports, evaluates, or runs what it is inspecting. Any SQL literal is handed to a second parser, because a Python AST does not understand SQL and a SQL parser does not analyse Python. The verdicts are four, not two: cleared, refused, nothing to judge, and rule never supplied. That last pair matters more than it looks. A control that was armed and found nothing is a different fact from a control that was never configured, and collapsing them into one green tick is how a control surface starts lying.

The screen runs before the narration, and it removes rather than masks. If a cell is below the disclosure floor it comes out of the frame, not out of the display. The model that writes the interpretation never sees it, so it cannot reason around it or back it out of a total.

Segregation of duties is by identity, not by role. SR 11-7 asks for independence between development and validation. A role check satisfies that on paper. Comparing identities is the stricter reading: a Platform Admin who started a run cannot approve it either, even though their role says they may approve.

One more, in the other direction. Proxy discovery flags and never refuses. Whether a correlated feature carries a business necessity is a Legal and Compliance judgment, not a platform judgment. A platform that refused on correlation alone would be wrong on the law and switched off inside a week. The job of that control is that nobody afterwards gets to say they did not know.

The control catalogue, and the regulation each control answers to

Twenty-six controls live in one catalogue. Twenty-three are enforced. Three are declared in the design and not implemented in this build, and those render dashed everywhere they appear rather than quietly counting as live. Every control chip in the app resolves through that one catalogue, so a control reads identically in the gate panel, the audit drill-down, the registry, and the manual.

RegimePrincipleWhat answers to it
SR 11-7 / PRA SS1/23Independence of validation from developmentSegregation of duties on signing, narration faithfulness, the eval gate, the human promotion gate
ECOA / Regulation BDisparate impact comes from what correlates with a protected attribute, not the attribute itselfProxy discovery, which flags and never refuses
GDPR Art. 5 / FCRA 604Purpose limitation and data minimisationThe purpose matrix, column scoping, PII controls in output text and in model context
GLBA Safeguards RuleAccess control and containmentColumn grants, no network egress, no filesystem or process access
Statistical disclosure controlA minimum cell size before an aggregate may be publishedThe disclosure floor and small-cell suppression, upstream of the narration
SOX change controlNothing runs that a reviewer has not readThe import allowlist, no dynamic code, code must parse
BCBS 239Risk data aggregation and lineageThe dataset fingerprint pin and OpenLineage events

The wording is always answers to, never complies with. A control serves a principle. Compliance is a determination a firm's own second and third lines make against their own policy, on systems with authenticated users and a real model inventory. This build has none of those, and it says so on screen.

Two routes, and only one of them has a screen

The nine-stage walkthrough is the live surface. Underneath it there is a second route: a four-agent LangGraph pipeline (Profiler, EDA, Modeler, Validator) with a real interrupt() human gate, a static topology, and a checkpointer. It produced the credit-risk runs sitting in the audit log and the models in the registry. It no longer has a screen of its own. That is a deliberate cut rather than an omission, and it is worth saying out loud because a visitor cannot click it.

The state split on that route is worth one line. The graph state carries the run id and the approval flag and nothing else. The heavy run state, with live harness handles in it, sits in the orchestrator's own store keyed by run id, so the checkpointer never has to serialise a file handle.

The modules

The pipeline and the control plane are separable modules, which is the whole claim to being a platform rather than a demo. The same harness wraps any future analysis.

  • govflow: the nine stages end to end, the autonomy tiers, the purpose matrix, the policy-scoped access build, and the single control catalogue.
  • codegen: the static gate, the SQL gate, the import allowlist, the result contract that is interpolated into the prompt and then enforced after execution, and a seeded corpus of real violations to test the gate against.
  • sandbox: parent-side spawn with a wall clock, a child entrypoint with resource limits, and a boot-time warm-up so the time cap measures the analysis rather than a cold import.
  • harness: the control plane: audit log, RBAC, PII, guardrails, eval gate, cost, model card, identity, tracing.
  • platform: certification, the model registry, the cross-run audit store, agent templates, playbooks, adoption metrics.
  • disclosure: small-cell suppression and the association measure behind proxy discovery.
  • evidence: the evidence pack, and the notebook and document renderings of it.
  • gateway, ml, datasets, rag, lineage, analyses, ui: the model gateway, the sklearn core, the dataset registry and contracts, retrieval, OpenLineage events, declarative analysis specs, and the Streamlit surfaces.

Runtime

One t3.small Elastic Beanstalk instance, no load balancer, nginx in front of Streamlit. CloudFront terminates TLS with an ACM certificate and Route 53 points at it. Two idempotent CloudFormation stacks own the whole footprint, and two scripts apply them.

The interesting part is the CloudFront cache policy, because it is where I was wrong for a while. The distribution has two behaviours. The default one carries the app with caching disabled and every viewer header forwarded, because the Streamlit WebSocket needs the upgrade headers passed straight through. /static/* is separate, cached at the edge and compressed.

That split is not tidiness. Streamlit's front end is 61 files and 2.5MB, content-hashed and immutable, and under the default behaviour every visitor pulled all of it uncompressed off the small instance. That was the six seconds of blank white on a cold load. It was not an Elastic Beanstalk cold start, which is what I assumed first: time to first byte was under a second the whole time. Compression needs both halves of that behaviour, because CloudFront compresses what it caches, and the caching-disabled policy drops Accept-Encoding from the cache key. Setting Compress: true alone does exactly nothing.

Build versus buy

The machine learning is bought off the shelf. The governance is the product.

That line settles most of these calls. Anywhere a credible off-the-shelf component exists, adopting it is more on-message than reimplementing it, because the claim being made is "I can govern the tools a bank already uses," not "I can rewrite them."

Two of these started as build decisions and I reversed them. Both reversals are in the repo, with dates.

DecisionCallWhy
Fairness metrics buy fairlearn Originally hand-rolled "for auditability", then reversed. If the pitch is that I govern off-the-shelf tools, hand-rolling the one metric a regulator cares most about undercuts it. Governing fairlearn is the stronger position.
Orchestration buy LangGraph Originally a plain Python state machine, which was right for a single-pipeline demo and wrong for a platform. LangGraph's graph is static, so it stays an inspectable workflow, and interrupt() plus a checkpointer are exactly the human gate and its pause.
The ML buy scikit-learn A logistic-regression baseline, on purpose. Reimplementing a classifier proves nothing relevant to the argument being made.
The frontend buy Streamlit, over Next.js One runtime, and load-bearingly so: the gate parses Python, the sandbox runs Python, the allowlist is Python imports, and fairlearn, statsmodels, DoWhy, lifelines and SHAP are Python-only. "Node entirely" means reimplementing fairlearn in TypeScript, which is the exact thing the thesis says not to do. "Node plus FastAPI" is a prioritisation trap. The honest cost is a single process, in-memory session state, and no real auth.
The code gate build Nothing off the shelf reads generated Python against a bank's policy and produces a per-check verdict a reviewer can read. It is about a thousand lines of standard-library ast. A gate tells you what was intended before it happens; a sandbox tells you what happened after. Both are needed, and this is the one that is demonstrable in a browser.
The SQL half of the gate buy sqlglot The Python AST does not understand SQL. sqlglot parses it and rewrites row filters into it. It parses, and never executes.
The query engine buy DuckDB Replaced ad-hoc pandas, so that generated SQL is a thing that can be gated and rewritten rather than string-matched.
Containment build, and stated as demo scope A subprocess with resource limits. A serious sandbox needs gVisor or Firecracker. That is written down as a non-goal rather than glossed, because claiming a demo subprocess is production containment is exactly the kind of thing a control surface should never do.
Provenance and tracing buy the standards OpenLineage and OpenTelemetry. Emitting a schema-valid lineage event and a real span is worth more than a bespoke table, because the receiving side already exists inside a bank. Captured in-process here, with no backend attached.
The document outputs buy marimo, Quarto Two audiences, two artifacts. A marimo notebook is plain .py, so a data scientist can review it in a pull request. The leadership view is a document that gets read once, forwarded, and filed, so Quarto serves it better than an app would. When the Quarto binary is absent, it writes the source and says so rather than faking a PDF.
Retrieval both Locally a TF-IDF index, which is free, deterministic, and labelled honestly as lexical rather than dense. In production, RDS pgvector with Bedrock Titan embeddings behind the same interface, and a runtime fallback to the local one.
Hosting buy Elastic Beanstalk, CloudFront Amplify Hosting only runs JavaScript front ends and cannot hold a persistent Python server. Render and Fly both work, and a render.yaml is still in the repo as a one-click alternative. AWS won because the deploy story is the one a bank will ask about.
Policy engine not yet Policy is in-process. Externalising it to OPA needs a server, and it changes where the policy lives rather than whether it exists. That is an architecture decision for a real deployment, not for a credibility artifact. It is on the dependency map, not claimed as built.
PII detection not yet Regex today. Presidio is the intended replacement, and the app labels it on screen as on the dependency map and not wired in this build. Better a small honest control than a big implied one.

The pattern underneath all of it: buy the thing a bank already recognises, build the thing that is the argument, and label the gap wherever a component is roadmap rather than reality.

The numbers

  • 676 tests across 51 modules, 8,000 lines of test against roughly 27,000 lines of source.
  • 26 controls in one catalogue: 23 enforced, 3 declared and drawn dashed.
  • 9 stages, 4 autonomy tiers, 6 personas, 8 datasets from 1,000 rows to 1.1 million, 9 gate checks with 4 verdicts each.
  • 14 allowlisted imports at the default tier and 29 at the open tier. Egress, filesystem, process control and dynamic code are denied at every tier.
  • AUC 0.8018 on German Credit at seed 42, with a fairness disparity ratio around 0.5 against a 0.8 threshold, which flags. The number moves with data and seed, which is the point.
  • 0 tokens and $0.0000 on a scripted run, 1.8 seconds end to end. Live mode is capped at $50 a month, process-global, so all public traffic shares one cap.
  • 24 runs and 249 audit events committed to the repo as the seeded record, every one of them produced by actually executing a run.

What it is not

Worth stating plainly, since the whole project is an argument about honesty in control surfaces.

// the honest list
  • No authentication. Personas are selectable, so segregation of duties is demonstrated rather than enforced against a real identity provider.
  • A single process with in-memory session state. That is Streamlit's shape, and it is the price of the one-runtime decision.
  • The subprocess sandbox is demo-scope containment, not gVisor.
  • Three of the twenty-six controls are declared and not implemented, and are drawn dashed wherever they appear.
  • The four-agent LangGraph route is real and tested but has no screen, so a visitor cannot run it.
  • Compliance is not claimed anywhere. Controls answer to principles. A firm's own second and third lines make compliance determinations, and this build has neither.
Run it yourself
No login, no cost, no prompt box. Pick a persona and a question, and try to make a control refuse you.
sentinel.sandip.dev
// palette