P1–P8 and the code that enforces them¶
Each of Reyn's eight OS invariants is enforced by a specific combination of type constraints, compiler checks, and runtime validation — not just by convention. This page maps each principle to the exact files and mechanisms that uphold it.
For the why behind each principle, see concepts/principles.md.
Quick reference¶
| Principle | Enforced by | Primary file(s) |
|---|---|---|
| P1 — Phase doesn't know next | Phase model has no next_phase field |
schemas/models.py |
| P2 — Skill owns the graph | SkillGraph in Skill; compiler validates DAG |
schemas/models.py, compiler/linter.py |
| P3 — OS executes | OSRuntime is the only caller of LLM and Control IR |
kernel/runtime.py |
| P4 — LLM picks from candidates | _build_candidates() gates choices; normalizer rejects unknown |
context_builder.py, kernel/runtime.py |
| P5 — Workspace is SSoT | All writes go through Workspace with permission gate |
workspace/workspace.py, op_runtime/file.py |
| P6 — Events are audit truth | EventLog is append-only; state recovery reads events |
events/events.py, events/state_log.py |
| P7 — OS is skill-agnostic | OP_KIND_MODEL_MAP is the only op catalogue; linter rejects skill-specific strings |
op_runtime/registry.py, compiler/linter.py |
| P8 — Instructions don't list fields | Schema is injected via candidate_outputs, not baked into instructions |
context_builder.py, kernel/runtime.py |
P1 — Phase declares only input_schema and instructions¶
What it means: A Phase must not know which phase comes next, what the output schema is, or who its parent Skill is.
How it's enforced:
schemas/models.py — the Phase Pydantic model has no next_phase or output_schema field. There is nowhere to put it. The expander (compiler/expander.py) parses phase frontmatter and would raise a validation error if these fields appeared.
The next phase is determined at runtime by OSRuntime from skill.graph.transitions, not from anything the Phase knows.
# schemas/models.py
class Phase(BaseModel):
name: str
instructions: str
input_schema: str | None = None
allowed_ops: list[str] = []
permissions: PermissionDecl = ...
# ← no next_phase, no output_schema
P2 — Skill declares graph and final_output¶
What it means: Phase connections (who can transition to whom) live in the Skill, not in any Phase.
How it's enforced:
schemas/models.py — SkillGraph holds transitions: dict[str, list[str]] and can_finish_phases: list[str].
compiler/linter.py — _find_cycle() performs a DFS on the transition graph and raises LintError if a cycle exists. It also checks that entry is reachable in the graph.
kernel/runtime.py — at transition time, OSRuntime checks next_phase in skill.graph.transitions[current_phase] before accepting the LLM's choice.
P3 — OS is the runtime engine¶
What it means: The LLM describes what to do; the OS does it. Skills and phases never call the LLM or execute ops directly.
How it's enforced:
kernel/runtime.py — OSRuntime contains the only call to call_llm() (from llm/llm.py). No skill code path reaches call_llm directly.
kernel/control_ir_executor.py — the only place that calls op handlers. Phase instructions can ask for a file op; they cannot execute one.
The separation is structural: Phases are Pydantic data objects with no methods that do IO. There is no phase.run() method.
P4 — LLM picks only from OS-provided candidates¶
What it means: The LLM cannot choose an arbitrary next phase or artifact type. The OS provides an explicit list; the LLM picks from it.
How it's enforced:
context_builder.py — build_frame() calls _build_candidates() (in kernel/runtime.py) and embeds the result in ContextFrame.candidate_outputs. Each candidate carries next_phase, control_type, schema_name, and the full artifact_schema.
kernel/runtime.py — _normalizer.normalize() checks that control.next_phase is one of the candidates. Unknown values are rejected before the output is acted on.
# kernel/runtime.py (simplified)
candidates = self._build_candidates(skill, current_phase)
frame = build_frame(..., candidate_outputs=candidates)
raw = await call_llm(frame)
output = normalizer.normalize(raw, allowed_candidates=candidates)
# ↑ raises if output.control.next_phase not in [c.next_phase for c in candidates]
P5 — Workspace is the single source of truth¶
What it means: All data passed between phases lives in the workspace. Phases read and write only through Control IR ops, which are gated by the permission system.
How it's enforced:
workspace/workspace.py — all reads and writes go through Workspace.read_artifact() / write_artifact() / write_file(). There is no in-memory dict that phases share.
op_runtime/file.py — the file op handler calls Workspace.write_file(), which calls PermissionResolver.check() before touching the filesystem. Writes that aren't declared in permissions.file_write are rejected.
events/events.py — Workspace.write_artifact() emits a workspace_updated event. Any write that doesn't go through Workspace is invisible to the event log and therefore to crash recovery.
P6 — Events are the audit truth¶
What it means: Every state change emits an event. The event log is append-only and replay-capable.
How it's enforced:
events/events.py — EventLog exposes only emit() (appends) and to_list() / to_json() (reads). There is no delete(), update(), or truncate() method.
events/state_log.py — the WAL is a JSONL file written with monotonically increasing seq values. The recovery path in kernel/runtime.py reads this file forward to reconstruct state — it never writes backwards.
kernel/runtime.py — every meaningful action (phase start, LLM call, op start, op complete, transition, finish, crash) has a corresponding emit() call. Missing an emit is a P6 violation detectable by audit.
P7 — OS code contains no skill-specific strings¶
What it means: No phase name, artifact type, or domain-specific field name appears as a literal in OS code.
How it's enforced:
op_runtime/registry.py — OP_KIND_MODEL_MAP maps op kind strings (e.g. "file", "mcp") to Pydantic models. This is the only place op kind strings appear in OS code. A new op kind requires adding one entry here, not scattering the string across modules.
compiler/linter.py — ALL_OP_KINDS = frozenset(OP_KIND_MODEL_MAP.keys()). The linter uses this set to validate allowed_ops in phase frontmatter. A misspelled op kind is a lint error, not a silent runtime failure.
kernel/control_ir_executor.py — _build_phase_tool_catalog() derives the tool schema for the LLM from the Pydantic model (OP_KIND_MODEL_MAP[kind]), not from any hardcoded field list.
Detection rule (from CLAUDE.md): if a literal naming a specific phase, artifact type, or field appears in OS code — it's a P7 violation.
P8 — Phase instructions don't enumerate artifact fields¶
What it means: The output artifact schema (its fields and types) is injected at runtime via candidate_outputs, not written into phase.instructions.
How it's enforced:
context_builder.py — build_frame() takes candidate_outputs: list[CandidateOutput]. Each CandidateOutput carries artifact_schema: dict — the full JSON Schema for the expected output. This is appended to the system prompt by llm.py at call time.
kernel/runtime.py — _build_candidates() reads the schema from next_phase.input_schema (for transitions) or skill.final_output_schema (for finish). The Phase itself never touches these schemas.
The practical consequence: you can change an artifact's schema without editing any phase instructions. The LLM sees the new schema through candidate_outputs on the next run.
Adding a new op kind (3 touch points)¶
This is the canonical example of P7 in practice: a new op kind requires exactly three changes, all in OS code, none in any skill.
1. Define the Pydantic model (schemas/models.py)¶
class MyOpIROp(BaseModel):
kind: Literal["my_op"]
target: str
options: dict = {}
# Add to the ControlIROp union:
ControlIROp = Annotated[
FileIROp | MCPIROp | ... | MyOpIROp,
Field(discriminator="kind")
]
2. Register in the op registry (op_runtime/registry.py)¶
from reyn.schemas.models import MyOpIROp
OP_KIND_MODEL_MAP: dict[str, type[BaseModel]] = {
...
"my_op": MyOpIROp,
}
OP_PURITY: dict[str, OpPurity] = {
...
"my_op": OpPurity.side_effect, # or pure / world / external
}
ALL_OP_KINDS updates automatically (it's derived from the map).
3. Implement the handler (op_runtime/my_op.py)¶
from reyn.schemas.models import MyOpIROp
from . import register
from .context import OpContext
async def handle(op: MyOpIROp, ctx: OpContext, caller: str) -> dict:
# execute the op
return {"kind": "my_op", "status": "ok", ...}
register("my_op", handle)
Import it in op_runtime/__init__.py:
What you get for free after these 3 steps:
- Linter validates allowed_ops: [my_op] in phase frontmatter
- ControlIRExecutor dispatches to your handler
- LLM sees the op's schema in the system prompt (via _build_phase_tool_catalog)
- Purity classification drives correct memo/replay behaviour on resume
Also update the reference doc¶
docs/reference/runtime/control-ir.md must stay in sync with OP_KIND_MODEL_MAP (from CLAUDE.md). Add a section for my_op in the same PR that adds the implementation.
See also¶
- concepts/principles.md — full rationale for each principle
- reference/runtime/control-ir.md — op kind catalogue
- reference/runtime/llm-output-contract.md — the JSON the OS validates
- deep-dives/contributing/testing.md — test tier requirements