The hard 80% of a B2B SaaS. Solved once. Proven on every commit.
Samen is a foundry for launching many B2B products from one substrate. Customer data is masked by default, support sees it only through a second person's approval, erasure destroys keys instead of rows, and the AI agent can only propose. One command emits a running product that passes its own gate. Every guarantee ships with a sabotage that proves its test can fail.
Nothing is configured per surface. Each one renders what the field already returns.
no_plaintext_pii to no_pan_columnsEvery B2B product rebuilds the same 80%. And the same leak with it.
Auth, billing, a CRM, a support desk, files, search, exports, an admin tool for your own staff. Every one of those surfaces is also a door onto customer data, each with its own idea of who may open it. Samen builds the 80% once, at the substrate, and makes the safe answer the default value of a field, not a feature someone remembers to add. Then it tries to break every promise it makes.
One command, a running product
Not a scaffold. A thing you can boot, with its own gate.
Customer data that defaults to safe
Seven features that only work because the planes share one core.
Everything else a B2B SaaS needs
Surfaces, vendors and a cockpit for running many products.
A test that cannot fail is a bug
Sabotages, mutants, and CI an agent can afford to run.
One command. A product you can boot.
mix samen.gen.app doesn't hand you a folder of TODOs. It emits a product with a web UI, a JSON:API, seeds, observability, tests and its own verifier gate, and that app passes the gate on its first run with nothing edited in between.
- Billing, notifications, flags, an operator workspace and a deny-by-default API, mounted, not copied
- One authored resource with a real PII field, so the vault and reveal path are live from the first migration
- Four generators: app, scope, resource and agent, plus a fail-honest
--deploy
The root gate does exactly this on every PR: generate a fresh app, run its whole gate, seed it, boot it, probe its routes, then sabotage it.
•••• isn't a redaction. It's the value.
Declare pii_attribute :email and the value goes to a per-subject-keyed vault. The row keeps a vt_… token; plaintext is nowhere at rest. Every reader gets %Masked{} back: the UI, the API, the CSV writer, the log line. Forgetting to mask is not a mistake available to you.
- Validated, then tokenized: Money, Email, Phone, Address… each type's cast runs before encryption
- CSV masks by omission: the column isn't in the file, so there's nothing to un-redact
- A verifier in a separate OS process attests no tier holds plaintext
Support can see PII. Only with a second person's yes.
A dispatcher needs the address that bounced. Pretending support never sees data just moves the work into a spreadsheet nobody audits. Samen keeps the exception inside the system and makes it expensive, visible and self-expiring.
CHECK makes that row impossible, not just a form rule. built- Time-boxed: the auto-revoke job commits in the same transaction as the approval
- Never renewed in place: an extension is a new request and a new approval
- Tenant-readable: a hash-chained log the operator cannot edit, the tenant can read without asking
Your admin tool is the biggest leak in the company. Unless it's the same app.
The operator plane isn't a second application with its own queries. It's the same objects rendered for a different actor. Support sees the real UI, the real layout, the real bug. The PII inside it renders ••••.
- Tenant: reads its own org in the clear; scoping is enforced at the resource, not per query
- Operator: masked impersonation, grant-capable, every read in the tenant's log
- Aggregate: token-blind, over resources with zero PII columns
Switch the actor. The rows don't move; what resolves inside them does.
Rosa dispatches for Blue Ridge, so her actor is a tenant member of that org: every vaulted field on her own drivers resolves in the clear. No grant, no ceremony; this is the ordinary case.
Support opened a masked impersonation session with a reason. Same drivers, same order, same FMCSA badges, read through the same Driftwood.Reads.driver_roster; name and CDL resolve to %Masked{} because the plane is :operator and no grant is held.
The portfolio view runs as the token-blind aggregate actor over projections that have no pii_ column at all. Nothing is masked because nothing is maskable, and a lane with fewer than five tenants is suppressed to ⊘.
dispatchertenant plane
| Driver | CDL # | State | CDL expiry | Med card | Status | FMCSA | Dispatch |
|---|---|---|---|---|---|---|---|
| MVMarcus Vale | D4471-8820 | IL | 2027-03-14 | 2026-11-02 | on_load | OK | Dispatch |
| DWDana Whitfield | D5120-3364 | MA | 2028-06-30 | 2027-01-18 | available | OK | Dispatch |
| TBTheo Brannigan | D3388-1907 | OH | 2027-09-02 | 2026-09-30 | available | BLOCKED: medical card expired | Dispatch (blocked) |
| PRPriya Raghunathan | D6642-5518 | IL | 2029-02-11 | 2027-05-06 | out_of_service | BLOCKED: out of service | Dispatch (blocked) |
| COCurtis Okafor | D2907-4471 | OH | 2028-12-01 | 2027-08-22 | on_load | OK | Dispatch |
operator · supportoperator plane
| Driver | CDL # | CDL state | CDL expiry | Status | FMCSA | Reveal |
|---|---|---|---|---|---|---|
| ·•••• | •••• | IL | 2027-03-14 | on_load | OK | Reveal driver record Request reveal |
| ·•••• | •••• | MA | 2028-06-30 | available | OK | Reveal driver record Request reveal |
| ·•••• | •••• | OH | 2027-09-02 | available | BLOCKED: medical card expired | Reveal driver record Request reveal |
| ·•••• | •••• | IL | 2029-02-11 | out_of_service | BLOCKED: out of service | Reveal driver record Request reveal |
| ·•••• | •••• | OH | 2028-12-01 | on_load | OK | Reveal driver record Request reveal |
token-blind actor, no orgaggregate plane
| Lane | Tenants | Loads | Gross |
|---|---|---|---|
| TX->CA | 14 | 412 | $771,400 |
| IL->GA | 11 | 366 | $648,900 |
| FL->NY | 9 | 297 | $602,300 |
| WA->AZ | 6 | 209 | $389,700 |
| MT->ND | 2 | ⊘ | ⊘ |
Pinch to zoom: the desktop screen, scaled to fit.
Redrawn from driftwood's own screens: DriftwoodWeb.BrokerLive, DriftwoodWeb.OperatorImpersonationLive and the framework's Samen.Web.Operator.AggregateLive, with fictional tenant data. Tenant and operator render through the same read layer and the same Samen.Api.PiiResolution resolver; only the actor's plane differs. The FMCSA badge stays readable on both, so support can work the problem without the person.
"Delete my data" is a DELETE. That isn't erasure.
The row goes; the value survives in the audit log, last night's rollup, a queued job's arguments, the CDC stream and the backup. Samen erases by destroying the subject's key: the ciphertext everywhere becomes unreadable at once, including in tiers you forgot you had.
- A positive over a finite set, not a negative over every copy that might exist
- The audit chain survives: you can prove a reveal happened, not recover what it showed
- A new tier that stores plaintext turns CI red instead of slipping past a migration
Nothing was walked, scrubbed or rewritten. One destruction, and every tier is unreadable at the same instant.
Watch what happened. Never what they typed.
Session replay is how support finally sees the bug. It's also usually a recording of every value a user typed. Samen's replay records references and shapes: which record, which field, how long the text was. The value never enters the recording, so no viewer can get it back.
[erased]. built- Per-viewer playback: the tenant admin sees their org clear, an operator sees ••••
- Shred-aware: after erasure every reference resolves to
[erased]; the replay rows aren't touched - Inert playback: a script-free, sandboxed iframe
/operator/replays/:org_id/:id/settings/replays/:idOne recording of the real CRM contacts page. Four viewers. The stored bytes never change.
An operator inside an impersonation session for Blue Ridge, the only way an operator can open a replay. Every referenced field resolves to ••••.
The same operator, holding a second-party-approved reveal grant on Ada Whitfield. Her row resolves through the vault; nobody else's does. A grant is per subject.
Blue Ridge's own admin, on the tenant plane: their org, in the clear. Typed text is still only a length; the recording never had it.
The same admin after Samen.Erasure.shred of Ada Whitfield. Her key is gone, so every reference to her resolves to [erased]. The replay rows were not touched.
Session replaycode unchanged since recording
ContactsShow archived+ New contact
| Name | Phone | Company | Title | |
|---|---|---|---|---|
| ·AV••••Ada Villanueva | ••••ada.v@laurelfork.example | ••••+1 555 0177 | ▒▒▒ (18) | ▒▒▒ (15) |
| ·AW••••Ada WhitfieldAda Whitfield[erased] | ••••ada.whitfield@blueridge.exampleada.whitfield@blueridge.example[erased] | ••••+1 555 0142+1 555 0142[erased] | ▒▒▒ (20) | ▒▒▒ (13) |
| ·LH••••Lena Hartmann | ••••lena@shenandoahcold.example | ••••+1 555 0108 | ▒▒▒ (21) | ▒▒▒ (5) |
| ·MO••••Marcus Okafor | ••••marcus@cumberlandmills.example | ••••+1 555 0119 | ▒▒▒ (16) | ▒▒▒ (13) |
| ·PR••••Priya Raman | ••••priya.raman@blueridge.example | ••••+1 555 0163 | ▒▒▒ (20) | ▒▒▒ (10) |
Pinch to zoom: the desktop screen, scaled to fit.
Real data, redrawn: one session recorded by the ADR-052 recorder over the real Samen.Web.CRM.ContactsLive (fictional Blue Ridge contacts) and replayed by the real player for each viewer. Labels, payloads, cell text and reference outcomes are exported by scripts/landing/replay_demo_export_test.exs; the JSON is abridged (ids shortened, some keys dropped), never altered. The player itself shows the recorded screen in a script-free sandboxed iframe.
One thread. Two truths.
Your support team and your customer talk in one realtime thread. Paste any object id and it unfurls into a live card, clear to the customer and •••• to support, resolved per viewer through the same two gates every page uses.
if operator branch exists in the card. built- The broadcast carries an id, never a body: every subscriber re-reads under its own actor
- Any catalogued object unfurls the day it's catalogued, with zero card code
- Identity masked by default; disclosing it is an opt-in, recorded act
Persist the message through Ash. Its body goes to the vault.
{:chat_message, %{message_id, sender_party, refs}}
no body field exists on the wire
Paste an object id and it unfurls into a live card: clear on one side, •••• on the other.
/chat/:id/operator/desk-chat/:idPinch to zoom: the desktop screen, scaled to fit.
Redrawn from Samen.Web.Chat.ThreadLive, mounted twice by driftwood: samen_chat_routes on the tenant plane and again at /operator/desk-chat with plane: :operator. The person card is the framework's; the driver card is DriftwoodWeb.Chat.DriverCard, registered as data on the mount. Message bodies are vaulted too, so the operator reads the tenant's words as ••••. Proven by chat_unfurl_masking_test.exs: the live operator DOM is scanned for leaks.
The agent proposes. A human approves. The write runs as the human.
An agent near customer data is a governance problem before it's a feature. So samen's agent loop starts at authority: the agent holds no credential that can write. A write is a proposal parked for a person; on approval it executes under that person's actor, recorded as agent-originated.
- Durable, multi-step tool use with fail-honest budgets: 8 turns, 12 tool calls, 10 minutes by default
- Grounded in the machine catalog, not guessed field names
mix samen.gen.agentscaffolds a new agent with its own masking proof
search_recordsread · personal fields come back ••••fetch_recordread · masked like any other actorassign_record_ownerwrite · calling it executes nothingRecords the tool, the turn, the arg key names and a sha256 digest, never an argument value. A person who isn't the requester decides.
no decision in 24h → withdrawn, nothing executed
Executes once, inside the decision, bound to that digest. actor is the approver; requested_by is the AI principal, which holds no write authority anywhere.
the blast radius is what the approver could do by hand
/ai/agents/:ida support-triage run, parked at the human gate"Why is load BR-4471 late, and who should own it?" Two reads, one proposal, and a person decides.
dispatchertenant plane
- tool:
- assign_record_owner
- turn:
- 3
- arg keys:
- id, resource, user_id
- args digest:
- 9c41d07be2…e07a
Only key NAMES and a digest are recorded on the approval, never argument values (INV-1). Approving executes the write as you, once, inside the decision; the digest binds it to exactly this proposal. Deadline: 2026-10-14 10:31Z.
Vault-routed. An agent run is masked-only toward the model on every plane (ADR-047 §4.4); this panel resolves on YOUR plane.
- search_records "BR-4471" → 3 hits
- fetch_record support.agent → full_name •••• · email ••••
- assign_record_owner proposed → waiting for a person
| # | Status | Tool | Arg keys | Tokens | Provider |
|---|---|---|---|---|---|
| 1 | done | search_records | limit, query | 806/94 | scripted SIMULATED — not a real model |
| 2 | done | fetch_record | id, resource | 1,031/151 | scripted SIMULATED — not a real model |
| 3 | proposed | assign_record_owner | id, resource, user_id | 1,077/143 | scripted SIMULATED — not a real model |
Pinch to zoom: the desktop screen, scaled to fit.
Redrawn from Samen.Web.AI.AgentLive, which driftwood inherits through one samen_ai_routes call. The agent is Driftwood.Support.TriageAgent: a name, a goal prompt and three tool kinds, nothing else. Driftwood configures no model provider, so its runs are honest about it: the deterministic Scripted provider, with a SIMULATED badge on every turn. Built and gated: ADR-047 batches A1–A7 landed.
The table stakes, without the leaks.
CRM, billing, a support desk, work tracking, automation, files, search, CSV, settings, notifications, flags, analytics. Each ships as a scope blueprint a product mounts in one call, and each renders through the same masking default as everything else.
- Search over a non-PII index: ⌘K finds the record without the index ever holding the name
- Files quarantined by default: promoted only after a clean scan
- Automation with approvals: workflows compile to Reactor; approvals carry the distinct-party CHECK
Delete every vendor package. The kernel still passes.
Payments and email are where frameworks quietly marry a vendor: a webhook signature here, a customer id there, until leaving costs a quarter. Samen's kernel names zero Stripe strings. Adapters live in sibling packages, and removing all of them leaves samen_core and samen_web green.
- Three email vendors that disagree: Basic-Auth, SNS-RSA and Svix-HMAC behind one conformance suite
- Webhooks are hints, not facts: verify, fetch current state, apply idempotently, survive reordering
- Usage metering as an insert-only ledger, with quotas summed from it
Run ten products from one seat.
Samen exists to launch many products, so the operator needs one view across them: which app is healthy, which tenant is churning, which flag is rolled out where. The cockpit reads every product over a token-blind report wire, so the portfolio view never becomes the leak.
- Tenant names masked by default, unmasked per role down to account level: a salesperson sees their accounts
- Fleet directives push flags across products; an unreachable app degrades honestly, never a fake "applied"
- Explainable health scores and an MRR-movement ledger per tenant
A test that can't fail is a bug.
Each guarantee ships with a sabotage: a committed patch that breaks exactly that behaviour. The harness applies it, demands that the named tests fail (not just that something breaks), reverts, and checks the tree is restored byte for byte. Then a mutation gate asks the converse: what break did nobody think to sabotage?
- 3,960 mutants across 228 targets, enumerated from the AST, each run against only its owning tests
- A survivor fails the build until a test kills it or a ledger row proves it can't matter
- 25 verifiers check structure the tests can't: no PII columns, catalog parity, fleet wire…
Sabotage 187 is real: the database CHECK still backstops the write, but the clean policy refusal degrades, and the named test catches it. Both layers are load-bearing.
Verdicts, not logs.
This codebase is written by AI agents, so its CI is built for them: cheap, fast and mechanical. quick during a round, pr before a pull request, full nightly. Every call fits a single tool call, ends in one verdict line, and resumes where it stopped.
quick when nothing changed, because every step is cached by the content it reads. A failure is never cached. measured- A flaky test still fails the run: fix the flake, don't retry it green
- Mirrored on GitHub Actions: the PR check verifies every step ran once, on this commit
- The old
ci.shtook about 13 minutes and never fit one tool call; agents cut it up by hand
Lines from real runs on this repo, trimmed. A red step prints a digest and its log path; the verdict also lands in _ci/last.json.
What this is not, stated as precisely as what it is.
A project that can draw its own boundary is one whose other claims you can price. Each limit below is written in the repo in the same words. When a reviewer finds a claim that outruns its evidence, the fix is a commit, and the retraction stays visible.
No live vendor is wired in CI.
Billing and email are proven against hermetic fakes and recorded transports through a real conformance suite, never a real account. Generated apps boot with both unconfigured and say so. The seams are proven; the wires are yours.
Support can see PII.
Through a grant a second person approved, that expires on its own and that the tenant can audit. "Support never sees plaintext" was once on this page; it was false by construction and was retired.
The verticals are proof, not product.
driftwood (freight), pawchart (veterinary) and demo exist so the substrate must survive unrelated domains. They aren't for sale. If a vertical does something clever, that's a bug in the kernel.
188 lines is a mount number, not an app size.
pawchart mounts framework surfaces in about 188 authored lines; pawchart itself is about 3,900. An earlier page conflated the two, and the overclaim was corrected rather than reworded.
US-only, and not on Hex yet.
One Postgres per host, no per-tenant region selection. The framework ships as path dependencies inside this monorepo; publishing waits on a stated trigger.
Erasure can't reach what samen never wrote.
A spreadsheet someone exported, or a downstream system that stored its own plaintext, is outside the boundary. The page won't pretend otherwise.
Run it in ten minutes, or read it for an afternoon.
Generate an app and make it prove itself.
You need Elixir 1.20 on OTP 29 and a local PostgreSQL with pgvector. No Docker, no API keys, no accounts.
If the generated app needs a fix from you to go green, the generator is broken, not your setup.
For readers who'll never run it.
Most of what's interesting here transfers to a stack that has nothing to do with Elixir.