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.

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:

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.

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:

  1. Contenttickets, users, comments.
  2. Identity — scopes (tickets.view_own, tickets.view_all, tickets.resolve, tickets.admin); roles (requester, agent, admin).
  3. Access grants — requesters can create and view their own tickets; agents can view all tickets and post comments; admins can delete.
  4. Stateopen → in_progress → resolved → closed, transitions scope-gated.
  5. Computeresolution_time fires on ticket.resolved, accesses the tickets Content, computes the elapsed time from open to resolved, writes it back.
  6. Channels — an outbound channel to a Slack webhook.
  7. Events — when a ticket is resolved, send a summary to the Slack channel.
  8. 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.

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:

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:

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.

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