Skip to content

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.

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 — gitignored

This 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.sema and src/adapters/storage.sema are 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 to run. A sibling tests/ directory is discovered for check and assure only. Verification code can never leak into production control flow, and nothing in run will 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.

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 Money
from finops.reconcile import amount_delta_abs
from 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.

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-logisticsfrom 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 lines

Read 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 silver and above an explicit row is required — so a later code.exec sneaking 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.sema uses it for combinators like pipeline); 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) == 0

The 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] root in the manifest, as crisis-logistics/sema.toml does), narrow per-boundary policies (RegulatedExport) inside it.
  • Note policy.change in the forbid list: the ledger policy forbids changing itself. Do this in every root policy.

The escalation ladder — take the first rung that suffices:

  1. def — deterministic logic. Most code stays here.
  2. simulate def — one model-authored function with a typed result, contracts, and a budget. No identity, no tools, no loop.
  3. agent — the model call gains identity, use tools, and its own budget envelope. Still a callable.
  4. 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 — so sema circuit resume reuses 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.

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/context suites over inline strings — the semantic-library example keeps a templates.sema whose suites (library_editor_system, integrate_book_task) are typed Prompt[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. Use rf"…" 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 its validate block 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).

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 cli merge with defaults-< file < env < cli precedence and where validation at the boundary — so no function body ever needs env.read, and the effect rows stay honest.
  • where clauses 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, never container.get. The container is mentioned once, on main (@LedgerApp in examples/finops-ledger/src/main.sema); everything else declares needs.
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.