The Termin model
A Termin application is described in constrained English, compiled to an intermediate representation, and served by a conforming runtime. The language has exactly eight primitives. They combine under a fixed set of composition rules. This page enumerates the primitives, shows how they compose, and explains why applications built from them are easier to reason about than applications written in a general-purpose language.
If you are evaluating Termin for a project, /what-is-termin/ is the better starting point. This page is for readers who want to understand the conceptual foundation — why the primitives are the way they are, and what the composition rules buy you.
The eight primitives
Can Computers Show Every Person Correct Behavior Instantly — the mnemonic names them in order.
- Content
- Compute
- State
- Events
- Presentation
- Channels
- Boundaries
- Identity
Eight primitives and nothing else. Everything a Termin application does is expressed as a combination of these.
The primitives in detail
Content
Typed records with fields, constraints, and optional state. Fields have declared types (text, number, date, currency, one-of, reference). Constraints are declarative: required, unique, minimum, maximum.
Content called "products":
Each product has a SKU which is unique text, required
Each product has a name which is text, required
Each product has a unit cost which is currency
Each product has a category which is one of: "raw material", "finished good", "packaging"
Confidentiality is a field-level property of Content: any field can carry a scope requirement that redacts its value from readers who do not have that scope. Confidentiality taints downstream: any expression that depends on a confidential field inherits the same redaction. You cannot extract a confidential value by computing on it and returning the result.
Each employee has a salary which is currency, confidentiality is "salary.access"
Compute
A named operation that runs in response to a trigger. A compute declares its shape (one of Transform, Reduce, Expand, Correlate, or Route), the content types it accesses, the scope required to execute it, and its audit level. Three built-in providers:
"cel"— pure CEL expression evaluation. CEL is the Common Expression Language, a small sublanguage Termin uses for conditions and computed values. The default provider; used when noProvider isline appears."llm"— a large language model with explicit input-field / output-field wiring. Field-to-field completion, no tool use."ai-agent"— an autonomous agent with tool-style access to the runtime's content API. Used for tasks that require reading, writing, and reasoning across multiple records.
A CEL compute reads like a declared transformation over declared content:
Compute called "calculate order total":
Transform: takes an order, produces an order
`order.total = order.lines.reduce((acc, line) => acc + line.quantity * line.unit_price, 0)`
Anyone with "orders.write" can execute this
Audit level: actions
Anyone with "orders.admin" can audit
An "llm" compute wires a prompt into an input field and a response back into an output field:
Compute called "complete":
Provider is "llm"
Accesses completions
Input from field completion.prompt
Output into field completion.response
Trigger on event "completion.created"
Directive is ```
You are a helpful assistant. Be concise and clear.
If you don't know the answer, say so honestly.
```
Objective is ```
Answer the following prompt from the user.
```
Anyone with "agent.use" can execute this
Audit level: actions
An "ai-agent" compute uses a directive plus an objective and reaches out to the runtime's content API by its own plan, within its declared accesses. See the agent_chatbot example in the compiler repository for the full pattern.
Audit is a feature of Compute. Every compute declares an audit level — none, actions, or debug. The runtime records inputs, outputs, and intermediate state according to the level, into a generated audit-log content type that is itself a first-class Content and is queryable through the normal access-grant rules.
State
Named states per content type, with scope-gated transitions. The set of states and the set of permitted transitions are fixed at compile time. There is no construct for transitioning to an arbitrary state or skipping the transition rules.
State for products called "lifecycle":
A product starts as "draft"
A product can also be "active" or "discontinued"
A draft product can become active if the user has "inventory.write"
An active product can become discontinued if the user has "inventory.admin"
Events
A runtime-produced signal that something has happened: a record was created, updated, deleted, or transitioned; a channel message arrived. Events are observed, not invoked. You cannot fire an event manually to bypass the conditions that would normally produce it.
Events are how state changes reach Computes and Channels without a direct function call. A compute that listens for product.updated runs whenever the runtime produces that event — not because something else called the compute.
Presentation
How content renders to a role. Pages are declared inside user-story blocks — "As a role, I want to do X so that Y" — and described in terms the framework can compile: show a page, display a table, accept input for specific fields, subscribe to changes. Pages are bound to content and to access grants; a role that cannot view a field cannot see it rendered, and a role without the access grant for a verb cannot invoke it. Field-level visibility is inherited from Content-level confidentiality, not declared per-page.
As a warehouse clerk, I want to see all products and their current stock levels
so that I know what we have on hand:
Show a page called "Inventory Dashboard"
Display a table of products with columns: SKU, name, category, status
Allow filtering by category, warehouse, and status
Allow searching by SKU or name
This table subscribes to stock level changes
Channels
A typed interface to an external system. Channels declare the content they carry, a direction (outbound, inbound, bidirectional), a delivery guarantee (reliable, realtime, batch, auto), and the scope required to use them. Endpoints are supplied at deploy time in a deploy-config file, not in the .termin source. External integrations — posting to Slack, calling an HTTP API, receiving a webhook — happen only through Channels. There is no "just make an HTTP request" primitive in a Termin app.
Channel called "note-sync":
Carries notes
Direction: outbound
Delivery: reliable
Requires "messages.all" to send
When `note.created`:
Send note to "note-sync"
Log level: INFO
Boundaries
An isolation unit. Every application has an implicit boundary around its own Content and its own Computes. Cross-boundary data flow is explicit — it goes through Channels, not through shared memory. Boundaries are the unit of identity propagation, audit, and redaction.
An application IS a boundary. Multiple applications communicate through Channels with declared endpoints. Within a boundary, Content is shared across Computes; across a boundary, every data transfer is named.
Identity
The identity primitive covers three closely related concepts: Scopes, Roles, and Users.
- Scopes are named permission strings. They have no implicit hierarchy; a scope is what it is.
- Roles aggregate scopes. A role is "the set of scopes this identity carries."
- Users are the actual identities that authenticate and carry a role.
A .termin file declares the scopes, the roles, and how authentication works. The identity provider — a pluggable runtime component, configured at deploy time — fills in the user data: who exists, what their current role is, how their authentication credentials are verified. The reference runtime ships a stub provider for development; production deployments plug in enterprise single-sign-on standards (SSO, SAML, OIDC — the protocols corporate IT uses for login) or a custom provider.
Users authenticate with stub
Scopes are "inventory.read", "inventory.write", and "inventory.admin"
An "employee" has "inventory.read"
A "manager" has "inventory.read" and "inventory.write"
An "administrator" has "inventory.read", "inventory.write", and "inventory.admin"
The split between the spec (scopes, roles, authentication contract) and the provider (user records, credential verification) is deliberate: it keeps identity pluggable without letting the provider violate the access rules the spec declares.
How they compose
The primitives are not independent. They combine under a fixed set of rules.
Access grants — the central composition
An access grant is the composition of Identity × verb × Content. It is the single way data moves in a Termin application. Access grants are not a ninth primitive; they are what combining Identity and Content looks like when you say "this scope can perform this verb on this content."
Anyone with "inventory.read" can view products
Anyone with "inventory.write" can update products
Anyone with "inventory.admin" can create or delete products
Verbs are view, create, update, delete, and audit. The runtime evaluates every operation against the grants and denies anything not permitted. There is no path to data that bypasses the grants — not from the auto-generated REST API, not from a page, not from a compute, not from a channel action.
A worked example — a help desk
Seven of the eight primitives threaded through one application:
- Content —
tickets,users,comments. - Identity — scopes (
tickets.view_own,tickets.view_all,tickets.resolve,tickets.admin); roles (requester, agent, admin). - Access grants — requesters can create and view their own tickets; agents can view all tickets and post comments; admins can delete.
- State —
open → in_progress → resolved → closed, transitions scope-gated. - Compute —
resolution_timefires onticket.resolved, accesses theticketsContent, computes the elapsed time from open to resolved, writes it back. - Channels — an outbound channel to a Slack webhook.
- Events — when a ticket is resolved, send a summary to the Slack channel.
- Presentation — a requester-facing page listing their tickets; an agent-facing table of the open queue; an admin-facing configuration page.
(The eighth primitive, Boundaries, is implicit: the help desk is one application, so everything above lives inside one boundary.)
Every layer is declarative. Every cross-layer interaction is named. The compiler produces a single IR (intermediate representation — a JSON artifact the runtime consumes) that describes every operation the app can perform.
Nothing in the help desk can happen outside this list. There is no "and then also do X" hidden in controller code. If a ticket gets moved to resolved, it is because someone with the tickets.resolve scope called the resolve transition. If a Slack message is posted, it is because the event handler sent one. A reviewer can reconstruct every consequence of every operation from the spec alone.
Why this is easier to reason about
Four related reasons, each with a direct consequence for how you review, extend, and trust the code.
The language cannot express the bug
Most high-impact vulnerabilities are "the code does something the framework never meant to permit." Termin closes common classes of those by removing the construct entirely.
- SQL injection requires constructing a query string from user input. Termin has no string-concat-into-query operation. Queries are generated by the runtime from the IR.
- Broken access control requires skipping an auth check. Access grants are the only path to data; there is no "raw" API behind them to skip to.
- Undeclared state transitions require writing a transition the spec did not declare. The state set is closed at compile time; an undeclared transition is a compile error.
- Data exfiltration through a confidential field requires escaping taint. Taint is a static property of every expression.
You cannot make these mistakes in a Termin app because you cannot write the code that makes them.
Every data flow is visible
A reviewer reading a .termin file can answer, without reading a single line of implementation code:
- Who can read this field? Grep for the content name; read the access grants.
- What writes this field? Compute accesses declare it; transition rules declare it.
- What happens when a record is created? Event triggers declare it.
- What external systems does this app touch? Channels declare it.
- What data leaves this boundary? Cross-boundary sends are explicit.
In a general-purpose language, answering any of these requires a full-program analysis. In Termin, the spec is the answer.
Composition does not produce emergent behavior
Adding a primitive to a Termin app is a local extension. A new Content type adds new create/read/update/delete (CRUD) endpoints with the declared permissions; it does not change how existing content behaves. A new Compute fires on its declared trigger; it does not affect other computes. A new Channel is opaque to everything that does not reference it.
This is unusual. In most frameworks, adding a new middleware, a new decorator, or a new module can change the meaning of code written before it. Termin has no open-ended extension point of that kind. The composition rules are closed.
One consequence: you can review a change in isolation. A pull request that adds a new compute is reviewed by reading the compute and its trigger — not by tracing how it interacts with every other compute in the app.
The compiler is a proof assistant
When termin compile succeeds, a set of structural properties hold by construction:
- Every content name used anywhere in the spec is declared.
- Every scope used in an access grant or transition rule is declared.
- Every compute's declared accesses are consistent with the content types it actually references.
- Every reference field points to a real content type with a compatible shape.
- Every state-machine transition uses states the machine declares.
- No raw SQL is constructible; no identifier bypass is possible; no compute can exceed its declared access set.
These are not runtime checks. They are properties of the compiled IR. If the compiler accepts the spec, these properties are true. The guarantees page enumerates the full Tier 1 list and links each to the conformance test that backs it.
What this does not get you
Honest framing matters. Some things Termin's model deliberately does not address.
- Correct specification. If the spec is wrong — a missing permission, a state machine with a logic error, a confidentiality label on the wrong field — Termin compiles it faithfully. The framework removes whole classes of mechanical error. It does not remove design error.
- Applications that do not fit the primitives. Rich client-side interactivity, long-running transactions across external services, heavy numerical or graph processing, products whose core value is a bespoke UI — all of these are better done in a general-purpose language. See the fit diagnostic on /what-is-termin/.
- Operational security. An administrator with filesystem access to the runtime can read the database directly. The runtime defends applications from users; it does not defend the host from administrators. See /security/ for the full boundary.
- Correctness of external providers. A custom Compute provider or a Channel endpoint can do anything the runtime permits. The guarantees apply to declared accesses, not to what a provider does once it is inside its boundary. See the Tier 2 and Tier 3 sections on the guarantees page.
For applications Termin fits, the payoff is substantial. For applications it does not, the wrong tool is the wrong tool, and saying so is part of the contract.
Comparison with a general-purpose framework
A concrete scenario: an inventory manager can update products but not delete them.
Rails (simplified)
class ProductsController < ApplicationController
before_action :authenticate_manager, only: [:update]
before_action :authenticate_admin, only: [:destroy]
def update
Product.find(params[:id]).update(product_params)
redirect_to products_path
end
def destroy
Product.find(params[:id]).destroy
redirect_to products_path
end
private
def authenticate_manager
redirect_to root_path unless current_user.role == "manager"
end
def authenticate_admin
redirect_to root_path unless current_user.role == "admin"
end
end
The permission enforcement relies on the developer remembering to call before_action on every action that needs it. A new action added later without the before_action is unprotected. The framework does not enforce the rule; it provides a way to express it. Reviewing requires reading every controller.
If an API endpoint is added in api/v1/products_controller.rb, the same rule must be repeated there. If a background job updates products, the rule must be repeated there. Every context is its own set of checks to remember.
Termin
Anyone with "inventory.write" can update products
Anyone with "inventory.admin" can create or delete products
A manager role that has inventory.write but not inventory.admin cannot delete. There is no code path to forget: all update and delete operations on products, whether initiated from the web UI, the REST API, a compute, a transition, or a channel action, go through the same runtime enforcement. The rule is declared once and enforced everywhere.
Reviewing the rule takes three lines. Changing it takes editing those three lines.
See also
- /what-is-termin/ — the shorter explainer, fit diagnostic, and name origin.
- /guarantees/ — the three-tier model, with a link to the conformance test backing each structural claim.
- /try/ — install, compile the warehouse example, run it.