The complete TOL reference, rendered for reading. Point your own AI at it, or download the raw markdown to paste into a chat.
Download .mdTOL Handbook
TOL - the Total Orchestration Language. The reference for reading and writing TOL directly, by hand or with an AI - the same spec Topolog's own authoring models read.
Authoring. Write TOL however suits you: visually in the IDE, or directly - including with an AI. Point any model (Claude, GPT, Gemini) at this handbook, describe your goal, and paste the result into Expert Mode (Shift+E in the plan IDE). The engine validates every construct before it lands, so a plan that clears the validator runs identically whether a human, the IDE, or your own AI wrote it. Your TOL is the source of truth - the canvas, schedule, and forecasts are all views computed from it. (Declared field values survive editing; only comments and exact formatting are normalised - see "What round-trips byte-stable, what doesn't" below.)
What problem TOL solves#
You're planning a piece of work - a product launch, a research programme, a renovation, a hiring round, a financial year. It has tasks, deadlines, costs, dependencies, decisions you haven't taken yet, and outcomes you can't predict with certainty. You want to know how long it'll take, how much it'll cost, how likely it is to succeed, and what the best version of the decisions you're sweeping looks like.
TOL is the language you write the plan in. The Topolog engine reads it and gives you back a probability distribution over outcomes - cost, completion time, success probability, account-balance trajectory - along with a Pareto frontier across the decisions you've declared variable. The thing that makes TOL different from "just a planning tool" is that the language is shaped so the engine can answer those questions analytically, without ever running an unbounded simulation.
Vocabulary#
A few terms recur throughout. Skim once, refer back as needed.
- Plan - the structural document. A single
plan "..." { ... }block declaring tasks, agents, deliverables, decisions, milestones, edges, and outcomes. - Task - a leaf unit of work. Has an agent, a duration estimate, optional cost, optional effects on plan state.
- Outcome - a named result a task can produce (boolean / scalar / categorical). The engine tracks its distribution.
- Agent - who or what does a task. Internal / external / AI / (rare) universe. May be an individual, a pool, or a joint group. The literal
@openis a runtime placeholder - the scheduler picks the cheapest available qualified member of an implicit pool determined by the task's area / role constraints. - Deliverable - a typed object tasks consume, produce, or transform. Lives in the
deliverable_registry(owned by the plan) orunowned_deliverable_registry(acquired from outside). - Decision - a variable the engine sweeps over. Either continuous (gridded) or discrete (enumerated); see Decisions for the classification rule.
- Monte Carlo iteration - one stochastic run of the plan with all random draws realised. The engine runs many of them (typically thousands) to build the outcome distribution.
- Pareto cell - one point in the decision-space the engine sweeps. Continuous decisions are gridded; discrete decisions enumerate their cardinality. Each cell gets its own batch of Monte Carlo iterations.
- Sentinel - a terminal node of the plan-DAG. Tagged
success,failure, orpartial. - Edge - a directed connection between nodes, optionally carrying a deliverable or a gate predicate.
- Effect - a declarative state delta applied atomically when a task completes (
mutate,transition,produce,consume,release, plus the population verbs). - TOLScript - the small total expression language embedded inside TOL at let / derived / when / produces / function-body positions.
Totality, briefly#
Every well-formed TOL plan terminates with a defined result in bounded time. You don't engineer for termination - the language guarantees it. Structurally: discrete decisions are capped at cardinality 32 per axis, deliverable instance counts come from literal integers or bounded decisions, user-defined references form acyclic dependency graphs (you can't define a thing in terms of itself, directly or transitively), evolution between events is closed-form (linear or exponential - no Euler stepping), and TOLScript cannot recurse, cannot use unbounded control flow (the lexer rejects while, do, loop, goto, break, continue and 22 other keywords as identifiers), and runs under a per-evaluation instruction budget.
If you find yourself reaching for a construct the language doesn't allow, that's usually a sign the model belongs in a solver, not in TOL.
Plan shape#
A minimal plan - goal, decisions to sweep, Pareto objectives, an outcome, a deliverable, an agent, a task, a sentinel, an edge:
plan "My Plan" {
version: "0.1"
// 1. Goal - one-line description of what the plan is for.
goal { name: "Ship the thing" }
// 2. Decisions - variables the engine sweeps over.
decisions {
budget: money in [10000gbp, 50000gbp]
promo: boolean
office: enum[small, medium, large]
headcount: integer in [1, 5]
}
// 3. Pareto objectives - what we're optimising.
pareto {
minimize: account.balance.trajectory.drawdown
maximize: account.balance.trajectory.last
}
// 4. Outcomes - named results tasks can produce.
outcome menu_done: boolean
// 5. Deliverable registry - typed objects tasks operate on.
deliverable_registry {
Account {
chained_type: chain
parent_id: null
properties { balance: money = 80000gbp }
evolution { balance: exponential(0.04 per 1y) }
initial_instances: 1 { }
}
}
// 6. Agents - who does the work.
agent alice { type: internal }
// 7. Tasks - leaf units of work.
task design_menu "Design the menu" {
agent: alice
estimate: 4h
cost: 200gbp
produces: [menu_done]
}
// 8. Sentinel + edge - terminal node and the task's path to it.
sentinel done { end_state: success }
edge e1 design_menu -> done { carries: null }
}
Available Pareto objectives
Dotted paths in the pareto block name derived plan quantities the engine computes per Monte Carlo iteration and aggregates across the iteration set. The headline ones:
| Dotted path | What it measures |
|---|---|
account.balance.trajectory.drawdown | Running maximum drawdown of an Account deliverable's balance over the plan timeline. Use this to optimise against worst-case cash position. |
account.balance.trajectory.last | Terminal balance at the plan's end. Use for "how much do we keep?". |
account.balance.trajectory.min / .max | Running min / max of the balance over the plan timeline. |
plan.duration.mean / .p50 / .p95 | Total plan duration (mean / median / 95th percentile across iterations). Use to optimise against completion-time risk. |
plan.cost.mean / .p95 | Total plan cost; same percentile shapes. |
p_success / p_partial / p_failure | The probability the plan reaches a success / partial / failure sentinel (the terminal verdict) across iterations - the headline "P(success)" objective. A bare name, no aggregator. Distinct from outcome.<id>.p_true (one outcome's resolution): p_success rolls up the whole terminal-gate partition, so it is the right maximize: target when several outcomes compose into the verdict. |
outcome.<id>.p_true | For a single boolean outcome - the probability it resolves true across iterations. |
outcome.<id>.mean / .p50 / .p95 | For scalar outcomes - moments of the realised distribution. |
The general shape is <noun-path>.<aggregator>, where the noun-path resolves to a deliverable property, a plan-level quantity, or an outcome, and the aggregator is one of mean, p50, p95, min, max, last, drawdown, p_true (boolean outcomes only). Not every aggregator is meaningful on every noun; the IDE surfaces a picker.
Noun-path naming. Dotted paths use the lowercased deliverable-type name as the head when the plan declares a single instance of that type (e.g. account.balance.… for the Account registry entry with initial_instances: 1). The literal prefix outcome.<id>.… introduces an outcome path regardless of the outcome's own naming convention. plan.… is a built-in prefix for plan-level aggregates (plan.duration.…, plan.cost.…). Multi-instance deliverables and per-instance aggregates use a richer path shape the IDE composes for you; you can write them by hand too.
Plan - top-level fields#
| Field | Required | Notes |
|---|---|---|
"..." quoted goal text | yes | Quoted string right after plan. |
version: "..." | no | Author-supplied version tag. |
team_id: <ident> | no | Identifier of the owning team. |
mode: design / mode: execution | no | Lifecycle (see below). |
start_date: <ISO timestamp> | no | Plan anchor, e.g. start_date: 2026-01-01T00:00:00Z (a full timestamp, not a bare date). Needed for absolute-deadline enforcement (below) and for any start_at: clock anchor. |
goal { name; description; status; deadline: <date> } | no | Embedded goal; deadline: is the plan-wide target and the anchor for the goal.deadline shorthand. |
Deadlines are advisory but checked. A deadline: on a milestone (or the goal) does not hard-stop the plan, but once start_date is set the engine compares each milestone's modelled earliest-finish to its deadline and raises HW-5 (deadline at risk) when it is more likely than not to miss. Without start_date an absolute deadline cannot be mapped onto the schedule, so it stays purely decorative - set start_date if you want deadlines enforced.
Plan lifecycle: design vs execution
A plan moves through two phases.
In mode: design the plan-DAG is allowed to contain cycles at non-leaf levels - for example, a Decision milestone with an outgoing "feedback" edge to an earlier Research milestone, modelling a real-world loop. Cycles between leaf-level tasks are still rejected, but the higher-level loops let you express the plan's causal structure honestly while you're still authoring. Monte Carlo, critical path, and Pareto sweeps cannot run in design mode.
In mode: execution all cycles have been resolved - either wrapped as iteration blocks (a cycle expanded into a bounded sequence) or removed. The leaf-DAG is acyclic and the engine can run.
Omitting mode is the legacy behaviour: equivalent to execution-strict (no permissive design-mode semantics). Mode lives on the plan rather than per-node so the engine has one consistent invariant set to enforce.
Execution anchors the prior in reality. Once a plan is executing, the engine keeps an append-only event log beside it and conditions every forecast on what has actually happened, so the spectrum is the distribution given the past, not the prior from t=0. You do not author this; it is captured on the /execute page. Marking a task done is treated as an observation that moves it across the execution frontier (past vs future): a mandatory gate collects the realised values that completion resolves - the decisions it enacted, its actual spend + time, and the realised outcomes / deliverables / hires / gate outcomes - which then pin the Money, Pareto, and forecast charts to the left of "now". You write the prior (this handbook); execution writes the facts. (Engine detail: the realisations live on each task's observations block; see tol-spec.md SI-59.)
Decisions#
A decisions { ... } block enumerates variables the Pareto sweep explores. Every decision row has the shape <name>: <type> [in [<lo>, <hi>]]. The type determines whether it's continuous (gridded) or discrete (enumerated).
decisions {
starting_balance: money in [1000gbp, 10000gbp] // continuous
growth_rate: float in [0.0, 0.2] // continuous
team_size: integer in [1, 30] // discrete (cardinality 30)
run_promo: boolean // discrete (cardinality 2)
office_size: enum[small, medium, large] // discrete (cardinality 3)
}
The classification rule:
| Surface | Classification | Constraint |
|---|---|---|
money in [a, b] | continuous | gridded at configurable resolution per axis |
float in [a, b] | continuous | gridded |
integer in [a, b] | discrete iff (b - a + 1) ≤ 32; rejected at parse otherwise | the engine enumerates every value |
boolean | discrete (cardinality 2) | - |
enum[v1, v2, ...] | discrete (cardinality = list length); rejected at parse if length > 32 | - |
There is no "continuous integer" surface today - every integer decision is bounded, and bounded integers with cardinality > 32 are rejected as combinatorial-optimisation territory (route those to a solver). If you genuinely want a real-valued sweep on what's logically an integer, declare it as float in [a, b] and round at the read site.
Continuous decisions are admitted only in value-expression positions (cost expressions, arithmetic, let right-hand sides). Discrete decisions are admitted everywhere - gate predicates, iteration generators, effect when clauses, distribution parameters - because within a Pareto cell their value is fixed and the engine's variance-reduction tricks still hold.
A free-variable reference in expression positions is written ?<name> - e.g. cost: ?starting_balance.
Coupling a decision to the forecast (decision → outcome prior). A decision that only feeds task costs moves the money axis but not P(success) - so the optimiser just minimises spend and the frontier collapses to "always pick the cheapest". To give a decision real agency, layer a new outcome prior on it with conditional_overrides on the outcome: a when: gate over a discrete decision raises (or lowers) that outcome's default_prior inside the Pareto cells where it fires. Now "invest more" buys a higher success probability, and the sweep returns a genuine spend-vs-success trade-off.
decisions { gtm: enum[lean, balanced, aggressive] }
outcome pmf_strong: boolean {
default_prior: 0.45 // lean GTM
conditional_overrides: [
{ when: gtm = balanced; apply: { default_prior: 0.65 } }
{ when: gtm = aggressive; apply: { default_prior: 0.85 } }
]
}
The coupling is applied per Pareto cell, so it shows in the frontier (the sweep), not in the single unconditional spectrum you see before sweeping.
Coupling a decision to cost (decision → drawdown). The complement to the outcome-prior link: give the decision a cost_scale clause so each value scales every task cost. Without it, the value that lifts P(success) is free and strictly dominates - a trivial frontier; with it, "invest more" genuinely costs more and you get a real cost-vs-return trade-off.
decisions {
gtm: enum[lean, balanced, aggressive] cost_scale { lean: 0.8, balanced: 1.0, aggressive: 1.4 }
}
Now both axes move: aggressive GTM lifts P(success) and profit (via the prior coupling above) AND raises drawdown (via the cost scale), so the optimiser trades return against cash risk. The multipliers must be positive and their keys must be the decision's values (SI-44d). A boolean decision takes cost_scale { true: 1.4, false: 1.0 }.
Pareto block#
Declares the multi-objective optimisation surface. Plan-root only.
pareto {
minimize: account.balance.trajectory.drawdown
maximize: account.balance.trajectory.last
}
The engine returns the non-dominated frontier across all Pareto cells. There is no single "optimal" plan - the frontier is the answer; the operator picks a point matching their risk preference.
Outcomes#
outcome menu_done: boolean
outcome cost_overrun: scalar { default_prior: { distribution: normal, mean: 0, std: 100 } }
outcome decision: categorical { yes, no, defer }
Three types: boolean, scalar, categorical { v1, v2, ... }.
default_prior is the distribution the engine falls back to when the outcome is referenced (typically by a downstream gate predicate) on a Monte Carlo iteration where no producing task fired. The most common reason this happens is a gate cutting off the branch that would have produced the outcome - for example, an early-failure milestone short-circuiting the rest of the plan. Without a prior, the engine has to treat the outcome as undefined; with a prior, it samples from the declared distribution and keeps going. Required on scalar / categorical outcomes referenced from gates that may be reached without producers; optional on booleans (default: false).
Outcomes are produced by tasks two ways:
- Bare list -
task t { ... produces: [outcome_id, ...] }. Use this for outcomes whose value is determined by the task firing at all (typical for booleans: "did the milestone happen?"). - Explicit block -
task t { ... produces { name: expr, ... } }. Use this when the outcome's value is computed (typical for scalars: "how many signups did this campaign produce?").
A task may use one form or the other, not both. Rule of thumb: bare list for boolean flags, explicit block for value bindings.
Reading an outcome - two shapes. Inside a gate or an effect when clause, an outcome is referenced as <outcome_id>.value and reads the current Monte Carlo iteration's realisation (e.g. gate: verified.value = true). Inside a pareto { ... } dotted path, outcome.<id>.<aggregator> reads a cross-iteration aggregate (e.g. maximize: outcome.menu_done.p_true). The first lives inside one sample path; the second summarises across many.
Deliverable registry#
Deliverables are the typed objects tasks consume and produce. Two flavours:
deliverable_registry { ... }- types the plan owns and constructs.unowned_deliverable_registry { ... }- types the plan acquires from outside (book a venue, hire a consultant) and may release. Same entry shape; semantically distinct.
Every deliverable entry has a chained_type:
chain- an ordered state machine. Instances move through ordered states declared by child entries: the parent type (chained_type: chain) holds the identity; its children (chained_type: state_setwithparent_id: <parent>) declare the legal states. Example:Lease(chain, parent_id: null) with childLeaseState(state_set, parent_id: Lease, predicates:[draft, signed, archived]).state_set- unordered set membership. Instances tag with any combination of states declared inpredicates: [...]directly on the entry. Example: aCustomerisnew,paying, orchurned.
The predicates: field is meaningful on state_set entries; on chain entries it's normally absent (the states live on the child state_set entry instead). A state_set entry may omit predicates: entirely when the type has no meaningful states - the entry then behaves as a populated typed bag whose instances carry only their properties. Use this shape for types you create-and-count but never tag (e.g. Engineer { chained_type: state_set; initial_instances: 3 } in the hiring idiom below).
deliverable_registry {
// A money-typed chain with no states (just numeric properties).
Account {
chained_type: chain
parent_id: null
properties { balance: money = 80000gbp }
derived {
annual_interest := "balance * 0.04"
}
evolution { balance: exponential(0.04 per 1y) }
initial_instances: 1 { }
}
// A state_set type with three declared states.
Customer {
chained_type: state_set
parent_id: null
predicates: [new, paying, churned]
properties {
monthly_rate: money
signup_time: time
}
derived {
ltv := "monthly_rate * 24"
age_days := "now() - signup_time"
is_long_term := "age_days > 365"
}
}
}
Properties declare typed numeric attributes with optional defaults. Types: money, count<T>, duration, time, int, float, plus any author-declared quantity type.
Derived properties are pure TOLScript expressions evaluated lazily at the read site. They can reference primary properties and other derived properties on the same instance; they cannot mutate, cannot reference other instances, and the dependency graph must be acyclic.
Evolution laws specify continuous-time integration between events. Two closed-form families: linear(<rate> [per <duration>]) and exponential(<rate> [per <duration>]). Zero exponential rates and non-positive per durations are rejected - a constant-value property is declared linear(0).
initial_instances: <N> { <bindings> } seeds N instances of the type at plan start. The count must be a literal integer (no ?varname, no dist(...)) - it's structural plan setup, not stochastic content.
Abstract types + polymorphic queries. A deliverable type may declare one or more parents via is. The parent can be either a concrete deliverable_type or an abstract_type - the same shape but with no instances of its own.
deliverable_registry {
abstract_type Employee {
properties { monthly_salary: money, hire_date: time }
}
deliverable_type Engineer is Employee, Agent {
chained_type: state_set
predicates: [hired, productive, terminated]
threads: [coding, review]
properties { specialty: enum<frontend, backend, ml> }
}
}
Queries against an abstract type are polymorphic: query(Employee) matches every concrete is Employee subtype (Engineer, SalesRep, Designer). Predicates inside the where clause can only reference fields declared on the queried type - to access subtype-specific fields, narrow with as: (e as Engineer).specialty.
Multi-parent is allowed, but parents' property declarations must be compatible: two parents may declare a same-named property only if it has the same type. There is no method-resolution order - only property-contract intersection - so diamonds are impossible. Use multi-parent when an entity belongs to two cross-cutting categories at once (an Engineer is both an HR-tracked Employee and a schedulable Agent; a SessionGuitarist is both a SessionCandidate and an Agent).
Shareability - runtime contention over a deliverable
A deliverable entry may declare shareability: N to cap how many tasks may simultaneously occupy it. Positive integer, ≥ 1. Omit it (the default) for the overwhelming majority of deliverables - documents, decisions, sign-offs, software artefacts - where the underlying medium is effectively infinitely shareable and the question of contention doesn't arise. Declare it when the deliverable represents a single physical object, a venue, or a finite-stock resource that runtime tasks would queue for.
deliverable_registry {
guitar { chained_type: state_set; shareability: 1; predicates: [tuned] } // one guitar
workbench { chained_type: state_set; shareability: 3; predicates: [setup] } // three stations
design_doc { chained_type: state_set; predicates: [drafted] } // omit → ∞
}
shareability pairs with a per-edge (capacity: K) annotation on each consumes: / uses: reference (see Tasks below). The two together let you express resources that admit partial occupancy - a workbench with 3 stations where one task needs 2 stations and another needs 2, so they can't both be in-flight. With both annotations omitted, plans behave as before: unconstrained shareability, unit capacity, no contention check.
The runtime check fires at scheduler pickup, not at plan validation: when you try to pick up a task, Topolog sums every active claim ({in-flight ∪ pickup-candidate}) against the deliverable's shareability; if it would overflow, the pickup is blocked with a message naming the contended deliverable. Plan-time validation only checks structural well-formedness (SI-68) - there is no "you forgot to declare shareability" warning because the unconstrained default is what most authors actually want and the check is non-vacuous only at the moment of contention. Cross-goal contention is by pure type-name match (a workbench in goal A contends with a workbench in goal B); conflicting declarations across goals resolve to the MIN (the most-restrictive author wins).
Forbidden on chained_type: pool - pool's initial_count plus produce: N / consume: N effects already encode fungible stock-and-flow; layering shareability on top would be redundant. For "three workbench stations" use a non-pool deliverable with shareability: 3 and per-task (capacity: K), not a pool.
Agents#
agent alice {
type: internal
kind: individual
availability: schedule {
mon { 0900-1700 }
tue { 0900-1700 }
wed { 0900-1300 }
}
cv: 0.2 // coefficient of variation on duration / cost
}
agent design_pair {
type: internal
kind: joint
constituents: [alice, bob] // a fixed set who all participate
}
agent eng_team {
type: internal
kind: pool
members: [alice, bob, carol] // any one of them, scheduled by availability
}
agent gpt {
type: ai
cost_per_call: 0.02usd
cost_per_token: 0.00001usd
inference_latency: 3s
}
type: is one of internal, external, ai, universe. type: ai agents support the three AI cost/latency fields. kind: distinguishes how multiple humans are combined: individual (one person), joint (a fixed group, scheduled by everyone's availability), pool (any one of N, scheduled by the first available).
Threads decide what runs in parallel. Each agent has one default execution lane (its thread), capacity one: two tasks owned by the same agent serialise on that thread even when they are structurally independent in the DAG. This is why a solo founder's plan is mostly sequential no matter how the tasks branch - one person, one thread - and why piling work onto a single agent is the usual cause of a plan that "takes forever". To model genuine parallel lanes for one agent, declare named threads (agent dev { threads: [feature, hotfix] }) and place a task on one with thread: hotfix. A universe agent has unbounded concurrency (the world does many things at once), so its iteration instances can overlap - see concurrent: true on iterations. Getting threads right is what separates an honest schedule from a fantasy where everything happens at once.
Pool agents may also be declared with the compact pool form:
pool eng_team {
type: internal
members: [alice, bob, carol]
}
The compact form is sugar; the IDE may emit either form on round-trip.
Tasks#
task design_menu "Design the menu" {
agent: alice // a declared agent, or @open
estimate: 4h // lognormal duration with the agent's cv
cost: 200gbp // overrides agent default
area: design // calibration bucket
description: "..."
consumes: [draft_lease] // owned-deliverable handles consumed at task start
produces: [menu_done] // bare-list outcome producer
precondition: design_brief_signed // a gate that must resolve true
acquires: [printer] // unowned deliverables acquired
releases: [printer] // unowned deliverables released
rationale: { confidence: HIGH } // optional provenance metadata
effects { /* see below */ }
}
Or with the explicit produces form:
task survey_pass "Survey" {
agent: alice
estimate: 4h
cost: 200gbp
produces { signups: 50, lifespan: 30d }
}
A task uses produces: (bare list) OR produces { ... } (explicit block) - not both.
Atomicity: keep leaf tasks small (the 4-hour cap). A task run by a human agent (type: internal / external) must have a mean estimate of at most 4 hours - the validator rejects anything larger (SI-2 / SI-70). A leaf task is one sitting of focused work, not a phase. Anything bigger is decomposed: a multi-day effort becomes a milestone of ≤4h tasks; a repeated unit (twelve partner onboardings, eight deals) becomes an iteration. universe and ai agents are exempt - a six-week procurement wait or a single model call is one modelled event. This cap is what keeps estimates calibratable and the schedule honest, and it is the most common authoring error - decompose first, size tasks second.
Per-task cv (uncertainty). Append cv <n> to an estimate to set that task's coefficient of variation - the spread of its lognormal duration - overriding the agent default: estimate: 4h cv 0.5. Calibrate it: routine, well-trodden work is cv 0.2-0.3; normal delivery 0.4-0.5; novel, externally-blocked, or first-time work (a new acquisition channel, a regulator's response, launch day) 0.7-1.5. One uniform cv across every task is a calibration smell - vary it by how much you actually know.
Owned vs unowned deliverables. consumes: [...] / produces: [...] move owned deliverables - types declared in deliverable_registry, which the plan constructs and destroys. acquires: [...] / releases: [...] move unowned deliverables - types declared in unowned_deliverable_registry, which the plan books from the outside world and gives back. The two pairs are surface-distinct because they have different costs (acquiring an external venue debits the account; producing an owned Customer does not).
uses: [h] is a non-destructive deliverable reference. Like consumes:, the task can't pick up until the handle exists; unlike consumes:, the deliverable is not destroyed at task completion - it persists for downstream tasks. Use uses: for tasks that read a deliverable's properties (peer review reading a Paper, an audition reading a SessionCandidate without "consuming" them) or that need a non-consumable resource (a guitar used during recording, not destroyed).
Per-edge capacity (capacity: K). Any consumes: / uses: reference may carry an optional (capacity: K) postfix declaring how many units of the referenced deliverable's shareability budget this task claims for its entire pickup-to-done window. Positive integer ≥ 1; defaults to 1 when omitted (the common case - "I need one of these"). Pairs with the deliverable-side shareability: field (see Deliverable registry above) to model resources that admit partial occupancy.
deliverable_registry {
workbench { chained_type: state_set; shareability: 3; predicates: [setup] }
}
task t_assemble { agent: a; estimate: 30min; consumes: [workbench(capacity: 2)] }
task t_finish { agent: a; estimate: 30min; consumes: [workbench(capacity: 2)] }
task t_inspect { agent: a; estimate: 30min; uses: [workbench(capacity: 2)] }
With a budget of 3 and three tasks claiming 2 apiece, the scheduler admits any one alone but never any pair (2 + 2 > 3). Both consumes: and uses: references contribute to the claim sum - the consume-vs-use distinction is whether the deliverable persists after the task, not whether it occupies capacity during the task's window. The capacity postfix can also wrap inline property-match shorthand and all(...) queries - anywhere a reference is legal, (capacity: K) is too.
start_at: <time> pins a task to a wall-clock time. The task fires at max(start_at, dependency_ready, thread_free) - start_at is a lower bound, not a hard pin if upstream dependencies aren't ready yet. Use for fixed-time events: a wedding ceremony at 4pm regardless of preparation finish, a conference talk at a published slot, a market open at 9am. start_at must evaluate to a time at or after plan start (load-time check).
Inline property-match shorthand. Any deliverable-ref position (consumes:, uses:, gate predicates) accepts the inline form <Type> { field: value, ... } as sugar for query(Type).where(t => t.field == value && ...).first():
task SubmitPaper "Submit" {
consumes: Paper { state: draft } // sugar
// equivalent: consumes: query(Paper).where(p => p.state == draft).first()
agent: pi
duration: 2w
effect: transition Paper to state submitted
}
The validator type-checks each field: value pair against the named type's registry entry. Useful for picking a single instance by a small set of property constraints without writing the full query.
agent: @open leaves the agent unbound. The scheduler resolves it at run time to the cheapest available qualified member of an implicit pool determined by the task's area: and any other declared constraints. Use this when the plan doesn't care who does the task as long as someone qualified is free.
Effects
Tasks may declare an effects { ... } block holding declarative state deltas applied atomically at task completion. Ten verbs (counting destruct and destruct into ... as one verb with an optional into clause); every one admits an optional when "<tolscript-source>" guard.
The example below shows each verb in isolation - kitchen, menu_v1, staff_x, alive_robots, etc. are placeholder handle names. In a real plan they'd be declared deliverables in the registry or outcomes referenced by id.
effects {
// the four essentials
mutate kitchen.headcount by 5
transition menu_v1 to state signed_off when "review_passed"
produce menu_done
consume menu_v1
release staff_x
// population / construction verbs
construct Robot from chassis, battery { hp: 100, speed: 5 }
construct_many Robot count: 5 from blueprints { hp: 100 }
produce_many Widget count: 12 { weight: 2 }
destruct old_unit
destruct old_unit into Scrap[3] { grade: 1 }
transition_sample alive_robots count: 3 to state decommissioned
}
Effects within a block are an unordered set - two effects must not write the same <handle>.<property>. The when guard runs at task completion; if the predicate is false the entire effect is a no-op.
transition vs transition_sample. transition <handle> to state <s> moves a single named handle into state <s>. transition_sample <set> count: <N> to state <s> randomly draws N instances from a population type (e.g. all Customer instances currently in paying) and moves all of them to <s> together. Use the singular form when you have one specific instance in hand (an order, a lease, a contract); use the sample form when you're modelling churn or attrition over a cohort.
Task-header vs effect-verb forms. Two surfaces, related operations, differing in when they apply - and the spelling differs in a way that's easy to miss: the header form is plural-with-colon (consumes: [h], produces: [outcome], acquires: [h], releases: [h]); the effect-verb form is singular-no-colon (consume h, produce o, release h).
- The header form takes effect at task start -
consumes:reads the handle at start;produces:declares the task as the structural producer of the outcome (the value still resolves at completion). - The effect-verb form takes effect at task completion, alongside every other effect in the block.
For most authoring, the two surfaces are equivalent: the deliverable-flow check treats them as cross-satisfying - the effect-block form satisfies the header form and vice versa. Both surfaces exist for historical reasons and the validator unifies them. Declaring the same handle on both surfaces of the same task is redundant but not an error - the validator merges them. The IDE emits the header form for the simple cases and the effect-block form when there's a when guard or other effect-block context.
What runs at sample time (Phases 4-6 + A-E engine work, 2026-05). The spectrum sampler maintains a per-MC-iteration live handle world that reflects every instance currently alive. Each event (produce_many, construct, consume, release, transition, mutate, …) updates this world at task completion. Author-visible consequences:
exists(EntityQuery {...}),count(...),any(...),all(...)gates evaluate against the live world. A gate likecount(EntityQuery { target: Paper }) >= 3correctly fires only when three Papers are alive at the moment the edge is evaluated.- Implicit producer-consumer ordering. A task with
consumes: [X]automatically depends on tasks that produce X - even with no explicit edge. The consumer's earliest-start is raised to the earliest producer's completion. Types withinitial_instancesdeclared skip this wait (supply assumed at t=0). Implicit edges that would close a cycle with the static DAG are skipped (the consumer fires without the wait - that scenario almost certainly indicates an authoring mistake the validator should catch first). - Chain refs disambiguate instances.
@track.composition.arrangedmatches the oldest live track instance currently in statearranged(the leaf step is the state filter; intermediate steps describe registry-side hierarchy and aren't consulted at runtime).@Paper[1]picks the second matching instance after FIFO ordering.Paper { state: draft, rank: 1 }(inline property-match) narrows by multiple property pairs. - Cardinality on
consumes:is honoured.consumes: [Paper [3, 3]]destroys 3 Paper instances per task fire (lower-bound convention). When fewer instances are available, the task fires with partial supply and emitsRT-CARDINALITY-NOT-MET. mutate <handle>.<property> by <value>accumulates. The instance's property is set to(existing ?? 0) + valueat task completion. Downstream gates reading the property see the updated value. Numeric properties only - non-numericmutateis a future extension.acquire/releaselifecycle for unowned deliverables. Types declared inunowned_deliverable_registry { ... }(alongside the owneddeliverable_registry) follow check-out semantics:acquiremarks the instance with an internal_busy: trueflag;releaseclears it. Authors can query busy/free viaany(EntityQuery { target: Tool }, _busy = true). The flag survives across tasks in the same MC sample.- Runtime issues surface in
spectrum.runtime_issues. When a consume finds no match, a mutate targets a missing instance, or any other silent no-op fires, the sampler emits anRT-*code aggregated across samples. The spectrum still returns successfully; these are advisory signals for the author. See the runtime-issue table below the SI table.
Milestones#
Concrete milestones group child items:
milestone MenuLaunch "Menu launch" {
description: "Stages of getting the new menu out"
deadline: 2026-09-01
area: design
task design_menu "Design" { ... }
task print_menu "Print" { ... }
}
Container edges need a leaf edge underneath (transmission). When you connect two milestones at the container level (edge A -> B), you must also author a leaf cross-edge from a task inside A to a task inside B - the milestone-zoom view and the task-zoom view of the flow have to agree. Skipping it triggers SI-26 / SI-73 (a container edge with no leaf flow, or leaf flow with no container edge); a milestone whose children carry no transmitted edge triggers SI-75 / SI-76 / SI-77 (orphan / dead-end / unreachable tasks). Practically: when you split work into a milestone and wire it to its neighbours, route one real task→task edge across each boundary - the container edge is the bare summary, the leaf edge carries the gate. The IDE's Quick Fix synthesises these mechanically, but authoring them keeps the two zoom levels honest.
Parametric milestones are templates with type and/or value parameters, instantiated at use sites:
milestone Pipeline<Input, Output> (depth: int) "Pipeline template" {
description: "Generic pipeline"
task t1 "Process" { ... }
}
// call site (legal inside another milestone body):
use Pipeline<RawData, ProcessedData>("3")
Type args are bareword identifiers (deliverable type names or built-ins). Value args are quoted TOLScript expressions. The use graph is acyclic - a parametric milestone cannot instantiate itself directly or transitively.
Iterations#
Iterations express bounded repetition over a generator. The canonical form is the over: field; max_count caps the number of expansions and is mandatory.
iteration weekly "Weekly review" {
over: schedule(every 1w, start 2026-01-05)
max_count: 26
template: milestone Review { ... }
}
iteration onboard "Onboard each existing customer" {
over: query({ target: Customer, filters: [{ field: state, op: =, value: new }] })
max_count: 200
template: milestone OnboardingCohort { ... }
}
iteration customer_arrivals "Customers arrive over Q1" {
over: arrivals({ distribution: poisson, rate: 5.0 })
max_count: 1500
template: milestone NewCustomer { ... }
}
iteration revisions "Revise the draft until it passes review" {
over: until(draft_accepted = true)
max_count: 8
template: milestone Revision { ... }
}
iteration songs "Five tracks on the EP" {
over: range(5)
max_count: 5
template: milestone TrackProduction { ... }
}
Five generator forms:
range(N)- N independent expansions, indexed 0..N-1. Static - unfolds at plan time. (Distinct from the TOLScript builtinrange(lo, hi), which produces a list. Same name, different namespaces; the iteration-generator form takes one arg, the builtin takes two.)until(<gate_expr>)- keep expanding until the gate evaluates true. Bounded bymax_count.query(<entity_query>)- one expansion per matching deliverable instance, evaluated at plan time. Use to iterate over an existing population (everyCustomerinstate == new, everyLeasepast its deadline).arrivals({ distribution: poisson, rate: <r> })- temporal: Poisson event stream with raterper unit time. Each Monte Carlo iteration draws its own arrival sequence within the plan's time window. Use to model customer arrivals, support tickets, equipment failures.schedule(every <duration> [, start <date>])- temporal: wall-clock periodic. Fires every<duration>from the start date. Use for monthly reviews, daily standups, quarterly board meetings.
Static generators (range, until, query) let the engine pre-compute the unfolded DAG once and reuse it across every Monte Carlo iteration - sample paths share topology, which is cheaper. Temporal generators (arrivals, schedule) are re-evaluated per iteration because the event stream varies.
concurrent: true - parallel instances. By default a range iteration's instances share their agent's single thread, so they serialise in the schedule even though they are structurally independent. That is right for a person (one founder cannot onboard twelve partners simultaneously) but wrong for a universe agent whose instances genuinely overlap - eight enterprise procurement cycles, N council reviews, N market-response delays all run in parallel in the real world. Add concurrent: true and each instance's universe-agent tasks get their own lane, so the plan span stops growing linearly in the instance count. It only re-threads universe-agent work; tasks owned by people stay serial, so the flag can never pretend one person is in eight places at once.
iteration enterprise_deals "Run N enterprise deals in parallel" {
over: range(8)
max_count: 8
concurrent: true // the six-week procurement waits (a universe agent) overlap
template: milestone Deal { ... }
}
Edges, gates, sentinels#
outcome verified: boolean
agent alice { type: internal }
milestone Launch "Launch milestone" {
deadline: 2026-09-01
task t1 "Setup" { agent: alice; estimate: 1h }
task t2 "Verify" { agent: alice; estimate: 30min; produces: [verified] }
}
sentinel done { end_state: success } // success | failure | partial
edge e1 t1 -> t2 { carries: null }
edge e2 t2 -> done {
carries: null
gate: verified.value = true and t1.completed_at < Launch.deadline
}
Gates are pure boolean predicates over plan state. Admitted: outcome value comparisons (<outcome_id>.value > 0.5, verified.value = true), task lifecycle timestamps (<task_id>.started_at, <task_id>.completed_at), milestone deadlines (<milestone_id>.deadline), iteration tick predicates, and boolean combinators (and, or, not). Forbidden: effects, mutation, anything with a side effect - the validator enforces gate purity.
Every leaf task reaches a sentinel (SI-10), and the terminal gates must partition. Each outdegree-zero task must edge to a sentinel, so every path ends in a success / partial / failure verdict - plans are outcome-complete by construction. When several edges fan into the terminal sentinels over the same outcomes, their gates must be disjoint and exhaustive: exactly one fires on every realisation, or the forecast double-counts (overlap) or loses probability mass (gap). The canonical pattern composes boolean signals - e.g. with pmf, profit, retain:
edge e_ok work -> s_success { gate: pmf = true and profit = true and retain = true }
edge e_mid work -> s_partial { gate: pmf = true and (profit = false or retain = false) }
edge e_bad work -> s_failure { gate: pmf = false }
These three gates are mutually exclusive and cover every combination, so P(success) + P(partial) + P(failure) = 1. Reach for this composition rather than a conditional produces over a categorical verdict (effect-value positions want numbers, not category labels). To make a decision move the forecast, layer the signals' priors on it with conditional_overrides (see Decisions). SI-9-G restricts sentinel-incoming gates to boolean / categorical outcome predicates precisely so this disjointness stays statically checkable.
Plan-level let - the TOLScript bridge#
let annual_growth = "0.04"
let monthly_burn = "10000 * (1 + annual_growth)"
let cohort_size = "if (run_promo) { 100 } else { 50 }"
Every let body is a quoted TOLScript source string. The TOL parser is non-evaluating - it preserves the source verbatim; the TOLScript evaluator runs the script at evaluation time against the surrounding plan state.
Use let for anything you want to compute once and reference from many places (a shared growth rate, a derived cohort size, a normalised metric).
Named procedures#
function add(a: int, b: int) -> int = "a + b"
function double(x: int) -> int = "add(x, x)"
function ltv(monthly: money, months: int) -> money = "monthly * months"
Body is quoted TOLScript source. The call graph is acyclic (no direct or transitive self-call); TOLScript's runtime RECURSION_BLOCKED provides defence in depth.
TOLScript essentials#
TOLScript is the inner total expression language. Embedded inside TOL at:
let <name> = "<source>"(plan root)derived { p := "<source>" }(deliverable_type)produces { name: <expr> }(explicit task produces - restricted to numeric / cost / duration literals; richer expressions live inletand are referenced by name)effect when "<source>"(any effect verb)function f(...) -> T = "<source>"(plan root)
Inline TOL effect and binding positions still admit the constant shapes (5, 30d, 500gbp) directly without quoting; reach for TOLScript when you need expression-level computation.
Lexical features
Numeric: 42, -3.14, 1.5e6. Duration: 30min, 4h, 2d, 12w, 1y. Money: 500gbp, 0.02usd. Percent: 50%. Strings: "hi" (double-quoted, standard escapes). Booleans: true, false. Null: null.
Expressions
// arithmetic
1 + 2 * 3
(a - b) / c
// comparisons (non-chaining - parenthesise compound forms)
x > 0
y >= z
// boolean
not (a and b) or c
// if-then-else
if (x > 0) { x } else { -x }
// multi-branch if/elif/else (sugar - lowers to nested ternaries)
if (score >= 6) { accept }
elif (score >= 4) { revise }
else { reject }
// records and field access
let r = { a: 1, b: 2 }
r.a
// lists and indexing
let xs = [1, 2, 3]
xs[0]
// fold over a list
fold (acc, 0, x in xs) { acc + x }
// for as statement
for (x in xs) { x * 2 }
// pattern match (literal + binding patterns)
match x {
0 => "zero"
n => "non-zero"
}
// chain-walk on records - extend / is / as
let signed = extend(draft_lease, "Lease") { signer: "alice" }
signed is Lease // → true
signed as Lease // → signed (or throws TYPE_MISMATCH)
extend / is / as are the chain-walk operators. extend(<parent>, "<TypeName>") { ... } produces a new record that merges the parent's fields with the bindings you supply and tags it with the named type. <value> is <TypeName> is a runtime type check (returns a bool); <value> as <TypeName> is the same check but throws TYPE_MISMATCH on failure and returns the value on success. Use these when you're moving an instance one step along its declared state machine - e.g., turning a LeaseState.draft record into a LeaseState.signed record by extending it with the signing-event fields.
Functions
fn add(a: int, b: int) -> int { a + b }
fn double(x: int) -> int { add(x, x) }
double(7) // → 14
Functions cannot recurse (direct or transitive).
Builtins (partial list)
abs, min, max, int_to_float, float_to_int, string_length, list_length, range(lo, hi), random(...), now(). The distribution family names - normal, lognormal, uniform, beta, gamma, exponential, triangular, bernoulli, categorical, poisson, mixture - appear as the first argument of random(...) inside TOLScript or dist(...) inside TOL.
now() returns the simulated current time at the call site's evaluation point - not wall-clock time. The exact moment depends on where you call it: inside a task body let it's the task's start time on this Monte Carlo iteration; inside an effect parameter (or an effect when clause) it's the task's completion time; inside a derived property on a deliverable it's the read site's evaluation time; inside a gate predicate it's the upstream task's completion event time. Across all positions the rule is the same - now() is the time of the surrounding evaluation event on the current sample path. Banned in evolution-law rates (those must reduce to compile-time constants) and in plan-root constant lets.
Cross-task time references. Inside an expression you can reference another task's recorded time via <task_id>.done_time() (completion time on the current MC iteration) or <task_id>.pickup_time() (start time). The referenced task must be upstream in the DAG - forward references to a downstream task's time are a load-time error (a temporal cycle would let a task see its own future). Use this to drive rate curves, conditional effects, and Pareto objectives that depend on the timing of named milestones:
// RSVP arrival rate as a curve over time-since-invitation
let weeks_since_invite = (plan_t() - SendInvitations.done_time()) / 1w
let arrival_rate = rsvp_curve(weeks_since_invite)
Time helpers. day_of_week(t) returns 1-7 (Mon-Sun); month(t) returns 1-12; hour(t) returns 0-23; is_weekend(t) returns a bool. Pure functions over time values. Use in gate predicates, derived properties, and TOLScript bodies for calendar-aware scheduling outside of arrival processes (which already accept piecewise-constant day-of-week rate schedules).
Query slicing operators. Method-chain queries support .take(n), .skip(n), and .flat_map(λ) alongside the existing .where, .map, .order_by, .first, .last, .count, .sum, .min, .max. Use .take(n) to limit to the first n results, .skip(n) to drop the first n, and .flat_map to flatten nested collections (e.g. all tranches across all funded grants). n must be a literal integer or a bounded decision.
// Best 3 positive experiments by creation time
query(Experiment)
.where(e => e.result == positive)
.order_by(e => e.created_at)
.take(3)
// All tranches across all funded grants (flatten one level)
query(Grant)
.where(g => g.state == funded)
.flat_map(g => g.tranches)
Distributional draws - recommended forms
Inside TOL itself (estimate / cost / property binding positions), use the inline dist(<family>, <params>) form:
task survey_pass "Survey" {
agent: alice
estimate: dist(lognormal, mean: 4h, cv: 0.3)
cost: dist(lognormal, mean: 200gbp, cv: 0.2)
produces { signups: dist(normal, mean: 50, std: 10) }
}
This is what the IDE emits and what every example in this handbook uses.
Inside TOLScript expressions (let / derived / function / when bodies), dist(...) is not available as a function call - use the equivalent random(<family>, ...) builtin instead. The two are semantically identical; the surface differs because one is parsed by the TOL parser and the other by the TOLScript parser.
Areas#
area: is an optional tag on tasks, milestones, deliverable entries, and other items. Used for the Bayesian calibration multiplier. Free-form identifiers like design, procurement, qa.
Rationale and provenance#
Most authoring-relevant items admit an optional rationale: { ... } block holding LLM-provenance metadata (confidence, sources, search_used). The IDE renders this; it does not affect plan execution.
Common authoring idioms#
Money trajectories
deliverable_registry {
Account {
chained_type: chain
parent_id: null
initial_balance: 150000 // the Pareto/drawdown budget - REQUIRED
properties { balance: money = 150000gbp } // the mutable balance effects move
evolution { balance: exponential(0.04 per 1y) }
initial_instances: 1 { }
}
}
task pay_salaries "Monthly salary run" {
agent: cfo
estimate: 1h
effects {
mutate Account.balance by -50000gbp
}
}
Declare BOTH initial_balance: and properties { balance: money }. They are different fields serving different layers, and the money model only works with both: initial_balance is the starting cash the Pareto money pass reads (omit it and the budget is 0 - drawdown and the cash trajectory all read zero, silently), while properties { balance: money } is the live balance that mutate Account.balance and gate predicates (Account.balance < X) actually read - omit it and every mutate Account.balance is a runtime no-op (RT-MUTATE-NO-MATCH, which validate() reports as 0 errors). Set both to the same starting figure.
The Pareto-objective dotted name account.balance.trajectory.drawdown is the running maximum drawdown across Monte Carlo iterations.
Costs debit, revenue credits. The money model folds in BOTH task cost: debits and explicit mutate Account.balance by +<amount> credits at each task's completion, so account.balance.trajectory.* (drawdown, end balance, insolvency) and profit_mean reflect real revenue-minus-burn - a plan that books its income is not mistaken for pure burn. (total_cost stays burn-only; it is spend, not net.)
Tie revenue to outcomes with a when guard. Prefer mutate Account.balance by 30000gbp when "deal_won = true" over an unconditional credit. The money pass counts a guarded credit at its EXPECTED value (amount x P(deal_won)), and because that probability is read from the outcome prior - which a decision's conditional_overrides shift - the revenue MOVES with your decisions. That is what makes the Pareto profit frontier non-degenerate: aggressive GTM raises P(pmf) -> raises expected revenue -> raises profit, instead of "every deal closes". The pass resolves simple guards (a boolean outcome, = true / = false, and and-conjunctions of them) and literal amounts; an or / not guard or a dist(...) amount is skipped (counted as zero) rather than mis-counted, so keep revenue guards simple and express "did we hit the target?" as a boolean outcome (e.g. reached_profit) gated on the work.
Gate a sentinel on solvency (insolvency → failure, an earned forecast). A sentinel-incoming edge may gate directly on a derived balance quantity: edge convergence -> s_bankrupt { gate: account.balance.trajectory.last < 0gbp }, with the success sentinel taking the complement (... -> s_profitable { gate: account.balance.trajectory.last >= 0gbp }). The spectrum replays each Monte-Carlo sample's cash position and fires the gated sentinel from THAT balance, so P(failure) carries the plan's real revenue-vs-burn risk - and the engine never overrides which sentinel fires: the sentinel stays ground truth, you simply opt in by authoring the gate. Available quantities are account.balance.trajectory.last (end balance), .drawdown (running max drawdown), and .min (lowest point). This account.balance.* comparison is the ONLY scalar a sentinel gate may reference - every other sentinel gate must still be a boolean/categorical decider. Because the comparison is evaluated per-sample (not over the assignment-enumeration space), a complementary < X / >= X pair on the same quantity is a valid disjoint partition, so SI-9 / SI-9-G accept it.
Churning cohort
deliverable_registry {
Customer {
chained_type: state_set
parent_id: null
predicates: [new, paying, churned]
properties { monthly_rate: money = 50gbp }
initial_instances: 100 { }
}
}
iteration monthly "Monthly cycle" {
over: schedule(every 30d)
max_count: 24
template: milestone CustomerCycle {
task churn_step "Churn 10% of paying" {
agent: ops
estimate: 1h
effects {
transition_sample Customer count: 10 to state churned
when "monthly_rate > 0"
}
}
}
}
Hiring decision
decisions {
team_size: integer in [3, 10]
}
deliverable_registry {
Engineer {
chained_type: state_set
parent_id: null
initial_instances: 3 { }
}
}
task hire_round "Hiring round" {
agent: hr
estimate: 2w
effects {
construct_many Engineer count: ?team_size from candidate_pool { }
}
}
The Pareto sweep enumerates each team_size ∈ {3, 4, 5, 6, 7, 8, 9, 10} as its own cell.
Validator codes you may see in the IDE#
| Code | What it means |
|---|---|
SI-1 | Plan-DAG well-formedness - leaf-level acyclicity (in execution mode). |
SI-2 / SI-70 | Task atomicity - a human-agent (internal / external) task's mean estimate must be ≤ 4h. Decompose larger work into a milestone or iteration; universe / ai agents are exempt. |
SI-10 | Sentinel reachability - every outdegree-zero task must edge to a sentinel; no dangling leaf work. |
SI-13 | Edge cardinality compatibility - what an edge carries matches both endpoints' declared types. |
SI-14 | Edge target is reachable from the source via the gate predicate. |
SI-23 | Gate-query cycle prevention - a gate predicate cannot depend on a node downstream of the gate. |
SI-9-G | Sentinel gate shape - edges into a sentinel may only gate on boolean / categorical outcome_predicates (combined via and / or / not), or be ungated. Lift count / time / scalar conditions into a producing task that emits a categorical bucket so SI-9's rollup-disjointness check has a complete decision procedure on the edges that decide which sentinel fires. |
SI-26 | Mode-aware milestone-edge transmission rules. |
SI-31 | Gate purity - gates have no Effect-class terms. |
SI-32 | Evolution-law well-formedness - finite rate, non-zero exponential, strictly-positive per duration, one law per property. |
SI-33 | Effect disjointness - two effects in a task must not write the same <handle>.<property>. |
SI-34 | Produces / effects { produce } agreement - the validator unifies the header and effect-verb surfaces; neither may contradict the other within the same task (e.g. produces: [x] on the header and consume x in effects { } is rejected). Same-handle on both surfaces is redundant but not an error. |
SI-36 | fold first-argument is a handle-set expression (not a scalar literal). |
SI-40 | Parametric milestone resolution + arity + call-graph acyclicity. |
SI-41 | Time-window query well-formedness - at_time_window(t_start ≤ t_end), state_held_for(_, d ≥ 0). |
SI-42 | Named-procedure call graph is acyclic. |
SI-43 | Derived-property dependency graph on a deliverable type is acyclic. |
SI-44 | Decision-position rules - continuous decisions only in expression positions; discrete admitted everywhere. |
SI-55 | Subset-transition well-formedness - transition_sample count non-negative; target_state in the type's declared state set. |
SI-56 | Effect when clause is a pure boolean TOLScript expression. |
SI-57 | Discrete-decision cardinality ≤ 32 per axis. |
SI-58 | Every consumed deliverable handle has a matching releaser somewhere in the plan. |
SI-72 | This plan participates in a cross-workspace dependency cycle. Every plan in your portfolio is internally acyclic, but cross-workspace edges close a loop across the union DAG so the meta-scheduler cannot find a schedule. The federation-level meta-scheduler is the authority; the IDE surfaces SI-72 in the Problems drawer so it agrees with the orange "1 ERROR" pill on the /plan list card. Fix by removing or inverting a cross-plan edge, or by moving one of the involved plans to Design (which removes it from the active pool). |
SI-73 | Bottom-up edge transmission - the dual of SI-26. If a Task t_a inside container A has an edge to anything in container B, then a container-level edge A → B must also be authored. Catches under-authoring: leaf-level flow exists but the collapsed-milestone view of the plan doesn't show it. The milestone-zoom view and the leaf-zoom view of the plan must agree about flow. Iteration sources are exempt (same as SI-26). Hard in execution mode, warning (HW-73) in design mode. Auto-fix: the IDE Problems drawer surfaces a "Quick Fix" button on every HW-73 / SI-73 row that mechanically synthesises every missing container edge - each synthesized edge carries the OR of the backing leaf-edge gates so SI-9 (sentinel mutual-exclusion) sees the same firing surface at the milestone-zoom view as at the leaf-zoom view. See lib/tol/quickFix.ts::computeHW73Fixes for the IDE auto-fix and lib/tol/migrations/hw73UnderAuthoredEdges.ts::migratePlanForHW73 for the one-shot data-backfill that applies the same patch to plans authored before SI-73 landed. |
HW-74 | Per-level Miller's Law cap (advisory). Every container in the plan hierarchy (plan root, each Milestone, each Iteration template) has at most 7 non-sentinel direct children. Sentinels are exempt; Iterations count as one child at their parent level. Fix by re-decomposing the offending container into 2–7 sub-containers grouped by theme. Promotes the AI-authoring 1-7 guideline to a structural advisory. Always a non-blocking HW-74 warning in BOTH design and execution - an over-broad sibling set is a cognitive-load smell, not a correctness defect, so it never blocks Resolve & Promote / Activate. (The synthesised terminal-partition join lands at plan root and can itself tip a 7-child plan to 8.) |
SI-75 | Transmit-down completeness - incident form (legacy). When a Milestone has external (non-sentinel) edges - either container-level inbound/outbound or cross-container leaf edges - every direct Task child of that milestone must be incident on at least one leaf-level edge. Catches the fully-orphan shape (indeg=0 AND outdeg=0); the directional companions SI-76 / SI-77 catch the dead-end / unreachable shapes. This promotes the authoring-algorithm Transmit op (authoring-algorithm.md §5) to a structural invariant: when a container is decomposed via Split → Transmit → WireSiblings, every child becomes the endpoint of at least one transmitted leaf edge per active direction. Skipping Transmit produces orphan children - tasks that visualise as floating cards on the IDE canvas because no leaf edge incident on them carries the parent's flow down. Tasks at plan root, tasks inside iteration bodies (per-tick semantics), and tasks inside truly-isolated milestones (no external flow) are exempt. Auto-fix: the one-shot data-backfill lib/tol/migrations/si75TransmitDown.ts::migratePlanForSI75 mechanically synthesises transmit-down leaf edges via a five-priority source-selection ladder (existing inbound transmit pattern → existing outbound → container-level inbound → container-level outbound → any cross-container leaf flow); the same backfill simultaneously satisfies SI-76 and SI-77 by adding both inbound and outbound edges as needed. Hard in execution mode, warning (HW-75) in design mode. |
SI-76 | Outdegree completeness - directional companion to SI-75. For every Task t whose containing milestone has at least one external outbound non-sentinel edge, t must have outdeg ≥ 1 at the leaf level - i.e. there must be at least one leaf edge starting from t. Catches the dead-end shape (indeg>0 AND outdeg=0): a task that satisfies SI-75's incident requirement but renders as a stub-tailed card with no arrow leaving it, because the Transmit-down outbound step was skipped during decomposition. Sentinel destinations are excluded from the trigger (SI-10 already enforces task-to-sentinel reachability). Exemptions and auto-fix are the same as SI-75 - the migration adds an outbound leaf edge per affected task via priorities 2 / 4 / 5'. Hard in execution mode, warning (HW-76) in design mode. |
SI-77 | Indegree completeness - directional companion to SI-75. For every Task t whose containing milestone has at least one external inbound edge, t must have indeg ≥ 1 at the leaf level - i.e. there must be at least one leaf edge ending at t. Catches the unreachable shape (indeg=0 AND outdeg>0): a task that satisfies SI-75's incident requirement but renders with no inbound arrow and can never be triggered in the leaf-level DAG, because the Transmit-down inbound step was skipped during decomposition. Exemptions and auto-fix are the same as SI-75 - the migration adds an inbound leaf edge per affected task via priorities 1 / 3 / 5. Hard in execution mode, warning (HW-77) in design mode. |
SI-IPM-1 / SI-IPM-2 | Inline property-match (<Type> { field: value } in consumes: / uses:) - type must exist; fields must be declared on the type or its parents (or the special state). |
SI-TTR-1 / SI-TTR-2 / SI-TTR-3 | Cross-task time ref (<task_id>.done_time() / .pickup_time()) - referenced task must exist; cannot reference self; cannot reference downstream. Walks precondition, effects, and start_at. |
SI-MP-1 / SI-MP-2 / SI-MP-3 | Multi-parent is X, Y - every parent must be declared; same-named property contracts across parents must have compatible types; the is graph must be acyclic. |
SI-Q-1 / SI-Q-2 | EntityQuery.take / .skip must be non-negative literal integers. |
SI-SA-1 / SI-SA-2 | task.start_at consistency - SI-SA-1 flags start_at resolving to before plan.start_date (negative minutes); SI-SA-2 flags absolute-timestamp start_at with no plan.start_date to anchor it. |
HW-5 | Deadline at risk (advisory) - a milestone's modelled earliest-finish is more likely than not to miss its deadline:. Only fires when plan.start_date is set (the absolute deadline needs the anchor). |
SI-LT-1 | Uses-after-consume - a task with uses: [X] whose ancestor on every DAG path has consumes: [X]. The handle is destroyed before the using task can read it. Refined by inline-property-match disjointness: filters like Paper { state: draft } vs Paper { state: submitted } target disjoint instances and don't trip the rule. |
SCRIPT-PARSE | Embedded TOLScript source (in a let / derived / function / when) failed to parse. |
HW-* | Non-blocking health warning. Plan still runs; the IDE flags the issue. |
Runtime issues you may see on a spectrum#
In addition to SI-* (load-time) and HW-* (health), the spectrum sampler emits RT-* issues when it silently no-ops a handle-mutation event at sample time. Issues land in spectrum.runtime_issues aggregated by (code, task_id) with a count recording how many MC samples observed the issue. The spectrum still computes; these are best-effort signals that something the author wrote did not fire as intended.
| Code | Severity | Fires when |
|---|---|---|
RT-CONSUME-NO-MATCH | error | Destructive op (consume / destruct / construct from inputs) found no live instance. Often "over-consume" - a prior task consumed the only available instance, or no producer fired. |
RT-RELEASE-NO-MATCH | warning | release found no live instance (the handle was never acquired or already released). |
RT-ACQUIRE-NO-MATCH | warning | acquire found no live instance to acquire. |
RT-TRANSITION-NO-MATCH | warning | transition target handle does not exist. |
RT-MUTATE-NO-MATCH | warning | mutate <handle>.<property> by <value> target handle does not exist. |
RT-CARDINALITY-NOT-MET | warning | A consume like [Paper [3, 3]] needed 3 instances but found fewer; the task fires with partial supply. |
RT-NO-PRODUCER | warning | A consumer's wait-for-handle floor had no reachable producer for a consumed type with no initial supply. Effectively the consume can't be satisfied this sample. |
A plan must pass all SI checks before the engine runs. HW codes are advisory.
Direct-editing notes#
- Statement separators. TOL accepts both newlines and
;between fields inside a block -agent: alice; estimate: 1hon one line is equivalent to the same fields on two lines. The renderer prefers newlines for top-level fields and;for the compact one-line declarations the IDE emits (agent alice { type: internal; kind: individual }). Both round-trip. - Identifiers are case-sensitive
[A-Za-z_][A-Za-z0-9_]*. Type names by convention start uppercase; agents / tasks / outcomes lowercase. - Comments are
//line comments and/* ... */block comments. - Quoted strings use double quotes with standard escapes (
\",\\,\n). - Quoted TOLScript source uses the same string conventions. Multi-line scripts are written on one line with
\nescapes, or by concatenating adjacent string literals. schema: { ... }on deliverable entries is a legacy v3 JSON-Schema-shaped block predating theproperties { }form. The parser still admits it for back-compat, but the IDE emitsproperties { }exclusively; ignoreschema:on read and preferproperties { }on write.- What round-trips byte-stable, what doesn't. All declared field values (every
<name>: <value>field, every block body, every nested AST node) survive parse → render → parse identically. Comments and whitespace formatting don't -//line comments and/* */blocks are stripped at parse time and not re-emitted; the renderer canonicalises indentation, integer-vs-float trailing zeros, and trailing-comma placement. If you need a comment to survive, put it in adescription:orrationale:field instead.
What TOL is not#
- Not Turing-complete. This is the central design choice. If you need unbounded computation, the model belongs in a different system.
- Not a general DSL for combinatorial optimisation. Discrete decisions are capped at cardinality 32 per axis. Route job-shop or large-permutation problems to a solver and import the result as a fixed input.
- Not a temporal logic. Time-window queries (
at_time,state_held_for) read from the executor's event log; they don't model speculative futures.
Where to go next#
The Topolog IDE is the supported authoring path. Use it. If you find a TOL construct the IDE doesn't surface and you've reached for direct editing, that's a UI gap worth reporting.