Architecture & Best Practices
Sema’s constructs are opinions about where things go: effects belong on signatures, policies belong in reviewable artifacts, prompts belong in typed suites, tests belong next to the unit they defend. A codebase that fights those opinions fights the compiler. This page is the layout that works with them, derived from the shipped examples — chiefly finops-ledger, graphrag, and crisis-logistics — with the mechanics on Project Layout.
The canonical skeleton
Section titled “The canonical skeleton”finops/├── sema.toml # identity + the dials: assurance floor, engine determinism, model lockfile├── src/ # one module = one .sema file = one concern│ ├── main.sema # thin entry point: wire, iterate, log — no business logic│ ├── domain.sema # structs/enums, sem descriptors, invariants, pure domain helpers│ ├── models.sema # pinned model bindings (rev, quant, role, calibration)│ ├── policies.sema # named policies + the pure helpers they govern + their tests│ ├── config.sema # args/config suites, provide factories, container — all wiring│ ├── storage.sema # the effectful edge: fs/db/net adapters, typed SQL│ ├── reconcile.sema # a pure-core feature module (the actual algorithm)│ ├── supervision.sema # supervise blocks, restart/fallback/heal envelopes│ └── assurance.sema # cross-module tests and properties├── tests/ # discovered by check/assure ONLY — never by a plain run├── docs/api/ # generated by `sema doc` — never hand-edited└── .sema/ # runtime state: venv, packages, journals, runs — gitignoredThis is the shape of examples/finops-ledger/ almost file for file, and the
smaller examples are subsets of it (examples/graphrag/src/ is
types / embed / similarity / store / api / main). Two mechanics make it work
(LANGUAGE.md §5.18, D80):
- Module identity is the file stem, globally unique.
src/is walked recursively, so you may group files into subfolders — but grouping is physical only.src/storage.semaandsrc/adapters/storage.semaare the same module id, and having both is a loud error:duplicate module 'storage': … module ids are file stems, globally unique across src/ subfolders and tests/. Imports resolve by last segment, so moving a file into a folder breaks no import. tests/is invisible torun. A siblingtests/directory is discovered forcheckandassureonly. Verification code can never leak into production control flow, and nothing inrunwill execute it.
.sema/ holds journals, run state, and installed packages; the repo’s
.gitignore excludes it (sema/.gitignore line 12). Committing it ships
machine-local state and hash-chained journals as if they were source.
Module decomposition
Section titled “Module decomposition”The corpus pattern, module by module:
| Module | Owns | Corpus reference |
|---|---|---|
domain.sema |
structs, enums, sem descriptors, invariants, pure domain algebra (operators, predicates) |
examples/finops-ledger/src/domain.sema |
models.sema |
every model … = model(…) binding — pinned rev, quant, role, calibration |
examples/finops-ledger/src/models.sema |
policies.sema |
named policy suites, the pure helpers they justify (redaction, sanitization), and tests for those helpers |
examples/finops-ledger/src/policies.sema |
| pure feature modules | the algorithm, mostly !{} / !{model.embed} |
examples/finops-ledger/src/reconcile.sema, examples/graphrag/src/similarity.sema |
storage.sema / adapters |
the effectful edge: db.*, fs.*, typed SQL |
examples/finops-ledger/src/storage.sema |
supervision.sema |
supervise envelopes: restart, fallback, heal |
examples/finops-ledger/src/supervision.sema |
assurance.sema |
cross-module test blocks and end-to-end properties |
examples/finops-ledger/src/assurance.sema |
main.sema |
a thin def main(): inject, iterate, log |
examples/finops-ledger/src/main.sema (13 lines) |
domain.sema is where meaning is declared once — types carry their own
documentation and their own checks:
enum MatchState: unmatched | candidate | reconciled
struct Money: sem "A signed monetary amount in minor units" currency: str sem "Settlement currency code" minor_units: i64 invariant len(currency) == 3
def same_currency(a: Money, b: Money) -> bool !{}: return a.currency == b.currency
operator +(left: Money, right: Money) -> Money !{}: require same_currency(left, right) return Money(currency=left.currency, minor_units=left.minor_units + right.minor_units)main.sema stays thin — the finops entry point is a config lookup, a loop, and
a log line. Everything with a decision in it lives in a module that assure
can reach without going through main:
from finops.domain import Moneyfrom finops.reconcile import amount_delta_absfrom finops.storage import load_statement_lines
def main() -> None !{fs.read, observe.record}: lines = load_statement_lines("statement.txt") booked = Money(currency="EUR", minor_units=4200) posted = Money(currency="EUR", minor_units=4200) log.info("batch loaded", lines=len(lines), delta=amount_delta_abs(booked, posted))When to split: a module earns its own file when it acquires its own effect
row, its own policy attachment, or its own assure grade — those three attach at
module granularity (§5.18), so a file that wants two of anything is two files.
Don’t split by layer reflexively: policies.sema keeps its helper functions
and their tests in the same file, because the redaction helper is meaningless
apart from the policy that requires it.
Naming conventions
Section titled “Naming conventions”Derived from the shipped examples, not aspiration:
| Kind | Convention | Corpus evidence |
|---|---|---|
| package / project dir | kebab or snake; hyphens normalize to _ in the import root |
crisis-logistics → from crisis_logistics.domain import … |
| modules | snake_case single-concern nouns |
domain, reconcile, supervision |
| structs / enums / traits / events | PascalCase |
LedgerEntry, MatchState, IncidentQuarantined |
| policies, config/args suites, containers, components | PascalCase |
LedgerOps, LedgerConfig, LedgerCli, LedgerApp, LedgerRuntime |
| functions, fields, agents, circuits, models, monitors, subscribers, templates | snake_case |
choose_assignment, agent researcher, circuit synthesize, anomaly_writer, monitor public_briefing_drift |
| enum variants | lowercase | usd | eur, unmatched | candidate | reconciled |
constants (top-level NAME = expr) |
UPPER_SNAKE; leading _ for module-private caches |
BASE_EPOCH, TOPIC; _CACHE in examples/graphrag/src/main.sema |
| test names | full sentences stating the property | test "exact matching requires both currency and amount": |
The split is principled: nominal artifacts you attach and review
(types, policies, configs, containers) are PascalCase; things you call
are snake_case — and agents and circuits are callables, so
agent researcher(…) and circuit synthesize(…), never Researcher.
Test names are sentences because assure prints them as the verdict line —
"delta is symmetric and zero on identical amounts" reads as a property held
or falsified, where test_delta_1 reads as noise.
Effect discipline: the row is the architecture diagram
Section titled “Effect discipline: the row is the architecture diagram”Sort every module onto one side of the !{} boundary and keep it there. The
effectful edge is thin adapters; the core is pure and therefore fuzzable,
memoizable, and replay-exempt:
import io
def load_statement_lines(path: str) -> list[str] !{fs.read}: mut lines = [] for line in io.lines(path): if len(line.strip()) > 0: lines.append(line) return linesRead the finops modules by their rows and you have drawn the system:
| Module | Effect rows | Reading |
|---|---|---|
domain.sema |
!{} everywhere |
pure algebra |
reconcile.sema |
!{} for exact matching; !{model.embed} for similarity; !{fs.read, model.embed, observe.record} at the top |
core with one calibrated edge |
ingest.sema |
!{fs.read, ffi.call, model.invoke, observe.record} |
the adversarial-input boundary |
storage.sema |
!{db.read} |
one capability, one module |
reporting.sema |
!{} sanitizers; !{fs.write, net.connect, model.invoke} exports under @RegulatedExport |
pure prep, governed egress |
main.sema |
the union row | wiring only |
Rules that keep it that way:
- Declare rows explicitly on everything public. Inference is fail-closed,
but at
assure silverand above an explicit row is required — so a latercode.execsneaking into a dependency shows up as a signature diff in review, not a silent change. - Never ship
!{*}in application code. It is the loud escape hatch — flagged at check time, refused under any restricting policy. The one legitimate home is generic higher-order code that truly cannot know its callees’ rows (stdlib/sema/circuits.semauses it for combinators likepipeline); your modules are not that. - Review heuristic: diff the effect rows before the bodies. A PR that turns
a
!{}module into a!{net.connect}module is an architecture change whatever the diff size says.
Contracts and verification: what goes where
Section titled “Contracts and verification: what goes where”Each verification construct has one home:
| Construct | Placement |
|---|---|
invariant |
on the domain struct, in domain.sema — holds at every construction site forever |
require / ensure |
on pub seams — they are part of the public signature the verification cache keys on |
soft check semantics(…) |
at model and generated-value boundaries only (see the typed-SQL example below) |
test "…": |
next to the unit it defends, in the same module |
| cross-module tests, properties | assurance.sema or tests/ — the fuzz-facing surface |
A feature module carries its contracts and its tests together:
from finops.domain import Money, same_currency
def amount_delta_abs(a: Money, b: Money) -> i64 !{}: require same_currency(a, b) ensure result >= 0 if a.minor_units >= b.minor_units: return a.minor_units - b.minor_units return b.minor_units - a.minor_units
test "delta is symmetric and zero on identical amounts": a = Money(currency="EUR", minor_units=1250) b = Money(currency="EUR", minor_units=-300) ensure amount_delta_abs(a, b) == 1550 ensure amount_delta_abs(b, a) == 1550 ensure amount_delta_abs(a, a) == 0The authored test is not redundant with the fuzzer. assure fuzzes every
ensure from the parameter types, but a strong require (here:
same-currency pairs) can starve random generation — the verdict is then
amber, “incomplete — generated 0/64 inputs satisfying the function
preconditions”, not a fake green. Your named tests are the evidence that
survives when generation can’t reach the precondition; write them for exactly
those functions.
Grade policy: set [assurance] default = "silver" in the manifest and
treat it as the floor. Bronze is for a spike you haven’t shaped yet; gold is
for release-critical modules — finops declares assure gold per module, the
crisis example holds silver. Precedence is manifest < module assure <
per-function @assure, so tightening one hot module never requires touching
the rest.
Governance layout: policies are code review artifacts
Section titled “Governance layout: policies are code review artifacts”All policies live in policies.sema as named, reviewed declarations — the
finops ledger ships exactly three (LedgerOps, RegulatedExport,
AnalystWorkbench), each attached by name (@LedgerOps) where it governs:
from ledgergov.domain import BankLine
policy StatementIngest: allow: fs.read("inbound/**"), fs.write("state/**") model.invoke, model.embed forbid cap: net.connect except "bank-gateway.internal:443" code.exec, proc.spawn, policy.change examples: allow: fetch("https://bank-gateway.internal:443/statements") deny: code.exec(BankLine.raw_description) proc.spawn("python", ["parse.py", BankLine.raw_description]) justification "Bank files are untrusted input; ingest must stay deterministic and auditable."examples:blocks are executable documentation — validated at compile time, so the policy’s advertised behavior can’t drift from its rules. Write the deny examples first, naming the concrete field you’re afraid of (BankLine.raw_description); that is the threat model, in code, in review.- Posture vs. program policy. The runtime posture (the sealed root your
deployment grants) is the ceiling; program policies compose with it by
lattice meet, so nested attachment only shrinks authority — a program
policy can forbid more than the posture, never allow more. Design
accordingly: put the broad envelope in one root policy (
[policy] rootin the manifest, ascrisis-logistics/sema.tomldoes), narrow per-boundary policies (RegulatedExport) inside it. - Note
policy.changein the forbid list: the ledger policy forbids changing itself. Do this in every root policy.
Agents, circuits, and durability
Section titled “Agents, circuits, and durability”The escalation ladder — take the first rung that suffices:
def— deterministic logic. Most code stays here.simulate def— one model-authored function with a typed result, contracts, and abudget. No identity, no tools, no loop.agent— the model call gains identity,use tools, and its own budget envelope. Still a callable.circuit— orchestration of several agents becomes a durable aggregate. The run is a memoized WorkTree under.sema/runs/<run-id>/— journaled, with content-keyed completed-leaf memos — sosema circuit resumereuses every finished leaf instead of re-spending the tokens.
from ledgergov.models import triage_writer
agent researcher(question: str) -> str by triage_writer: sem "Collect one attributable finding and separate fact from inference" budget model_calls=2, tokens=512 ensure len(result) >= 1
agent writer(evidence: list[str]) -> str by triage_writer: sem "Synthesize the evidence with provenance and explicit uncertainty" budget model_calls=1, tokens=512 ensure len(result) >= 1
circuit synthesize(questions: list[str]) -> str !{model.invoke}: budget agents=8, spawn_depth=0, model_calls=16, tokens=8000 evidence = parallel [researcher(question) for question in questions] return writer(evidence)Every model path in the corpus carries a budget — every agent and every
circuit in examples/agent-research/src/main.sema and
examples/agent-software/src/main.sema declares budget … with model calls,
tokens, and (on circuits) agents and spawn_depth. Treat a budget-less model
path as a review defect: a child cannot mint a fresh budget, so the envelope
you write at the circuit is the real spend ceiling.
Placement: model pins in models.sema (rev, quant, and — critically —
role=generator vs role=verifier, so a convenient generator can’t quietly
become its own judge); agents and circuits in the feature module that owns the
workflow (dispatch.sema in crisis-logistics), not in a generic agents.sema
grab-bag, unless — like the harness above — agents are the feature.
Prompts and templates
Section titled “Prompts and templates”Prompt text is program surface. Keep it out of expression position:
template briefing_system(domain: str) -> Prompt[str]: sem "Stable system context for statement triage" role system: text f"You are a precise assistant for {domain}." text "Cite evidence and refuse unsupported claims."template/contextsuites over inline strings — the semantic-library example keeps atemplates.semawhose suites (library_editor_system,integrate_book_task) are typedPrompt[Book]values, versioned and diffable like any declaration (examples/semantic-library/src/templates.sema).- Triple-quoted strings are raw —
"""…"""takes no escapes, so prompt blocks with backslashes, quotes, and braces survive verbatim. Userf"…"when a prompt scaffold needs live{holes}and raw backslashes. sql"…"templates over string SQL — a typed SQL value interpolates as parameters, never as text, and itsvalidateblock is where the soft semantic check earns its keep:
from ledgergov.domain import BankLine
def load_lines(db: Db, account_id: str) -> list[BankLine] !{db.read}: sem "Tenant-scoped read through a typed SQL interpolation" query = validate sql""" select id, raw_description from bank_lines where account_id = {account_id} order by posted_epoch_s desc """: ensure sql.read_only(value) ensure sql.has_parameter(value, "account_id") check semantics("query cannot read outside the requested account", value, alpha=0.01) return db.query(query)The hard ensures prove what is provable (read-only, parameter present); the
check semantics adds calibrated evidence for what isn’t. That ordering —
deterministic checks first, statistical evidence on top — is the house style
everywhere a model touches data (examples/finops-ledger/src/storage.sema).
Configuration and DI: one wiring module
Section titled “Configuration and DI: one wiring module”All wiring lives in config.sema — the finops file holds the entire chain
args → config → provide → component → container, and nothing else in the
codebase constructs a dependency by hand:
from ledgergov.models import triage_writer
config LedgerConfig: source yaml "config/ledger.yaml" optional source env prefix "FINOPS_" tenant: str = "default" paths: inbound_dir: str = "inbound/statements" models: triage_writer: temperature: f32 = 0.1 where 0.0 <= value <= 2.0 max_tokens: int = 2048 where value > 0
provide configured_writer(cfg: LedgerConfig) -> ModelClient lifetime singleton: sem "Attach validated sampling config to the pinned triage model" return triage_writer.with(cfg.models.triage_writer)
component IngestRuntime: sem "Scoped dependency object for one ingest invocation" lifetime scoped(run) inject: cfg: LedgerConfig writer: ModelClient named "triage_writer"
container IngestApp: config LedgerConfig bind ModelClient named "triage_writer" = configured_writer(LedgerConfig) bind IngestRuntime lifetime scoped(run)- Source overlays, not env reads.
source yaml … optional+source env prefix "FINOPS_"+source climerge with defaults-< file < env < cli precedence andwherevalidation at the boundary — so no function body ever needsenv.read, and the effect rows stay honest. whereclauses on model sampling knobs (temperature,max_tokens) turn a bad deploy-time override into a loud config error instead of a quiet quality regression.- Consumers say
inject T, nevercontainer.get. The container is mentioned once, onmain(@LedgerAppinexamples/finops-ledger/src/main.sema); everything else declares needs.
Anti-patterns
Section titled “Anti-patterns”| Anti-pattern | Why it fails | Fix |
|---|---|---|
Everything in main.sema |
one module = one effect row, one policy, one grade — a monolith forces the union row and the weakest story on all of it | split until each file has one row and one reason to change |
!{*} in application code |
flagged at check, refused under any restricting policy, and it erases the architecture information the row exists to carry | write the real row; silver+ demands it on the public surface anyway |
Inline with policy(…) scattered ad hoc |
authority decisions become undiscoverable; nothing is reviewed as an artifact | named policies in policies.sema, attached with @PolicyName; deny-examples for every feared input |
| Prompts as inline f-strings at call sites | prompt drift is invisible in review; no types, no reuse | template/context suites in a prompts module; sql"…" for queries |
Committing .sema/ |
ships machine-local venvs, journals, and run state as source | it’s in .gitignore for a reason; keep it there |
Expecting test blocks to run under sema run |
tests are check/assure-only by design — tests/ isn’t even discovered by run (D80) |
verification runs in sema assure; wire smoke behavior into main if you need a runtime probe |
| Deep folder taxonomies as namespaces | folders are physical grouping only; module ids are file stems, and a duplicate stem across folders is a hard error | flat, well-named stems; folders only to shelve related files |
- Cheat Sheet — every keyword and operator on one page.
- Declarations — each top-level form and its clauses.
- Project Layout — manifest keys, imports, visibility, and dependency mechanics.
- Policy and Effects — the governance machinery this page places.