Ghyll Documentation
This book is the canonical reference for installing, operating, and extending ghyll. The top-level README covers what ghyll is and why it’s built the way it is; this book covers everything else.
How this book is organized
-
Why ghyll — the design rationale in depth. Five core decisions (fixed roles, typed clauses, gate-driven routing, sandbox-only execution, refusal as a feature), with the alternatives considered and the cross-links to the ADRs. Read this if you want to understand why before how.
-
User Guide — the operator path: install, configure, run.
- Getting Started
- Configuration
- CLI Reference
- Operator Guide — the deep end-to-end walkthrough, including the Tier 2 verdict modal, sandbox setup table, and vault deployment.
- Memory & Sync
- Troubleshooting
- Glossary — arrow, pass, clause, verdict, stratum, op-id, residue, attestation, self-cert, and the other v2 vocabulary, each with a worked example and a “where you’ll see this in the CLI” pointer.
-
Architecture — how ghyll is built.
- System Design
- Architecture Flows — sequence diagrams for init, dispatch, verdict modal, amendment, and recovery.
- Subsystem deep-dives: package graph, session loop, routing, checkpoint format, sync protocol, vault API, error types.
-
Internals — the implementation details: dialect modules, context management, drift detection, injection detection, tool execution, the workflow system, and sub-agents.
-
Architecture Decisions — every architectural choice ghyll makes is recorded as an ADR. The list at the end of the SUMMARY is in order; ADR-008 onward documents the v2 gate-and-arrow architecture.
Where to start
- New here? Read the top-level README for the position and the why, then come back to Getting Started.
- Already running ghyll and want to deploy properly? Jump to the Operator Guide.
- Curious about a specific design decision? Check the Decisions index — every choice has rationale, alternatives considered, and the date it landed.
- Want to read the gate-and-arrow spec? The canonical design
reference is
specs/architecture/in the repository.
Project state
ghyll is at a stable, releasable state — Tier 0-4 of the prod-readiness roadmap shipped, all adversarial findings closed. The latest release is documented in the CHANGELOG; remaining feature work is tracked in GitHub Issues.
Why ghyll
Most coding agents optimize for speed and breadth — they finish faster, they touch more files, they accept more vague prompts. ghyll optimizes for correctness and pays for it in friction.
The honest second half of that pitch is the position: ghyll is wrong for a lot of work. Writing a CRUD endpoint, fixing a typo, generating a migration, or wiring glue between two libraries — these are throughput tasks, and ghyll’s gate ceremony is pure overhead. Use a faster agent.
ghyll is right when a defect reaching deployment is expensive: novel architecture, distributed-system invariants, long-horizon projects where the cost of “looks fine, ships fine, breaks in production” is paid in days of incident response. There the friction is the feature.
This chapter walks through the five design decisions that distinguish ghyll, in the order they matter.
The risk ghyll is built to manage
ghyll’s correctness mechanism is behavioral, not infrastructural. Drift detection, sandboxes, retries, and rollback help — but they catch a class of failure that’s downstream of the real problem.
The real problem is models confidently delivering work they were never positioned to evaluate. A model trained on millions of similar-looking codebases produces plausible code on demand. It also produces plausible reviews of that code on demand — and plausible explanations, plausible tests, plausible “I checked it” statements. Past a certain depth of task, the model’s output is indistinguishable from work that was actually done.
Every design decision below follows from treating that as the central risk.
1. Roles are fixed (the diamond)
The decision
ghyll runs four roles end-to-end: analyst → architect →
implementer → integrator. The set is fixed at build time. You
cannot add a reviewer or a tester role at runtime; you cannot
swap the diamond for a different shape.
The alternative
The obvious alternative is “roles as runtime config” — the operator declares a role set in a YAML file at the start of a project, and ghyll uses whatever was declared.
Why this beats it
Role-shape drift is one of the most reliable ways for an agent to lose accountability. If the integrator can also act as the implementer, “I checked it” and “I wrote it” become the same sentence — and the cross-check disappears. The analyst→architect handoff exists precisely because the analyst doesn’t get to be the architect; they don’t get to mark their own homework. Letting the operator collapse roles is letting them remove the contract the diamond enforces.
Fixed roles guarantee that every project enforces the same separation. An ADR can change the diamond shape, but a single operator under deadline cannot.
Reference
- ADR-008 — the decision to deprecate runtime workflow roles.
- V2-ADR-003 — why four roles, why this shape.
specs/architecture/roles/— the four role contracts, embedded into the binary at build time via//go:embed.
2. Transitions are arrows
The decision
An arrow named analyst→architect/default is the project’s
declaration that the analyst hands work off to the
architect in the default context. Before the runtime
will dispatch a pass on that arrow, every
clause attached to it (e.g. tests-pass, lint-clean,
no-todo-marker) must reach a verdict — pass, fail, or
insufficient-basis. If a clause is still unevaluated, the
arrow stays open and the operator is asked what to do.
The pass is the runtime invocation of the arrow; the
stratum decides whether MiniMax M2.5 or
GLM-5 runs that pass. Every cross-role handoff is a first-class
artifact in this shape — source role, target role, context (which
bounded context within the project), stratum, and a list of typed
gate clauses — and arrows are persisted in
.ghyll/grid.v1.yaml and replay across sessions.
Note on “context”: in ghyll’s v2 docs (this page, the operator guide, getting-started) “context” means a bounded context in the DDD sense — a logical scope of the project, like
checkoutorinventory. This is NOT the LLM context window. The LLM-context-window meaning still applies indocs/internals/context.md. This is the single most-likely-to-confuse term in the whole doc set.
An arrow from analyst to architect exists; an arrow from analyst to integrator does not. Undeclared transitions don’t silently proceed — they suspend, and the operator is asked to declare a new arrow (on-the-spot arrow creation) or refuse the work.
The alternative
The obvious alternative is “implicit handoff”: each role does its work and produces an artifact; the next role picks up whatever was produced. The path between roles is whatever happens to happen.
Why this beats it
Undeclared paths are how agents quietly skip steps. An LLM that
“just goes ahead and implements” is bypassing analysis; an LLM
that returns a pass verdict on a clause it didn’t actually
evaluate is bypassing the gate. A typed arrow makes that bypass
a visible event the operator has to acknowledge:
- The arrow doesn’t exist → the runtime suspends and asks.
- The clause’s
unevaluatedstatus doesn’t auto-derive topass→ the operator sees it. - The arrow’s pass count, lock status, and last verdict are persisted → forensic reconstruction is mechanical.
Reference
specs/architecture/v2-design.md— gate-and-arrow rationale in full.specs/architecture/gates.md— evaluation types, depth types, arrows, routing, attestation.- V2-ADR-008 — why arrows and passes are separate entities.
- ADR-013 — the Pass entity + registry.
- V2-ADR-013 —
adding the
tests-passconcept to the closed catalogue. - Architecture flows — sequence diagrams for the major arrow flows.
3. Clauses are typed on two axes
The decision
Each clause carries two type tags:
- Evaluation type —
machineorattested.machine: a deterministic check ghyll can run (tests pass, lint clean, schema validates, no-todo-marker grep returns nothing).attested: an operator reads, judges, records a verdict (the typed verdict goes through the Tier 2 modal flow).
- Depth type —
depth-robustordepth-sensitive.depth-robust: a small model can produce or evaluate this honestly.depth-sensitive: the work requires the deep tier to avoid plausible-looking errors.
A depth-sensitive clause produced by an under-depth model has
status unevaluated — not pass, not fail.
In the verdict modal, unevaluated is the default state of every
clause the runtime hasn’t yet asked you about; in /list-arrows
and ghyll arrow show, an arrow with any unevaluated clauses
cannot transition closed.
The alternative
The obvious alternative is “verification is verification” — every check is the same kind of thing, run by whatever happens to be in front of it.
Why this beats it
Collapsing the axes is where shallow agents go wrong.
- “Does this code pass tests?” is a
machine/depth-robustcheck. Any model can runmake testand read the exit code. - “Does this design respect the cross-context invariant from the
analyst’s spec?” is
attested/depth-sensitive. It needs the deep tier and an operator’s judgment. Asking the fast tier produces fluent-sounding nonsense.
The two questions read similarly in casual prose (“is this
verified?”) but require completely different machinery. The type
system makes the difference legible and the dispatcher refuses
to run a depth-sensitive clause on the fast tier.
unevaluated is first-class because it is the status that most
resembles “green but will break on deployment” — and it must
never be hidden in a summary. ghyll’s project-status reporting
treats unevaluated differently from both pass and fail.
Reference
specs/architecture/gates.md— clause type axes, the full evaluation contract.- V2-ADR-005 — why the concept catalogue is closed (18 concepts, embedded).
- V2-ADR-006 — per- concept schema files.
4. Routing follows the gate, not the model
The decision
A pass is one runtime invocation of an arrow — open the arrow, run its clauses, collect verdicts, close. Multiple passes can run against the same arrow over a project’s life.
A pass runs at the lowest tier that meets the maximum depth requirement of the clauses on the arrow. Self-assessed task complexity is not a routing input. The model doesn’t decide which model to use — the gate does.
If an arrow’s clauses are all depth-robust, the fast tier runs
the pass. If even one clause is depth-sensitive, the dispatcher
escalates to the deep tier. No exceptions, no overrides from
inside the pass.
The alternative
The obvious alternative is “ask the model how hard this is” — every coding agent that ships routing does some variant of this.
Why this beats it
Every routing system that asks the model “is this hard?” gets the wrong answer reliably:
- Easy tasks get over-escalated (the model defaults to “looks complex” to be safe → cost balloons).
- Hard tasks get plausibly handled by the small model (the model is fluent enough to claim it understood when it didn’t → the bug ships).
Gate-driven routing eliminates the question entirely. The depth-tag on the clause is the answer. Adding a clause means re-running routing; the routing decision is reproducible because it depends only on the arrow definition, not on a fresh model call.
Reference
- ADR-007 — tier-based routing decision.
dialect/router.go— context-depth routing implementation.
5. Sandbox-only execution
The decision
ghyll runs in YOLO mode: tool calls from the model execute directly, with no confirmation, no permission check, no filtering. The sandbox boundary is the only security boundary.
The runtime detects whether it’s inside a sandbox at startup
(cmd/ghyll/sandbox.go checks for Docker, Podman, bubblewrap,
sandbox-exec, Firejail, Kubernetes pod). Operators can opt into
hard-refusal via GHYLL_REQUIRE_SANDBOX=1.
The alternative
The obvious alternative is “in-process permission gates” — every tool call is reviewed by a layer that decides whether it’s safe. Commercial coding agents do this extensively.
Why this beats it
In-process permission gates are theatre. They protect against careless model output but not against compromised or adversarial output:
- The model can call shell with arbitrary arguments → no permission layer covers every escape path.
- The model can write to a file the permission layer “trusts” → permission layers always have trust roots.
- Operators learn to bypass friction → “is this safe?” prompts collapse to “yes” reflex.
A real sandbox is honest. It catches every escape (it’s the operating system, not a guess about what the operator meant). The friction is upfront (you set it up once) instead of per-tool-call.
This was a contested decision: an early prod-readiness pass added in-process control layers (env scrub on bash, git subcommand allowlist, symlink refusal in edit/file ops). They were all reverted on the explicit grounds above; see the design pushback in the CHANGELOG under v2026.30.x for the list of reverts.
ghyll trusts nothing it can’t sandbox. If you can’t sandbox it, don’t run ghyll there.
Reference
cmd/ghyll/sandbox.go— detection table.- Operator guide — Sandbox setup — copy-pastable recipes for each sandbox.
Bonus: ghyll can refuse
The ghyll init definition phase scans the project, applies the
modify rules, and synthesizes a grid. If the project is empty,
greenfield, single-file, or otherwise a poor fit, ghyll surfaces
that. The operator can override (it’s still your project), but
the refusal — or the “are you sure?” prompt — is a feature.
This is the position again, made operational: ghyll knows it’s not right for everything, and it tells you when it’s about to be wrong.
See also
- Operator guide — install, configure, run.
- Architecture flows — the sequence diagrams for the gate-and-arrow runtime.
- Architecture decisions — all 17 main ADRs, 13 v2 ADRs, and 9 v4 ADRs.
Getting Started
This page walks you through installing ghyll, configuring it, initializing your first project, and running your first session. The deep walkthrough — verdict modals, sandbox setup, vault deployment — lives in the Operator Guide; read this page first.
Prerequisites
- Go 1.25+ (only required if you build from source).
- Git (for memory sync via the
ghyll/memoryorphan branch). - A sandbox. ghyll runs in YOLO mode by design — tool calls execute directly. You MUST run ghyll inside a sandbox. See the Sandbox setup section of the operator guide for the full table (Docker / Podman / bubblewrap / sandbox-exec / Firejail / Kubernetes). Without a sandbox, a compromised model endpoint can execute arbitrary code with your user privileges.
- Access to SGLang endpoints serving at least one of the supported models (MiniMax M2.5, GLM-5, DeepSeek, Qwen).
Installation
From a release
Download the latest tarball from
GitHub Releases. Each
release ships ghyll and ghyll-vault binaries for
linux_amd64, linux_arm64, and darwin_arm64.
Each tarball is flat — tar czf ... -C <dir> . packs ghyll,
ghyll-vault, LICENSE, and README.md at the archive root.
Extract into a per-release directory and add it (or a symlink to
the ghyll binary) to your PATH:
mkdir -p ~/.local/share/ghyll-v2026.30.247
tar xzf ghyll_linux_amd64_v2026.30.247.tar.gz \
-C ~/.local/share/ghyll-v2026.30.247
ln -sf ~/.local/share/ghyll-v2026.30.247/ghyll ~/.local/bin/ghyll
ln -sf ~/.local/share/ghyll-v2026.30.247/ghyll-vault ~/.local/bin/ghyll-vault
~/.local/bin/ghyll version
From source
git clone https://github.com/witlox/ghyll
cd ghyll
make build-bin
Binaries land in bin/ghyll and bin/ghyll-vault.
First run — config bootstrap
The first time you run ghyll, it auto-writes a default config
template to ~/.ghyll/config.toml (mode 0o600) and exits with
a hint:
$ ghyll run
ℹ wrote default config at /home/you/.ghyll/config.toml; edit the model
endpoints and re-run.
Edit ~/.ghyll/config.toml and point the model endpoints at your
SGLang instances. The shipped template has commentary on every
field; the Configuration page is the
reference.
The auto-written file IS the shipped template
(config/example.toml).
In practice only the endpoint = "..." lines under
[models.m25] and [models.glm5] need to change for a minimal
working setup. Resist the urge to keep a hand-rolled “minimal
config” stashed somewhere — the template is the canonical
starting point, and every field has a documented default on the
Configuration page.
Bootstrap a project — ghyll init
The gate-and-arrow runtime needs a grid — a per-project file
that declares the arrows (role-to-role
transitions) the runtime will dispatch. Arrows live in
.ghyll/grid.v1.yaml after ghyll init. You can cat the file
to see the raw declarations, or use /list-arrows in a session
for the rendered view.
Note on “context”: in the rest of this page (and the operator guide), “context” means a bounded context in the DDD sense — a logical scope of the project, like
checkoutorinventory. This is NOT the LLM context window. The LLM-context-window meaning still applies indocs/internals/context.md. See the glossary entry for the worked example.
ghyll init builds the grid for you:
cd ~/repos/myproject
ghyll init --op-id you@example.com
What this does:
- Scans the project for bounded contexts (auto-declares a
defaultcontext if none detected). - Walks the four role pairs (init → analyst → architect → implementer → integrator) using the role contracts embedded in the binary.
- Loads the closed concept catalogue (also embedded —
gates/concepts/*.yaml). - Builds a proposal for each (role-pair, context) tuple.
- Auto-accepts proposed clauses; clauses missing required catalogue args are skipped with a residue note for a future amendment.
- Writes
.ghyll/grid.v1.yamlatomically.
What the operator actually sees on a fresh greenfield repo:
$ ghyll init --op-id you@example.com .
init complete: 4 arrows across 1 contexts; grid at .ghyll/grid.v1.yaml
(default context auto-declared — no bounded contexts detected)
residue: 2 clauses auto-skipped (required args without defaults)
Clauses the bootstrap couldn’t auto-confirm (because their
catalogue schemas require args ghyll has no default for) land in
the grid’s residue: list with a machine-parseable reason —
for example init-v1: auto-skipped (required args without defaults): path-glob. A later grid
amendment can supply the missing
values; until then, the affected clauses stay unevaluated.
If you re-run ghyll init and the grid already exists, ghyll
refuses (no clobber). Use a grid amendment to evolve it.
What’s embedded vs. on-disk
ghyll init reads its role contracts and concept catalogue from
the binary — there is no source tree to install alongside. The
table below shows what comes from which surface; bookmark it
when you’re chasing down “where did that come from?”.
| Artifact | Where it lives | How it gets there |
|---|---|---|
| Role contracts (analyst / architect / implementer / integrator) | embedded in the ghyll binary | //go:embed specs/architecture/roles/*.md at build time |
| Concept catalogue (clause schemas) | embedded in the ghyll binary | //go:embed gates/concepts/*.yaml at build time |
| Default config template | embedded in the ghyll binary; written to ~/.ghyll/config.toml on first run | //go:embed config/example.toml; copied verbatim at mode 0o600 |
| Grid (arrow declarations) | per-project at .ghyll/grid.v1.yaml | written by ghyll init; immutable after write |
| Engine DB (passes, findings, attestations index) | per-project at .ghyll/engine.db | sqlite, opened on session start |
| Attestation audit | per-project at .ghyll/attestations.jsonl plus the per-pass tree under .ghyll/attestations/ | appended on every verdict, fsync’d before accept |
| Checkpoints | global at ~/.ghyll/memory.db and the ghyll/memory git orphan branch | sqlite + Merkle DAG + ed25519 signing |
First session — ghyll run
ghyll run .
The prompt shows the active model and working directory:
ghyll [m25] ~/repos/myproject >
The minimum daily-driver slash commands are:
| Command | Effect |
|---|---|
/list-arrows | Show the project’s arrows from .ghyll/grid.v1.yaml. |
/run-arrow <id> [--context <ctx>] | Dispatch a pass on the arrow. |
/op-id <id> | Declare an operator session bound to an op-id (for attestations). |
/attest <ref> <verdict> | Attest a pending clause without going through the modal. |
/drain-amendments | FIFO-drain the pending amendment queue under the active op-id (diamond v4). |
/adversary {enable|disable|status} | Toggle the §11 adversarial-cycle hook bundle (diamond v4). |
/invalidate-arrow <id> [--reason <text>] | Invalidate an arrow; writes an audit row to arrow_invalidations (diamond v4). |
/deep | Temporarily force the deep tier (GLM-5). |
/fast | Restore auto-routing. |
/plan | Enter plan mode (deeper reasoning). |
/status | Show active model, lock state, deep/plan flags, turn count, and tool depth. |
/exit | End the session (creates a final checkpoint). |
This is the subset you’ll touch every day; the full set
(including /attestations, /passes, /op-id none, /quit,
and the on-disk user commands) is documented in the
Operator Guide.
Your first attestation
The shortest path from a fresh ghyll init to a recorded
verdict is six commands. Every step
below shows the actual terminal output, not abbreviated ....
1. Initialize the grid.
$ ghyll init --op-id you@example.com .
init complete: 4 arrows across 1 contexts; grid at .ghyll/grid.v1.yaml
(default context auto-declared — no bounded contexts detected)
residue: 2 clauses auto-skipped (required args without defaults)
2. Start the session.
$ ghyll run .
ghyll [m25] ~/repos/myproject ▸
3. List arrows to confirm the grid.
ghyll [m25] ~/repos/myproject ▸ /list-arrows
grid arrows (4, version=1):
init→analyst/default init → analyst stratum=L0 context=default clauses=3
analyst→architect/default analyst → architect stratum=L1 context=default clauses=6
architect→implementer/default architect → implementer stratum=L1 context=default clauses=8
implementer→integrator/default implementer → integrator stratum=L2 context=default clauses=5
Each column: the arrow id (<source>→<target>/<context>),
the human-readable source→target pair, the
stratum (L0 = fast tier, L1+ =
escalates as the clauses demand), the bounded context, and
the clause count (how many things the operator may be asked to
attest, plus the machine clauses ghyll evaluates itself).
4. Declare the operator identity.
/op-id is required before /attest and before any pass that
records an attestation:
ghyll [m25] ~/repos/myproject ▸ /op-id you@example.com
op-id set: you@example.com
5. Run an arrow.
ghyll [m25] ~/repos/myproject ▸ /run-arrow analyst→architect/default
· pass-opened pass=p-7 role=analyst context=default
· pass-closed pass=p-7 role=analyst state/reason=closed:ok
✓ arrow analyst→architect/default dispatched: pass=p-7 status=valid clauses=6 blocking-clauses=0 blocking-findings=0
If one of the clauses is attested + depth-sensitive, the
runtime drains a Tier 2 verdict modal before the next prompt:
── attestation request ─────────────────
arrow: analyst→architect/default
clause: C2
concept: lint-clean
attestation-ref: att-analyst→architect/default-C2-v1
────────────────────────────────────────
verdict? [pass / fail / insufficient-basis / skip]:
The four verdicts mean:
- pass /
p— I looked at the work, it meets the clause’s contract.confirmunit, no payload. - fail /
f— I looked, it does not. Prompts for inspected locations (CSV). - insufficient-basis /
ib— I cannot evaluate from what I have. Prompts for a residue note; the field is capped by the grid’sresidue-note-max-bytes. Example residue note:"needs the analyst's threat-model doc for the checkout flow; not present in this branch yet". - skip /
s— punt to the next round; the modal re-presents on the next turn.
Three consecutive insufficient-basis verdicts on the same
clause fire the escalation prompt described in the
Operator Guide.
6. Confirm the verdict was recorded.
ghyll [m25] ~/repos/myproject ▸ /exit
ghyll: session ended; final checkpoint written.
$ ghyll arrow show analyst→architect/default --dir .
arrow: analyst→architect/default
source-role: analyst
target-role: architect
stratum: L1
context: default
clauses: 6
[0] no-todo-marker (id=C1, depth=depth-robust, min-tier=0)
[1] lint-clean (id=C2, depth=depth-sensitive, min-tier=2)
...
requirements: 1
findings: 0
attestations: 1
att-analyst→architect/default-C2-v1 kind=depth-type clause=C2 verdict=pass op=you@example.com
That last line is the recorded attestation. It is now persisted
in .ghyll/engine.db AND appended to .ghyll/attestations.jsonl
(plus the per-pass tree under .ghyll/attestations/).
Attestations are immutable — to correct a verdict, you
record a new attestation on a later pass; you do not edit the
existing one.
The full Tier 2 modal flow (escalation prompt, record-locations-inspected
unit, write-residue-note unit) lives in the
Operator Guide.
Optional: drift detection
Drift detection requires an ONNX embedding model and the ONNX Runtime shared library.
Install ONNX Runtime
# macOS
brew install onnxruntime
# Linux (Ubuntu/Debian)
# Download from https://github.com/microsoft/onnxruntime/releases
# Extract and place libonnxruntime.so in /usr/local/lib
Download the model
ghyll memory fetch-embedder # binary install, no source tree needed
# or, from the source tree:
make build-bin && ./bin/ghyll memory fetch-embedder
This downloads the GTE-small model (~127 MB) to
~/.ghyll/models/gte-small.onnx. Add --force to re-download an
existing file. The URL is [memory.embedder].model_url in
~/.ghyll/config.toml, falling back to the published GTE-micro
URL when unset.
Build with CGO
The ONNX embedder requires CGO. The default make build-bin
uses CGO_ENABLED=0 (static binaries, no ONNX). To build with
ONNX support:
CGO_ENABLED=1 go build -ldflags="-s -w" -o bin/ghyll ./cmd/ghyll
Without ONNX, ghyll works fine — drift detection is disabled gracefully. The warning at startup includes the path it tried.
Optional: vault server
For team memory search across repos:
# Add to ~/.ghyll/config.toml:
[vault]
url = "https://vault.internal:9090"
token = "team-shared-secret"
# Run the vault server (currently reads from ~/.ghyll/config.toml;
# --config flag tracked in github.com/witlox/ghyll/issues/26):
ghyll-vault
The vault deployment walkthrough — device keys, ed25519 setup, HTTPS — lives in the Operator Guide.
Where to go next
- Operator Guide — the canonical deep walkthrough.
- Configuration — every config field, with defaults.
- CLI Reference — all subcommands.
- Architecture Flows — sequence diagrams for init, dispatch, verdict modal, amendment, recovery.
Configuration
ghyll reads its configuration from ~/.ghyll/config.toml. A complete example is provided at config/example.toml:
cp config/example.toml ~/.ghyll/config.toml
# Edit endpoints to match your SGLang instances
Models
Each model requires an endpoint, dialect, and max context size:
[models.m25]
endpoint = "https://inference.internal:8001/v1"
dialect = "minimax"
max_context = 1000000
[models.glm5]
endpoint = "https://inference.internal:8002/v1"
dialect = "glm"
max_context = 200000
Available dialect families:
minimax(MiniMax M2.5, M2.7, …)glm(GLM-5, GLM-5.1, …)deepseek(DeepSeek-V3, DeepSeek-Coder, …)qwen(Qwen2.5-Coder, Qwen3-Coder, …)kimi(Kimi 2.5, Kimi 2.6 —moonshotai/Kimi-K2.5,moonshotai/Kimi-K2.6)
See ADR-007 and
ADR-v4-009
for the Kimi reasoning_content exclusion rule.
Tested backends. The CSCS-hosted gateway at
https://ai-gateway.svc.cscs.ch/v1 is the tested Kimi-K2.6 endpoint.
The K2-Thinking alias is intentionally deferred (ghyll sends no
tool_choice); operators pasting dialect = "kimi-thinking" get a
loud validation error rather than a silent fall-through to the
default minimax dialect.
Endpoint authentication (api_key)
The optional api_key field forwards a Bearer token on every
chat-completion request:
[models.cscs-glm5]
endpoint = "https://ai-gateway.svc.cscs.ch/v1"
dialect = "glm"
max_context = 200000
api_key = "sk-..." # optional; empty == no Authorization header
Resolution precedence (highest first):
GHYLL_API_KEY_<MODEL>— model-scoped env var.<MODEL>is the TOML model key upper-cased with any non-[A-Z0-9_]rune replaced by_. So[models.cscs-glm5]becomesGHYLL_API_KEY_CSCS_GLM5.GHYLL_API_KEY— global fallback. Use this for single-tenant hosts; the scoped form for multi-endpoint setups (CSCS gateway + a local dev endpoint).cfg.Models[name].api_keyfrom TOML.
An empty resolution (no TOML field, no env var) emits no
Authorization header at all — preserving the zero-config
behaviour for endpoints that do not require auth.
Redaction guarantees.
ghyll config showprintsapi_key: <unset>/<env>/<toml>for each model — provenance only, never the value or its length.- 401 / 403 upstream responses are surfaced as a fixed
authentication failedmessage; the raw response body is discarded before any logging in case the gateway echoed the Bearer header. - The attestation JSONL audit trail (
.ghyll/attestations.jsonl) and the engine sqlite store (.ghyll/engine.db) have no api_key-bearing column; regression tests grep both for sentinel tokens. - Git commit trailers carry the model NAME (or
stamp_label); the endpoint URL and api_key are never embedded.
A handoff to a different model resolves the key fresh against the TARGET model — distinct keys per endpoint stay separated.
Quantized variants
A quantized model in the SAME family uses the SAME dialect. Quantization (Q4/Q5/Q8, GGUF/AWQ/GPTQ) changes the endpoint backing the model, not the wire protocol — SGLang, vLLM, and llama.cpp all expose the OpenAI tool-call format that the dialect already speaks. Configure each quantized variant as its own [models.<name>] block with the endpoint pointing at the quantized backend:
[models.qwen-coder-q4]
endpoint = "http://localhost:11434/v1" # llama.cpp server hosting Q4_K_M
dialect = "qwen"
max_context = 32000
[models.qwen-coder-q8]
endpoint = "http://localhost:11435/v1" # vLLM hosting Q8
dialect = "qwen"
max_context = 65000
The dialect identifier is the same; the model name and endpoint disambiguate. The effective depth (and thus the appropriate routing tier) differs across quantizations — a Q4 of a 70B model is NOT the same as the full-precision model. Operators are responsible for setting max_context to match the backend’s reported context window and for mapping each quantized variant to the right routing tier.
If a quantization vendor ships a non-standard tool-call format (rare but documented for some early GGUF tool-format experiments), a new dialect file is needed instead. Stick with the family dialect unless the wire format actually differs.
Kimi 2.5 / 2.6 (CSCS gateway)
The Kimi family carries an extra reasoning_content field on every assistant turn. ghyll round-trips the field on the wire (assistant-only) so the next turn sees the prior reasoning trace; the field is excluded from the canonical checkpoint hash per ADR-v4-009.
[models.kimi-k26-cscs]
endpoint = "https://ai-gateway.svc.cscs.ch/v1"
dialect = "kimi"
model = "moonshotai/Kimi-K2.6" # literal id on the OpenAI `model` field
max_context = 200000
# api_key = "sk-..." # CSCS Bearer; override via GHYLL_API_KEY_KIMI_K26_CSCS
The Kimi backend enforces a strict tool-call id shape: functions.<name>:<index>. A non-conformant id (e.g. a vanilla UUID) surfaces ErrParseToolCall and the session loop emits an operator-facing diagnostic that names the offending shape — the dispatch is REFUSED rather than silently executed. This is the documented sentinel of a wrong-version or misconfigured Kimi backend.
Wire model field. The optional model field is the literal string sent on the OpenAI chat/completions request body’s model field. CSCS-style gateways route on this field, so paste the canonical mixed-case id (moonshotai/Kimi-K2.6) verbatim. Omitting model falls back to the dialect string (legacy behaviour for the 4 other dialects). Validation is case-insensitive on the dialect key, so dialect = "moonshotai/Kimi-K2.6" also loads (it is mapped to the kimi family) — but for clarity the recommended idiom is dialect = "kimi" + model = "moonshotai/Kimi-K2.6".
Routing
Controls automatic model selection:
[routing]
default_model = "m25" # Fast tier model
deep_model = "glm5" # Deep tier model (escalation target)
context_depth_threshold = 32000 # Escalate to deep tier above this
tool_depth_threshold = 5 # Escalate after N sequential tool calls
enable_auto_routing = true # Set false to disable routing
# v2 gate-floor bridge: when a v2 gate declares MinTier (depth-sensitive
# clause requirement) at or above this rank, the dispatcher MUST land
# on deep_model and de-escalation is blocked.
#
# Rank scale (runner.DepthRank):
# 0 = NONE 1 = SHALLOW 2 = MOCKED 3 = REALISTIC
#
# Default = 2 (MOCKED): gates demanding MOCKED or REALISTIC depth
# escalate to deep_model regardless of context-depth signals.
# Set to 0 to disable the gate-floor mechanism entirely (legacy v1
# behavior).
gate_floor_escalate_at_rank = 2
Override with --model flag: ghyll run . --model glm5
Memory
Controls checkpointing and drift detection:
[memory]
branch = "ghyll/memory" # Git orphan branch name
auto_sync = true # Background push/pull
sync_interval_seconds = 60 # Sync frequency
checkpoint_interval_turns = 5 # Checkpoint every N turns
drift_check_interval_turns = 5 # Check drift every N turns
drift_threshold = 0.7 # Cosine similarity threshold
[memory.embedder]
model_url = "https://huggingface.co/Xenova/gte-small/resolve/main/onnx/model.onnx"
model_sha256 = "398a29991324e0b383afa13375d681ced3079c83e097fb1ebd9290d7498523b3" # optional: verify after download
model_path = "~/.ghyll/models/gte-small.onnx"
dimensions = 384
When model_sha256 is set, ghyll memory fetch-embedder verifies
the downloaded hash before installing — a CDN compromise or HF
account takeover that doesn’t reproduce the exact bytes is rejected
and the file is removed. The default URL is SHA-256 pinned in the
binary; setting your own model_sha256 is only required when you
override model_url.
Tools
Tool execution timeouts and web settings:
[tools]
bash_timeout_seconds = 30
file_timeout_seconds = 5
web_timeout_seconds = 30 # Timeout for web_fetch/web_search
web_max_response_tokens = 10000 # Max tokens returned by web_fetch (truncated with [truncated] marker)
web_search_backend = "duckduckgo" # Search backend (currently only duckduckgo)
prefer_ripgrep = true
Sub-Agents
Controls for the agent tool — model-dispatched sub-agents that run focused tasks:
[sub_agent]
default_model = "m25" # Model for sub-agents (default: routing.default_model)
max_turns = 20 # Maximum turn-loop iterations
token_budget = 50000 # Maximum total tokens consumed
timeout_seconds = 300 # Wall-clock timeout for entire sub-agent execution
Sub-agents run synchronously (the parent session blocks). They have access to all tools except agent, enter_plan_mode, and exit_plan_mode.
Workflow
Controls project instruction loading:
[workflow]
instruction_budget_tokens = 2000 # Max tokens for instructions in the system prompt
fallback_folders = [".claude"] # Folders to check if .ghyll/ is absent
Workflow files are loaded from <repo>/.ghyll/ (or fallback folders):
.ghyll/
instructions.md # Project-level behavioral instructions
commands/ # Slash command definitions (review.md, verify.md, etc.)
Global instructions from ~/.ghyll/instructions.md are prepended; project instructions are appended (project has the “last word”). If combined content exceeds the token budget, global instructions are dropped first.
The four diamond roles (analyst / architect / implementer / integrator) are NOT loaded from <repo>/.ghyll/roles/. Per ADR-008, they are contracts embedded into the binary at build time from specs/architecture/roles/*.md. A roles/ subdirectory under .ghyll/ or .claude/ is intentionally ignored — see Workflow System for the loader’s behavior.
Vault (optional)
Team memory search server:
[vault]
url = "https://vault.internal:9090"
token = "team-shared-secret"
Localhost vault (http://localhost:9090) doesn’t require a token.
Defaults
If a field is omitted, these defaults apply:
| Field | Default |
|---|---|
routing.default_model | "m25" |
routing.context_depth_threshold | 32000 |
routing.tool_depth_threshold | 5 |
routing.gate_floor_escalate_at_rank | 2 (MOCKED) |
memory.branch | "ghyll/memory" |
memory.sync_interval_seconds | 60 |
memory.checkpoint_interval_turns | 5 |
memory.drift_threshold | 0.7 |
tools.bash_timeout_seconds | 30 |
tools.file_timeout_seconds | 5 |
tools.web_timeout_seconds | 30 |
tools.web_max_response_tokens | 10000 |
sub_agent.max_turns | 20 |
sub_agent.token_budget | 50000 |
sub_agent.timeout_seconds | 300 |
workflow.instruction_budget_tokens | 2000 |
workflow.fallback_folders | [".claude"] |
CLI Reference
ghyll
ghyll run [dir] [--model <model>] [--resume]
Start an interactive coding session.
dir— working directory (default:.)--model— lock to a specific model for the session (disables auto-routing and tier fallback)--resume— load the previous session’s final checkpoint summary as context. Restores plan mode state and links the new session to its predecessor.
ghyll run . # auto-detect model
ghyll run . --model glm5 # force GLM-5
ghyll run . --resume # continue from last session
ghyll run ~/repos/myproject # specify directory
ghyll init --op-id <id> [project-dir]
Bootstrap the project: discover contexts + roles, assemble the grid, persist .ghyll/grid.v1.yaml. Refuses to overwrite an existing grid. See Operator Guide — ghyll init for the full pipeline detail.
ghyll init --op-id alice@example.com .
ghyll init attest --op-id <id> [--dir <path>]
Tier 3 / gate-2 producer for init attestations: emits one on-the-spot record per arrow in the grid (AttestedByRole=init), persisted through the standard Record path. Idempotent on re-run. See Operator Guide — ghyll init attest.
ghyll init attest --op-id alice@example.com --dir .
ghyll config show
Display the loaded configuration.
ghyll memory log
Show the local checkpoint chain (hash, parent, turn, summary).
a1b2c3d4e5f6 2026-04-12 10:30 [m25] @alice turn 5 fixed auth race condition
d4e5f6a1b2c3 2026-04-12 10:45 [glm5] @alice turn 10 compaction summary
ghyll memory search <query>
Vector-search past checkpoints by semantic similarity.
ghyll memory search "auth race condition"
ghyll memory sync
Manually sync the checkpoint chain to/from the vault (push local checkpoints to the orphan branch, fetch remote ones). See Operator Guide — ghyll memory.
ghyll memory sync
ghyll memory fetch-embedder [--force] [--help]
Download the ONNX embedding model used by drift detection to
[memory.embedder].model_path (default ~/.ghyll/models/gte-small.onnx).
Reads [memory.embedder].model_url from ~/.ghyll/config.toml; falls
back to the published GTE-micro URL when the config is absent.
Security and correctness contracts:
- Idempotent by default: existing files are kept. Use
--forceto re-download. - HTTPS-only.
http://URLs are rejected unless the host is loopback (127.0.0.1,localhost,::1) — supply-chain guard against a plaintext download of executable model weights. Redirect hops are re-validated: an https://-to-http:// 302 is refused. - SHA-256 pin on the default URL — a CDN compromise or HF account
takeover that doesn’t reproduce the exact bytes is rejected. When
you override
model_url, set[memory.embedder].model_sha256alongside it to opt into the same verification; leaving it empty skips the check. - Rejects 0-byte responses and
Content-Type: text/html(the latter catches gated-repo login walls). - Atomic write: streams to
<base>.<rand>.tmpthen renames on success. A partial download (network drop, server error, size-cap hit) leaves any existing file untouched. - Size cap: 1 GiB. Misconfigured
model_urlpointing at a giant blob fails loud instead of filling the disk. - Timeout: 15 minutes (covers the ~30-60 MB default on a constrained HPC uplink).
- HOME must be set (refuses to derive
.ghyll/paths from CWD). model_pathaccepts a leading~/(expanded to$HOME). Shell variables ($HOME/...) and other-user homes (~alice/...) are rejected with a directed error rather than silently treated as literal directory names.
ghyll memory fetch-embedder # download if missing
ghyll memory fetch-embedder --force # re-download
ghyll memory fetch-embedder --help # show full usage
Custom backend example (set both URL and pin):
[memory.embedder]
model_url = "https://internal.cdn/models/gte-small.onnx"
model_sha256 = "45b71fe98efe5f530b825dce6f5049d738e9c16869f10be4370ab81a9912d4a6"
model_path = "~/.ghyll/models/gte-small.onnx"
The ONNX Runtime shared library is also required; the command prints
a hint at the end with install pointers (macOS: brew install onnxruntime; Linux: github.com/microsoft/onnxruntime/releases). The
hint also prints on the skip-when-exists path so an operator hitting
“embedder unavailable” at session start after a successful download
still sees the runtime install reminder.
ghyll engine status [--dir <path>]
Render a project-level summary of engine state: arrow / finding / requirement / amendment / evaluation-run / attestation counts. Reports ghyll-engine-status: missing when v2 was never initialized. See Operator Guide — ghyll engine status.
ghyll engine status --dir .
ghyll engine replay [--dir <path>]
Re-fan-out every persisted entity through the Journal observers, useful to rebuild derived state after a code change to a fan-out consumer. See Operator Guide — offline CLI commands for the full engine subcommand set.
ghyll engine replay --dir .
ghyll engine recover [--dry-run] [--dir <path>]
Preview crash recovery: open passes whose runner is gone, attestation-pending passes preserved across restart, evaluation runs reconciled from the JSONL audit log. --commit is refused; apply recovery by starting a session via ghyll run. See Operator Guide — ghyll engine recover.
ghyll engine recover --dry-run --dir .
ghyll engine verify-attestations [--dir <path>]
Walk .ghyll/attestations.jsonl and report records that violate the schema (missing fields, wrong kind/clause_id pairing, unknown verdict, §12.2 self-cert violation). Non-zero exit on failure. See Operator Guide — ghyll engine verify-attestations.
ghyll engine verify-attestations --dir .
ghyll arrow show <arrow-id> [--dir <path>]
Render one arrow’s live state: definition (source/target role, clauses, requirements), open findings, and all recorded attestations. See Operator Guide — ghyll arrow show.
ghyll arrow show A-checkout --dir .
ghyll version
Print the version string.
In-Session Commands
| Command | Effect |
|---|---|
/deep | Temporarily switch to the deep-tier model. Refused when --model was passed. |
/fast | Restore auto-routing and clear plan mode. |
/plan | Enter plan mode (deeper reasoning, higher tier preference). All tools remain available. |
/status | Show active model, lock state, deep/plan flags, turn count, tool depth. |
/exit | End the session cleanly. Cancels any in-flight modal read; creates a final checkpoint. |
/quit | REPL alias for /exit. |
/op-id <id> | Declare the operator identity for this session. Required before /attest. |
/op-id | Show the active op-id (or “(none)”). |
/op-id none (or clear) | Clear the active op-id. |
/attest <ref> <verdict> [reason] | Record an attestation verdict on a depth-type or on-the-spot attestation. |
/attestations [<arrow-id>] | List recorded attestations, optionally filtered by arrow. |
/passes | List currently-open passes from the PassRegistry. /passes <pass-id> shows one pass’s full state. |
/list-arrows | Render the grid snapshot (sorted arrow IDs + source→target / stratum / context / clause count). |
/run-arrow <arrow-id> [--context <ctx>] | Dispatch one arrow synchronously; surface pass-open/close events inline. |
/drain-amendments | FIFO-drain the pending amendment queue under the active op-id; refuses without /op-id. Each commit emits OpEventAmendmentDrained per ADR-v4-005. (diamond v4 / Gap 2) |
/adversary {enable|disable|status} | Toggle the §11 adversarial-cycle hook bundle the dispatcher consults on depth-sensitive arrows. enable refuses with no-dialect-configured when no active model resolves to a configured endpoint. (diamond v4 / Gap 1) |
/invalidate-arrow <arrow-id> [--reason <text>] | Invalidate one arrow; persists an audit row to .ghyll/engine.db arrow_invalidations (ADR-v4-008). Refuses without /op-id. |
/<name> | User-defined slash command loaded from .ghyll/commands/<name>.md. The file contents are injected as user input for the next turn. |
See Operator Guide — Slash commands for worked examples (op-id, attest, run-arrow, verdict modal).
Slash Commands
User-defined commands are loaded from .ghyll/commands/ in your repository (or ~/.ghyll/commands/ globally). Each .md file becomes a command. When typed, the file content is injected as a user message.
.ghyll/
commands/
review.md # /review --- inject review prompt
verify.md # /verify --- inject verification checklist
Built-in commands (table above) take precedence over user-defined commands with the same name.
ghyll-vault
Running
ghyll-vault
Starts the team memory search server on :9090. Requires [vault] section in config.
ghyll-vault version
Print the version string.
Environment
ghyll reads configuration from ~/.ghyll/config.toml. The following paths are used:
| Path | Purpose |
|---|---|
~/.ghyll/config.toml | Configuration |
~/.ghyll/memory.db | Checkpoint store (SQLite) |
~/.ghyll/keys/ | Ed25519 signing keys |
~/.ghyll/models/ | ONNX embedding model |
~/.ghyll/instructions.md | Global workflow instructions |
~/.ghyll/commands/ | Global slash commands |
<repo>/.ghyll/ | Project workflow (instructions + commands; roles/ here is ignored — see ADR-008) |
<repo>/.ghyll.lock | Session lockfile |
The memory-sync git worktree is NOT in the project tree — it is created in a system temp dir via os.MkdirTemp (memory/sync.go:67) so the working copy stays clean.
Operator Guide — gate-and-arrow flow
This guide shows the human-operator surface of ghyll’s gate-and-arrow runtime. The four canonical role contracts and the machine-clause concept catalogue are embedded into the binary (integrator findings H-1 / H-2), so a fresh install needs no manual file-placement to get to a working grid.
First run / bootstrap
The first-time path is four commands:
# 1. install ghyll, then run once to seed the config
ghyll run .
# → "wrote default config at ~/.ghyll/config.toml; edit the model
# endpoints and re-run"
# 2. edit ~/.ghyll/config.toml — drop in real model endpoints
# (the template ships with the canonical four entries; only the
# base_url + api_key fields typically need to change)
$EDITOR ~/.ghyll/config.toml
# 3. produce the project's first grid
ghyll init --op-id alice@example.com .
# → "init complete: <N> arrows across <M> contexts; grid at
# /path/to/project/.ghyll/grid.v1.yaml"
# 4. start the session
ghyll run .
What to edit in ~/.ghyll/config.toml
The seeded config ships placeholder endpoints. Step 2 (“drop in real
model endpoints”) means changing the endpoint = "..." line under
each [models.*] block to your provider’s base URL. A minimal edit
looks like:
[models.minimax]
endpoint = "https://api.example.com/v1/minimax-m25"
api_key = "sk-..."
[models.glm]
endpoint = "https://api.example.com/v1/glm5"
api_key = "sk-..."
# Kimi 2.5 / 2.6 via the CSCS gateway. The `dialect` field is the
# routing key (lowercase, one of: minimax, glm, deepseek, qwen, kimi).
# The `model` field is the literal id sent on the OpenAI request
# body's `model` field — operators paste the canonical mixed-case id
# so the CSCS gateway routes to the right backend.
[models.kimi]
endpoint = "https://ai-gateway.svc.cscs.ch/v1"
dialect = "kimi"
model = "moonshotai/Kimi-K2.6"
api_key = "sk-..."
If model is omitted, the dialect string falls back as the wire
model field (preserves legacy behaviour for the 4 other dialects).
When model is set, it appears verbatim on the request — that is the
“appears on the OpenAI request” contract.
Leave the rest of the template alone unless you have a specific reason to override defaults; the depth ladder, routing thresholds, and sandbox settings ship with sane values.
Endpoint authentication — api_key precedence
When the endpoint requires a Bearer token, ghyll resolves the value at request time from three sources, highest to lowest:
| Layer | Source | Example |
|---|---|---|
| 1 | GHYLL_API_KEY_<MODEL> env var | GHYLL_API_KEY_CSCS_GLM5=sk-... |
| 2 | GHYLL_API_KEY env var (global fallback) | GHYLL_API_KEY=sk-... |
| 3 | api_key = "..." under [models.<name>] in TOML | api_key = "sk-..." |
<MODEL> in the scoped env var name is the TOML model key
upper-cased with any non-[A-Z0-9_] rune replaced by _ — so
[models.cscs-glm5] becomes GHYLL_API_KEY_CSCS_GLM5. The TOML
file is written at mode 0o600 on first seed, but a checked-in
config can safely ship with a placeholder and override via env on
production hosts.
ghyll config show prints the resolved provenance as
api_key: <unset> / <env> / <toml> — the value itself is
never printed, logged, or surfaced through error messages
(401/403 upstream responses are replaced with a fixed
authentication failed string before any logging occurs).
What happens under the hood:
-
Step 1:
ghyll runcalls the config bootstrap. When~/.ghyll/config.tomlis missing, the embeddedconfig/example.tomltemplate is written verbatim at mode 0o600 (it may carry endpoint URLs that look like secrets), and the process exits cleanly so you can fill in the real values before reconnecting. Existing files are never clobbered — a malformed TOML surfaces the parse error, not an overwrite. (C-2) -
Step 3:
ghyll initruns the four-stage bootstrap pipeline end-to-end (C-1):- Profile the project directory (greenfield vs brownfield, bounded-context detection, language detection).
- Load the embedded concept catalogue (H-1).
- For each of the four diamond role-pair arrows
(
init → analyst,analyst → architect,architect → implementer,implementer → integrator) and for each bounded context, build a clause proposal from the upstream role’s exit-gate clauses (H-2 — the role files are embedded; you do NOT need a copy ofspecs/architecture/roles/on disk). - Auto-confirm every clause whose default args satisfy the
concept schema; auto-skip any clause whose schema requires
args that have no default. Skipped clauses are NOT silently
dropped — they land in the grid’s residue list with a
machine-parseable reason (
init-v1: auto-skipped (required args without defaults): …) so a later amendment can supply the missing values. - Persist
.ghyll/grid.v1.yamlatomically (temp → fsync → rename, ADR-010). The grid file is immutable after write; subsequent amendments producegrid.v2.yaml,grid.v3.yaml, etc.
If
.ghyll/grid.v1.yamlalready exists,ghyll initrefuses to clobber it. If profiling finds no bounded contexts (typical for a greenfield repo), a singledefaultcontext is auto-declared so the resulting grid is non-empty; you can rename or split it later via an amendment. -
Step 4: The session opens, restores any prior attestation
- pass + finding state from
.ghyll/engine.db, and presents the prompt. Use/list-arrowsto see what’s been declared and/run-arrow <id>to drive a specific arrow.
- pass + finding state from
ghyll [m25] /path/to/project ▸ /list-arrows
grid arrows (4, version=1):
init→analyst/default init → analyst stratum=L0 context=default clauses=3
analyst→architect/default analyst → architect stratum=L1 context=default clauses=5
...
ghyll [m25] /path/to/project ▸ /run-arrow analyst→architect/default
· pass-opened pass=p-7 role=analyst context=default
· pass-closed pass=p-7 role=analyst state/reason=closed:ok
✓ arrow analyst→architect/default dispatched: pass=p-7 status=valid clauses=5 ...
Pass-open / pass-close and insufficient-basis-rounds-exceeded events surface inline; the operator-verdict modal (Tier 2) drains through the standard REPL pre-prompt drain so an attestation prompt that fires mid-dispatch still gets your attention before the next input.
Slash commands
All commands accepted in the REPL:
| Command | Effect |
|---|---|
/deep | Temporarily switch to the deep-tier model. Refused when --model was passed. |
/fast | Restore auto-routing and clear plan mode. |
/plan | Enter plan mode (deeper reasoning, higher tier preference). |
/status | Show active model, lock state, deep/plan flags, turn count, tool depth. |
/exit | End the session cleanly. Cancels any in-flight modal read; creates a final checkpoint. |
/quit | REPL alias for /exit. |
/op-id <id> | Declare the operator identity for this session. Required before /attest. |
/op-id | Show the active op-id (or “(none)”). |
/op-id none (or clear) | Clear the active op-id. |
/attest <ref> <verdict> [reason] | Record an attestation verdict on a depth-type or on-the-spot attestation. |
/attestations [<arrow-id>] | List recorded attestations, optionally filtered by arrow. |
/passes | List currently-open passes from the PassRegistry. |
/passes <pass-id> | Show one pass’s full state. |
/list-arrows | Render the grid snapshot (sorted arrow IDs + source→target / stratum / context / clause count). Hints when the grid is empty. |
/run-arrow <arrow-id> [--context <ctx>] | Dispatch one arrow synchronously; surface pass-open/close + IB-rounds-exceeded + adversarial-cycle round events inline. (C-3 / I-H-2) |
/drain-amendments | FIFO-drain the pending amendment queue under the active op-id; refuses without /op-id. Each commit fires OpEventAmendmentDrained with typed outcome per ADR-v4-005. (diamond v4 / Gap 2) |
/adversary {enable|disable|status} | Toggle the §11 adversarial-cycle hook bundle the dispatcher consults on depth-sensitive arrows. enable refuses with no-dialect-configured when no active model resolves to a configured endpoint. (diamond v4 / Gap 1) |
/invalidate-arrow <arrow-id> [--reason <text>] | Invalidate one arrow; persists an audit row to .ghyll/engine.db arrow_invalidations (ADR-v4-008). Refuses without /op-id. The audit row carries operator identity, reason, and timestamp. |
/<name> | User-defined slash command loaded from .ghyll/commands/<name>.md. The file contents are injected as user input for the next turn. |
/op-id <identity> — what it is and why it matters
An op-id is an email-like string identifying the human at the
keyboard. You set it once per session with /op-id you@example.com.
Every attestation you record is tagged with this op-id. §12.2
(self-cert) enforces that you cannot attest your own work — if you
played the analyst role on an arrow, your op-id cannot also serve as
the attesting authority on that arrow’s clauses. The CLI rejects
op-ids with control bytes, separators, or Unicode-format runes (see
validateAndNormalizeOpID).
Example:
ghyll [m25] /path/to/project ▸ /op-id alice@example.com
op-id set: alice@example.com
To clear: /op-id clear or /op-id none. To inspect: /op-id
with no argument.
/attest <attestation-id> <verdict> [reason] — example
/op-id must be set first; the slash command stamps every attestation
with the active op-id:
ghyll [m25] /path/to/project ▸ /op-id alice@example.com
op-id set: alice@example.com
ghyll [m25] /path/to/project ▸ /attest att-analyst→architect/checkout-C1-v1 pass "verified test coverage"
✓ attestation att-analyst→architect/checkout-C1-v1 recorded: verdict=pass by op-id=alice@example.com
Attestation IDs come from the runtime:
att-<arrow-id>-<clause-id>-v<N>for depth-type attestations.att-<arrow-id>-v<N>for on-the-spot attestations.
The canonical <arrow-id> shape emitted by the bootstrap is
<source-role>→<target-role>/<context> (for example,
analyst→architect/default); use that form anywhere an arrow ID is
expected.
The verdict is one of pass, fail, insufficient-basis
(plus aliases: p, ok, f, no, ib).
The verdict flows through the AttestationStore — persisted to
the engine sqlite table, audited to the flat
.ghyll/attestations.jsonl AND to the per-pass tree at
.ghyll/attestations/v<N>/<context>/stratum-<S>/<role-pair>/<pass-id>.jsonl.
fsync runs before the verdict is reported accepted (operator-spec
durability invariant).
Three consecutive insufficient-basis verdicts on the same
clause fire the escalation event configured by
insufficient-basis-rounds-max in the grid file.
/run-arrow — example
ghyll [m25] /path/to/project ▸ /run-arrow analyst→architect/checkout
· pass-opened pass=p-7 role=analyst context=checkout
· pass-closed pass=p-7 role=analyst state/reason=closed:ok
✓ arrow analyst→architect/checkout dispatched: pass=p-7 status=valid clauses=2 blocking-clauses=0 blocking-findings=0
The depth tier is resolved by runner.RouteArrow over the
arrow’s clauses (max-over-clauses per gates.md §8). The context
defaults to the arrow’s own declared context; pass
--context <ctx> to override. When the role/context lock is
held by another pass the command surfaces
ErrRoleContextBusy with the holding pass ID; when the grid is
empty, the hint is no grid; run \ghyll init` first`.
Verdict modal (Tier 2 / ADR-016)
The four verdicts, in operator terms:
- pass: I looked at the work, it meets the clause’s contract.
- fail: I looked, it does not — record what I inspected.
- insufficient-basis: I cannot evaluate from what I have — record a residue note describing what’s missing.
- skip: punt to next round (the same prompt re-presents).
When the dispatcher signals that a clause is awaiting attestation, the REPL drains a verdict modal BEFORE the next prompt. You see:
── attestation request ─────────────────
arrow: analyst→architect/checkout
clause: C2
concept: lint-clean
attestation-ref: att-analyst→architect/checkout-C2-v1
────────────────────────────────────────
verdict? [pass / fail / insufficient-basis / skip]:
pass/p—confirmunit, no payload.fail/f— prompts for inspected locations (record-locations-inspectedunit; CSV).insufficient-basis/ib— prompts for a residue note (write-residue-noteunit; capped by the grid’sresidue-note-max-bytes).skip/s— clause stays pending; the next turn re-presents.
After three consecutive insufficient-basis verdicts the
escalation prompt fires:
── escalation: 3 insufficient-basis rounds ──
arrow: analyst→architect/checkout
clause: C2
options:
1) accept risk (record residue note; finding → accepted-risk)
2) route upstream (record rationale; pass aborts; deeper-tier retry)
─────────────────────────────────────────────
choice (1 or 2):
The chosen verdict is recorded as an AttestationRecord with
the residue/rationale as the payload. There is no default — you
must choose.
/exit cancels any in-flight modal cleanly; the queued items
re-present on the next session start (Recovery republishes).
Residue notes
When you pick insufficient-basis, you’re recording why you couldn’t
decide — what evidence was missing, what would change your verdict.
A good residue note names the artifact you wanted to inspect and
didn’t have. The grid’s residue-note-max-bytes caps the field.
Bootstrap residue
Clauses the bootstrap couldn’t auto-confirm (required args missing)
land in the grid’s residue list with a machine-parseable reason. A
later grid amendment can supply the missing values — until then,
the affected arrow’s clause stays unevaluated.
State at a glance
A novice reading ghyll arrow show needs to know how three layered
statuses interact: clause, pass, arrow. Sketch:
| Status family | Values | Where you see it |
|---|---|---|
| Clause status | unevaluated, pass, fail, insufficient-basis, skipped | ghyll arrow show per-clause line; verdict modal |
| Pass status | open, closed:ok, closed:failed, aborted | /passes listing; pass-opened / pass-closed events |
| Arrow status | unevaluated, valid, invalid (derived from the pass + the clause set) | /list-arrows; ghyll arrow show header |
An arrow with any unevaluated clause cannot transition to valid;
a pass cannot close ok while any of its clauses still need an
attestation.
Amendments
When the grid needs to evolve (a new bounded context appears, an arrow needs a clause added, a residue note is being resolved), the integrator role enqueues an amendment. The amendment serializes through a global lock, aborts any open passes on the affected arrow, appends the new arrow definitions, and bumps the grid version. The CLI for triggering one manually is not yet exposed — today it’s runtime-driven by the integrator role.
The adversarial cycle (operator’s view)
ghyll periodically runs an adversarial pass — a fresh adversary
attacks the current state of an arrow’s evidence, raises findings,
and the original producer is asked to fix them. The operator sees
producer-fix-signal events; when the producer plateaus (loop-bomb
detected, see below), the operator is asked to break the deadlock.
Op-ids on a team
Use stable, distinct op-ids per operator (email addresses are recommended). The self-cert rule (§12.2) means two operators on a project MUST have different op-ids — sharing one breaks the cross-check.
Offline CLI commands
These work without a running session.
ghyll init --op-id <id> [--language <lang>] [--force-traits] [project-dir]
The bootstrap pipeline driver covered in detail above. The
positional project-dir defaults to . when omitted. Refuses
to overwrite an existing grid; rejects op-ids that contain
control bytes, path separators, “..” substrings, Unicode format
runes (RTL override, ZWSP, ZWJ, BOM), a leading dot or dash,
a trailing dot, or > 256 bytes.
--language accepts go, python, cpp, rust, auto
(default — derives from the profile’s detected file
extensions), or none (skip the trait block). Comma-separated
for polyglot repos: --language go,python. The chosen
guideline + the language-agnostic engineering.md are inlined
into <project>/.ghyll/instructions.md inside
<!-- ghyll-traits-begin --> ... <!-- ghyll-traits-end -->
markers. Re-running without --force-traits leaves an existing
trait block alone; with --force-traits rewrites just that
slice (operator prose above + below is preserved).
The library of opt-in guidelines lives at
~/.ghyll/guidelines/{engineering,ci,go,python,cpp,rust}.md,
seeded on first ghyll run. Edit them; the next ghyll init
on a project picks up the edits.
ghyll init attest --op-id <id> [--dir <path>]
Tier 3 / gate-2 CORR-A-18: the production producer for init
AttestationRecords. Reads the project grid via bootstrap.Read,
emits one on-the-spot record per arrow with
AttestedByRole=init, persists through the standard Record
path (tree writer primary + engine catch-up). Idempotent on
re-run.
$ ghyll init attest --op-id alice@example.com --dir /path/to/project
ghyll init attest: 3 init attestations recorded for op-id=alice@example.com (grid v1, 3 arrows)
Op-id reject criteria mirror ghyll init and the /op-id
slash command (same validator).
ghyll engine status [--dir <path>]
Render a project-level summary: arrow count, finding count, amendment backlog, attestation count, evaluation runs.
$ ghyll engine status --dir /path/to/project
ghyll-engine-status: present
engine: /path/to/project/.ghyll/engine.db
arrows: 12
findings: 3
requirements: 8
classifications: 8
amendments: 0 pending, 2 drained
evaluation runs: 47
attestations: 5
A project that has never initialized v2 emits
ghyll-engine-status: missing and exits cleanly.
ghyll engine recover [--dry-run] [--dir <path>]
Preview what crash recovery would do at the next session start.
Opens the engine read/write, runs the reconciliation logic
inside a transaction that is always rolled back, prints the
report. The real recovery happens automatically when you start
a session with ghyll run; this CLI exists so you can preview
it first.
$ ghyll engine recover --dry-run --dir /path/to/project
recover (dry-run): /path/to/project/.ghyll/engine.db
orphans aborted: 2
orphans preserved: 1 (attestation-pending)
evaluation_runs flipped: 3 (from JSONL verdicts)
events:
- recovery-pass-aborted-crash pass=P-1 arrow=A1 clause= no live process at restart; closed_at=...
- recovery-attestation-republished pass=P-3 arrow=A1 clause=C5 att-ref=att-X preserved at ...
- recovery-attestation-replay pass=P-2 arrow=A2 clause=C7 att-ref=att-Y verdict=pass mapped=pass
note: --dry-run; no changes persisted. Start a session
with `ghyll run` to apply recovery for real.
The output covers three reconciliation classes (per ADR-015 Part D):
- orphans aborted — open passes whose runner process is gone;
marked
aborted:crash. - orphans preserved — open passes with a pending depth-type
attestation (so the operator can still deliver a verdict). The
pass row stays
openandrecovered_atis stamped. - evaluation_runs flipped — clauses with
end_status=runningAND a verdict in the JSONL audit log;end_statusis reconciled to match the verdict (pass→pass,fail→fail,insufficient-basis→runningso the dispatcher re-emits the hint).
The --commit flag is explicitly refused — apply recovery via
ghyll run.
ghyll engine verify-attestations [--dir <path>]
Walk the project’s .ghyll/attestations.jsonl audit trail and
report any record that violates the schema (missing required
fields, kind/clause_id pairing wrong, unknown verdict, §12.2
self-cert). Useful for compliance / audit review.
$ ghyll engine verify-attestations --dir /path/to/project
attestation-verify: 5/5 records OK
A failed audit returns a non-zero exit code and prints each issue’s line number + reason.
ghyll memory <subcommand>
Memory sync + search subcommands.
| Subcommand | Effect |
|---|---|
ghyll memory search <query> | Vector-search past checkpoints by semantic similarity. |
ghyll memory log | Show the local checkpoint chain (hash, parent, turn, summary). |
ghyll memory sync | Manual sync to/from the vault. |
ghyll arrow show <arrow-id> [--dir <path>]
Render one arrow’s live state: definition (source / target role, clauses, requirements), open findings on the arrow, and all recorded attestations.
$ ghyll arrow show analyst→architect/checkout --dir /path/to/project
arrow: analyst→architect/checkout
source-role: analyst
target-role: architect
stratum: L1
context: checkout
clauses: 2
[0] no-todo-marker (id=C1, depth=depth-robust, min-tier=0)
[1] lint-clean (id=C2, depth=depth-sensitive, min-tier=2)
requirements: 1
[0] R1 (min-depth=2)
findings: 0
attestations: 2
att-analyst→architect/checkout-C1-v1 kind=depth-type clause=C1 verdict=pass op=alice
att-analyst→architect/checkout-C2-v1 kind=depth-type clause=C2 verdict=insufficient-basis op=alice
Environment variables
| Variable | Effect |
|---|---|
GHYLL_REQUIRE_SANDBOX | 1/true/yes/on: refuse to start outside a recognized sandbox. Unset: warn only. |
GHYLL_SANDBOX_ASSUME_SAFE | Bypass the sandbox check with the given reason string (audited in the warning). |
GHYLL_LOG_LEVEL | debug/info/warn (default)/error. Routes diagnostics. |
GHYLL_LOG_FORMAT | text (default) or json. |
§12.2 self-cert in plain terms
You can’t grade your own homework. If you were the analyst on an arrow, you cannot also be the attestor — someone else (or, equivalently, a session using a different op-id and declaring a different role) must verify.
When you attest a clause, your declared role MUST NOT equal the arrow’s source role or its target role. ghyll enforces this at two boundaries:
- The runtime AttestationStore rejects a
Recordcall whereAttestedByRoleis either endpoint, case-insensitive + trimmed. The slash command always usesoperatoras the role, which bypasses the conflict by design. - The engine schema has CHECK constraints mirroring the runtime check. Out-of-band SQL inserts can’t bypass the rule.
The verifier (ghyll engine verify-attestations) also detects
self-cert in the on-disk JSONL — so a tampered audit file
surfaces failure.
Loop-bomb detection in the producer-fix cycle
When the adversarial cycle runs and the model is asked to fix what the adversary surfaced, ghyll fingerprints the model’s output each round. Two rounds with identical output and unresolved findings = the model is stuck; ghyll aborts and asks the operator to step in.
When the adversary loop runs with a producer hook (typically
the model itself responding to findings), the harness computes
a SHA-256 digest of the producer’s response artifact each
round. Two consecutive rounds with identical artifacts AND
still-open findings = the producer isn’t actually changing
anything = ErrProducerLoopBomb. The cycle aborts; the
operator must intervene.
Where to look when something goes wrong
| Symptom | Look at |
|---|---|
| Attestations aren’t persisting | .ghyll/engine.db (sqlite) — run ghyll engine status |
| Audit trail looks short | .ghyll/attestations.jsonl + the per-role-pair tree under .ghyll/attestations/ |
| Background sync errors | .ghyll/ghyll.log (slog file) |
| Operator events lost | The OperatorBus is in-process; check the JSONL writer + status output |
| Lock contention (“role-context-busy”) | ghyll arrow show the arrow; /passes to see who holds the lock |
/list-arrows says “no grid; run ghyll init first” | Run ghyll init --op-id <id> to produce .ghyll/grid.v1.yaml. |
ghyll run exits with “wrote default config” | First-run config bootstrap — edit ~/.ghyll/config.toml and re-run. |
/drain-amendments refuses with “no op-id set” | Declare your operator identity first via /op-id you@example.com; the audit row needs it. |
/adversary enable returns “no-dialect-configured” | No active model resolves to a configured endpoint. Set routing.default_model in ~/.ghyll/config.toml to a model whose endpoint is reachable, OR start with --model <name> that maps to a dialect. |
/invalidate-arrow refuses with “no op-id set” | Declare the operator identity first; arrow_invalidations rows carry op-id, reason, timestamp. |
/invalidate-arrow refuses with “arrow … not in grid” | Use /list-arrows to confirm the arrow id; the wire form is the canonical <source-role>→<target-role>/<context> shape produced by ghyll init. |
| I made a wrong verdict | Attestations are immutable; the only correction is a new attestation on a later pass OR a grid amendment that supersedes the arrow. |
| Pass / clause / attestation state looks wrong | ghyll engine status first, then ghyll arrow show <id> for specifics. |
Sandbox setup
ghyll is sandbox-only by design (CLAUDE.md: “Tools are direct
OS calls — no permission layer (sandbox handles security)”). The
sandbox is the layer that restricts what bash / git / web tools
can touch. Without one, a compromised model endpoint runs
arbitrary code with your privileges.
Recommended sandboxes (detected automatically by ghyll):
| Sandbox | How to wrap |
|---|---|
| Docker / Podman | Run ghyll inside a container with bind-mounted project dir |
| bubblewrap | bwrap --bind ~ /home/me --bind /tmp /tmp ghyll run . |
| sandbox-exec (macOS) | sandbox-exec -f profile.sb ghyll run . |
| Firejail | firejail --noprofile ghyll run . |
| Kubernetes | KUBERNETES_SERVICE_HOST triggers detection |
Enforcement modes (GHYLL_REQUIRE_SANDBOX):
- unset /
0/false: warning only, ghyll starts. 1/true/yes/on: refuse to start without a detected sandbox. Override withGHYLL_SANDBOX_ASSUME_SAFE=<reason>(the reason is logged for audit).
Vault (team memory) setup
The vault is an HTTP server (cmd/ghyll-vault) that synchronizes
checkpoints across multiple operators. Production deployments
MUST configure ed25519 verification via WithKeysDir(dir);
the empty-keysDir mode is TEST-only and logs a warning at
startup.
Layout:
<vault-storage>/
devices/
alice.pub # ed25519 public key (PEM)
bob.pub
…
Each device generates a key pair on first run
(memory.LoadOrGenerateKey); the operator hands .pub to the
vault admin. Checkpoint signing happens automatically; the
vault refuses any checkpoint whose signature doesn’t verify
against the registered device’s pub key.
Related architecture documents
specs/architecture/gates.md— the gate schema; §3.7 (amendment), §11 (adversarial cycle), §12.2 (self-cert)docs/decisions/009-016— the v2 ADRs (self-cert, attestation store, role-context lock, operator bus, pass entity, orchestrator, pass persistence, Tier 2 modal)specs/architecture/components/attestation.md— operator attestation flow specdocs/architecture-flows.md— sequence diagrams for the three load-bearing flows
Memory & Sync
ghyll maintains a tamper-evident checkpoint chain for conversation memory, synced via a git orphan branch.
Checkpoints
Every N turns (configurable), ghyll creates a checkpoint containing:
- Structured summary of recent work
- Vector embedding for similarity search
- Files touched and tools used
- Hash chain link to previous checkpoint
- Ed25519 signature from the device key
Checkpoints are append-only and never modified after creation.
Hash Chain
Each checkpoint’s hash covers all fields except the hash and signature themselves. The parent hash links to the previous checkpoint, forming a Merkle DAG. This makes tampering detectable — modifying any checkpoint breaks all subsequent hashes.
Signing
Checkpoints are signed with the device’s ed25519 private key (~/.ghyll/keys/<device-id>.key). Keys are generated automatically on first run.
Public keys are distributed via the memory branch at devices/<device-id>.pub.
Git Sync
Checkpoints sync via a git orphan branch (ghyll/memory) in the project repo:
- Push: after each checkpoint, committed and pushed in the background
- Pull: on session start, fetches remote checkpoints from other devices
- Conflict-free: append-only design means fast-forward merges always work
- Offline: checkpoints accumulate locally and push when connectivity returns
The orphan branch shares no history with code branches.
Team Memory
When multiple developers work on the same repo, their checkpoints are visible to each other. Drift detection can backfill from team checkpoints when relevant.
For faster search across repos, use the optional vault server (ghyll-vault).
Drift Detection
Every N turns, ghyll measures cosine similarity between the current context embedding and the most recent checkpoint. If similarity drops below the threshold (default 0.7), backfill is triggered — injecting relevant checkpoint summaries into the context.
Requires the ONNX embedding model (ghyll memory fetch-embedder). Without it, drift detection is disabled gracefully.
Commands
ghyll memory log # show checkpoint chain
ghyll memory search "race condition" # search summaries
ghyll memory fetch-embedder # download GTE-micro ONNX model
ghyll memory fetch-embedder --force # re-download even if file exists
ghyll memory sync # manual push/pull of the orphan branch
Troubleshooting
Common Issues
“another ghyll session is active (pid N)”
Only one ghyll session can run per repository at a time. Check if another terminal is running ghyll in the same directory. If the previous session crashed, the stale lockfile will be automatically reclaimed.
“wrote default config at ~/.ghyll/config.toml; edit the model endpoints and re-run”
First run wrote a default config and exited — edit ~/.ghyll/config.toml (drop in real model endpoints) and re-run. The auto-bootstrap is implemented in cmd/ghyll/config_bootstrap.go (the C-2 surface): when no config exists, the embedded default template is written with 0o600 and ghyll exits cleanly so the operator can fill in endpoints. See Configuration for the full reference.
“default model ‘m25’ has no endpoint configured”
The routing default model must have a corresponding [models.m25] section in config.toml.
“all model endpoints unreachable”
Both SGLang endpoints are down. Check network connectivity to your inference cluster. ghyll retries 3 times with exponential backoff before giving up.
“embedding model not available, drift detection disabled”
This is a warning, not an error. Run ghyll memory fetch-embedder to download the ONNX model for drift detection. ghyll works fine without it.
“stream interrupted after N tokens”
The connection to the model endpoint dropped mid-response. The partial response is preserved. You can retry by sending the same request.
Checkpoint verification warnings
unverified checkpoint from @device (unknown key)— the device’s public key is not on the memory branch. Wait for the next sync, or manually fetch.checkpoint chain broken at X from @device— a checkpoint was tampered with or a sync was incomplete. The checkpoint is excluded from backfill.
Performance
Token counting is approximate
ghyll uses character-based estimation (~4 chars/token for M2.5, ~3 chars/token for GLM-5). This is intentionally approximate to avoid tokenizer dependencies. The reactive compaction fallback handles cases where the estimate is too low.
Sync is slow on first run
The first sync creates the orphan branch (an --orphan checkout on the existing clone, not a re-clone of the repo) and pushes; subsequent syncs are incremental.
Tool depth limit
After tool_depth_threshold (default 5) the router escalates to the deep tier; a hard ceiling of 50 sequential calls aborts the chain (ADR-004). The escalation threshold is NOT the abort — it’s the routing signal that shifts the next call to the deeper model. If a model keeps requesting tool calls past the ceiling, the session stops with an error. This prevents runaway loops from buggy models.
Architecture Overview
Ghyll is a coding agent for self-hosted open-weight models, with a typed-clause + arrow-based correctness mechanism layered on top. It is sandbox-only by design — the runtime executes tool calls directly with no permission layer; the sandbox boundary is the security boundary. See Why ghyll for the rationale.
This page is the entry to the architecture chapter. Each subsystem has its own dedicated page; this page sketches the shape and points you at the right deep-dive.
System diagram
+----------------------------------------------------------+
| Developer machine (inside an operator-chosen sandbox) |
| |
| ghyll (single Go binary) |
| +----------------------------------------------------+ |
| | cmd/ghyll CLI, session loop, slash cmds | |
| | cmd/ghyll/modal Tier 2 verdict modal (TermModal)| |
| | runner/ gate-and-arrow runtime | |
| | - clause evaluation | |
| | - dispatcher / RunArrow | |
| | - InsufficientBasisTracker | |
| | - OperatorBus, AttestationStore | |
| | engine/ sqlite store + journal replay | |
| | + crash recovery (ADR-015) | |
| | bootstrap/ project init, grid build, | |
| | propose + modify rules | |
| | catalogue/ closed concept vocabulary | |
| | (embedded gates/concepts/*.yaml)| |
| | gates/concepts/ 18 clause concept schemas | |
| | dialect/ model-specific code | |
| | router.go gate-driven routing | |
| | minimax.go MiniMax M2.5 dialect | |
| | glm.go GLM-5 dialect | |
| | deepseek.go DeepSeek dialect | |
| | qwen.go Qwen dialect | |
| | parse.go shared OpenAI tool-call parser | |
| | helpers.go sanitize / strip helpers | |
| | context/ context manager | |
| | manager.go compaction + backfill | |
| | drift.go cosine drift detection | |
| | injection.go prompt-injection signal | |
| | memory/ checkpoint store | |
| | store.go sqlite + hash chain | |
| | crypto.go ed25519 sign / verify | |
| | embedder.go ONNX runtime (optional) | |
| | sync.go git orphan branch sync | |
| | vault_client.go HTTP client for ghyll-vault | |
| | stream/ SSE client + terminal renderer | |
| | tool/ direct OS operations | |
| | workflow/ project-instructions loader | |
| | ui/ user-facing terminal output | |
| | config/ TOML loader + validation | |
| | internal/ pathglob, safefile, skipdirs | |
| | vault/ team-memory HTTP server | |
| +----------------------------------------------------+ |
| |
| .ghyll/ (project-local) |
| grid.v1.yaml bootstrap-built grid |
| engine.db sqlite runner state |
| attestations/<pass>/ per-pass tree audit log |
| attestations.jsonl flat aggregate audit log |
| |
| ~/.ghyll/ (user-global) |
| config.toml endpoints + thresholds |
| memory.db sqlite checkpoint store |
| keys/ ed25519 device keys |
| models/ ONNX embedding model |
+----------------------------+-----------------------------+
| HTTPS
+------------+------------+
| |
SGLang endpoints ghyll-vault (optional)
(MiniMax M2.5, GLM-5, team-memory HTTP search
DeepSeek, Qwen) against vault.db
Architecture pages
The big subsystems each have their own page:
- Architecture Flows — sequence diagrams for the major flows: init, dispatch, Tier 2 verdict modal, amendment commit, crash recovery.
- Package Graph — Go package layout,
dependency direction, and the role of the
types/leaf package. - Session Loop — the state machine at
the heart of
cmd/ghyll: init, turn execution, compaction, handoff, backfill, shutdown. - Context-Depth Routing — the decision table that picks a tier from the depth requirements of an arrow’s clauses. ADR-007 documents the rationale.
- Checkpoint Format — canonical serialization, ed25519 signing, hash-chain verification, storage layout.
- Sync Protocol — git-based memory sync via the
ghyll/memoryorphan branch. - Vault API — HTTP API exposed by
ghyll-vaultfor team memory search. - Error Types — typed errors per package, sentinel patterns, error-flow across boundaries.
See also
- Why ghyll — design rationale for the five core decisions.
- Architecture decisions — 17 main ADRs, 13 v2-pivot ADRs, and 9 v4 ADRs.
specs/architecture/— canonical design reference (current code, not aspirational).
Configuration
The full configuration reference lives on the Configuration page. A minimal working config:
[models.m25]
endpoint = "https://inference.internal:8001/v1"
dialect = "minimax"
max_context = 1000000
[models.glm5]
endpoint = "https://inference.internal:8002/v1"
dialect = "glm"
max_context = 200000
[routing]
default_model = "m25"
deep_model = "glm5"
context_depth_threshold = 32000
enable_auto_routing = true
Architecture flows — sequence diagrams
ASCII sequence diagrams for the three load-bearing flows in ghyll’s gate-and-arrow runtime. Each shows which components participate and the order of operations.
Flow 1: Operator attestation (CLI /attest)
The path a /attest slash command takes from REPL keystroke to
durable engine row + JSONL audit + tracker pulse. This is the
CLI escape-hatch path the operator types when they want to attest
a clause out-of-band; the routine path is Flow 4 (the Tier 2
verdict modal).
Operator Session Attestation Journal Engine JSONL Tree IBTracker
(REPL) Store (Consumer) Store Writer Writer
| | | | | | | |
| /attest | | | | | | |
|─────────────>| | | | | | |
| | parseAttestationRef |
| | (id → arrow, clause, version) |
| | | | | | | |
| | Grid.Lookup(arrowID) → source/target roles |
| | | | | | | |
| | Record(rec) | | | | | |
| |─────────────>| validate | | | | |
| | | (§12.2 self- | | | | |
| | | cert check) | | | | |
| | | + insert | | | | |
| | | + version++ | | | | |
| | | + fanout-->-(observer slice copy under lock) |
| | | | | | | |
| | | observer 1: ─>journal.enqueue(rec) | | |
| | | observer 2: ────────────────────────────>Write(line)+fsync |
| | | observer 3: ──────────────────────────────────────────>Write(line)+fsync |
| | | observer 4: ──────────────────────────────────────────────────────>Record() |
| | | | | | | |
| | | | dequeue | | | |
| | | | INSERT OR IGNORE |
| | | |─────────────>| persist | | |
| | | | | (immutable) | | |
| | | | | | | |
| "✓ recorded" | | | | | |
|<─────────────| | | | | | |
Key invariants:
- Step 4 (fsync inside the JSONL Writer observer) returns
BEFORE
Recordreturns to the CLI handler. Per ADR-010 and the operator-attestation spec, the JSONL line is fsync’d before/attestprints its success message back to the operator — so a crash between Record and the prompt cannot leave the operator with a “succeeded” message and no on-disk record. (The modal surface in Flow 4 has the same invariant phrased in modal terms: the verdict is reported as accepted only after the writers return.) - §12.2 enforcement fires inside
validateBEFORE any write. A self-cert attempt errors out withErrAttestationSelfCert; no row hits any storage layer. - The Journal observer is the durable path. The JSONL writers are the audit-trail path. The engine table is the source of truth per ADR-010.
- The IBTracker receives every verdict — whether the verdict
came in via the CLI here or via the modal in Flow 4. Three
consecutive
insufficient-basison the same clause emitOpEventInsufficientBasisRoundsExceededon the bus.
Flow 2: Adversarial cycle with producer-fix harness
The bounded multi-round remediation cycle from gates.md §11.
Dispatcher AdversarialOrchestrator Factory Adversary ProducerFixHarness Producer FindingsStore OperatorBus
| | | | | | | |
| Run(attack) | | | | | | |
|──────────────>| | | | | | |
| |─ Round 1 ──────────>| | | | | |
| | factory.New(1)─────| | | | | |
| | | | | | | round-start |
| | | | | | |<─────────────|
| | Attack(ctx, attack) | | | | | |
| |─────────────────────|─────────>| | | | |
| | | | falsify | | | |
| | | | open-sweep | | | findings |
| | | | classify | | | raised |
| | | |─Raise(F1)────|──────────────────────────────>| |
| |<────────────────────|──────────| | | | |
| | report | | | | | |
| | open findings? YES | | | | | producer-fix-|
| |─publish─────────────|──────────|──────────────|────────────────|─────────────|>signal |
| | | | | | | |
| | ProducerRemediate(ctx, open) |
| |──────────────────────────────────────────────>| | | |
| | | | | round++ | | |
| | | | | digest-prev | | |
| | | | | producer.Run() | | |
| | | | |────────────────|>(transition findings) |
| | | | |<─artifact──────| | |
| | | | | sha256(artifact) | |
| | | | | loop-bomb check | |
| | | | | | | |
| |<──────────────────────────────────────────────| nil OR ErrProducerLoopBomb |
| | | | | | | |
| | re-check convergence| | | | | |
| | (no open ≥ threshold? → exit) |
| | | | | | | converged |
| | | | | | |<─────────────|
|<──────────────| | | | | | |
| result | | | | | | |
Key invariants:
- Round-fresh Adversary per ADR-014: every round the factory
builds a new Adversary instance. The previous round’s atomic
usedflag is dead state. - Twice-per-round convergence check: once after Attack returns (no new findings + no above-threshold opens → exit), once after ProducerRemediate (zero open above threshold → exit mid-round). The double check avoids one wasted adversary round when the producer resolves everything.
- Loop-bomb detection is inside the harness, not the
orchestrator. ProducerFn returns an artifact digest; identical
digest across two rounds + still-open findings → abort with
ErrProducerLoopBomb.
Flow 3: Amendment commit
The gates.md §3.7 amendment flow: integrator-raised
“missing-cross-context-spec” finding → analyst response →
grid v(N+1).
Integrator AmendmentQueue AmendmentCommitter PassRegistry Pass RoleLockTable Grid AmendmentObserver OperatorBus
| | | | | | | | |
| Enqueue(req)| | | | | | | |
|────────────>| | | | | | | |
| | byID + pending append | | | | | |
| | observer fires: AmendmentEventEnqueue | |
| |─────────────────────────────────────────────────────────────────────────────────>| |
| | | | | | | | INSERT amendments |
| | | | | | | | (drained_at NULL) |
| | | | | | | | |
| (analyst produces new ArrowDefinitions) | |
| Commit(req, newArrows) | | | | | | |
|────────────────────────────────>| validate(req) | | | | | |
| | | acquire c.mu | | | | | |
| | | (serialize commits) | | | | |
| | | | | | | | |
| | | passes.All()──>| | | | | |
| | |<──────────────| | | | | |
| | | for each pass on SourceArrow + Open: | |
| | | p.Abort("amendment drained")───>(p.lockToken.Release) | |
| | | | |─────────>| | | pass-closed |
| | | | | | | |<──────────────────|
| | | | | | | | |
| | | for each newArrow: Grid.Append(def)─────────────────>| | |
| | | | | | version++ | | |
| | | | | | | | |
| | | queue.MarkDrained(req.ID) | | | |
| |─delete byID, add seenIDs, emit AmendmentEventDrain───────────────────>| |
| | | UPDATE amendments |
| | | SET drained_at = now WHERE id |
| | | |
| | | bus.Publish(amendment-drained) |
| | |───────────────────────────────────────────────────────────────────────>|
|<──────────────────────────────| res = {GridVersionBefore, GridVersionAfter, AppendedArrows, AbortedPasses, ...} |
Key invariants:
- Pass abort runs BEFORE arrow append. Per ADR-001x (the
commit ADR; see
amendment_commit.go): a partial-append failure leaves passes correctly aborted because they ran first. - drained_at persists via the queue’s
MarkDrained→AmendmentEventDrain→ journal observer → UPDATE. Without this, the amendment re-replays as pending on next session start. status=complete|partial-append-erroron the operator event distinguishes between a clean commit and one where some arrows didn’t land. The grid version still bumps forward for the arrows that succeeded — gates.md §3.7 forbids regress.
Flow 4: Tier 2 verdict modal (ADR-016)
The path a dispatcher-requested attestation takes from “clause
is awaiting verdict” through the interactive REPL modal to a
persisted AttestationRecord. Distinct from Flow 1 (which is
the /attest CLI escape hatch); this is the routine path.
Dispatcher OperatorBus modalDriver LineReader TermModal AttestationStore Tree+JSONL
| | | | | | |
| clause c flips AwaitingAttestation=true |
| Publish(OpEventAttestationRequested, payload={src/tgt/ctx/stratum/grid_ver/adv_role, |
| Detail=hint-json with arrow_id, clause_id, concept, attest_ref}) |
|─────────────>| | | | | |
| | fanout | | | | |
| |─────────────>| OnEvent(ev) | | | |
| | | enqueueVerdict(ev): | | |
| | | - parse hint json (capped at 64 KiB) |
| | | - copy Payload onto modalRequest |
| | | - dedup via inFlight[attest_ref] |
| | | - cap-check pending queue |
| | | <─enqueue or drop+OpEventModalBackpressure |
| | | | | | |
| --- REPL turn boundary: DrainPending fires before ui.Print(prompt) ---- |
| | | | | | |
| | | DrainPending(sessionCtx): |
| | | snapshot pending; pending=nil |
| | | for req in snapshot: |
| | | if ibTracker.IsCrossed(c) → handleEscalation |
| | | else → handleVerdict |
| | | PresentVerdict(ctx, hint) |
| | |─────────────>| Next(ctx) | | |
| | | |────────────>| (blocks) | |
| | | | | operator types verdict |
| | | |<────────────| "pass" | |
| | |<─VerdictSubmission{Pass, Confirm, payload} |
| | | buildRecord(req, sub): |
| | | opID = opIDProvider() (read AT CALL TIME — gate-1 F-20) |
| | | resolve src/tgt/ctx/stratum via arrowResolver fallback |
| | | ValidateUnitPayload(unit, payload, residueMaxBytes) |
| | | store.Record(rec) | | |
| | |───────────────────────────────────────>| |
| | | | | validateAttestation
| | | | | + validateAttestationTier2
| | | | | (PassID + Unit + payload +
| | | | | HintJSON + adv-role "__")
| | | | | primaryWriter (tree)─────>|
| | | | | | EncodeAttestationPath(rec)
| | | | | | (purefn; safeSegment hashes
| | | | | | '.'/'..'/oversize; init special-case)
| | | | | | append + fsync (gate-1 F-11)
| | | | | | publish OpEventPathTruncated
| | | | | | if hash-substituted
| | | | |<─────────────| ok
| | | | | byID[id] = rec; version++
| | | | | snapshot observers; release s.mu
| | | | | (CONC-H-3: fanout OUTSIDE lock)
| | | | | observers run:
| | | | | - JSONL writer (forward-only Observer)
| | | | | - IBTracker.Record (verdict → reset/inc)
| | | | | - Journal observer → engine row
| | | If sub.Verdict == AttestationFail: |
| | | bus.Publish(OpEventClauseFailVerdict) BEFORE Record |
| | | (CONC-M-1: fail signal survives Record reject) |
| | | clear inFlight[attest_ref] |
| | | continue snapshot iter; bound at 8 rounds |
Escalation path (after 3 consecutive insufficient-basis):
| | | handleEscalation(req): |
| | | PresentEscalation(ctx, hint) |
| | |─────────────>| Next(ctx) → option (1 or 2) + residue/rationale |
| | | bus.Publish(OpEventEscalationPresented) AFTER prompt |
| | | | (CONC-H-5: paired with Resolved) |
| | | opt 1 → verdict=pass + residue; opt 2 → verdict=fail + rationale |
| | | store.Record(rec) | | |
| | | ibTracker.Reset(clause) |
| | | bus.Publish(OpEventEscalationResolved) |
Key invariants:
- opID read at PresentVerdict-call time (gate-1 F-20) —
not at enqueue. So a mid-pass
/op-idswap takes effect on the next modal. - inFlight dedup by attestation-ref (gate-1 F-12) — the same dispatcher republish is presented at most once per drain.
- DrainPending snapshot-then-iterate with 8-round cap (gate-1 F-5). On any error, the unprocessed tail re-queues (gate-2 CONC-C-3/C-4).
/exitcancels sessionCtx (gate-1 F-14) so a blocked modal read aborts cleanly; the items re-queue on the next session start via Recovery’s republish.- Tree writer is the primary (gate-1 F-1); flat JSONL is
a forward-only Observer. The tree is also the authoritative
load surface (
LoadFromTree). - Path traversal guarded (gate-2 SEC-C-1): safeSegment
hash-substitutes
.and..before they reachfilepath.Join.
Where the diagrams live in code
| Flow | Primary file(s) |
|---|---|
| Attestation (CLI) | cmd/ghyll/session.go handleAttestCommand, runner/attestationstore.go, runner/attestation_jsonl.go, runner/attestation_tree.go, engine/attestations.go, engine/journal.go |
| Adversarial cycle | runner/orchestrator.go, runner/producer_fix.go, runner/adversarial.go |
| Amendment commit | runner/amendment.go, runner/amendment_commit.go, engine/journal.go (handleAmendment) |
| Verdict modal (Tier 2) | cmd/ghyll/modal_driver.go, cmd/ghyll/modal/modal.go, cmd/ghyll/modal/linereader.go, runner/dispatcher.go (OpEventAttestationRequested publish), runner/attestation_tree.go (PrimaryWriter) |
Package Graph
Ghyll is organized into a set of Go packages with a strict, acyclic dependency graph. All dependencies point downward from the entry points toward leaf packages, with no cycles permitted. The v2 gate-and-arrow surface (runner/, engine/, bootstrap/, catalogue/, gates/concepts/) layers on top of the v1 dialect / memory / stream foundation; nothing in the v2 layer is imported by the v1 packages.
Dependency Diagram
cmd/ghyll
+-----------+-----------+-----------+-----------+-----------+-----------+
| | | | | | |
v v v v v v v
modal/ bootstrap/ engine/ runner/ dialect/ context/ stream/
| | | ^ ^ | | |
| | | | | | | |
| v v | | v v v
+-----> catalogue/ ----+ | | memory/ | (types)
^ ^ | | | | ^
| | | | v v |
bootstrap/ +--- gates/concepts | | config/ memory/ |
| (embed YAML) | | ^ ^ |
v | | | | |
tool/ workflow/ internal/ | | | | |
| | | | | | | |
v v v v v v v |
types/ internal/ (leaves) (no upward imports) | |
| |
cmd/ghyll-vault | |
| | |
v | |
vault/ | |
/ | \ | |
engine/ | memory/ --------------------+ |
| | |
+----+----> ui/, config/, types/ -----------+
The two binaries (cmd/ghyll and cmd/ghyll-vault) sit at the top. cmd/ghyll wires the v1 substrate (dialect / context / stream / tool / memory) together with the v2 gate-and-arrow surface (runner / engine / bootstrap / catalogue / modal). cmd/ghyll-vault reuses engine/ and memory/ to serve team memory over HTTP. Lower-level packages never import upward.
Key v2 layering rules:
runner/does not importbootstrap/,engine/,catalogue/,dialect/, orcmd/.... It is the runtime kernel; callers wire it. It depends only oninternal/pathglobandinternal/skipdirsplus the standard library.engine/importsrunner/(the persistent store consumes runner record types) butrunner/does NOT importengine/. The Journal observer pattern keeps the direction one-way.bootstrap/importsrunner/andcatalogue/to populate the grid and validate clauses; nothing importsbootstrap/exceptcmd/ghyll.catalogue/embedsgates/concepts/*.yamlvia//go:embed. Thegates/concepts/tree is not a Go package; it is a data directory.internal/{pathglob,safefile,skipdirs}are leaves with no project imports.
Package Reference
Entry points
| Package | Import path | Purpose | Depends on |
|---|---|---|---|
| cmd/ghyll | ghyll/cmd/ghyll | CLI entry, session loop, repl, all v2 subcommands, callback wiring | bootstrap, catalogue, cmd/ghyll/modal, config, context, dialect, engine, memory, runner, stream, tool, types, ui, workflow |
| cmd/ghyll/modal | ghyll/cmd/ghyll/modal | Tier 2 verdict modal (TermModal, LineReader, sanitize) | runner |
| cmd/ghyll-vault | ghyll/cmd/ghyll-vault | Vault server entry | config, memory, ui, vault |
v2 gate-and-arrow surface
| Package | Import path | Purpose | Depends on |
|---|---|---|---|
| runner | ghyll/runner | Gate-and-arrow runtime: clause evaluation, dispatcher, RunArrow, InsufficientBasisTracker, OperatorBus, AttestationStore, adversarial orchestrator, amendment queue | internal/pathglob, internal/skipdirs |
| engine | ghyll/engine | sqlite-backed persistent store + Journal observer fanout + Replay (sessionstart load) + Recovery (crash reconciliation) | runner |
| bootstrap | ghyll/bootstrap | ghyll init: auto-propose, modify rules, orphan-symbol extraction, role-clause parsing, session registry, grid build | catalogue, internal/pathglob, internal/safefile, internal/skipdirs, runner, project root (for embedded role specs) |
| catalogue | ghyll/catalogue | Closed concept vocabulary (per-language bindings); embeds gates/concepts/*.yaml | project root (for embedded YAML) |
(gates/concepts/ ships YAML schemas only; it is not a Go package.)
v1 substrate
| Package | Import path | Purpose | Depends on |
|---|---|---|---|
| types | ghyll/types | Shared types: Message, ToolCall, ToolResult | (none – leaf) |
| config | ghyll/config | TOML loader, model/endpoint mapping, validation | internal/safefile |
| tool | ghyll/tool | Direct OS operations: bash, file, git, grep, edit, glob, web | internal/safefile, types |
| memory | ghyll/memory | Checkpoint store, embedder, hash chain, sync, vault client | internal/safefile |
| dialect | ghyll/dialect | Model-specific functions, router (incl. v2 gate-floor), handoff | config, memory, types |
| stream | ghyll/stream | SSE client, response assembly, terminal renderer | types |
| context | ghyll/context | Context manager, compactor, drift detector, injection detector | types |
| vault | ghyll/vault | Team memory HTTP server, search | engine, memory |
Support packages
| Package | Import path | Purpose | Depends on |
|---|---|---|---|
| ui | ghyll/ui | User-facing terminal output (CLI); slog handles diagnostics | (none – stdlib only) |
| workflow | ghyll/workflow | Project instructions + slash commands loader (reads .ghyll/, .claude/) | internal/safefile |
| internal/pathglob | ghyll/internal/pathglob | Path-glob matcher used by bootstrap modify rules and the runner orphan scan | (none – leaf) |
| internal/safefile | ghyll/internal/safefile | Safe file open / read helpers (path-traversal-resistant) | (none – leaf) |
| internal/skipdirs | ghyll/internal/skipdirs | Default directory skip list for the orphan scanner | (none – leaf) |
The types/ Package
The types/ package is a leaf with no dependencies of its own. It contains the shared types that multiple packages need to pass around:
- Message – a context window entry (role, content, tool calls).
- ToolCall – a structured tool invocation parsed from model output.
- ToolResult – output from tool execution.
These types were originally defined in context/ and tool/, but that created hidden import dependencies. For example, dialect/ functions accept []Message and return []ToolCall, and stream/ returns ToolCall in responses. If these types lived in context/, both dialect/ and stream/ would need to import context/, contradicting the intended dependency direction.
Extracting them into a dedicated leaf package keeps the graph honest and makes cross-package type sharing explicit.
Key Constraints
- types/ is a leaf. No dependencies. Any package can import it.
- internal/{pathglob,safefile,skipdirs} are leaves. No project dependencies. Any package can import them.
- dialect/ depends on types/, config/, and memory/ only. It does not depend on context/, runner/, or stream/. Router and handoff functions receive state as arguments rather than importing those packages directly. The router consumes a v2 gate-floor rank as an
int(not arunner.DepthRank) precisely so the package stays near-leaf. - context/ depends on types/ only. It does not depend on dialect/, memory/, or stream/. Cross-cutting flows use callbacks provided by cmd/ghyll (see Session Loop).
- stream/ depends on types/ only. It does not depend on dialect/ or context/. It sends messages and returns responses.
- runner/ does not import any other project package. It is the v2 runtime kernel; bootstrap/, engine/, and cmd/ghyll wire it from above.
- engine/ imports runner/ but not the other way around. The Journal observer pattern keeps persistence one-directional: runner publishes typed events, engine consumes them.
- bootstrap/ depends on runner/ and catalogue/. It calls into the runner’s grid + clause types and uses the catalogue to validate concept references.
- catalogue/ depends on nothing project-level (it embeds
gates/concepts/*.yamlvia//go:embed). - cmd/ghyll is the composition root. It is the only package that sees all others. It wires callbacks between packages that cannot import each other (dialect <-> context, runner <-> dialect, modal <-> runner, etc.).
One Session Per Repository
A repo lockfile (<repo>/.ghyll.lock) enforces single-session access. This prevents concurrent git worktree operations on the memory branch, chain file corruption, and double sync goroutines. See Session Loop for details on how the lockfile is managed.
Context-Depth Routing
Ghyll automatically selects which model to use based on the current session state. The router, implemented in dialect/router.go, evaluates a decision table on every turn and returns a routing decision. It only decides – the actual compaction and handoff are orchestrated by cmd/ghyll.
Inputs
The router evaluates the following inputs each turn:
| Input | Source | Type |
|---|---|---|
| context_depth | context/manager (token count) | int |
| tool_depth | context/manager (sequential tool calls) | int |
| model_locked | cmd/ghyll (–model flag) | bool |
| deep_override | cmd/ghyll (/deep command) | bool |
| active_model | routing state | string |
| backfill_triggered | context/drift | bool |
| context_depth_threshold | config (default 32000) | int |
| tool_depth_threshold | config (default 5) | int |
| context_compacted_below | context/manager (post-compaction depth) | int |
| gate_floor | runner (RoutingRequirement.MinTier of the traversing arrow, encoded as a depth rank 0..3) | int |
| gate_floor_escalate_at_rank | config (routing.gate_floor_escalate_at_rank, default 2) | int |
| gate_floor_disabled | config (routing.gate_floor_disabled, default false) | bool |
Decision Table
Rows are evaluated top to bottom. The first matching row wins. The v2 gate-floor rows (1-3) take precedence over per-turn signals because gates.md §7.1 forbids laundering a depth-sensitive clause through an insufficient tier.
| # | Condition | Decision | Target | Needs Compaction | Note |
|---|---|---|---|---|---|
| 1 | gate_floor out of range (< 0 or > 3) | invalid | (current) | no | Programmer/config error. RejectedFloor is set. |
| 2 | model_locked AND gate_floor active | gate_locked_conflict | (locked) | no | –model lock collides with §7.1. Session must route to operator attestation. |
| 3 | model_locked | none | (locked) | no | The –model flag is absolute. No routing changes occur. |
| 4 | gate_floor active AND no deep_model configured | gate_unsatisfiable | (current) | no | §7.1 unsatisfiable. Session must NOT silently dispatch on the insufficient tier. |
| 5 | gate_floor active AND active != deep_model | escalate (gate-floor) | deep_model | no | The arrow’s MinTier exceeds gate_floor_escalate_at_rank; authoritative over /deep, context-depth, tool-depth. |
| 6 | deep_override AND active == default_model | escalate | deep_model | no | User requested /deep. Temporary override. |
| 7 | backfill_triggered AND active == default_model | escalate | deep_model | no | Drift detected, additional context loaded. |
| 8 | context_depth > threshold AND active == default_model | escalate | deep_model | yes | Context too large for fast tier. Compact first. |
| 9 | tool_depth > tool_threshold AND active == default_model | escalate | deep_model | no | Complex multi-tool chain detected. |
| 10 | context_compacted_below < threshold AND active == deep_model AND NOT deep_override AND NOT gate_floor active | de-escalate | default_model | no | Context reduced enough to return to fast tier; blocked while a gate floor is active. |
| 11 | (none of the above) | none | (current) | no | Steady state. Continue on current model. |
“Gate floor active” means !gate_floor_disabled && gate_floor_escalate_at_rank > 0 && gate_floor >= gate_floor_escalate_at_rank. The runner consumes the decision in dialect/router.go (Evaluate, see the gateFloorActive predicate at the top of the function); cmd/ghyll translates the typed Reason field (gate-floor, gate-unsatisfiable, gate-locked-conflict) into the operator-visible event downstream.
The router returns a RoutingDecision{Action, TargetModel, NeedCompaction, Reason, RejectedFloor}. When NeedCompaction is true, cmd/ghyll runs compaction on the current model before executing the handoff. The router never calls compaction itself.
State Transitions
Session start --> M2.5 (default)
M2.5 --> GLM-5:
- context_depth > threshold (after compaction on M2.5)
- tool_depth > threshold
- /deep command
- backfill triggered
GLM-5 --> M2.5:
- compaction reduces context below threshold AND no /deep override
- (automatic -- no explicit /fast command needed)
Any --> locked:
- --model flag at startup
- Once locked, no transitions until session ends
Handoff Protocol
Every model switch follows this sequence:
- Create a checkpoint on the current model (captures pre-switch state).
- If escalating due to context depth: compact first on the current model.
- Call the target dialect’s
HandoffSummary(checkpoint, recentTurns)to format context for the new model. - Replace the context window with the handoff summary.
- Update routing state (active model, deep override flag).
- The next stream request goes to the new model’s endpoint.
Tier Fallback
Tier fallback is distinct from routing. It is handled by stream/, not dialect/router, and is orthogonal to routing decisions:
- Only active when auto-routing is enabled (no –model lock).
- Triggers after 3 retries on the active endpoint fail.
- Uses
dialect/handoffto reformat context for the alternate tier. - Does not update routing state permanently – routing still evaluates normally on the next turn.
Session Loop
The session loop is the state machine at the heart of cmd/ghyll. As the composition root, it is the only place that sees all packages and orchestrates the cross-cutting flows between them.
Lifecycle Overview
INIT --> READY --> TURN --> (TURN | COMPACT | HANDOFF | BACKFILL | SUB-AGENT) --> ... --> SHUTDOWN
A session progresses through initialization, then alternates between waiting for user input and executing turns. Turns may trigger compaction, model handoff, or memory backfill as needed. The session ends with a clean shutdown that persists final state.
States
INIT
Initialization runs the following steps in order:
- Load configuration (
config/). - Load or generate the device ed25519 key (
memory/). - Open the SQLite checkpoint store (
memory/). - Initialize the ONNX embedder if available (
memory/). - Set up the git worktree if needed (
memory/sync). - Acquire the repo lockfile (
<repo>/.ghyll.lock) – exit if already held. - Start the background sync goroutine (
memory/sync). - Pull remote checkpoints and public keys (
memory/sync). - Resolve the active model from config and the
--modelflag. - Build initial routing state (plan mode = off).
- Load workflow from
.ghyll/(or fallback.claude/). Merge global + project instructions. - Build the system prompt via the active dialect + workflow instructions.
- If
--resume: load previous session checkpoint, inject summary as backfill, restore plan mode. - Initialize the context manager (
context/). - Initialize the stream client (
stream/). - Transition to READY.
If any step fails fatally (missing config, locked repo), the process exits with an error. Non-fatal failures (embedder unavailable, sync failure) produce a warning and the session continues with reduced capabilities.
READY
The session waits for user input, displaying the prompt:
ghyll [m25] ~/repos/myproject >
- On user input: transition to TURN.
- On
/deep: set DeepOverride in routing state, then transition to TURN. - On Ctrl-C or
/exit: transition to SHUTDOWN.
TURN
The main execution cycle. Each turn proceeds through these steps:
1. Token Count. Count tokens in the current context using the active dialect’s tokenizer. Pass the count to routing and context manager.
2. Pre-turn Check. If tokens exceed 90% of the active model’s maximum context, transition to COMPACT before continuing.
3. Routing Decision. Evaluate the routing decision table (see Routing). Depending on the result:
- Model locked: skip.
- Needs compaction: go to COMPACT, then HANDOFF.
- Escalate or de-escalate: go to HANDOFF.
4. Send. Build messages using the active dialect and send them to the model endpoint via the stream client. Error handling:
ContextTooLong: go to COMPACT (reactive), retry once.Retryable: the stream client retries internally (3 attempts with backoff).AllTiersDownorModelLocked: surface error, return to READY.- Fallback eligible (auto-routing): reformat context via
dialect.HandoffSummary, send to alternate endpoint. - Partial response: surface to user, return to READY.
5. Parse Tool Calls. Extract tool calls from the model response using the active dialect’s parser.
6. Update Context. Add the assistant message and tool calls to the context window.
7. Execute Tools. For each tool call, execute it and add the result to context. Increment the tool depth counter. If the model response contains more tool calls, loop back to step 4.
8. Checkpoint Check. At configured intervals (default every 5 turns):
- Create a checkpoint (
memory/). - Run injection signal detection (
context/). - Push to vault if configured (
memory/vault_client). - Queue for git sync (
memory/sync).
9. Drift Check. At configured intervals (default every 5 turns):
- Embed the current context.
- Compare with the latest checkpoint.
- If drift exceeds the threshold, transition to BACKFILL.
10. Reset Tool Depth. If this was a user-initiated turn (not a tool continuation), reset the tool depth counter.
11. Return to READY.
COMPACT
Entered from TURN when context is too large (proactive) or when the model rejects the context as too long (reactive).
- Select turns to summarize (all except the last N).
- Get the compaction prompt from the active dialect.
- Build a compaction request (
context/). - Send the compaction request to the active model – this is a separate API call.
- Replace old turns with the summary (
context/manager). - Create a compaction checkpoint (
memory/). - Measure drift post-compaction (
context/). - Recalculate the token count.
- Return to the caller (TURN step 2 or HANDOFF).
If compaction fails (model error on the compaction call), the error is surfaced. If this was a reactive compaction and the retry also fails, ErrReactiveRetryFail is surfaced and the session returns to READY.
HANDOFF
Entered from TURN when the router decides to switch models.
- If triggered by context depth: run COMPACT first.
- Create a handoff checkpoint on the current model.
- Format context for the target model:
dialect.HandoffSummary(checkpoint, recentTurns). - Replace the context window (
context/manager). - Update routing state (active model, clear/set deep override).
- Update the stream client endpoint.
- Display:
switched to glm5, loaded from checkpoint N. - Return to TURN to continue with the new model.
BACKFILL
Entered from TURN when drift detection finds the conversation has moved significantly from known checkpoints.
- Search local checkpoints by embedding similarity (
memory/). - If local results are insufficient and vault is configured, search the vault (
memory/vault_client). - Verify signatures on all candidate checkpoints (
memory/). - Discard unverified checkpoints with a warning.
- Select top-k verified checkpoints within the token budget.
- Inject summaries into context (
context/manager) – this is additive, never replacing existing context. - Display:
backfill from checkpoints X, Y. - If backfill added significant context, re-evaluate routing (may escalate to deep tier).
- Return to TURN.
SHUTDOWN
- Create a final checkpoint if the session had activity.
- Final sync push (blocking, with timeout).
- Release the repo lockfile.
- Close the SQLite store.
- Close the ONNX session.
- Exit.
Repo Lockfile
One ghyll session is permitted per repository at a time. This is enforced by <repo>/.ghyll.lock:
- The lock is acquired in INIT and released in SHUTDOWN.
- It contains the PID and a timestamp for stale lock detection.
- If the lock exists and the PID is alive: exit with an error.
- If the lock exists and the PID is dead: warn and acquire the lock.
The lockfile is gitignored. It prevents concurrent git worktree operations on the memory branch, concurrent chain file writes, and double sync goroutines.
Callback Wiring
Because packages like context/ and dialect/ cannot import each other, cmd/ghyll wires them together using function callbacks:
// context/manager receives:
type ManagerDeps struct {
TokenCount func([]types.Message) int
CompactionCall func(CompactionRequest) (string, error)
CreateCheckpoint func(CheckpointRequest) error
Embed func([]types.Message) ([]float32, error)
}
// dialect/router receives:
type RouterInputs struct {
ContextDepth int
ToolDepth int
ModelLocked bool
DeepOverride bool
ActiveModel string
BackfillTriggered bool
Config config.RoutingConfig
}
This pattern keeps the dependency graph acyclic while allowing the complex cross-cutting flows that the session loop requires.
Checkpoint Format
Checkpoints are the fundamental unit of ghyll’s memory system. Each checkpoint captures a snapshot of session state at a point in time, forming an append-only, tamper-evident chain secured by cryptographic hashing and ed25519 signatures.
This document describes checkpoint format version 1. The format is forward-compatible: unknown fields are preserved but ignored.
Checkpoint Structure
type Checkpoint struct {
Version int `json:"v"`
Hash string `json:"hash"` // hex(sha256(canonical content))
ParentHash string `json:"parent"` // previous checkpoint hash, or "0"*64
DeviceID string `json:"device"`
AuthorID string `json:"author"`
Timestamp int64 `json:"ts"` // unix nanos
RepoRemote string `json:"repo"` // git remote URL
Branch string `json:"branch"` // git branch at time of checkpoint
SessionID string `json:"session"` // unique per ghyll invocation
Turn int `json:"turn"`
ActiveModel string `json:"model"` // model name from config (e.g., "m25", "glm5")
Summary string `json:"summary"` // structured natural language
Embedding []float32 `json:"emb"` // vector from ONNX model
FilesTouched []string `json:"files"`
ToolsUsed []string `json:"tools"`
InjectionSig []string `json:"injections,omitempty"`
Signature string `json:"sig"` // hex(ed25519.Sign(privkey, hash))
}
Canonical Serialization
To compute the hash of a checkpoint:
- Take all fields except
hashandsig. - Serialize as JSON with keys sorted alphabetically.
- No whitespace, UTF-8 encoding.
- Hash =
hex(sha256(serialized bytes)).
This deterministic serialization ensures that any two implementations produce the same hash for the same checkpoint content.
Signing
The signature covers the hash string, not the raw content:
Signature = hex(ed25519.Sign(privateKey, []byte(Hash)))
This means verification needs only the hash and public key. The hash binds the signature to the content deterministically.
Verification
Verification is a two-step process:
- Hash verification – recompute the canonical hash from the checkpoint content and compare it with the stored hash. A mismatch indicates content tampering.
- Signature verification – verify the ed25519 signature against the device’s public key.
Chain Verification
Each device maintains its own chain of checkpoints. Chains are independent across devices.
To verify a chain:
- Checkpoints must be ordered by position in the chain.
- For each checkpoint after the first:
checkpoint[i].ParentHashmust equalcheckpoint[i-1].Hash. - The first checkpoint in a chain has
ParentHashset to"0"*64(64 zero characters).
Remote chains are verified at import time during sync. Partial imports work correctly: if the local store has checkpoints [c0, c1] and a remote device adds [c2, c3], verification confirms that c2.ParentHash == c1.Hash and c3.ParentHash == c2.Hash.
Local Storage (SQLite)
Checkpoints are stored locally in ~/.ghyll/memory.db:
CREATE TABLE checkpoints (
hash TEXT PRIMARY KEY,
parent TEXT NOT NULL,
device TEXT NOT NULL,
author TEXT NOT NULL,
ts INTEGER NOT NULL,
repo TEXT NOT NULL,
branch TEXT NOT NULL,
session TEXT NOT NULL,
turn INTEGER NOT NULL,
model TEXT NOT NULL,
summary TEXT NOT NULL,
embedding BLOB NOT NULL, -- float32 array, binary
files TEXT NOT NULL, -- JSON array
tools TEXT NOT NULL, -- JSON array
injections TEXT, -- JSON array, nullable
sig TEXT NOT NULL,
verified INTEGER DEFAULT 1, -- 0 = unverified remote
imported INTEGER DEFAULT 0 -- unix timestamp of import
);
CREATE INDEX idx_checkpoints_session ON checkpoints(session);
CREATE INDEX idx_checkpoints_device ON checkpoints(device);
CREATE INDEX idx_checkpoints_repo ON checkpoints(repo);
The checkpoint table is append-only: no UPDATE or DELETE statements are ever issued against it.
Git Memory Branch
Checkpoints are also stored on a git orphan branch (ghyll/memory) for team synchronization:
ghyll/memory (orphan branch)
devices/
<device-id>.pub ed25519 public key
repos/
<sha256(git-remote-url)>/
checkpoints/
<checkpoint-hash>.json individual checkpoint files
chains/
<device-id>.jsonl ordered hash chain per device
The chain file (JSONL format) contains one line per checkpoint, ordered chronologically. It is used to verify chain integrity without loading every individual checkpoint file:
{"hash":"abc...","parent":"000...","ts":1712345678}
{"hash":"def...","parent":"abc...","ts":1712345700}
See Sync Protocol for details on how checkpoints are synchronized across devices.
Git Sync Protocol
Ghyll synchronizes memory checkpoints between team members using a git orphan branch. There is no custom network protocol – all synchronization flows through the existing git remote.
Branch Initialization
On the first session in a repository, if the ghyll/memory branch does not exist, ghyll creates it:
git checkout --orphan ghyll/memory
git rm -rf .
mkdir -p devices/ repos/<repo-hash>/checkpoints repos/<repo-hash>/chains
git add devices/<device-id>.pub
git commit -m "init: device <device-id>"
git push origin ghyll/memory
git checkout -
The orphan branch shares no history with any code branch. It exists purely for memory storage.
Worktree Setup
To avoid switching branches in the working repository, ghyll uses a git worktree. The worktree is created in a system temp directory via os.MkdirTemp("", "ghyll-memory-*") (see memory/sync.go:67), so the project tree stays clean and the path never collides with the repo’s own files:
git worktree add --detach <os-tempdir>/ghyll-memory-XXXXXX ghyll/memory
All memory file operations happen in this worktree, so the developer’s working branch is never disturbed. The worktree path is held on the Syncer and discarded at session shutdown; there is no <repo>/.ghyll-memory/ directory.
Writing Checkpoints
When a checkpoint is created during a session:
- Write
<hash>.jsonto<worktree>/repos/<repo-hash>/checkpoints/. - Append a chain entry to
<worktree>/repos/<repo-hash>/chains/<device-id>.jsonl. - A background goroutine commits and pushes:
git -C <worktree> add . git -C <worktree> commit -m "checkpoint <hash> by <device-id>" git -C <worktree> push origin ghyll/memory
Checkpoint writes are non-blocking. The session continues immediately after steps 1 and 2; the git commit and push happen in the background.
If the push fails due to a conflict:
git pull --ff-only origin ghyll/memory
git push origin ghyll/memory
This retries up to 3 times. After 3 failures, the checkpoint is queued for the next sync interval.
Because checkpoint filenames are content hashes, writing the same checkpoint twice produces identical content, making the operation idempotent.
Pulling Remote Checkpoints
At session start and periodically during the session, ghyll pulls remote checkpoints:
- Fetch the latest state:
git -C <worktree> fetch origin ghyll/memory - Fast-forward merge:
git -C <worktree> merge --ff-only origin/ghyll/memory - Scan for new checkpoint files not already in the local SQLite store.
- For each new remote device chain:
- Load
chains/<device-id>.jsonl. - Find the first checkpoint not in the local store.
- Import from that point forward.
- Verify chain integrity (each
parent_hashmatches the previous checkpoint’s hash). - Verify signatures against
devices/<device-id>.pub. - Insert into SQLite with
verified=1(orverified=0if signature verification fails).
- Load
- Import any new device public keys from
devices/.
Background Sync Loop
A goroutine runs throughout the session, ticking at a configurable interval (default 60 seconds). On each tick it pulls remote changes, then pushes any pending local checkpoints. When the session ends, it makes a final blocking push attempt before exiting.
Conflict Model
The append-only design means merge conflicts do not occur in practice:
- Each device writes its own checkpoint files (unique content hashes).
- Each device appends to its own chain file.
- No two devices write the same file.
git pull --ff-onlyalways succeeds after agit fetch.
The only conflict scenario is concurrent pushes from two devices, which is handled by the pull-then-retry mechanism.
Offline Operation
When the git remote is unreachable:
- Checkpoints accumulate in the local SQLite store and worktree.
- The chain file grows locally.
- On the next successful sync, all pending checkpoints push in a single commit.
- No data is lost. SQLite is the source of truth; git is the replication layer.
Shallow Fetch for Large Repositories
For repositories with extensive checkpoint history, ghyll supports shallow fetching:
git fetch --depth=1 origin ghyll/memory
This fetches only the latest tree without full history, which is sufficient for importing the current checkpoint set. Full history can be retrieved manually when needed by running git -C <worktree> fetch --unshallow origin ghyll/memory against the syncer’s worktree directory.
Vault HTTP API
The ghyll-vault server provides an optional team memory search service. It exposes an HTTP API for searching checkpoints by embedding similarity and for pushing new checkpoints from CLI clients.
Git-based sync handles basic team memory sharing without a vault. The vault adds real-time vector search across all team checkpoints, which is useful for larger teams or when checkpoint volume exceeds what local search can handle efficiently.
Authentication
- Remote vault: Requires a Bearer token in the
Authorizationheader. The token is configured inconfig.toml. Requests with a missing or invalid token receive a401 Unauthorizedresponse. - Localhost (127.0.0.1 or ::1): No authentication required. The token check is skipped entirely, making local development frictionless.
Endpoints
POST /v1/search
Search for checkpoints by embedding similarity.
Request:
{
"embedding": [0.1, 0.2, ...],
"repo": "sha256-of-remote-url",
"top_k": 5
}
The embedding field is a float32 array whose dimensions must match the configured embedding model. The repo field filters results to a specific repository. top_k controls how many results to return.
Response (200):
{
"results": [
{
"checkpoint": { ... },
"similarity": 0.87
}
]
}
Results are sorted by cosine similarity in descending order.
Errors:
400– Missing or malformed fields.401– Missing or invalid token (remote only).500– Internal server error.
POST /v1/checkpoints
Push a checkpoint to the vault.
Request:
{
"checkpoint": { ... }
}
The checkpoint object must include the hash and sig fields.
Response (201): Stored successfully.
Validation:
- Verify the checkpoint signature against known public keys.
- Verify the hash matches the content (recompute the canonical hash).
- If valid, store the checkpoint.
- If invalid, reject with
403 Forbiddenand a reason.
Errors:
400– Malformed checkpoint.401– Missing or invalid token (remote only).403– Signature verification failed.409– Checkpoint with this hash already exists. This is idempotent and not a true error – the checkpoint was already stored.500– Internal server error.
GET /v1/health
Health check endpoint.
Response (200):
{
"status": "ok",
"checkpoints": 12345,
"devices": 7
}
Storage
The vault uses the same SQLite schema as the CLI (see Checkpoint Format). Embedding similarity search uses brute-force cosine similarity on the embedding column. At the expected scale (under 100K checkpoints), this is sufficient. Indexing can be added later if needed.
Client Behavior
The CLI communicates with the vault through memory/vault_client.go:
type VaultClient struct {
URL string
Token string // empty for localhost
Timeout time.Duration // 5s per request
}
Key behaviors:
- Push failures are logged, not fatal. Checkpoint creation never blocks on vault availability.
- Search failures fall back to local memory. If the vault is unreachable, the CLI searches its local SQLite store instead.
- Localhost detection: The client parses the configured URL and checks if the host resolves to 127.0.0.1 or ::1. If so and no token is configured, it skips the Authorization header. If the vault is remote and no token is configured, the client logs a warning and disables vault features.
Error Types
Ghyll uses typed errors organized by package. Each package defines its own error types, and cross-package errors are wrapped with context at the boundary. All errors implement the standard error interface and support errors.Is / errors.As for matching.
Design Principles
- Each package defines its own error types and sentinel values.
- Cross-package errors are wrapped with context at the package boundary.
- Sentinel errors are used for conditions that callers need to match on.
- Structured error types carry additional context (line numbers, exit codes, retry information).
config/
Configuration errors are fatal at startup.
var (
ErrConfigNotFound = errors.New("config: file not found")
ErrConfigMalformed = errors.New("config: invalid TOML syntax")
ErrConfigValidation = errors.New("config: validation failed")
)
type ConfigError struct {
Path string
Line int
Message string
Err error
}
ConfigError wraps parse errors with file path and line number context for precise error reporting.
memory/
Memory errors cover the checkpoint store, hash chain integrity, and synchronization.
var (
ErrHashMismatch = errors.New("memory: recomputed hash does not match")
ErrSignatureInvalid = errors.New("memory: ed25519 signature verification failed")
ErrChainBroken = errors.New("memory: parent hash does not match previous checkpoint")
ErrUnknownKey = errors.New("memory: no public key found for device")
ErrKeyPermissions = errors.New("memory: private key has insecure file permissions")
ErrStoreReadOnly = errors.New("memory: checkpoint store is append-only")
ErrEmbedderUnavail = errors.New("memory: embedding model not available")
)
type SyncError struct {
Op string // "fetch", "push", "pull"
Attempt int
Err error
}
SyncError wraps git operation failures with the operation type and retry attempt number.
context/
Context errors relate to token limits, compaction, and drift detection.
var (
ErrContextTooLong = errors.New("context: exceeds model token limit")
ErrCompactionFailed = errors.New("context: compaction did not reduce context sufficiently")
ErrReactiveRetryFail = errors.New("context: reactive compaction retry failed")
)
type DriftError struct {
Reason string // "embedder_unavailable", "no_checkpoints"
Err error
}
type InjectionWarning struct {
Turn int
Patterns []string
}
InjectionWarning is not an error – it is a signal surfaced to the user when prompt injection patterns are detected in tool output.
stream/
Stream errors cover network communication with model endpoints and include retry/fallback classification.
var (
ErrStreamInterrupted = errors.New("stream: connection dropped mid-response")
ErrAllTiersDown = errors.New("stream: all model endpoints unreachable")
ErrModelLocked = errors.New("stream: locked model endpoint unreachable")
ErrRateLimited = errors.New("stream: rate limited")
)
type StreamError struct {
StatusCode int
Retryable bool
RetryAfter int // seconds
ContextTooLong bool // triggers reactive compaction
Message string
Err error
}
StreamError is the most structured error type. Its fields drive the session loop’s retry and fallback logic:
- Retryable – the stream client retries internally (up to 3 times with backoff).
- ContextTooLong – triggers reactive compaction in the session loop.
- RetryAfter – honors rate limit headers from the inference server.
tool/
Tool errors wrap execution failures with command context.
var (
ErrToolTimeout = errors.New("tool: execution timed out")
)
type ToolError struct {
Tool string // "bash", "file", "git", "grep"
Command string
ExitCode int
Stderr string
Err error
}
dialect/
Dialect errors are minimal, covering parse failures and unknown model identifiers.
var (
ErrParseToolCall = errors.New("dialect: failed to parse tool call from response")
ErrUnknownModel = errors.New("dialect: unknown model identifier")
)
vault/
Vault errors cover communication with the optional team memory server.
var (
ErrVaultUnauthorized = errors.New("vault: unauthorized (invalid or missing token)")
ErrVaultUnavailable = errors.New("vault: server unreachable")
ErrVaultRejected = errors.New("vault: checkpoint rejected (signature invalid)")
)
Error Flow Across Package Boundaries
Errors propagate upward through the dependency graph, with each boundary adding context:
tool/ errors --> wrapped by context/manager --> surfaced by cmd/ghyll
memory/ errors --> wrapped by context/manager --> surfaced by cmd/ghyll
stream/ errors --> handled by cmd/ghyll (retry/fallback logic)
dialect/ errors --> handled by cmd/ghyll (parse failures)
config/ errors --> handled by cmd/ghyll (startup, fatal)
vault/ errors --> handled by memory/vault_client --> logged, non-fatal
Vault errors are notably non-fatal. The vault is optional infrastructure, and its unavailability never prevents the CLI from functioning.
Dialect Modules
Each supported model has a dedicated dialect file with concrete functions. There are no shared interfaces — each dialect is hand-tuned for its model’s training, tool-calling format, and attention characteristics.
Function Signatures
Every dialect exports the same set of functions:
| Function | Purpose |
|---|---|
SystemPrompt(workdir) | Generate the system prompt for the model |
BuildMessages(msgs, sysPrompt) | Format messages for the OpenAI-compatible API |
ParseToolCalls(raw) | Parse tool calls from model response |
CompactionPrompt() | Return the instruction for context compaction |
TokenCount(msgs) | Estimate token count for a message list |
HandoffSummary(cp, recent) | Format context for handoff to this model |
MiniMax M2.5 (dialect/minimax.go)
The fast tier. Handles 80% of routine coding tasks.
- Token estimation: ~4 characters per token
- Max context: 1,000,000 tokens
- Compaction prompt: General-purpose summary instruction
- System prompt: Concise, action-oriented
GLM-5 (dialect/glm.go)
The deep tier. Handles complex reasoning and multi-step debugging.
- Token estimation: ~3 characters per token (slightly less efficient tokenizer)
- Max context: 200,000 tokens
- Compaction prompt: DSA-aware — emphasizes preserving structural decisions and rationale
- System prompt: Encourages step-by-step reasoning
Adding a New Dialect
To add support for a new model (e.g., Kimi K2):
- Create
dialect/kimi.gowith all six functions - Add the dialect name to
resolveDialect()incmd/ghyll/session.go - Add model config in
config.toml - Recompile
No interface changes needed. Each dialect is independent.
Why Not Interfaces?
The abstraction tax is real. A generic provider interface forces all models through the same code path, losing model-specific optimizations: custom system prompts tuned to training, model-specific tool-calling format parsing, compaction prompts that account for attention characteristics. See ADR-001 for the full rationale.
Context Management
The context manager (context/manager.go) is the single owner of the conversation context window. No other package directly mutates the message list.
Ownership Model
The manager holds a []types.Message slice protected by a mutex. Other packages interact through:
AddMessage()— append a messageMessages()— get a copy of the current windowPreTurnCheck()— run proactive compaction if neededReactiveCompact()— compact in response to model rejectionApplyBackfill()— prepend checkpoint summaries
Compaction
When the context window exceeds 90% of the active model’s limit, compaction triggers:
- Split messages into “to summarize” and “preserved” (last 3 turns)
- Send the turns-to-summarize to the model as a separate API call with the dialect’s compaction prompt
- Replace old turns with the model’s summary
- Create a checkpoint capturing the pre-compaction state
This is a separate API call — not the full context window. This prevents the compaction request itself from exceeding the model’s limit.
Reactive Compaction
If the proactive check underestimates and the model rejects with context_length_exceeded, reactive compaction fires. The request is retried exactly once after compacting.
Callback Wiring
The context manager can’t import dialect/ or stream/ (would create import cycles). Instead, cmd/ghyll provides callbacks at init:
ManagerDeps{
TokenCount: dialect.MinimaxTokenCount,
CompactionCall: session.compactionCall, // wires stream.Send
CreateCheckpoint: session.createCheckpoint, // wires memory.Store
}
This keeps the package graph acyclic while allowing cross-cutting flows.
Drift Detection
Drift detection monitors whether the conversation has strayed from the original task and triggers backfill when needed.
How It Works
Every N turns (configurable, default 5), ghyll:
- Embeds the current context window using the ONNX embedding model
- Retrieves the most recent checkpoint’s embedding
- Computes cosine similarity between the two
- If similarity drops below the threshold (default 0.7), triggers backfill
Measurement Target
Drift is measured against the most recent checkpoint, not the original task. This means:
- After checkpoint 0 (session start): measures against the initial embedding
- After checkpoint 3: measures against checkpoint 3’s embedding
- After compaction: measures against the compaction checkpoint
This tracks drift from recent work, not just the original goal.
Backfill
When drift is detected:
- Search local checkpoints by embedding similarity
- If local results are insufficient and vault is configured, search team memory
- Verify signatures on all candidates
- Select top-k within token budget
- Prepend summaries to context (additive — no messages removed)
Graceful Degradation
If the ONNX embedding model is not downloaded, drift detection is disabled entirely. ghyll displays a warning and continues normally. All other features work without it.
Thresholds
The drift threshold (default 0.7) controls sensitivity:
- Higher (e.g., 0.8): more sensitive, backfills more often
- Lower (e.g., 0.5): more tolerant, only backfills on major drift
- Configurable per-project in
config.toml
Injection Detection
ghyll scans conversation turns for prompt injection patterns at checkpoint creation time. This is detection only — the operator’s sandbox (Docker, bubblewrap, sandbox-exec, Firejail, Kubernetes, …) handles enforcement at the OS level.
What’s Detected
| Pattern | Examples |
|---|---|
instruction_override | “ignore previous instructions”, “you are now”, “act as if” |
sensitive_path | ~/.ssh/, /etc/shadow, .env, id_rsa, private_key |
base64_payload | Long base64-encoded strings that decode to valid UTF-8 |
system_prompt_modify | “modify your system prompt”, “rewrite your prompt” |
Scan Scope
Only user and tool messages are scanned. Assistant (model) responses are not scanned — if the model talks about injection patterns, it’s not a false positive concern.
What Happens
When injection signals are detected:
- The signal is recorded in the checkpoint’s
injectionsfield - A warning is displayed:
checkpoint 3: injection signal in turn 7 - The checkpoint is still created (detection, not prevention)
- The surrounding sandbox blocks any actual dangerous operations at the OS level
Why Detection Only
ghyll executes tools directly (YOLO mode by design). Blocking at the ghyll level would create a false sense of security and could be bypassed. A real sandbox — the operator chooses one from the sandbox table — provides OS-level isolation that cannot be circumvented from user space, regardless of what ghyll executes. See Why ghyll for the full rationale.
Tool Execution
ghyll provides twelve tools for direct OS operations and model coordination (enter_plan_mode and exit_plan_mode are counted as 2 of the 12 even though they share one section below). All tools execute immediately with no permission checks — the operator’s sandbox handles isolation. See Why ghyll — sandbox-only execution for the rationale and the Operator Guide sandbox table for setup recipes.
Available Tools
bash
Executes a shell command via exec.Command("bash", "-c", command).
- Timeout: configurable (default 30s)
- Output: stdout captured, stderr captured separately
- On timeout: process killed, error returned
read_file
Reads a file via os.ReadFile(path).
- Timeout: configurable (default 5s)
- Output: file contents as string
write_file
Writes content to a file via os.WriteFile(path, content, 0644).
- Timeout: configurable (default 5s)
- Output: confirmation message
grep
Searches for a pattern in a path. Prefers ripgrep (rg) if available, falls back to standard grep -rn.
- Timeout: configurable (default 30s)
- No matches: returns empty output (not an error)
git
Executes git commands in the working directory via exec.Command("git", args...).
- Timeout: configurable (default 30s)
- Output: stdout captured
edit_file
Surgically replaces a string in a file. Uses compare-and-swap with SHA256 content hashing to prevent overwriting concurrent modifications.
- Parameters:
path,old_string,new_string - Timeout: configurable (default 5s)
- Atomicity: reads file, computes hash, writes to temp, re-reads and verifies hash, renames atomically
- Errors: old_string not found, ambiguous match (multiple occurrences), file modified during edit
- Empty new_string: deletes the matched text
glob
Returns file paths matching a glob pattern, sorted by modification time (most recent first).
- Parameters:
pattern,path(base directory) - Supports:
**for recursive matching (single**segment) - Symlinks: broken and external-pointing symlinks are excluded; valid workspace symlinks are followed
- Timeout: configurable (default 30s)
web_fetch
Fetches a URL and returns the content converted to markdown. Subject to whatever network policy the surrounding sandbox enforces.
- Parameters:
url - Retry: 3 attempts with exponential backoff on connection errors and 5xx responses
- No retry: on 4xx errors (immediate failure)
- Truncation: response capped at
web_max_response_tokens(default 10,000) with[truncated]marker - Binary: rejected with error
- Timeout: configurable (default 30s)
web_search
Queries a search backend and returns structured results (numbered URLs).
- Parameters:
query - Backend: configurable (default: DuckDuckGo)
- Retry: same as web_fetch
- Limit: 10 results maximum
- Timeout: configurable (default 30s)
agent
Spawns a focused sub-agent on the fast tier model. The sub-agent has its own isolated context (no parent conversation history), runs a mini turn-loop, and returns a final answer.
- Parameters:
task - Model: configurable (default: fast tier)
- Turn limit: configurable (default 20)
- Token budget: configurable (default 50,000)
- Wall-clock timeout: configurable (default 300s)
- Tool access: all tools except
agent,enter_plan_mode,exit_plan_mode - Synchronous: parent session blocks until sub-agent completes
enter_plan_mode / exit_plan_mode
Model-initiated toggles for plan mode. Augments the system prompt with dialect-specific planning instructions. All tools remain available.
Timeout Enforcement
Every tool execution uses context.WithTimeout. When the timeout fires:
- The process is killed (SIGKILL)
ToolResult.TimedOut = true- An error message is returned to the model
Error Handling
Tool errors (non-zero exit code, file not found, etc.) are returned in ToolResult.Error. The error string is added to the context as the tool response, so the model can see what went wrong and adjust.
If the model sends malformed tool arguments (invalid JSON), ghyll returns a parse error instead of executing with empty arguments.
Workflow System
The workflow system loads project-specific instructions and
slash commands to guide model behavior during a session. It
does NOT load runtime roles — per
ADR-008,
the four diamond roles (analyst / architect / implementer /
integrator) are contracts embedded into the binary at build time
via //go:embed specs/architecture/roles/*.md, not files the
operator drops into a directory.
If you put a roles/ subdirectory under .ghyll/ or .claude/,
the loader ignores it intentionally (see workflow/loader.go).
The files there are reserved for an editing assistant’s working
roles — Claude Code reads .claude/roles/*.md when editing this
repo — and have no effect on ghyll’s runtime.
File Structure
~/.ghyll/ # Global (user-level)
instructions.md # Behavioral instructions for all projects
commands/ # Slash commands
<repo>/.ghyll/ # Project-level (overrides global)
instructions.md # Project-specific instructions
commands/
review.md # /review command
verify.md # /verify command
Loading Order
- Load global instructions from
~/.ghyll/instructions.md. - Load global commands from
~/.ghyll/commands/. - Check for
<repo>/.ghyll/— if found, load project instructions and commands (overriding global on name conflict). - If
.ghyll/absent, try fallback folders (default:.claude/). MapsCLAUDE.mdto instructions;commands/loaded identically. - If no workflow folder found, the session starts with the bare dialect prompt.
System Prompt Composition
The system prompt is built from a small fixed set of layers:
[Dialect base prompt] # MiniMax M2.5 / GLM-5 / DeepSeek / Qwen base
[Global instructions] # ~/.ghyll/instructions.md
[Project instructions] # .ghyll/instructions.md (authoritative)
[Plan mode overlay] # Dialect-specific planning instructions
There is no role-overlay layer. The runtime knows which role
owns the current arrow’s source and target (because the arrow
declares them), but no per-role system-prompt overlay is
applied — the role contracts in
specs/architecture/roles/
are consumed by bootstrap.ParseRoleFileEmbedded during
ghyll init, not injected into the model’s prompt.
Total workflow content is bounded by the instruction budget (default 2,000 tokens). If exceeded, global instructions are dropped first; if project alone exceeds, it’s truncated from the end.
Slash Commands
Each .md file in commands/ becomes a /<name> command. When
typed, the file content is injected as a user message. Built-in
commands (/deep, /fast, /plan, /status, /exit,
/list-arrows, /run-arrow, /op-id, /attest, /attestations,
/passes, /quit) take precedence over any same-named
user-defined command.
Fallback to .claude/
When .ghyll/ is absent, ghyll checks fallback folders
(configurable, default: .claude/). The mapping:
| .claude/ path | Treated as |
|---|---|
CLAUDE.md | instructions.md (if no instructions.md exists) |
instructions.md | instructions.md (takes precedence over CLAUDE.md) |
commands/ | commands/ (identical) |
roles/ | Intentionally ignored (per ADR-008) |
See also
- Why ghyll — Roles are fixed — the design rationale.
- ADR-008 — the decision to deprecate runtime workflow roles.
- Glossary — Role — what the four diamond roles are and how they relate to arrows.
Sub-Agents
Sub-agents are how the model delegates a self-contained sub-task (“go read these three files and summarize”) without consuming the parent session’s context. You don’t trigger them; the model calls the agent tool on its own. The output lands in your transcript as a tool-result, the same shape as any other tool call — you’ll see the sub-agent’s streaming output rendered inline, followed by the final answer when it returns. Sub-agents are useful when the parent’s context is already large or when the task is narrow enough that the parent doesn’t need the intermediate steps.
Sub-agents are focused model inference calls dispatched by the parent session via the agent tool. They run on the fast tier with isolated context and return their findings as a tool result.
How It Works
- The parent model calls
agent(task: "..."). - ghyll creates a fresh context with the dialect’s system prompt + workflow instructions (no plan mode).
- A mini turn-loop runs: send to model, parse tool calls, execute tools, repeat.
- When the model returns without tool calls (or limits are hit), the final answer is returned to the parent.
Isolation
Sub-agents are intentionally isolated from the parent:
- No parent turn history: the sub-agent sees only the system prompt and the task description — not the parent session’s prior messages or tool results.
- Workflow instructions ARE inherited: the parent’s
GlobalInstructionsandProjectInstructionsare appended to the sub-agent’s system prompt (cmd/ghyll/subagent.golines 78-86). The sub-agent runs against the same project conventions as the parent. - No “role” state to inherit: per ADR-008 ghyll has no runtime role-overlay layer in the system prompt — the four diamond roles are contracts attached to arrows, not modes the session is “in”. A sub-agent is dispatched by the model from whatever arrow’s pass is currently open in the parent; it carries no role tag of its own.
- No plan mode: sub-agents are fast, focused tasks — reasoning overhead is unnecessary.
- No checkpoints: sub-agent turns are not checkpointed or synced.
- No drift detection: no embedding, no backfill.
Limits
| Limit | Default | Config key |
|---|---|---|
| Max turns | 20 | sub_agent.max_turns |
| Token budget | 50,000 | sub_agent.token_budget |
| Wall-clock timeout | 300s | sub_agent.timeout_seconds |
When any limit is hit, the sub-agent terminates and returns a partial result describing what was accomplished.
Available Tools
Sub-agents have access to 9 of the 12 tools:
| Available | Excluded |
|---|---|
| bash, read_file, write_file, edit_file, git, grep, glob, web_fetch, web_search | agent, enter_plan_mode, exit_plan_mode |
The agent tool is excluded to prevent recursive sub-agent spawning (depth 1 only).
Design Choice: Synchronous Execution
Sub-agents run synchronously — the parent session blocks during execution. This is the same model as the bash tool (a long-running make test also blocks). The wall-clock timeout prevents indefinite blocking.
ADR-001: Ghyll Architecture
Date: April 2026 Status: Accepted
Context
Organizations with self-hosted GPU infrastructure (e.g., Cray EX with GH200 nodes) need a coding assistant CLI that is hyper-optimized for their specific open-weight models rather than a general-purpose tool supporting hundreds of providers. Existing tools (Claude Code, OpenCode, Gemini CLI) prioritize breadth over depth, paying an abstraction tax that degrades performance for any specific model.
Decisions
1. Go over TypeScript/Rust
Decision: Single Go binary, no runtime dependencies.
Rationale: TypeScript requires Node.js runtime, npm ecosystem, and carries abstraction overhead. Rust ecosystem complexity (“too big of a mess”) outweighs benefits for a tool this size (~10K lines). Go provides: fast compilation, single static binary, excellent stdlib for HTTP/JSON/exec, good ONNX Runtime bindings, cross-platform without ceremony.
2. Concrete dialects over provider abstraction
Decision: Each model gets a dedicated file with standalone functions. No shared interface, no adapter pattern.
Rationale: The abstraction tax is real. A generic provider interface forces all models through the same code path, losing model-specific optimizations: custom system prompts tuned to training, model-specific tool-calling format parsing, compaction prompts that account for attention characteristics (DSA for GLM-5, Lightning Attention for M2.5), token counting with the model’s actual tokenizer. Adding a model requires writing a new file and recompiling — this is intentional, not a limitation.
3. Context-depth routing over external router
Decision: Dialect router inside the Go binary, not a separate Python/LiteLLM service.
Rationale: Eliminates an entire infrastructure component and its failure modes. The routing logic is ~100 lines of Go based on context depth, tool depth, and user override. Model-specific thresholds live in the dialect module where they belong. No network hop, no separate process, no Python dependency.
4. Checkpoint-based handoff over full replay
Decision: Model switches use a checkpoint summary + last N turns, not full history replay.
Rationale: Full replay wastes tokens re-prefilling the entire history in the new dialect’s format. Checkpoint summaries are already being generated for drift detection. The lossy nature is explicit — the developer sees “⟳ switched to glm5, loaded from checkpoint 4” and can provide additional context if needed.
5. Git orphan branch over vault service for sync
Decision: Memory checkpoints sync via ghyll/memory orphan branch in the project’s git repo.
Rationale: Zero additional infrastructure. Developers already authenticate to git. Append-only checkpoints never conflict. The orphan branch is invisible in normal git workflows. Clone gives you the full team memory. No vault service to deploy, no new auth system, no new ports.
6. Merkle DAG over plain database
Decision: Checkpoints are hash-linked and ed25519 signed, forming a tamper-evident append-only log.
Rationale: Team memory is a trust surface. A compromised ghyll instance could push poisoned checkpoints. Hash-chain verification catches tampering. Ed25519 signatures provide attribution and non-repudiation. The cost is minimal (~200 lines for hash/sign/verify) and retrofitting later would require migrating all existing checkpoints.
7. Always-yolo over built-in permissions
Decision: No permission system in ghyll. All tool calls execute immediately.
Rationale: SRT (Anthropic’s Sandbox Runtime) provides OS-level sandboxing via Seatbelt (macOS) and bubblewrap (Linux). Building a second permission layer inside ghyll would be redundant, add complexity, and create a false sense of security. Defense in depth is provided by two separate projects, not two layers in one project.
8. ONNX download over bundled model
Decision: Embedding model (~60MB) is downloaded on first use, not bundled in the binary.
Rationale: Keeps the Go binary small (~15MB). Allows model updates without recompiling. Supports air-gapped environments by making the download URL configurable. Graceful degradation when model is unavailable — memory features disabled, core functionality unaffected.
Consequences
- Adding a new model requires Go code changes and recompilation
- No hot-swapping of model support
- Git is a hard dependency for memory features
- SRT (or equivalent sandbox) is assumed but not enforced
- Team memory trust depends on developer key management
ADR-002: Shared Types Leaf Package
Date: April 2026 Status: Accepted
Context
The original architecture placed Message and ToolCall types in context/, and ToolResult in tool/. This created hidden import dependencies: dialect/ functions take []context.Message and return []context.ToolCall, forcing dialect/ to import context/. Similarly, stream/ returns ToolCall in responses, requiring stream/ to import context/.
While Go doesn’t allow import cycles (so compilation would catch true cycles), the stated dependency graph was wrong — dialect/ and stream/ had undeclared dependencies on context/.
Decision
Extract shared types (Message, ToolCall, ToolFunction, ToolResult) into a types/ leaf package with zero dependencies. All packages import types/ freely without creating coupling.
Consequences
- Package graph is honest — declared dependencies match actual imports
- Adding a field to
Messageis a single-package change types/must remain a leaf — no dependencies allowed- Slight indirection:
types.Messageinstead ofcontext.Message
ADR-003: Embedding Excluded from Canonical Hash
Date: April 2026 Status: Accepted
Context
The canonical hash of a checkpoint originally included all fields except hash and sig. The Embedding field is a []float32 vector. Go’s json.Marshal for float32 values can produce different string representations across Go versions and platforms (e.g., trailing zeros, scientific notation for edge cases). If two Go versions produce different JSON for the same []float32, the hash differs and cross-device verification fails.
This was identified during adversary review as a high-severity correctness issue.
Decision
Exclude the Embedding field from the canonical hash computation. Embeddings serve search and drift detection — they don’t need integrity protection. The summary text (which is hashed) captures the semantic content that the embedding represents.
Consequences
- Cross-platform hash verification is reliable
- An attacker who modifies only the embedding can change search results but not the summary or metadata
- This is acceptable: embeddings are for convenience (search ranking), not trust (content integrity)
- If embedding integrity becomes needed, use a fixed binary encoding instead of JSON serialization
ADR-004: Tool Call Depth Limit
Date: April 2026 Status: Accepted
Context
The session loop processes tool calls recursively: model returns tool calls, ghyll executes them, sends results back, model may return more tool calls. A compromised or buggy model endpoint could return tool calls indefinitely, causing unbounded recursion, stack overflow, or unbounded context growth.
This was identified during adversary review as a critical severity finding.
Decision
Hard limit of 50 sequential tool calls per turn. After 50 tool calls without user input, the session returns an error and waits for the next user prompt.
The limit is a constant (maxToolDepth = 50), not configurable. Making it configurable would invite setting it to unlimited, defeating the purpose.
Consequences
- Protects against runaway model loops
- 50 is generous — normal coding tasks rarely exceed 10 sequential tool calls
- If a legitimate task needs >50 tool calls, the user can continue in the next turn
- The model sees the error message and can adjust its approach
ADR-005: Compaction as Separate API Call
Date: April 2026 Status: Accepted
Context
Context compaction summarizes older turns to reduce token count. The original design was ambiguous about whether the compaction request uses the full context window or a subset.
If compaction sends the full context (which is at 90%+ of the limit), the compaction request itself may exceed the model’s context limit. The compaction prompt plus the turns-to-summarize must fit within the model’s capacity.
This was identified during adversary review of the analyst specs.
Decision
Compaction is a separate API call containing only:
- The dialect’s compaction prompt
- The turns to summarize (all except the last N preserved turns)
The full context window is never sent in the compaction request. The compaction call is made to the same model endpoint but as an independent request.
Consequences
- Compaction cannot exceed the model’s context limit
- The compaction prompt and turns-to-summarize must fit in the model’s context (guaranteed since they’re a subset of the full context which was within limits before the recent growth)
- Compaction before handoff: runs on the current model first, then handoff uses the compacted context
- The context manager builds a
CompactionRequeststruct;cmd/ghyllwires the stream client to execute it
ADR-006: One Session Per Repository
Date: April 2026 Status: Accepted
Context
Multiple concurrent ghyll sessions in the same repository would cause:
- Concurrent writes to the git memory branch worktree
- Concurrent appends to the same chain file
- Double sync goroutines competing on git push
- Potential sqlite WAL corruption from multiple processes
This was identified during adversary review of the architecture.
Decision
Enforce one ghyll session per repository using a lockfile at <repo>/.ghyll.lock. The lockfile contains the PID of the holding process. Stale locks (dead PID) are automatically reclaimed.
The lockfile uses O_CREATE|O_EXCL for atomic creation to prevent TOCTOU race conditions.
Consequences
- Cannot run two ghyll sessions in the same repo simultaneously
- A crashed session’s lock is automatically recovered (dead PID detection)
- The lockfile is gitignored
- Different repos can run concurrent sessions (different lockfiles)
- This is the simplest solution; per-session worktrees or channel-based serialization were considered but add complexity for a scenario (concurrent sessions in same repo) that’s unlikely in practice
ADR-007: Tier-Based Routing with Dialect Families
Date: April 2026 Status: Accepted
Context
Ghyll’s router hardcodes specific model identifiers ("m25" for MiniMax M2.5 and "glm5" for GLM-5) in its escalation/de-escalation logic. When new model versions ship (GLM 5.1, MiniMax M2.7), this creates a forced choice: either rename identifiers (breaking existing configs) or add new dialect files that duplicate the existing ones verbatim.
The root issue is conflation of three distinct concerns:
- Routing tier — fast vs. deep, determined by context depth, tool depth, and user override.
- Model identity — the user-chosen name for a configured endpoint (e.g.,
"m27","glm51"). - Dialect family — the set of functions for prompt formatting, tool-call parsing, token counting, and compaction, determined by the model’s API contract, not its version number.
Decision
1. Router operates on tiers, not model names
The routing decision table references two config fields:
| Field | Meaning | Example |
|---|---|---|
routing.default_model | Fast tier model | "m27" |
routing.deep_model | Deep tier model | "glm51" |
Escalation targets deep_model. De-escalation targets default_model. The router never mentions a concrete model name in code.
2. Dialect families replace versioned dialect identifiers
Each ModelConfig specifies a dialect family string:
| Family | API contract | Files |
|---|---|---|
"minimax" | OpenAI-compatible, Lightning Attention characteristics | dialect/minimax.go |
"glm" | OpenAI-compatible via SGLang, DSA attention characteristics | dialect/glm.go |
The dialect family determines system prompt tuning, compaction strategy, token counting ratio, and handoff summary format. Version-specific quirks (if any) can be handled internally via config fields on ModelConfig — no new file required for a point release.
3. Model names are user-chosen, not framework-imposed
Config entries are keyed by arbitrary names:
[models.m27]
endpoint = "https://inference.internal:8001/v1"
dialect = "minimax"
max_context = 1000000
[models.glm51]
endpoint = "https://inference.internal:8002/v1"
dialect = "glm"
max_context = 200000
[routing]
default_model = "m27"
deep_model = "glm51"
Users running older hardware can keep default_model = "m25" with dialect = "minimax" — the dialect functions are the same.
Consequences
- Adding a new model version (e.g., M2.9) requires only a config change if the API contract is unchanged.
- Adding a genuinely new model family (e.g., DeepSeek V4) requires one new dialect file + recompilation.
- Existing configs must update
dialect = "minimax_m25"→dialect = "minimax"anddialect = "glm5"→dialect = "glm". This is a one-time migration. - The router no longer needs modification for model upgrades — only for changes to the routing algorithm itself.
- A/B testing old vs. new versions of the same family is config-only: define both models, point
default_modelat the one you want to test.
ADR-008: V2 Fixed Roles Deprecate Runtime Workflow Roles
Date: May 2026 Status: Accepted
Context
v1 ghyll loads workflow roles from disk at session start via
workflow.Load(globalDir, sc.Workdir, sc.Cfg.Workflow.FallbackFolders).
Operators define their roles as freeform markdown in .claude/roles/*.md
or .ghyll/roles/*.md; the session caches them in Session.wf.Roles
and Session.SwitchRole(name) lets the runtime switch between them.
v2’s correctness mechanism (specs/architecture/v2-design.md §3.3) is the
gate system, and the gate system requires roles to have fixed
contracts:
A role is not freely redefinable — a role you can argue with is a gate you can argue with. Roles may be extended with language-specific or behavioral additions, but their contracts (entry precondition, exit gate) are fixed.
The v2 role set is the “diamond”:
analyst → architect → implementer → integrator
with adversarial scrutiny and depth classification reframed as phases of every arrow rather than standalone roles.
The two surfaces are now incompatible:
- v1 runtime role loading lets an operator redefine the analyst role to skip the architect’s entry preconditions — the gate the architect arrow depends on becomes negotiable.
- v1 has no notion of arrow phases. The auditor/adversary roles from
.claude/roles/have no v2 counterpart.
Decision
1. Drop runtime free-form role loading
removed:
- The
Rolesfield fromworkflow.Workflow;workflow.Load()no longer reads.claude/roles/*.mdor.ghyll/roles/*.md. Session.wf.Roles,Session.activeRole, andSession.SwitchRole()fromcmd/ghyll/session.go.- The role-overlay branch in
composedSystemPrompt.
2. Four fixed roles, defined in specs as source-of-truth
The four diamond role contracts live at
specs/architecture/roles/{analyst,architect,implementer,integrator}.md
and are the canonical definition. The runtime does not load these from
disk for system-prompt construction — v2’s correctness mechanism is the
gate-and-arrow state machine, not a swappable system-prompt overlay.
Bootstrap’s auto-propose pass (bootstrap.ParseRoleFile) does read
these markdowns to extract role clauses for grid construction, but that
is a build-time/init concern: the parsed output flows into the typed
grid (.ghyll/grid.vN.yaml), not into a runtime overlay. Operators
cannot redefine the role contracts; the spec files are the contracts.
3. Workflow.Load() retains the other v1 responsibilities
workflow.Load() continues to:
- Load project instructions (the body of
.claude/CLAUDE.mdand.ghyll/CLAUDE.md). These compose into the system prompt per the invariants inspecs/architecture/data-structures.mdlines 359-365:- 46: instructions survive compaction (system-level)
- 47: global prepended, project appended (project has last word)
- 48: total tokens bounded by instruction budget
- Load slash commands (user-defined extensions like
/ultrareviewin.claude/commands/*.md). These remain operator-controllable.
What goes away: the Roles map on the returned *Workflow.
4. Adversarial and auditor functions reframe as arrow phases
Per specs/architecture/v2-design.md §3.5, every arrow carrying a
depth-sensitive clause runs three phases:
- Adversarial — separate instance attacks the upstream artifact.
- Remediation — bounded loop fixing or accepting-risk findings.
- Verification — gate clauses evaluated.
These are not roles. There is no standalone adversary or auditor in v2.
Consequences
Breaking changes
Session.SwitchRole()removed.cfg.Workflow.FallbackFoldersno longer used for role discovery (still used for CLAUDE.md discovery).- The
Workflow.Rolesfield is removed. - Operators who relied on custom roles in
.claude/roles/*.mdmust either:- Express their domain rules as arrow gates (the gate becomes the enforcement, not the role description).
- Use slash commands for ad-hoc workflows that don’t need gate enforcement.
- Stay on v1 (no longer supported).
Test impact
workflow.feature: ~10 of 28 scenarios drop (role loading, role switching, role inheritance). Remaining scenarios (instruction budget, CLAUDE.md composition, slash commands) retain coverage.sub-agents.feature: per ADR-009 (forthcoming), sub-agents narrow to a tool call rather than an inherited session topology.plan-mode.feature: unaffected — plan mode is advisory and orthogonal to the role system.
Migration path for operators
A v1→v2 migration note in the docs/usage/ section explains:
- If your
.claude/roles/X.mdencoded domain knowledge, that becomes arrow clauses in your project’s grid. - If your
.claude/roles/X.mdencoded prompts, that becomes a slash command. - If your
.claude/roles/X.mdencoded skip-checks, the only way is the gate clause approach — there is no equivalent for arguing past a gate.
Compatibility
.claude/CLAUDE.md is unchanged in shape (project instructions still
load). The v2 fixed roles are NOT loaded from .claude/roles/*.md —
those files become Claude Code’s own role definitions (used by the
build agents, not by ghyll’s runtime).
Alternatives considered
- Keep both layers. Rejected: the gate system loses its enforcement guarantee the moment a role can argue with it.
- Keep role loading as extensions only. Considered: operators add roles BEYOND the four fixed ones, but cannot redefine them. Rejected: the diamond is closed by design; opening it at the role layer means the next role’s gate is no longer compositional.
- Defer to a future release. Rejected: every release that ships with both surfaces compounds the v1↔v2 split this consolidation exists to close.
Related decisions
- ADR-007 — tier-based routing (orthogonal; still applies)
- specs/architecture/v2-design.md §3.3 — fixed roles
- specs/architecture/roles/*.md — per-role contracts (source of truth)
- specs/v2-final-plan.md — the consolidation plan that executed this decision
ADR-009: §12.2 self-cert scope includes target role
Date: 2026-05-19 Status: Accepted
Context
specs/architecture/gates.md §12.2 reads:
the producing role may not self-certify the definition it wants to use to continue.
The natural reading of “producing role” is the role producing the
artifact — i.e., the source role of the transition. On-the-spot
arrow definitions, however, have two endpoints: the source role
(handing off) and the target role (receiving). The definer hook
(an LLM-backed function in runner/onthespot.go::safeInvokeDefiner)
authors the new ArrowDefinition in isolation; the source and
target roles do not write the clauses or arguments. They are
stakeholders, not authors:
- The source role is about to hand off under the new contract — it benefits if the contract is easy to produce.
- The target role is about to receive under the new contract — it benefits if the contract is easy to consume.
The runner code at runner/onthespot.go:178-189 currently forbids
both source and target role from attesting, flagging this as
“conservative reading (F5)” and recommending an analyst pass to pin
the spec.
Decision
§12.2 forbids attestation by both the source role and the target role of an on-the-spot arrow definition. The conservative runner reading is the correct reading.
Rationale
The two roles are stakeholders with direct conflict-of-interest, not authors of the definition. A stakeholder attesting their own beneficial contract is the same failure mode as a producer attesting their own work: the gate becomes negotiable. The intent of §12.2 is that the operator attestation be an external check; allowing either stakeholder to attest leaves a private-vote channel where the beneficiary decides whether the contract is acceptable.
Symmetry across the two endpoints is also simpler to enforce and reason about than asymmetric rules.
What the check enforces
The runtime check is a syntactic gate at the schema boundary:
att.AttestedByRole must not equal source or target role strings.
The check does not verify that the operator presenting the
attestation actually holds the claimed role — that’s outside §12.2
scope and is the responsibility of the operator-identity layer
(authentication, audit log). §12.2 enforces the structural
constraint: “whoever attests, the recorded AttestedByRole is
neither endpoint.”
Who can attest
The attester must be a role that is not the source or target of the
arrow being defined. In the four-role diamond
(analyst → architect → implementer → integrator):
- For an
analyst → architecton-the-spot arrow:implementerorintegratormay attest. - For an
architect → implementerarrow:analystorintegratormay attest. - For an
implementer → integratorarrow:analystorarchitectmay attest.
If no operator can attest under a non-endpoint role (e.g., a
configuration where the diamond is reduced to two roles), the
on-the-spot definition cannot proceed. The runner surfaces this as
ErrSelfCertImpossible. The operator must amend the workflow (e.g.,
declare a third role) before retry.
Consequences
Schema update
specs/architecture/gates.md §12.2 paragraph 2 reads:
This definition is itself gated by operator attestation — neither the source role nor the target role may attest the definition, because both have conflict-of-interest as immediate stakeholders in the contract (ADR-009). The attestation must come from a role outside the (source, target) pair. If no such role exists in the current workflow, the on-the-spot definition fails with
ErrSelfCertImpossibleand the operator must amend the workflow before retry.
Code
runner.ResolveOnTheSpotalready enforces the role check.- Add sentinel
ErrSelfCertImpossiblefor the no-eligible-role case. The runner surfaces this when an operator presents an attestation that fails the role check AND the workflow has no other roles to attempt. AttestationStore.Record(per ADR-010) also enforces the role check at the persistence boundary, so a buggy or out-of-band recording path cannot bypass §12.2.
Test
runner/onthespot_test.go:160 already covers the target-role-cannot-
attest case (source self-cert should be refused,
target self-cert should be refused, case-insensitive variant).
Adds: a test asserting ErrSelfCertImpossible is returned when no
eligible role exists in the operator-presented attestation chain.
Alternatives considered
- Source role only. Narrower reading of “producing”. Rejected: target-role self-cert leaves the beneficiary deciding whether the contract is acceptable, defeating the gate.
- Either role + any other operator with conflict-of-interest. Generalized; harder to enforce because “conflict-of-interest” isn’t statically declarable in the schema. Rejected for v1.0.0; a future ADR can extend.
- Allow target role IFF the operator’s role audit log shows the target role didn’t participate in authorship. Rejected: authorship is owned by the definer hook, not by either role, so the audit log can’t distinguish. The structural rule is simpler.
Related
specs/architecture/gates.md§5.4, §12.2runner/onthespot.goResolveOnTheSpotspecs/invariants.mdinv 27 (producers cannot self-certify)specs/failure-modes.mdFM-49 (producer attempts self-attestation)- ADR-010 (AttestationStore enforces the constraint at the persistence boundary)
ADR-010: Attestation records — runner cache + engine persistence
Date: 2026-05-19 Status: Accepted
Context
runner.Clause.DepthTypeAttestationRef is a string field that links a
clause back to an operator-attested verdict (typically captured during
init’s depth-type assignment, since depth-type is itself
depth-sensitive and requires operator confirmation).
Today the runner carries the ref through to the engine layer
(engine/journal.go:455, engine/records.go:433,
engine/queries.go:399). The ref is opaque: it is read and persisted,
but no component owns the records the ref points at.
The previous-session note read:
DepthType attestation linkage — runner carries
Clause.DepthTypeAttestationRef; engine layer resolves it against the attestation store. Where the attestation store lives is open.
Scope (what this ADR owns vs. what it does not)
This ADR owns two attestation kinds:
depth-type— operator confirms a clause’sDepthType/MinDepthTierassignment during init. The record is the source of truth for the assignment going forward.on-the-spot— operator approves an on-the-spot arrow definition (per §12.2 and ADR-009). The record captures the attesting role and the suspension’s identity.
It does not own:
- Clause-verdict transitions during running passes (operator
marks a finding as
accepted-risk,fixed,invalidated). Those remain inrunner.FindingsStore.TransitionByOperatorand thefinding_transitionsengine table. The two surfaces capture different semantics: findings model defect lifecycle (mutable status); attestations record an immutable operator verdict on a schema element.
Decision
Attestation records (depth-type + on-the-spot) live in two coordinated places:
- Engine (sqlite) — authoritative, persistent. A new
attestationstable onengine.Store. Records are immutable once written. - Runner (in-memory) —
runner.AttestationStoreis the in-memory cache populated by:- Direct
Record(rec)calls during runtime as operator verdicts arrive (publishes an Observer event so the journal persists). - Engine replay at session start (loads the
attestationstable back into the cache before evaluation begins).
- Direct
Clause.DepthTypeAttestationRef resolves through
AttestationStore.Lookup(ref) returning the cached record. The
caller validates the record’s verdict, timestamp, and op_id.
Schema
CREATE TABLE attestations (
attestation_id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- 'depth-type' | 'on-the-spot'
arrow_id TEXT NOT NULL,
clause_id TEXT, -- NULL iff kind='on-the-spot'; NOT NULL iff kind='depth-type'
op_id TEXT NOT NULL,
attested_by_role TEXT NOT NULL,
source_role TEXT NOT NULL, -- the arrow's source role (for §12.2 audit)
target_role TEXT NOT NULL, -- the arrow's target role (for §12.2 audit)
verdict TEXT NOT NULL, -- 'pass' | 'fail' | 'insufficient-basis'
reason TEXT,
timestamp INTEGER NOT NULL,
grid_version INTEGER NOT NULL,
CHECK (kind IN ('depth-type', 'on-the-spot')),
CHECK ((kind = 'on-the-spot' AND clause_id IS NULL)
OR (kind = 'depth-type' AND clause_id IS NOT NULL)),
CHECK (verdict IN ('pass', 'fail', 'insufficient-basis'))
);
CREATE INDEX idx_attestations_arrow ON attestations(arrow_id);
CREATE INDEX idx_attestations_clause ON attestations(clause_id) WHERE clause_id IS NOT NULL;
The kind-pairing CHECK is symmetric: depth-type MUST have a clause_id; on-the-spot MUST NOT. A relaxation (depth-type with NULL clause_id) would let a buggy caller record an arrow-scoped depth-type attestation indistinguishable from on-the-spot, defeating the kind discriminator.
The clause_id is nullable specifically because on-the-spot
attestations attest the whole arrow definition (there is no
per-clause grain at that point). Depth-type attestations attest a
specific clause and always populate clause_id. The kind/clause_id
CHECK constraint pins this.
source_role and target_role are recorded so the store can
validate the §12.2/ADR-009 constraint at Record time (see
“Self-cert enforcement” below).
schema_version increments.
Self-cert enforcement (ADR-009 integration)
AttestationStore.Record(rec) rejects records where
attested_by_role equals source_role or target_role
(case-insensitive, trimmed). The sentinel error is
ErrAttestationSelfCert. This is the single enforcement point for
§12.2; runner.ResolveOnTheSpot still validates upstream so the
on-the-spot site can fail with the specific
ErrSelfCertification / ErrSelfCertImpossible errors before
attempting to record.
Centralizing the constraint at the store boundary means out-of-band recording paths (init’s depth-type confirmation, future operator UX endpoints) cannot bypass it.
Attestation IDs
Attestation IDs are deterministic so a clause’s
DepthTypeAttestationRef can be assigned at init before the
record is persisted by the journal consumer goroutine:
attestation_id = "att-" + arrow_id + "-" + clause_id + "-v" + grid_version
(clause_id omitted for on-the-spot)
This collapses the replay ordering problem (next section): the ref
is computable from clause + grid state alone; the store fills in
the body when the operator verdict arrives. Until then,
Lookup(ref) returns (zero, false) and the caller treats the
clause as having no attestation yet.
Replay ordering
Replay order on session start:
1. attestations (kind='depth-type' and 'on-the-spot' records)
2. arrows + clauses (grid)
3. findings
4. classifications
5. amendments
6. evaluation_runs
Attestations replay first because:
- Their primary keys (deterministic per the scheme above) do not depend on any other entity’s runtime state.
- Subsequent grid / findings replay may resolve attestation refs
through
AttestationStore.Lookup.
Grid replay does not require attestation refs to resolve; the
clause stores the opaque ref string. The ref is resolved only when
a clause is evaluated or when the engine surfaces attestation
status via the engine CLI. So even if an attestation row is missing
at replay (e.g., a partial dump), grid replay still succeeds —
Lookup returns (zero, false) and the caller treats the
attestation as absent.
JSONL verdict records (audit trail)
A separate component (runner.AttestationJSONLWriter) writes one
record per attestation to a project-local JSONL file
(.ghyll/attestations.jsonl) for audit. Source of truth:
the engine table, not the runtime cache. The writer subscribes
to journal events for attestations writes; the JSONL is appended
synchronously in the consumer goroutine so the audit trail and the
sqlite record are atomically consistent.
If the JSONL file is removed, replay reconstructs it from the
engine table (ghyll engine export-attestations).
Rationale
The existing pattern across the runtime is runner-cache +
engine-journal + replay-on-startup. FindingsStore,
ClassificationsStore, Grid, and AmendmentQueue all follow this
shape. Depth-type and on-the-spot attestations are conceptually the
same: append-only records with a lookup-by-ID surface, owned by the
runner at the hot path, persisted by the engine for durability and
cross-session replay.
The deterministic-ID scheme + replay-first ordering removes the chicken-egg dependency that an autoincrement ID + after-grid replay would introduce. Clauses can carry refs to records that may not yet exist; the surface is robust to that.
Putting clause-verdict attestations on FindingsStore (NOT here) and schema-verdict attestations on AttestationStore (here) is the right split because the two have different lifecycles: findings transition through states, attestations don’t.
Consequences
Code
- New file
runner/attestationstore.go:AttestationStoretype withRecord(rec AttestationRecord),Lookup(ref string) (AttestationRecord, bool),Observe(o AttestationObserver).Recordvalidates the §12.2/ADR-009 self-cert constraint. - New file
engine/attestations.go: store methods + record marshal + schema migration. engine.Journal.AttachAttestations(store *runner.AttestationStore)with the standard bounded-channel observer.engine.Replayloads attestation rows intorunner.AttestationStoreBEFORE grid replay.- New file
runner/attestation_jsonl.go:AttestationJSONLWriterreads from journal events.
Test
- Unit tests on
runner/attestationstore.gofor Lookup / Record / Observe / self-cert rejection. - Unit tests on the deterministic-ID scheme: same inputs → same ID; collision absence within a (grid_version, arrow_id, clause_id) tuple.
- Engine integration tests on the new table + replay ordering.
- BDD: attestation.feature scenarios using
DepthTypeAttestationRefresolve through the new store, removing the matching@deferredtags.
Alternatives considered
- Attestations on
FindingsStore. Overloads a defect-tracking surface with verdict records. Rejected (different lifecycle). - Engine-only, no runtime cache. Every clause evaluation that needs to resolve a ref pays a sqlite hit. Rejected — the hot path of clause evaluation is too tight.
- Autoincrement attestation IDs. Simpler ID scheme but introduces the replay-ordering chicken-egg. Rejected.
- Two parallel writes, no journal. Engine + runner write directly with no observer. Rejected — would break the replay-on-startup invariant and split the source of truth.
- JSONL writer reads from runtime cache. In-memory only; cache contents not durable. Rejected — audit trail must survive crash.
Related
runner/runner.go:152—DepthTypeAttestationReffieldengine/journal.go,engine/records.go,engine/queries.goengine/replay.go— replay path- ADR-009 (self-cert scope) — the constraint AttestationStore enforces
ADR-011: Per-(role, context) lock — single-active-role-instance
Date: 2026-05-19 Status: Accepted
Context
specs/invariants.md inv 19 declares the
single-active-role-instance invariant: at most one active pass per
(role-id, bounded-context-id) tuple at any time. Two adversarial
phases on the same arrow (FM-23) and two implementer instances on
the same context are both forbidden by this rule.
ADR-009 in docs/decisions/v2/009-three-locks.md names the
mechanism as a “Per-(role, context) lock” owned by the runner,
acquired at pre-spawn, released on pass termination.
The mechanism has been designed but not implemented in code.
Pass vs. clause: the granularity decision
A “pass” is one role’s traversal of one arrow on one (role, context)
tuple. A pass may run multiple clause evaluations
(Runner.Evaluate calls) sequentially, one per clause on the arrow.
The invariant is one active pass per (role, context), not one
active clause.
Therefore the lock is acquired around the whole pass, not
around each Runner.Evaluate call. The acquire/release points are:
- Acquire — by the dispatcher (engine layer in production; test
harness in BDD) BEFORE calling
Runner.Evaluatefor the first clause of the pass. - Release — by the same dispatcher AFTER the last clause of the pass returns (or on early termination / panic / error).
Runner.Evaluate itself does not acquire or release the lock.
The runner doesn’t know whether a given Evaluate call is the
first, middle, or last clause of a pass — the dispatcher does.
A consequence: passID is stable across all clauses of one
logical pass. Callers MUST use the same passID for every
Runner.Evaluate call in a single pass. The lock table uses
passID as the holder identity.
Process boundary
The lock table is in-memory and process-local. Cross-process
single-active-role-instance is enforced by cmd/ghyll/lockfile.go
(ADR-006: one session per repo). The lockfile guarantees only one
ghyll run process is active in a workdir at a time; the
in-memory role-context lock table inside that one process is
therefore sufficient.
AcquireLock in cmd/ghyll/lockfile.go already handles stale-PID
detection so a crashed previous process does not permanently block
the workdir. When the new process starts, it constructs a fresh,
empty RoleContextLockTable — there is no cross-session lock
state to recover. The “crash recovery” concern in the original
ADR draft was overstated.
Decision
Implement runner.RoleContextLockTable:
type RoleContextLockTable struct {
mu sync.Mutex
held map[roleContextKey]*roleContextLock
now func() time.Time
}
type roleContextKey struct{ Role, Context string }
type roleContextLock struct {
passID string
acquiredAt time.Time
expiresAt time.Time // zero = no TTL
}
Interface:
TryAcquire(role, ctx, passID string, ttl time.Duration) (RoleContextLockToken, error)— succeeds if no holder, returns*ErrRoleContextBusyon contention. The returned token carries the holder identity.RoleContextLockToken.Release()— drops the table entry iff the token still owns it. Idempotent: a second Release on the same token returns silently. A Release after a differentpassIDhas re-acquired the lock is a no-op (does NOT clobber the new holder). Silent no-op is the right semantic becausedefer tok.Release()is the idiomatic pattern; returning an error from Release would force every caller to wrap the defer in an error-handling closure.InspectHolder(role, ctx) (passID string, held bool)— monitoring only. The result is racy by construction (another goroutine may Release+TryAcquire between this call and the caller acting on the result). Use for logging, telemetry, the engine status CLI. For decisions, useTryAcquireand handle the busy error.ExpireOlderThan(now time.Time) (expired int)— sweeps entries whoseexpiresAtis pastnow. Zero-expiresAtentries are NOT swept. Useful for periodic in-session hygiene; not needed for cross-session recovery (see Process boundary).
TTL semantics
ttl parameter to TryAcquire:
ttl == 0— no auto-expiration. The caller is responsible for Release. Suitable for interactive sessions where the operator controls timing.ttl > 0—expiresAt = now + ttl. If the entry is still in the table afterexpiresAt, the nextTryAcquireon the same key sees the entry as stale and overwrites it. This handles the case where a pass forgets to Release (a bug) — the lock unblocks afterttlelapses on the next contended TryAcquire.
In-session TTL is the only meaningful expiration; cross-session is out of scope (Process boundary section). Zero-TTL entries that outlive their pass due to a bug are a logical error to be caught in code review and tests, not in production crash recovery.
Wiring
engine.Dispatcher (or test harness) — owns pass lifecycle:
token, err := runner.LockTable.TryAcquire(role, ctx, passID, ttl)
if err != nil { return ErrRoleContextBusy ... }
defer token.Release()
for _, clause := range arrow.Clauses {
runner.Evaluate(ctx, clauseID, passID, clause)
}
Runner does NOT carry a reference to the lock table. The lock is
a dispatch-layer concern. This keeps Runner.Evaluate focused on a
single clause and avoids the granularity mismatch the original ADR
draft had.
Rationale
The runtime has many small mutexes already (one per store, journal backpressure, observer slice). Adding a dedicated per-(role, context) lock table at the dispatch layer localizes the invariant to one place. Pass-level granularity matches the spec invariant directly: “one active pass per (role, context).”
Token-based Release keeps the API safe under defer + early-return patterns. The InspectHolder rename makes the racy nature explicit; callers reaching for decisions get pushed to TryAcquire.
Consequences
Code
- New file
runner/rolelock.go:RoleContextLockTableand friends. - New sentinel
ErrRoleContextBusycarrying the conflictingpassID, role, context, and acquire-time. - Dispatcher integration in
cmd/ghyll(and in BDD step helpers that simulate the dispatcher). - Engine status CLI surfaces
InspectHolderoutput if any (role, context) tuple is held — informational, no decision-making.
Tests
- Concurrent
TryAcquirefrom two goroutines on the same key — one succeeds, one getsErrRoleContextBusy. - Release is idempotent: a second Release on the same token succeeds; a Release after a different passID re-acquired the lock does not affect the new holder.
- TTL auto-sweep: a stale entry is overwritten by a new TryAcquire whose now() is past expiresAt.
- ExpireOlderThan: only entries with non-zero, past-deadline expiresAt are swept; zero-TTL entries untouched.
- Disjoint (role, context) tuples acquire independently.
- Empty role / context / passID inputs return errors (programmer mistakes).
Performance
TryAcquire and Release take the table mutex for a single map
lookup + optionally a write. Sub-microsecond. Not a bottleneck.
Alternatives considered
- Lock per
Runner.Evaluatecall. Rejected: wrong granularity. Two passes on the same (role, context) could interleave clause evaluations. sync.Mapper key with one-shot atomic CAS. Simpler API but loses holder identity on contention. Rejected:ErrRoleContextBusyneeds the holding passID.- DB-based lock table for cross-process locking. Rejected: ADR-006 (lockfile) already ensures one process; in-memory suffices and avoids DB latency on the hot path.
- Error return on stale-token Release. Rejected: defers would need wrappers; silent no-op is the idiomatic Go pattern and a stale-token-bug is a code-review concern, not a runtime-recoverable failure.
Related
specs/invariants.mdinv 19, inv 23specs/failure-modes.mdFM-23docs/decisions/v2/009-three-locks.md— three-lock topologycmd/ghyll/lockfile.go— process-level single-session enforcementrunner/runner.go—Runner.Evaluate(does NOT take the lock; the dispatcher does)
ADR-012: OperatorEvent bus is in-process pub/sub
Date: 2026-05-19 Status: Accepted
Context
Tier-1 of the prod-readiness rollout shipped a runtime-wide event
bus (runner.OperatorBus) that several components publish to:
runner.Passpublishes pass-opened / pass-closed.runner.AmendmentCommitterpublishes amendment-drained.runner.AdversarialOrchestratorpublishes adversarial-round-start / producer-fix-signal / remediation-converged / remediation-escalated.runner.InsufficientBasisTrackerpublishes insufficient-basis-rounds-exceeded.runner.AttestationJSONLWriter/AttestationTreeWriterpublish attestation-audit-durability-failed.runner.ProducerFixHarnesspublishes producer-fix-signal.
The bus has 14 wire-stable OperatorEventKind values. Subscribers
include the JSONL writer, the engine status CLI’s future
operator-event surface, and (eventually) an HTTP endpoint for
remote operators.
This ADR pins the design choices the bus has codified through Tier 1: in-process pub/sub, synchronous fanout outside the bus mutex, no event persistence at the bus layer.
Decision
1. In-process pub/sub, not a queue
The bus is sync.RWMutex-protected map of subscribers. Publish
fans out under a snapshot of the subscriber slice taken inside a
read lock; the slice is copied so a slow subscriber doesn’t hold
the bus lock during its callback. There is no queue, no
backpressure, no dropped-event counter.
Rationale: every subscriber today is in-process (the JSONL writer runs in the same Go runtime as the publisher). Adding a queue would complicate ordering guarantees (subscribers see events in publish order today) without solving a real problem.
When cross-process subscribers land (HTTP forwarder, external audit-pipe), they get their own queue at the subscriber’s edge — not at the bus.
2. Fanout is synchronous; subscribers must be fast
Subscriber callbacks run inside the publisher’s goroutine. The spec for subscriber implementations is: do the minimum, hand off to a goroutine if anything blocks. The journal-style observer pattern that the AttestationStore uses on its own surface is the template.
A slow subscriber today would block all publishers. We accept this because the runtime’s publishers (Pass, Orchestrator, etc.) are themselves single-goroutine — there’s no contention to unblock.
3. Events are NOT persisted at the bus layer
Persistence is the subscriber’s concern. The JSONL writer persists attestation events via the AttestationStore Observer (a separate, narrower channel). The engine.Journal persists state-machine events via the runner-store Observer pattern. The bus is for events that don’t have a durable home — operator-facing signals, escalations, lifecycle markers.
A future “operator event log” subscriber can opt into persisting every bus event, but the bus itself remains volatile.
4. Wire-stable event kinds
OperatorEventKind is string. Adding a new event kind is
additive (subscribers that don’t recognize the kind ignore it).
Renaming or repurposing a kind is a breaking change requiring an
ADR. The 14 current kinds are listed in runner/operatorbus.go.
Consequences
Code
runner/operatorbus.go (~130 LOC) implements OperatorBus,
OperatorEvent, the 14 OperatorEventKind constants, and
OperatorEventSubscriber. NewOperatorBus() + WithClock(fn)
construct one bus per session.
engineRuntime (the session-scoped runtime) holds one bus and
exposes it via Bus(). The bus is constructed in
openEngineWithOptions, fanned out to publishers + subscribers
in attachJournal, and lives until closeEngine.
Test impact
Eight tests in operatorbus_test.go pin the design (publish fans
out, multiple subscribers each see, subscribe-during-publish
does not deadlock, custom clock, no-subscribers no-op, concurrent
publish).
Alternatives considered
-
A Go channel per subscriber. Each Subscribe returns a chan; subscribers consume. Rejected: complicates the publisher’s fanout (needs select+default for non-blocking send) and forces every subscriber to manage its own consumer goroutine. The callback API is simpler.
-
A typed event interface with method dispatch instead of a Kind enum. Rejected: the events have a shared envelope (ArrowID, ClauseID, PassID, OpID, Detail, Payload) that fits one struct better than 14 types. Adding a new kind shouldn’t require a new type.
-
Persistent bus (write every event to a log). Rejected: the JSONL writer + engine.Journal already persist the events that matter; the bus carries ephemeral signals.
Related
runner/operatorbus.go— implementationrunner/operatorbus_test.go— design pins- ADR-010 (AttestationStore Observer) — the channel persistent attestation events flow through
- ADR-011 (per-(role, context) lock) — Pass publishes lifecycle events to the bus
ADR-013: Pass entity owns role-lock and registry membership
Date: 2026-05-19 Status: Accepted
Context
The runtime needs an object that represents “one role’s traversal of one arrow on one (role, context) tuple.” The components that need to observe or coordinate around a live pass are:
- The
RoleContextLockTable(ADR-011) holds the per-(role, context) lock. Acquired at pass entry, released at termination. - The engine-status CLI needs to enumerate open passes.
- The
AmendmentCommitterneeds to identify passes on a soon- to-be-invalidated source arrow so it can abort them. - The
OperatorBus(ADR-012) consumes pass-opened / pass-closed events.
Tier-1 of the prod-readiness rollout shipped runner.Pass and
runner.PassRegistry. This ADR pins the design.
Decision
1. runner.Pass is the unit of single-active-role-instance
A Pass instance owns:
- The lock token from
RoleContextLockTable.TryAcquire. Released inClose/Abort. - Identity (
passID,role,context,arrowID). - Lifecycle state (
Open/Closed/Aborted). - Open-at and close-at timestamps + close reason.
- An optional
OperatorBusfor pass-opened / pass-closed events.
OpenPass is the only constructor; it takes a PassOptions
struct and acquires the role-lock atomically with the
construction. Close / Abort are idempotent.
A pass is single-goroutine by contract: the dispatcher creates a
pass on one goroutine, invokes Runner.Evaluate sequentially,
then closes. The internal mutex protects State() /
ClosedAt() / CloseReason() from racing monitoring code.
2. runner.PassRegistry tracks live passes
The registry is an in-memory map keyed by passID. Register at
OpenPass success (typically by the dispatcher, not the Pass
itself — Pass doesn’t know about the registry), Unregister at
Close / Abort. The engine-status CLI’s /passes operator
command and the AmendmentCommitter’s pass-abort loop iterate the
registry.
Crash recovery: the registry is in-memory and process-local. A crashed previous process leaves nothing for the new process to recover. Cross-session pass tracking is intentionally out of scope (ADR-006 makes each session the unit of work).
3. Pass IS NOT a Runner
The dispatcher wraps Pass + Runner.Evaluate calls. A pass
runs many clauses; each Evaluate is a single-clause execution.
The runner doesn’t know about passes; the dispatcher orchestrates
the pass lifecycle around per-clause Evaluate calls. This
separation (per ADR-011) lets Runner.Evaluate stay clause-
scoped and lets the dispatcher own pass-scope concerns.
Consequences
Code
runner/pass.go (~210 LOC) implements Pass, PassOptions,
OpenPass, Close / Abort, accessor methods. runner/ projectstatus.go adds PassRegistry.
Test impact
Eight tests on Pass (acquire/release roundtrip, lock release on
Close + Abort, idempotent Close, busy returns
*ErrRoleContextBusy, input validation, bus-optional, custom
clock). Three tests on PassRegistry (register / unregister
roundtrip, snapshot, len). The dispatcher tests
(runner/dispatcher_test.go) exercise the integrated Pass +
RoleContextLockTable + PassRegistry trio.
Alternatives considered
-
Pass with embedded Runner. Each pass owns its own runner. Rejected: runners are cheap, but coupling them to passes makes testing harder (you can’t unit-test Evaluate without a Pass) and conflates two roles.
-
Pass auto-registers itself with the registry on Open. Rejected: the registry isn’t a singleton; tests use disposable registries. The dispatcher knows which registry to register with; pushing that responsibility into OpenPass would either add a registry field to PassOptions or assume a global, both worse.
-
Channel-based pass-lifecycle signaling instead of registry. The dispatcher subscribes to a channel for “new pass” / “closed pass” events. Rejected: the registry is a simpler API for snapshot-style queries; the channel-style is solved by the bus (pass-opened / pass-closed events) which is also published.
Related
- ADR-011 (per-(role, context) lock) — the lock Pass owns
- ADR-012 (OperatorEvent bus) — where lifecycle events publish
runner/pass.go,runner/projectstatus.go,runner/dispatcher.go
ADR-014: Adversarial orchestrator with factory + producer-fix harness
Date: 2026-05-19 Status: Accepted
Context
gates.md §11 describes a bounded multi-round remediation cycle:
- The adversary attacks the upstream artifact (clause falsification, open sweep, depth classification).
- Findings raised this round trigger a producer-fix signal.
- The producer attempts remediation (transitions findings, re-emits artifacts).
- The next round re-attacks the entire artifact.
- After
remediation-rounds-maxrounds without convergence, the orchestrator escalates to the operator.
Tier-1 of the prod-readiness rollout shipped two coordinated
components: runner.AdversarialOrchestrator (the bounded loop)
and runner.ProducerFixHarness (the producer-side hook with
loop-bomb detection). This ADR pins the design.
Decision
1. Orchestrator uses an AdversaryFactory, not a single Adversary
runner.Adversary is single-shot per validation-pass-3 F3: an
atomic.Bool flips on first Attack so a second call errors. The
orchestrator runs N rounds; it cannot reuse one Adversary instance.
Therefore the orchestrator takes an AdversaryFactory — a
function that returns a fresh Adversary per round. The factory
typically wraps NewAdversary(findings, classifications, runner)
and applies the same OpenSweep / Classify hooks each call.
2. ProducerRemediate is a separate hook, optional
Some test paths drive remediation directly through the
FindingsStore without a producer (e.g., the operator manually
attests accepted-risk). Those paths pass ProducerRemediate = nil and the orchestrator skips the producer call between rounds,
just re-checking convergence.
When a real producer participates, callers wrap it in
ProducerFixHarness and pass harness.ProducerRemediate() as
the hook. The harness adds two safety mechanisms:
- Loop-bomb detection via SHA-256 digest of the producer’s
response artifact. Same digest across two consecutive rounds
- open findings →
ErrProducerLoopBomb. The cycle aborts; the operator intervenes.
- open findings →
- producer-fix-signal event on the OperatorBus per round so observers (status CLI, JSONL writer, future operator UI) see the cycle’s progress.
3. Convergence is checked twice per round
Round N flow:
- Adversary fires; reports any newly-raised findings.
- If no open-above-threshold findings AND no new findings this
round →
OutcomeRemediationConverged(early exit). - ProducerRemediate runs (if set).
- Re-check open-above-threshold; if zero →
OutcomeRemediationConverged(mid-round exit). - Otherwise loop.
The double check matters: a producer that resolves all findings within its hook ends the cycle without an extra adversary round (saves time + tier budget).
4. Outcomes are typed
OrchestratorOutcome is a wire-stable string enum:
converged / escalated-after-max-rounds / producer-error /
canceled. Callers (CLI surfaces, future operator UI) switch on
the outcome.
Consequences
Code
runner/orchestrator.go (~190 LOC) implements
AdversarialOrchestrator, AdversaryFactory, DispatchRequest,
OrchestratorResult, the four outcomes.
runner/producer_fix.go (~130 LOC) implements
ProducerFixHarness, ProducerFn, ErrProducerLoopBomb.
Tests
runner/orchestrator_test.go — 8 tests covering happy
convergence, producer remediation, max-rounds escalation,
producer error, context cancel, lifecycle events.
runner/producer_fix_test.go — 8 tests covering happy path,
loop-bomb detection, same-artifact-with-no-open-findings
exception, fix-signal event, nil producer / harness, error
propagation, end-to-end integration with the orchestrator.
Alternatives considered
-
Single reusable Adversary with a Reset method. Rejected: the spec’s F3 invariant (single-use Adversary) ensures clean adversarial state per round; relaxing it would let stale per-round state leak across rounds.
-
ProducerRemediate as a required (not optional) hook. Rejected: many test scenarios drive remediation through manual finding transitions; forcing a producer would complicate them. nil is a clean “no remediation step” signal.
-
Loop-bomb detection at the orchestrator layer (not the harness). Rejected: the harness is the natural home for producer-side concerns (digest tracking is a producer property). The orchestrator stays focused on the cycle.
Related
- gates.md §11 — adversarial cycle spec
- ADR-012 (OperatorEvent bus) — producer-fix-signal, remediation-converged, remediation-escalated kinds
- ADR-013 (Pass entity) — the orchestrator runs ABOVE the pass layer; one cycle may span multiple passes
runner/orchestrator.go,runner/producer_fix.go,runner/adversarial.go
ADR-015: Pass entity persistence + JSONL becomes attestation source of truth
Status
Accepted (2026-05-20). Gate-1 adversarial review of this ADR
produced 18 findings; all remediated in-document. Disposition log:
specs/v2/validation-impl-pass-tier1-remediation.md.
Amends:
- ADR-010 (
010-attestation-store-runner-engine-split.md) — inverts the “engine table is source of truth” framing for attestations. - ADR-013 (
013-pass-entity-and-registry.md) — adds the persistence boundary the original Pass ADR deferred.
Context
The analyst pass on Tier 1 (see
specs/architecture/components/pass-persistence.md) surfaced three
gaps in the v2 engine:
runner.Passis in-memory only. The engine has tables for findings, arrows, amendments, attestations, evaluation_runs — but nopassestable. A crash mid-pass loses pass state.- Crash recovery semantics are under-specified. Today
state-machine.mdF-6 says “all running passes → aborted:crash”; the deferred BDD scenarios (state-machine.feature:204-233) require finer behavior: passes with attestation-pending clauses should survive a crash so the operator can still deliver a verdict. - The atomicity boundary between “attestation JSONL append” and “engine attestations INSERT” is untested. ADR-010 says the engine row is the source of truth; the JSONL is a derived audit trail. Under crash between fsync and INSERT, the JSONL has the verdict but the engine + clause state don’t — split-brain.
The operator decision in the analyst pass:
- Persist Pass state in v2 engine sqlite (new
passestable). - Crash recovery preserves passes with attestation-pending clauses;
all other open passes become
aborted:crash. - JSONL becomes the source of truth for attestations. The
engine
attestationstable is a derived cache rebuilt at replay from the JSONL plus any operator-fed late corrections.
Decision
Part A: passes table
Add passes to the engine schema, observed by the existing Journal
goroutine fanout pattern:
CREATE TABLE IF NOT EXISTS passes (
pass_id TEXT PRIMARY KEY,
role TEXT NOT NULL,
context TEXT NOT NULL,
arrow_id TEXT NOT NULL,
grid_version INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL CHECK (state IN ('open','closed','aborted')),
opened_at TEXT NOT NULL DEFAULT '',
closed_at TEXT NOT NULL DEFAULT '',
close_reason TEXT NOT NULL DEFAULT '',
recovered_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_passes_state ON passes(state);
CREATE INDEX IF NOT EXISTS idx_passes_arrow ON passes(arrow_id);
CREATE INDEX IF NOT EXISTS idx_passes_role_ctx ON passes(role, context);
-- Per F-7 / F-17 of the gate-1 review: end_status reconciliation
-- writes need provenance. ALTER on existing DBs, default '' so
-- runner-set rows are distinguishable from recovery-set rows.
ALTER TABLE evaluation_runs ADD COLUMN recovery_source TEXT NOT NULL DEFAULT '';
schemaVersion bumps from 2 to 3 (the ALTER TABLE is a real
migration; existing DBs need it applied once via
ensureSchemaVersion).
The columns map 1:1 onto runner.Pass plus a recovered_at
shadow column (set by crash-recovery to mark which rows survived
a restart via the attestation-pending exception, per
pass-persistence.md invariant 4). recovered_at is set-once
(F-12): Recovery skips passes where recovered_at != '', so a
re-run of Recovery on the same store produces an empty
RecoveryReport (idempotence).
runner.Pass gains an Observe(fn PassObserver) surface on
PassRegistry. Emit runs without acquiring any registry lock
(F-4): the observer list is registered one-shot at session start
and never mutated after that, so unlocked fanout is safe and
breaks the AB/BA deadlock with PassRegistry.All() → p.State().
The emit point in Pass.closeWith runs AFTER lockToken.Release
(F-4): closeWith captures the event payload while p.mu is held,
releases the mutex AND the lock token, THEN calls
p.registry.emit(payload) with no locks held. Lock ordering is
always p.mu → release → emit, never p.mu → emit. OpenPass
emits PassEventOpen after PassRegistry.Register returns (no
lock held at emit time).
To avoid duplicate audit (N-1, N-2): the existing
OperatorEvent publishes inside OpenPass / closeWith (kinds
OpEventPassOpened / OpEventPassClosed) are removed. The
PassEvent fanout via the registry is the single audit path;
the journal observer additionally bridges PassEvent to the
bus for downstream subscribers if the live session has any.
Pass events are critical-priority on the journal channel
(F-11): Journal.enqueue for jKindPass blocks indefinitely
rather than dropping after the 100ms budget. Invariant 1 (“Pass
state persisted on every transition”) requires this — a dropped
pass event means the engine row never updates, producing a
correctness lie at next-restart Recovery.
PassRegistry.Resume (F-3) reconstitutes a *runner.Pass
from a persisted PassRecord and re-acquires the per-(role,
context) lock via RoleContextLockTable.TryAcquire. Recovery
calls Resume for every preserved attestation-pending pass so:
PassRegistry.All()lists them (so/passesshows the preserved set).- The dispatcher refuses to open a competing pass on the same tuple (lock is held).
- The operator’s verdict path can
Resume(...).Close(...)the pass cleanly when the answer arrives.
Part B: replay ordering
engine.Replay order becomes:
1. attestations (rebuild from JSONL; not from engine table — see Part C)
2. grid arrows
3. requirements
4. classifications
5. findings
6. amendments
7. passes ← NEW
8. recovery scan ← NEW (Part D)
Passes load AFTER findings because the recovery scan in step 8 consults findings (to tag preserved passes’ findings with grid-version) and the attestation store (rebuilt in step 1) to detect attestation-pending clauses.
Part C: JSONL is source of truth for attestations
The current ADR-010 framing — “engine table is source of truth, JSONL is derived audit trail” — is inverted:
-
Write order: JSONL fsync first; engine INSERT second.
AttestationStore.Recordcalls the JSONL observer synchronously inside the same critical section, before the store’sbyIDmap mutates and before any other observer fires. -
Replay:
engine.Replayfor attestations reads the JSONL file at session start, parses each line, and INSERTs into the engineattestationstable (catch-up). The engine table is a cache, not a record-of-truth. -
Recovery: if the JSONL has a verdict but the engine has no corresponding row, the recovery scan inserts the missing row and reconciles
evaluation_runs.end_statusto match the verdict (Part D). -
JSONL state semantics (F-5):
- Missing JSONL AND engine has zero attestation rows →
fresh project, treat as empty stream.
loadFromJSONLreturns(loaded=0, truncated=false, err=nil). The file is created on the first successfulRecord. - Missing JSONL AND engine has attestation rows →
ErrAttestationAuditLost. Operator must restore or runghyll attestations rebuild --force(separate escape hatch). - JSONL exists but unreadable / corrupt header →
ErrAttestationAuditLost. - JSONL exists but trailing line truncated (F-6) →
lenient mode.
loadFromJSONLreturns(loaded int, truncated=true, err=nil), stops at the last complete record. Session.Open emitsOpEventAttestationAuditDurabilityFailedwith offset detail. The writer truncates the file at the last complete offset on the next successfulRecordso the bad bytes are overwritten cleanly.
- Missing JSONL AND engine has zero attestation rows →
fresh project, treat as empty stream.
-
JSONL observer special-casing for error channel (N-5):
AttestationObserver func(event AttestationEvent)has no error return; observers can’t fail theRecordcall. To enforce “JSONL fsync MUST succeed before in-memory mutation” per invariant 2, the JSONL writer is the first observer fired and is called inline withinRecord’s critical section. If it fails,RecordreturnsErrAttestationAuditWriteFailedand the in-memory map is not mutated; other observers never fire. TheAttestationObserversignature stays unchanged for backward compatibility; the JSONL writer is internally privileged.
This inversion is the load-bearing change. The JSONL is already
fsync-durable (runner/attestation_jsonl.go:46-94); making it
authoritative removes the ordering question and aligns with the
“every JSONL line must outlive the engine” invariant the operator
spec implies.
Part D: recovery component
New file: engine/recovery.go. Recovery is bounded by a single
sqlite transaction (F-10) so concurrent read-only CLIs see
pre- or post-recovery state but never a torn mid-recovery
snapshot.
// RecoveryDeps bundles the runner stores + injected deps. Distinct
// from ReplayTargets so Recovery's needs don't bleed into Replay's
// signature.
type RecoveryDeps struct {
Store *Store
Passes *runner.PassRegistry // for Resume on preserved passes (F-3)
Attestations *runner.AttestationStore // for JOIN-based detection (F-1)
LockTable *runner.RoleContextLockTable // for Resume to reacquire locks
IBTracker *runner.InsufficientBasisTracker // may reset counters
JSONLPath string // for re-truncation per F-6
Now func() time.Time // injection for F-12 idempotence
}
// Recovery scans the engine + JSONL state at session start,
// reconciles split-brain conditions, and returns a
// RecoveryReport whose Events list is the session.Open
// surfacing path (NOT the OperatorBus — F-18). Idempotent
// per F-12: skips passes where recovered_at != ''.
//
// REFUSES TO RUN if ReplayCounts.Errors is non-empty (F-13);
// returns ErrRecoveryReplayDirty so the operator gets a clear
// "previous start left malformed rows; investigate" message.
//
// Order of operations (all inside ONE BeginTx/Commit per F-10):
// 1. orphanScan: SELECT FROM passes WHERE state='open'.
// 2. attestationPendingScan: per-orphan, run the JOIN
// defined in pass-persistence.md (evaluation_runs ⋈
// attestations) to identify attestation-pending. Preserved
// orphans get PassRegistry.Resume + recovered_at stamp;
// RecoveryReport.Events appends recovery-attestation-
// republished with hint payload from the evaluation_runs
// row.
// 3. orphanAbort: remaining orphans → UPDATE passes SET
// state='aborted', closed_at=now, close_reason='crash'.
// RecoveryReport.Events appends recovery-pass-aborted-
// crash per pass.
// 4. evaluationRunReconcile: for every (run, ref) where
// run.end_status='running' AND ref has a JSONL verdict,
// Store.UpdateEvaluationRunReconciled writes the new
// end_status, recovery_source='recovery-attestation-replay',
// and recovered_at. Verdict → ClauseStatus mapping (F-7):
// attestation 'pass' → StatusPass
// attestation 'fail' → StatusFail
// attestation 'insufficient-basis' → StatusRunning
// (process-local InsufficientBasis flag cannot be
// reconstructed; keep status pending so dispatcher
// re-emits the hint on next traversal).
// RecoveryReport.Events appends recovery-attestation-
// replay per run.
// 5. (No torn-row detect; invariant 6 dropped per F-15.)
func Recovery(ctx context.Context, deps RecoveryDeps,
replayCounts ReplayCounts) (RecoveryReport, error)
var ErrRecoveryReplayDirty = errors.New(
"recovery: ReplayCounts.Errors non-empty; refuse to proceed")
The function is called once by cmd/ghyll/session.go:engineRuntime.Open
AFTER engine.Replay returns and BEFORE attachJournal subscribes
the live observers. This ordering ensures recovery’s writes don’t
re-journal back into the engine (the same invariant
engine/replay.go:18-20 enforces for replay). Recovery writes
go directly to the Store (engine layer); the in-memory
attestation cache is repopulated by loadFromJSONL at step 3 of
session_engine.Open. There is no in-memory store for
evaluation_runs (F-2); UpdateEvaluationRunReconciled writes
straight to sqlite.
Operator CLI ergonomics (F-14): ghyll engine replay now
prints a banner “this is a replay-only count; session start
additionally runs Recovery (use ghyll engine recover --dry-run
to preview)”. A new subcommand ghyll engine recover --dry-run
opens the store read/write, runs Recovery inside a BeginTx that
ROLLBACKs at the end, and prints what Recovery would do without
committing.
Part E: attestation-pending detection via JOIN (F-1 resolution)
A-3 (attestation-request persistence) does not require a new table. Gate-1 review F-1 surfaced that:
OpEventAttestationRequestedhas zero publishers in the codebase today — the bus was a false detection signal.- The
depth_type_attestation_refcarried onevaluation_runsIS the persistent attestation-request signal: it lands when the runner persists the clause run via the existingJournal.handleRunpath, before any operator answer.
Attestation-pending detection becomes a JOIN:
SELECT p.pass_id, e.clause_id, e.depth_type_attestation_ref, e.arrow_id
FROM passes p
JOIN evaluation_runs e ON e.pass_id = p.pass_id
LEFT JOIN attestations a ON a.attestation_id = e.depth_type_attestation_ref
WHERE p.state = 'open'
AND e.depth_type_attestation_ref != ''
AND e.end_status = 'running'
AND a.attestation_id IS NULL;
Every row in that result is an attestation-pending clause; its
pass survives recovery. The hint payload (arrow_id +
clause_id + depth_type_attestation_ref) is reconstructed
from the row and surfaced via RecoveryReport.Events.
A dedicated attestation_requests table (originally A-3) is
not added in Tier 1. It remains a Tier 2 consideration if
the operator-UI flow needs richer request metadata (timestamps,
operator who emitted, hint body); for crash recovery alone the
JOIN suffices.
Consequences
Positive:
- Pass state survives crashes; restart can resume operator-attention flows.
- Attestation records have a single source of truth (the JSONL on durable disk).
- Replay logic gets simpler: every entity has the same pattern (load from authoritative source → INSERT into engine cache).
- 8 of the remaining 48
@deferredBDD scenarios get a wirable substrate.
Negative:
- ADR-010 amended: downstream consumers reading attestation rows from the engine table need a note that the table is a derived cache.
- One extra sqlite write per Pass transition. Bounded by Pass throughput (≪ 100 transitions/second in single-operator flows).
- Recovery code path is new attack surface. Adversary pass MUST exercise: torn-row detection, JSONL-engine divergence under malformed input, recovery-during-recovery races, attestation- pending detection edge cases (no clauses / all clauses / cross-pass clauses).
- A-3 deferred. Operators relying on attestation-pending preservation see FM-7’s degraded behavior if the request was in-flight when the crash happened.
Alternatives considered
Alt 1: Keep ADR-010 framing; persist Pass to memory checkpoint chain. Rejected — the v1 memory chain is for operator-attestable session checkpoints, not high-frequency entity mutations. Per-Pass-transition signed chain entries would blow up chain length without operator value.
Alt 2: Single atomic transaction wrapping attestation insert + clause-status flip. Rejected — couples the runner’s clause status path to the engine’s transaction boundary; observer fanout becomes synchronous and blocking. Today’s pattern is “observer-fires-then-journal-fans-out”; making it transactional inverts that and breaks the existing FindingsStore / Grid / AmendmentQueue uniformity.
Alt 3: Defer pass persistence to v1.1. Rejected — crash-loses- state on a “correctness over speed” tool destroys trust. This is the kind of bug ghyll is supposed to prevent in other people’s code, not exhibit in its own.
Implementation seam
engine/store.go: addpassesto the CREATE TABLE block.engine/records.go: addPassRecordtype +UpsertPassfunction.runner/projectstatus.go(PassRegistry): addObserve/emitmirroringFindingsStore.runner/pass.go:OpenPasscalls observer with kind=open;closeWithcalls observer with kind=closed/aborted.engine/journal.go:AttachPassesregisters observer;handlePassroutes toUpsertPass.engine/replay.go: passes load step; callsRecoveryafter.engine/recovery.go: new file, implements the five-step scan.runner/attestationstore.go:Recordcalls JSONL observer inline (already does for the auditor JSONL writer; lift the invariant to “MUST succeed before in-memory mutation”).cmd/ghyll/session_engine.go: re-order to callRecoveryafterReplay, beforeattachJournal.
References
specs/architecture/components/pass-persistence.md(analyst output)specs/architecture/components/state-machine.mdF-4, F-6specs/features/state-machine.feature:120-233(BDD scenarios)specs/features/runner.feature:172-195(per-pass checkpoint)- ADR-010 (the inverted framing)
- ADR-013 (Pass entity, deferred persistence)
ADR-016: Operator verdict modal + tree-writer as primary
Status
Accepted (2026-05-20). Tier 2 of the prod-readiness roadmap.
Gate-1 adversarial review of this ADR produced 22 findings (6
critical / 9 high / 7 medium); all remediated in-document.
Disposition log:
specs/v2/validation-impl-pass-tier2-remediation.md.
Amends:
- ADR-015 Part C (
015-pass-persistence-and-jsonl-source-of-truth.md) — swaps which writer is the AttestationStore primary writer. Tier 1 wired the FLAT writer (.ghyll/attestations.jsonl). Tier 2 swaps the tree writer (.ghyll/attestations/v<N>/...) into the primary slot; the flat writer becomes an Observer (aggregate audit tail).
Context
The analyst pass on Tier 2 (see
specs/architecture/components/operator-attestation.md)
captured three operator decisions:
- The verdict UI is an interactive modal in the chat REPL —
blocks the turn until the operator answers, with a
skipoption for deferral. - The per-pass JSONL is the load-bearing audit surface
(
attestations/v<N>/<context>/stratum-<S>/<role-pair>/<pass-id>.jsonl). The flat aggregate stays as a tail. - The hint payload is synthesized from clause metadata (minimal). Producer-side richer hints are deferred to Tier 3.
The existing runner.AttestationTreeWriter (phase 9) writes a
tree but the path is keyed on attestation_id (per-clause file)
instead of pass_id (per-pass file), and contextFromArrow /
stratumFromArrow return the placeholder "default". Tier 2
fixes both.
The existing AttestationRecord carries attestation_id,
kind, arrow_id, clause_id, op_id, attested_by_role,
source_role, target_role, verdict, reason, timestamp,
grid_version. Tier 2 adds pass_id, unit, unit_payload_json,
hint_json.
Decision
Part A: Schema extension + migration
Per gate-1 F-2 / F-3 / F-6 / F-10 / F-25, the migration adds SEVEN columns (not four) and wraps the ALTERs in a single transaction:
BEGIN;
ALTER TABLE attestations ADD COLUMN pass_id TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN context TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN stratum TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN adversary_role TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN unit TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN unit_payload_json TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN hint_json TEXT NOT NULL DEFAULT '{}';
COMMIT;
pass_id: which pass produced the verdict. Pre-Tier-2 rows have''; verifier-tooling tolerates the empty string; Tier 2 Record path REJECTS empty PassID withErrAttestationPassIDEmpty(gate-1 F-6).context,stratum: stamped by the dispatcher at record- construction time soEncodeAttestationPathdoesn’t need a Grid lookup (gate-1 F-2).adversary_role: empty for normal records; populated byrunner/adversarial.gowhen a verdict is captured during an adversary-phase pass (gate-1 F-3). Self-cert check extends to forbidadversary_roleequal tosource_roleortarget_role.unit: one ofconfirm/record-locations-inspected/write-residue-note. Empty for pre-Tier-2 rows.unit_payload_json: JSON object whose shape depends onunit.'{}'forconfirm;{"inspected": [...]}forrecord-locations-inspected;{"residue": "..."}forwrite-residue-note.hint_json: the dispatcher-synthesized hint shown to the operator. Default'{}'(NOT empty string) so the verifier’sjson.Unmarshalparses pre-Tier-2 rows cleanly (gate-1 F-25). Shape:{"arrow_id": "", "clause_id": "", "concept": "", "attestation_ref": ""}. Tier 3 addslocations,basis,residue.
These columns are baked into the fresh CREATE TABLE in
engine/store.go — no migration path is preserved. (The
original ADR specified an ensureUnitColumns ALTER-based
migration; this was dropped pre-prod when the v2→v5 chain was
collapsed into a single baseline schema.)
Part B: Tree writer becomes primary
AttestationTreeWriter gains:
- A
PrimaryWriter() func(AttestationRecord) errormethod that mirrorsAttestationJSONLWriter.PrimaryWriter(). No*Gridargument (gate-1 F-2 / F-19). Inline marshal + write- fsync; returns the error inline so
AttestationStore.Recordfails closed if the tree write fails.
- fsync; returns the error inline so
- The file is opened
O_RDWR | O_APPEND(gate-1 F-11) so the writer can ReadAt forTruncateTrailingPartial. - A new
TruncateTrailingPartialAll(root) errormethod that walks every<root>/v*/<ctx>/stratum-*/<role-pair>/<pass-id>.jsonland calls TruncateTrailingPartial on each. Called by session.openEngineWithOptions after LoadFromTree returns truncated=true (gate-1 F-11). - A new
Observer()method that mirrors the flat writer’s flow (already exists; preserved for the rare fallback). - Path encoding moves to a new pure function (gate-1 F-2):
EncodeAttestationPath(rec AttestationRecord) (path string, truncated bool, err error). The helper:- Reads
pass_id(NOTattestation_id) for the file name. - Reads
context,stratum,source_role,adversary_role,target_roledirectly fromrec. No Grid lookup is required — the dispatcher stamped them at record-construction time. - Returns the tree-rooted relative path
v<N>/<context>/stratum-<S>/<role-pair>/<pass-id>.jsonl. - Init arrows special-case (gate-1 F-18): role-pair =
literal
"init", context = literal"init", stratum = literal"init". No__separator. - Three-role chain: when
rec.AdversaryRole != "", role-pair ="{source}__{adversary}__{target}"(gate-1 F-3). Self-cert: AdversaryRole MUST NOT equal SourceRole or TargetRole and MUST NOT contain__. - Empty PassID rejects with
ErrAttestationPassIDEmpty(gate-1 F-6). - Empty segment (sanitize produced empty string) also
triggers the byte-cap fallback to a
h-<sha256[:16]>name; truncated=true. - Per-component byte length capped at 255 (gate-1 F-17):
overflow returns
truncated=trueAND the PrimaryWriter appendspath-truncated:<segment>toAttestationRecord.ReasonAND publishesErrPathComponentTooLongvia the bus. The write proceeds with the hash-substituted segment so the verdict is not lost. - Step 7 formatting:
fmt.Sprintf("v%d", rec.GridVersion)(gate-1 F-26).
- Reads
session_engine.attachJournal swaps the primaryWriter:
// Before (Tier 1):
r.attestations.SetPrimaryWriter(r.jsonlWriter.PrimaryWriter())
r.attestations.Observe(r.treeWriter.Observer())
// After (Tier 2):
r.attestations.SetPrimaryWriter(r.treeWriter.PrimaryWriter())
r.attestations.Observe(r.jsonlWriter.Observer())
Boot-time loader also swaps (gate-1 F-1 / F-27): Tier 2’s
session.openEngineWithOptions calls
r.attestations.LoadFromTree(treeRoot, attCount > 0) INSTEAD
of LoadFromJSONL(flatPath, ...). The tree is the
authoritative source post-Tier 2. The flat file is
forward-only (Observer writes append; no Tier 2 path reads
from it). Tier 1’s LoadFromJSONL stays in place for
backward-compat tests but isn’t called from the production
session-open path.
LoadFromTree walks <root>/v*/<ctx>/stratum-*/<role-pair>/<pass-id>.jsonl
and unions every JSONL line into byID via recordReplay
(same idempotency contract as LoadFromJSONL). Truncation
behavior mirrors Tier 1: (loaded, truncated, err) return;
truncated=true triggers a TruncateTrailingPartialAll pass.
ghyll engine verify-attestations is extended to walk BOTH
the tree AND the flat aggregate; any divergence (a tree line
not in the flat, or vice versa) is reported with
ErrAttestationAggregateDivergence.
Part C: Verdict-unit schema + validation
New types:
// VerdictUnit names the shape of the operator's evidence.
type VerdictUnit string
const (
VerdictUnitConfirm VerdictUnit = "confirm"
VerdictUnitRecordLocationsInspected VerdictUnit = "record-locations-inspected"
VerdictUnitWriteResidueNote VerdictUnit = "write-residue-note"
)
// VerdictUnitPayload is the typed shape per-unit. JSON-marshaled
// into AttestationRecord.UnitPayloadJSON at write time.
type VerdictUnitPayload struct {
// Set when Unit == record-locations-inspected.
Inspected []string `json:"inspected,omitempty"`
// Set when Unit == write-residue-note.
Residue string `json:"residue,omitempty"`
}
// MaxResidueNoteBytes caps a write-residue-note payload at
// 16 KiB by default. Operator-configurable via the grid file's
// `residue-note-max-bytes` setting.
const DefaultMaxResidueNoteBytes = 16 * 1024
var (
ErrVerdictUnitInvalid = errors.New("verdict-unit-invalid")
ErrVerdictUnitMissingField = errors.New("verdict-unit-missing-field")
ErrVerdictResidueTooLong = errors.New("verdict-residue-too-long")
ErrVerdictInspectedEmpty = errors.New("verdict-inspected-empty")
)
// ValidateUnitPayload returns nil iff payload satisfies the
// unit's schema. Called at the AttestationStore.Record boundary
// BEFORE the primaryWriter fires.
func ValidateUnitPayload(unit VerdictUnit, p VerdictUnitPayload, maxResidueBytes int) error
AttestationStore.Record validates the unit before calling
the primary writer. A schema failure returns the typed error;
no JSONL line is written; no in-memory mutation happens.
Part D: Operator modal driver
New package: cmd/ghyll/modal (or inline in cmd/ghyll/session.go
— see architect note below). Exports:
// OperatorModalPrompt is the contract the chat REPL uses to
// present a verdict modal. Implementations: TermModal (tty
// interactive); StubModal (test injection); ScriptedModal
// (BDD replay from a fixture).
type OperatorModalPrompt interface {
// PresentVerdict blocks until the operator submits a verdict
// or returns ErrModalSkipped. The Hint argument carries the
// dispatcher-synthesized payload. Returns the verdict + unit
// + payload, ready to feed into AttestationStore.Record.
PresentVerdict(ctx context.Context, hint Hint) (VerdictSubmission, error)
// PresentEscalation blocks until the operator chooses option 1
// (accepted-risk + residue note) or option 2 (route-upstream +
// rationale). No default; no skip.
PresentEscalation(ctx context.Context, hint Hint) (EscalationChoice, error)
}
type Hint struct {
ArrowID string
ClauseID string
Concept string
AttestationRef string
}
type VerdictSubmission struct {
Verdict runner.AttestationVerdict
Unit runner.VerdictUnit
Payload runner.VerdictUnitPayload
}
type EscalationChoice struct {
Option int // 1 = accepted-risk; 2 = route-upstream
Residue string
}
var ErrModalSkipped = errors.New("modal-skipped")
The modal driver wires:
// In cmd/ghyll/session.go, post-attachJournal:
s.modalDriver = newModalDriver(s.modalPrompt, s.engine.AttestationStore(),
s.engine.Passes(), s.engine.Bus(), s.engine.IBTracker())
// modalDriver subscribes the bus.
s.engine.Bus().Subscribe(s.modalDriver.OnEvent)
modalDriver.OnEvent filters for OpEventAttestationRequested
and OpEventInsufficientBasisRoundsExceeded. On either, it
queues a modal-presentation request that the REPL turn loop
drains BEFORE the next model call.
Part E: REPL turn-loop integration
Session.Run (the chat loop) gets a new pre-turn check:
for {
line := s.readInput()
if handled := s.dispatchSlashCommand(line); handled.Handled {
// ... existing slash handling ...
continue
}
// NEW: drain pending modal prompts before the model call.
if err := s.modalDriver.DrainPending(ctx); err != nil {
s.output(fmt.Sprintf("⚠ modal error: %v", err))
}
// ... existing model invocation ...
}
DrainPending blocks until all queued modal-presentation
requests are answered (or the operator types skip for each).
Each Present* call records via AttestationStore.Record; the
tree writer’s PrimaryWriter persists; the chat loop continues.
Part F: Path encoding precision
EncodeAttestationPath algorithm (Part B above expands here):
input: AttestationRecord rec, Grid g (for arrow lookup)
output: path string, error
1. Resolve arrow def via g.Lookup(rec.ArrowID).
If not found → use rec.SourceRole / rec.TargetRole as
fallback (recovery path may run before grid replay
completes; defensive).
2. Determine role-pair string:
a. If arrow def has 3-role chain (adversary-augmented):
"{source}__{adversary}__{target}".
b. If init arrow: "init__{target_role}" (init has no
source role in the chain).
c. Otherwise: "{source}__{target}".
3. Determine context segment:
a. If init arrow: literal "init".
b. Else: arrow def's Context field (sanitized for
filesystem: replace any of [/\\:*?<>|] with "_").
4. Determine stratum segment:
"stratum-" + sanitized(arrow_def.Stratum).
5. Pass-id segment: sanitized(rec.PassID) + ".jsonl".
6. Per-component byte-length check (255-byte cap per ext4):
if any component exceeds 255 bytes, replace it with
"h-" + first 16 hex bytes of sha256(original). Emit
ErrPathComponentTooLong via the bus.
7. Return filepath.Join(treeRoot, "v"+gv, ctx, stratum,
rolePair, passFile).
Tier 3-augmented arrows (adversary phase added mid-pass) can change the role-pair between rounds. The path encoded at verdict-time captures the role chain AT VERDICT TIME; the tree may contain multiple files for the same logical “pass” if the chain mutated. Operator-tooling can join via the aggregate JSONL (which has all lines regardless of tree path).
Part G: hint synthesis (Tier 2 minimal)
runner.SynthesizeHint(clause, arrow_def) Hint:
func SynthesizeHint(c Clause, def ArrowDefinition) Hint {
return Hint{
ArrowID: def.ID,
ClauseID: c.ClauseID,
Concept: c.Concept,
AttestationRef: c.DepthTypeAttestationRef,
}
}
The dispatcher calls this when publishing
OpEventAttestationRequested and stores the resulting
JSON in the (eventual) attestation_requests table or
inlines it on the bus event. Decision (post-architect
review): inline on the event for Tier 2; promote to a
persisted table only if Tier 3’s session-ends-mid-attestation
flow needs it (Tier 1’s evaluation_runs.depth_type_attestation_ref
is enough for the basic recovery flow).
Consequences
Positive:
- 12 of 15 attestation.feature
@deferredscenarios become liftable. - The operator gets a real verdict UX, not a power-user slash command.
- The per-pass tree audit is structurally sound (matches the scenarios’ literal text).
- Unit-conditional validation closes the gate-1 F-1 finding about missing required fields.
- ADR-015 Part C’s “JSONL is source of truth” invariant is preserved — just shifted to the tree.
Negative:
- ADR-015 amended again (now ADR-016 amends Part C). Operators inspecting attestations should consult the tree as primary and the flat as aggregate.
- Schema migration runs at the v3→v4 boundary. Older binaries refuse to open v4 stores (verifySchemaVersion).
- The chat REPL gains a new blocking point. Operators who expected uninterruptible turn loops will be surprised.
- Test depth increases: every BDD scenario in F-1 through F-4 needs a ScriptedModal injection point.
- Path-encoding edge cases (255-byte cap, sanitization, three-role chains) require careful test coverage.
- Concurrent operator scenario stays deferred (Tier 3+).
Alternatives considered
Alt 1: Keep flat as primary, add a tree-rebuild CLI. Tier 2
would not swap PrimaryWriter; the flat continues as
source-of-truth; ghyll engine rebuild-tree materializes the
tree as needed for forensic review. Rejected — the scenarios
explicitly assert the per-pass tree is the primary write
surface (“a record is appended to … attestations/…”).
Alt 2: Add a separate AttestationStore-per-pass. One in-memory store per pass, each with its own primary writer. Rejected — multiplies the runner-layer state; the existing single AttestationStore with multi-PrimaryWriter chains doesn’t exist (PrimaryWriter is single-valued) but the tree writer’s path-encoding gives the per-pass-ness without state multiplication.
Alt 3: Defer the modal; ship the slash command only.
Operators type /attest <ref> pass --unit confirm. Rejected
— the F-3 / F-4 escalation flow with “operator must choose
between option 1 and option 2” doesn’t work via slash
command; you can’t make a slash command non-defaulted.
Modal is needed for the escalation prompt at minimum.
Implementation seam
engine/store.go: the 7 Tier 2 columns are part of the baselineCREATE TABLE attestations. (Pre-prod the v2→v5 ALTER chain was collapsed into a single fresh schema.)engine/records.go: new fields onAttestationRecord(in the engine variant — runner’srunner.AttestationRecordmirrors).runner/attestationstore.go:AttestationRecordgainsPassID,Unit,UnitPayload,HintJSON.Validate(rec)callsValidateUnitPayload.runner/attestation_tree.go:- File name is
<sanitized(rec.PassID)>.jsonl(was<rec.ID>.jsonl). contextFromArrow+stratumFromArrowuse the grid lookup; placeholder removed.- Add
PrimaryWriter()method. - Implement
EncodeAttestationPath.
- File name is
runner/dispatcher.go:SynthesizeHint(c, def)+ inline in thePublish(OpEventAttestationRequested)call.cmd/ghyll/session.go: subscribe modalDriver; newDrainPendingpre-turn step.cmd/ghyll/modal/(new package):OperatorModalPromptinterface +TermModalimpl +StubModalfor tests.tests/acceptance/: BDD step bindings useStubModal/ScriptedModalto drive verdicts.
References
specs/architecture/components/operator-attestation.md(analyst output)- ADR-010 (the original engine-table-is-source-of-truth, amended in Tier 1)
- ADR-015 (Tier 1 — pass persistence + JSONL flat as primary)
specs/features/attestation.feature(the 12 scenarios this enables)
ADR-017: ProjectStatus aggregator — pure read, no cache
Status: Accepted (2026-05-20)
Context: Tier 4 polish — backfilling the ADR for a component
shipped earlier (runner/projectstatus.go) without one.
Context
The operator-facing surfaces (ghyll engine status, ghyll arrow show, the operator HTTP endpoint planned for Tier 4) all need
the same project-level snapshot: arrow count, open passes,
finding counts by status, amendment backlog, attestation count
by kind. Each of these aggregations touches multiple runner-layer
stores (Findings, Classifications, Grid, AmendmentQueue,
PassRegistry, AttestationStore).
Three options were on the table:
- Cached aggregator —
ProjectStatuslives in the engine runtime, updated by observers on every store mutation. Surface reads are O(1). Cost: extra goroutine + cache invalidation logic across 5+ stores. - Pure read function —
CaptureProjectStatus(sources)walks the stores at call time, returns a value. Surface reads are O(N stores). Cost: each call duplicates work. - Lazy-cached aggregator — cache the result with a TTL; re-walk when expired. Surface reads are O(1) within the TTL. Cost: staleness window the operator can’t easily reason about.
Decision
Option 2 — pure read function.
ProjectStatus is an immutable snapshot returned by
CaptureProjectStatus(StatusSources). The aggregator holds no
state across calls. Each call walks all the input stores anew.
func CaptureProjectStatus(src StatusSources) ProjectStatus
StatusSources is a struct of pointers to the runner-side
stores; the caller assembles it from the engine runtime.
Rationale
The aggregator surfaces hit at human cadence — a few times per
second at most (operator typing /status, an HTTP poller every
few seconds). Walking 5 stores costs sub-millisecond on the
benchmark dev host. The cache invariant — “the snapshot reflects
the stores’ state at THIS moment” — is more valuable than the
CPU saved, because operator diagnosis depends on it. A cache that
lags the stores by even a few hundred ms produces “I just
attested but /status shows zero” confusion.
The pure-function design also eliminates the failure mode where a mutation observer registration is forgotten — a new store added in a later phase that doesn’t subscribe its observer would leak into a stale cache forever. The pure function picks up new stores at the call site (the engine runtime explicitly threads them in) without observer plumbing.
Consequences
Positive:
- Operator surfaces always see fresh state — no “I see a phantom finding because the cache is stale” debugging.
- No goroutine + no cache-invalidation surface area.
- A new store added later just needs threading into
StatusSources; no observer wiring. - The aggregator is trivially testable: pass in mock stores, assert on the returned snapshot.
Negative:
- Each call duplicates work. Mitigated by the human-cadence call pattern + the small store sizes.
- A pathological caller (HTTP poller at 100 Hz) would burn CPU. We accept this — operators don’t poll that fast; an automated HTTP forwarder is the only realistic case and it can subscribe to OperatorBus events directly (already a higher-resolution surface than the snapshot).
Alternatives considered
- Cached aggregator (option 1): rejected per the staleness rationale above.
- Lazy-cached aggregator (option 3): rejected for the same reason; the operator’s mental model demands “what I see is what is”, not “what I see is what was up to N seconds ago.”
Implementation
runner/projectstatus.godefinesProjectStatus,PassSnapshot,FindingStatusCounts,StatusSources, andCaptureProjectStatus.- The engine runtime exposes a
ProjectStatus()accessor that buildsStatusSourcesfrom its embedded stores and callsCaptureProjectStatus. - The
/statusREPL command +ghyll engine statusCLI both render the snapshot viaRender(out)on the result. - Benchmark:
runner/projectstatus_test.go BenchmarkCapture— sub-microsecond on typical projects (≤ 100 arrows).
References
runner/projectstatus.go(production)runner/projectstatus_test.go(contract)cmd/ghyll/engine_status_cmd.go(CLI consumer)- Related ADRs: 010 (attestation store split), 013 (pass entity), 015 (pass persistence)
ADR-001: The v2 pivot — correctness over speed and breadth
Status: Accepted (2026-05-18)
Context:
v1 ghyll positioned itself as “Claude Code for self-hosted models” — a delivery-positioned agent with dialects, drift-aware memory, and streaming as differentiators. The motivating evidence for a pivot came from an artifact-level audit of a prior project (“Kiseki”) where work reported complete proved substantially incomplete on cloud deployment: ~17 of 40 requirements specified-but-shallow (tests that ran the code but asserted almost nothing); 23 operations claimed “THOROUGH” by the verification role with zero tests. The root cause was prose-only role definitions with no structural enforcement — definitions drifted because nothing held them.
The 2026 market is moving toward more agent autonomy (longer unsupervised loops, parallel-agent throughput). v2 moves the opposite direction: constrained autonomy, mandatory gates, operator-in-the-loop, explicit refusal of low-risk projects.
Decision:
v2’s differentiator is behavioral, not infrastructural. ghyll optimizes for correctness over speed and breadth, pays in friction. v2 is:
- Correct for a narrow class of work: novel architecture, correctness-critical systems, long-horizon projects where a defect reaching deployment is expensive.
- Wrong for CRUD, migrations, glue code, rapid prototyping — where throughput is the win and ghyll’s friction is pure cost.
The schema enforces a typed gate system (clauses, arrows, passes, findings) rather than relying on prose definitions. v1 code stays as continuity infrastructure (dialects, memory, streaming) but is no longer the differentiator.
Consequences:
- Rules in: an opinionated workflow (the diamond); structural refusal of transitions when arrows don’t close; explicit residue reporting; init refusal for projects where ghyll is wrong.
- Rules out: throughput-optimized parallel-agent execution; silent skipping of checks; “complete” as a binary; one-tool-for-everything positioning.
- Risk: the operator-in-the-loop premise constrains scale. Single-process per project; single-operator default (multi-operator supported but not optimized). Distributed ghyll is out of scope for v1.
- Honesty cost: ghyll must be able to say “use a fast agent instead” at init time. If refusal acceptance rate is near 0 in practice, refusal becomes dead friction and the differentiator weakens.
See specs/architecture/direction.md for the full pivot rationale.
ADR-002: State-space-iteration as the conceptual frame
Status: Accepted (2026-05-18; D14)
Context:
After three rounds of operator decisions on the schema, the mechanics (arrows, clauses, passes, statuses, findings, invalidation, grid amendment) were typed but lacked a unifying mental model. The operator proposed framing the system as “a vector space with transitions, that has iterations over the space.”
The framing helps several open questions at once: pass/arrow
identity (operators vs iterations), concept schemas (function
signatures in an operator algebra), and the discipline that every
transition must be reproducible from (state, operator).
Decision:
gates.md is read through the lens of a state-transition system
over an extensible grid. The vocabulary is fixed:
- Cells are points in the state space:
(stratum, bounded-context)pairs, each holding clause statuses, findings, and a derived arrow status. - Arrows are operators — transition functions.
- Passes are iterations — one application of an operator.
- Invalidation is an operator that resets cells to an earlier state.
- Grid amendment is an operator that extends the state space (adds dimensions).
- The fixed point is “every cell in vN has arrow status
complete, R = 0, C = 0.” Convergence is not guaranteed.
The accurate formal name is Kripke structure over an extensible state space — not a vector space, since there is no linearity, scaling, or addition over cells.
Consequences:
- Rules in: discipline that every operator must be well-defined on the state; pass identity = iteration index; arrow identity = operator identity. State-space comparisons (distance to fixed point, residue R as a scalar) are first-class.
- Rules out: silent transitions, hidden global state, time- dependent operator behavior that the cell state doesn’t capture.
- Implementation guidance: concept schemas typed as operator signatures; finding state machine bounded by the enumerated set (no ad-hoc additions); locks owned at the boundaries between state space sub-aggregates (per-clause, per-(role, context), project-wide).
See gates.md §0 for the explicit framing, and
specs/domain-model.md “State-space model” section for the
implementation-facing summary.
ADR-003: Four-role diamond; no standalone adversary or auditor
Status: Accepted (2026-05-18; round 4 Q1, then phase 4 corrections)
Context:
The initial design considered a five- or six-role diamond (analyst → architect → [adversary?] → implementer → auditor → integrator). Operator decisions then dropped the standalone adversary role in favor of an adversarial phase of every arrow. Phase 4’s mid-flight correction further dropped the standalone auditor role on the same logic: depth classification, like adversarial scrutiny, should be a phase of every arrow rather than a role in the sequence.
The discriminator: “a role in a sequence can be skipped; a phase of a transition cannot.”
Decision:
The diamond is exactly four roles:
analyst → architect → implementer → integrator
There is no standalone adversary role and no standalone auditor role. The work those roles would have done becomes the universal adversarial phase on every depth-sensitive arrow:
- Clause-falsification (try to make declared depth-sensitive clauses fail).
- Open sweep (find defects no clause names).
- Depth classification (walk requirements on the depth ladder).
Each diamond arrow that carries any depth-sensitive clause runs
this adversarial phase first. Pure-machine arrows run verification
only.
Consequences:
- Rules in: uniform adversarial scrutiny on every arrow that matters; depth classification on every arrow that has depth-sensitive requirements; no skippable role.
- Rules out: writing
adversary.mdorauditor.mdas role files; bypass paths where “this arrow doesn’t need an audit” becomes possible. - Implementation: the adversarial-phase orchestrator is a
separate component (
adversarial.md); spawns the syntheticadversaryrole-id perADR-004. - Constraint: the producer of an arrow cannot attack itself. Enforced structurally via the synthetic role-id mechanism.
See gates.md §1, §11; specs/architecture/components/adversarial.md.
ADR-004: Synthetic role-ids (init, adversary) distinct from role files
Status: Accepted (2026-05-18; D21)
Context:
With no standalone adversary or auditor role (ADR-003), the per-arrow adversarial phase still needs an identity:
- Who is the producer of the init arrow’s attested clauses (since no role file owns init)?
- What identity appears in the attestation path
(
<role-pair>) when the adversarial phase runs? - How does a fresh model instance with clean context get distinguished from the upstream producer in audit logs?
gates.md §1 says “no standalone adversary role” but gates.md
§10.2 records attestations with a <role-pair> path. Cold-pass
finding #5 surfaced the contradiction: §11 needs a “separate
instance from the producer” but no role-id exists for that
separation.
Decision:
The harness carries synthetic role-ids that are NOT role files:
| Synthetic role-id | Use |
|---|---|
init | Producer for the initialization arrow’s attested clauses. Bound to a fresh harness instance at project start. |
adversary | Producer for the per-arrow adversarial phase. Bound to a fresh model instance with clean context per round. |
These identities appear in attestation paths and finding
provenance. Path encoding uses __ (double underscore) as the
separator:
init__analyst— the init arrow’s pathanalyst__adversary__architect— the analyst→architect arrow’s adversarial-phase pathimplementer__adversary__integrator— same pattern for implementer→integrator
This does not contradict ADR-003: adversary as a role-id
(identity) is structurally distinct from adversary as a role
file (contract). The schema forbids the file, not the identity.
Consequences:
- Rules in: clean attribution in audit logs; structural
enforcement that producer cannot attack itself (different
role-ids); a stable path encoding for filesystem-safe
<role-pair>components. - Rules out: adding a
init.mdoradversary.mdrole file; conflating role contract with role identity. - Implementation: the synthetic role-ids are reserved strings the harness owns; not declarable at init. Adding a new synthetic role-id requires harness changes, not project config.
- Path-safety:
__is filesystem-portable; no Unicode glyphs, no path separators, ≤ 255 bytes per component. Cold-pass finding #13 was that the original path encoding (using→) wasn’t filesystem-safe; this fixes it.
See gates.md §1.1, §10.2;
specs/architecture/operator-decisions-round-4.md D21.
ADR-005: Concept-named catalogue with per-language bindings
Status: Accepted (2026-05-18; D1, D2, D18)
Context:
The initial machine-clause catalogue contained nine names including
clippy-clean — a Rust-specific tool. For a multi-language coding
agent, baking specific instrument names into the catalogue
contradicts the “language-agnostic” claim. The catalogue needed
to separate concept (what property is asserted) from instrument
(what tool decides the property).
Additionally, the original analyst role file’s machine clauses referenced operations not in the catalogue (mode-determinable-from-repo, no-open-divergence, arrow-artifact-present, unique-definition), so the catalogue had to grow to accommodate them.
Decision:
The catalogue is closed at the concept layer and language-agnostic. Concepts name what property is asserted; per-language instrument bindings decide what tool runs the check.
The closed set of 18 concepts (originally 17; tests-pass added
by ADR-013):
Universal base (auto-applied):
compiles, lint-clean, no-todo-marker, every-step-bound.
Auto-inserted on adversarial arrows:
no-open-finding, every-requirement-meets-min-depth.
Per-arrow declared at init:
no-orphan-symbol, mutation-score, tests-pass,
kill-server-fails-integration, trace-link-present,
acyclic-dependency-graph, unique-definition, predicate-form,
arrow-artifact-present, cardinality-check,
mode-determinable-from-repo, single-active-role-instance.
Language bindings:
- The harness ships NO language bindings.
- Each project declares its bindings at init time
(
lint-clean.go = staticcheck && go vet,mutation-score.rust = cargo-mutants, etc.). - If a needed binding is absent at any later point, the harness suspends and re-enters init to obtain it (D18).
Consequences:
- Rules in: language-agnostic concept vocabulary; per-language customization that is operator-declared (not harness-assumed); growth path for the catalogue is harness-version-bumped, not per-project-extended.
- Rules out: instrument-specific concept names; silent fallback when a binding is absent; per-project catalogue extensions (no custom concepts without harness changes).
- Friction: every project pays the binding-declaration tax at init. No “works out of the box for Go” shortcut. This is deliberate — declared bindings make assumptions visible.
- Catalogue maintenance: new concepts require deliberate harness changes. Catalogue is the harness’s primitive vocabulary; adding to it is a versioned harness release.
See gates.md §5.1, §5.2; specs/domain-model.md §“Catalogue”;
specs/ubiquitous-language.md §E.
ADR-006: Per-concept typed argument schemas
Status: Accepted (2026-05-18; D13)
Context:
Phase-3 architect-lens finding #1: “Per-arrow instances reference
catalogue concepts by name and supply arguments, but no concept’s
argument signature is specified.” Without typed signatures, the
catalogue is uncallable — an implementer cannot write
Concept.evaluate(args) because they don’t know what args looks
like.
The choice was between:
- Per-concept schema files (introspectable, validatable).
- Hand-written signatures in harness source code (typed but not introspectable from outside).
- Uniform
args: map[string]anyblob with per-concept validation (ergonomic, not typed).
Decision:
Each of the 18 catalogue concepts has a typed schema file at
gates/concepts/<concept-name>.yaml. Schemas are shipped with the
harness and are introspectable.
Schema shape (illustrative):
# gates/concepts/mutation-score.yaml
concept: mutation-score
description: Mutation testing reports a score above a declared threshold
arguments:
scope:
type: path-glob
required: true
threshold:
type: number
required: true
range: [0.0, 1.0]
evaluator:
contract: machine
produces: { pass: boolean, score: number, killed: int, survived: int }
default-cost: 3
Per-arrow clause instances must validate against the schema; init enforces this during auto-propose’s “modify” path.
Consequences:
- Rules in: introspectable concept vocabulary (tooling can list all concepts and their arguments); typed evaluator-contract enforcement at the catalogue boundary; concept schemas as documentation that lives next to the implementation.
- Rules out: uniform-blob arguments; concept changes that bypass schema versioning; runtime discovery of “what arguments does concept X take” via reflection on Go code.
- Maintenance burden: each concept gets one YAML file
(~17 files). Schema changes require harness-version coordination
with existing project grids that reference the old schema. A
concept-schema versioning field will likely be needed
(
concepts.mdopen question 3). - Type system: common arg types —
path-glob,artifact-ref,language-id,severity,pass-result— are reused across concepts (defined inconcepts.md“Common types”).
See gates.md §5.1; specs/architecture/components/concepts.md;
specs/domain-model.md §“Concept”, §“ConceptSchema”.
ADR-007: Hybrid artifact IDs — path-default with manual IDs where stability matters
Status: Accepted (2026-05-18; D11)
Context:
Phase-3 architect findings #2 and #3 surfaced that both
no-orphan-symbol (the trace from exported symbols to spec
clauses) and the dependency declaration syntax need stable
identifiers across artifacts. Without stable IDs, content
rewording invalidates downstream arrows on every typo, OR
dependencies must be declared at file granularity (overbroad,
forcing conservative invalidation).
Three pure options:
- Content-hash IDs — auto-derived from artifact content; zero authoring overhead; hashes change on every wording fix.
- Manually-assigned IDs — every artifact entry carries an
explicit
id:field; survives rewordings; ID-stamping is overhead. - Path-based addressing — IDs are paths (
invariants.md#section-3-cardinality-1); stable until file reorg; brittle to refactors.
Decision:
Hybrid: path-default with manual IDs where stability matters.
- The default addressing is path-based (e.g.,
invariants.md#section-3-cardinality-1). Suits most clauses. - Clauses that other arrows declare dependence on, or that need to
survive content rewordings, get a manually-assigned
id:field (e.g.,INV-001,FEAT-USER-001,INT-PAYMENTS-ORDER-001). - The operator decides per clause whether a manual ID is needed.
Default is path-based; explicit
id:is the marked case. unique-definition(catalogue concept) enforces ID uniqueness where manual IDs are present.
Dependency declarations may target any granularity:
file— any change to the file invalidates.section— path-based; change to the named section invalidates.clause-id— manual-ID precision; change to the identified clause invalidates.
Consequences:
- Rules in: progressive disclosure of identifier complexity — most clauses use cheap path addressing; the high-stability clauses pay the manual-ID tax. Operators decide the tradeoff per clause.
- Rules out: content-hash IDs (hashes shift on typos, making dependency tracking trigger-happy); single-mechanism addressing (path-only or manual-only).
- Discoverability signal: an arrow with many
file-granularity dependencies that gets frequentlyinvalidatedis a sign the operator should upgrade those dependencies tosectionorclause-id. Operator-tooling should surface this. - Naming convention: manual IDs have no enforced schema; the
operator picks a namespace convention per project (e.g.,
INV-NNN,FEAT-<area>-NNN).
See gates.md §2.1 “Artifact ID conventions”;
specs/architecture/components/concepts.md (unique-definition,
trace-link-present).
ADR-008: Arrow identity is structural; passes are iterations
Status: Accepted (2026-05-18; D12, D29)
Context:
Phase-3 architect finding #10: “Per-pass status with checkpoint
history is asserted, but pass identity is undefined. Is a
re-traversal after invalidated a new pass on the same arrow, or
a new arrow instance?” Without a clear answer, the state machine
has no identity to apply state to, and history-replay / finding
carryover are undefined.
In the state-space-iteration frame (ADR-002), arrows are operators and passes are iterations. They are conceptually distinct and should have distinct identities.
Decision:
Arrow identity = (role-pair, stratum, bounded-context, grid-version).
Immutable.
- Re-traversal after
invalidatedagainst the same grid version is a new pass on the same arrow. - A new grid version produces new arrow-ids for every dependent cell. The schema is monotone in grid version.
Pass identity = a unique pass-id (UUID or timestamp-based)
with attributes:
{ pass-id, arrow-id, started-at, completed-at, pass-status }
Pass status set: running, completed, aborted.
aborted carries a reason field (D29 added the enum to handle
non-invalidation aborts):
invalidated— upstream amendment per ADR-012; arrow status becomesinvalidated; findings preserved with grid-version tag.operator-interrupt— operator stopped the pass; arrow status unchanged.crash— harness or model failure; arrow status unchanged.manual-stop— operator closed session mid-pass; arrow status unchanged.requires-deeper-artifact— operator routed aninsufficient-basisclause upstream after N rounds (ADR-009gates.md§10).
Only invalidated aborts change arrow status. Other abort reasons
leave the arrow at whatever the latest completed pass
established.
Consequences:
- Rules in: clean separation of “what operator is being applied” (arrow) from “which application” (pass); per-pass checkpoint history; deterministic state transitions on re-traversal.
- Rules out: “the arrow’s status changed mid-pass” semantics (status changes are between completed passes); invalidation as a hidden side effect (only changes arrow status when explicitly signaled).
- Implementation: an arrow’s current status is the latest
pass’s derived status. Pass history lives in the checkpoint log
(per ADR-009 ownership); in-memory state holds only
runningpasses. - Cross-version queries: “current status of arrow A” returns
the status of the latest pass that matches the arrow’s
(role-pair, stratum, context)triple, irrespective ofgrid-version. Historical pass queries can scope to a specific grid-version.
See gates.md §7.1a; specs/domain-model.md §“Arrow”, §“Pass”;
specs/ubiquitous-language.md §G.
ADR-009: Three locks, three owners
Status: Accepted (2026-05-18; D34, D35)
Context:
Validation-pass-2 finding #9 surfaced inconsistent concurrency
primitives across the component specs: runner.md declared a
“per-clause lock,” state-machine.md mentioned “if concurrent
updates somehow reach the engine,” attestation.md had “its own
serialization.” Three different locks were alluded to with no
single owner. An implementer would invent locks that conflict.
Decision:
Three locks. Three owners. No others.
| Lock | Owner | Scope | Used by |
|---|---|---|---|
| Per-clause transition lock | State Machine engine | (pass-id, clause-id) | All callers proposing clause-status transitions |
Per-(role, context) lock | Runner | (role-id, bounded-context-id); expires on pass termination | Enforces single-active-role-instance at pre-spawn |
| Project-wide grid write-lock | Amendment component | Project | Held during grid v(N+1) commit; init takes it at end-of-init for v1 write (D35) |
Boot recovery order on harness restart is also fixed:
- State Machine engine recovers first — reads checkpoint log,
reconstructs running-pass state, marks orphan
runningpasses asabortedwithreason: crash. - Amendment component recovers second — validates
.ghyll/grid.currentmatches an existing grid file; alerts on divergence; cleans up orphaned temp files; releases any crashed-while-held lock. - Runner becomes ready third — after the prior two are reconciled, accepts new pass starts.
Consequences:
- Rules in: single source of truth per lock; structural enforcement of single-active-role-instance; deadlock-free boot recovery (sequential phases, not concurrent).
- Rules out: multiple owners of the same lock; ad-hoc locks invented by future components; “if concurrent updates somehow reach the engine” — they don’t, because the per-clause lock owns transitions.
- Concurrency contract: components that need transitions go through the engine (which acquires its own lock); components that spawn passes go through the runner (which acquires per-(role, context)); components that commit grid changes go through the amendment component (which acquires the project-wide lock).
- Init special case: init writes the v1 grid by acquiring the amendment component’s project-wide lock at end-of-init. The lock is uncontested at that point (no other arrow has run yet).
- Orphaned-lock recovery: the amendment component detects crashed lockholders via dead-PID-check; releases the lock as part of boot recovery (FM-27). No operator action required.
See gates.md §5.1 (single-active-role-instance);
specs/architecture/components/state-machine.md,
runner.md, amendment.md;
specs/cross-context.md §“Locks and concurrency”.
ADR-010: Versioned grid files + grid.current pointer
Status: Accepted (2026-05-18; D31)
Context:
Validation-pass-3 finding #5: init.md originally wrote a single
.ghyll/grid.yaml (one file, overwritten on each amendment);
amendment.md wrote versioned files (.ghyll/grid.v(N+1).yaml).
Two layouts for the same artifact. ADR-008 fixed arrow identity to
include grid-version — so the grid version is a first-class
concept, and the on-disk layout should reflect that.
Decision:
On-disk grid layout:
- Each commit writes a new
.ghyll/grid.v<N>.yaml. Immutable after write. .ghyll/grid.currentis a small pointer file. One line:v<N>naming the active version.- Atomic update sequence on amendment / init write:
- Write content to
.ghyll/grid.v(N+1).yaml.tmp. fsync()the temp file.fsync()the containing directory.rename(.tmp, grid.v(N+1).yaml)(atomic).fsync()the directory.- Write
.ghyll/grid.current.tmpcontainingv<N+1>. rename(grid.current.tmp, grid.current)(atomic).
- Write content to
- Init’s first write produces
grid.v1.yamlandgrid.current = "v1". - Retention policy is operator-decided at init (default: keep all; may declare max-age or max-count).
Consequences:
- Rules in: versioned audit trail of grid history (every
amendment leaves a file); atomic update visibility (a reader
observing
grid.current = v(N+1)is guaranteed to seegrid.v(N+1).yamlintact due to fsync ordering); pointer indirection allows atomic version-bump without rewriting the grid content. - Rules out: single-file overwrite-on-amendment (loses history); content-hash-named files (operator can’t easily identify “current” or browse history by version).
- Recovery surfaces (FM-13, FM-14, FM-15):
grid.currentpoints at missing file → engine refuses to start; operator must restore or re-point.grid.currentcorrupted → engine refuses withgrid-current-malformed.- Multiple grid versions but
grid.currentabsent → engine does NOT silently pick latest; operator must declare.
- fsync ordering is load-bearing (FM-12 + cold-pass finding #10). The OS rename-before-fsync pattern is a real-world hazard on ext4 / NFS / etc. The schema specifies the order; the implementation must follow it.
- Disk growth: versioned files accumulate. A long-running project may have hundreds of grid versions. Retention policy prunes older versions; default keep-all is operator-overrideable at init.
See gates.md §2; specs/architecture/components/amendment.md F-3;
specs/features/amendment.feature “Successful atomic write of
v(N+1) with fsync ordering”; specs/failure-modes.md FM-12, 13,
14, 15.
ADR-011: Initialization is auto-propose + operator-confirm
Status: Accepted (2026-05-18; D20, D41)
Context:
The v0 grid ships with the harness but does not contain project-specific clauses — those depend on the project’s bounded contexts, languages, and concrete arguments. Project initialization turns v0 into v1 (the project-specific grid). The question was who drives this:
- Operator-authored: operator writes the grid by hand (high-quality but slow; bus-factor risk).
- Agent-authored: the harness drafts the entire grid (fast but high-error; needs its own gate; risks regress).
- Auto-propose + operator-confirm: the harness drafts; the operator confirms / modifies / extends / skips each proposal.
gates.md §2.2 said “operator-owned, agent-assisted” but didn’t
specify the mechanism. Validation-pass-2 finding #7 + #12 surfaced
that init’s own behavior was underspecified (especially the
adversarial-phase application to init).
Decision:
Init runs in two sub-phases, both within the single init arrow:
Sub-phase A — Project profile + context discovery.
Init interrogates the operator (or scans the repo in brownfield
mode) to determine: bounded contexts, languages used, refusal-or-
proceed. The producer is the operator; the artifact is the
proposed context list. single-active-role-instance(init, *)
skips the context check (init is project-scoped, not
context-scoped).
Sub-phase B — Per-(role-pair, context) auto-propose.
Once contexts are declared, init iterates all
(role-pair, context) arrows and proposes the role file’s full
exit-gate clause set per arrow:
- Auto-propose: harness drafts each clause from
roles/<role>.md’s exit-gate table. - Operator returns one of four verdicts per clause:
confirm— accept as-is.modify— raise costs (never lower), tighten thresholds, refine arguments. Schema enforces raise-only.extend— add per-context clauses beyond the role-file default.skip— drop the clause for this(role-pair, context)arrow. Requires a residue entry recording why.
- Grid is recorded only after every proposed clause has received a verdict.
Init’s own arrow has an adversarial phase that attacks the proposed grid:
- A fresh
adversaryinstance reads the proposed grid + the operator’s context-discovery rationale. - Sub-activities apply: clause-falsification (missing clauses operator silently dropped), open sweep (residue not declared), depth classification (dependency granularity wrong).
- Findings raise; remediation runs; verification auto-inserts
no-open-finding+every-requirement-meets-min-depth.
Consequences:
- Rules in: operator-owned grid that’s still fast to produce (harness does the typing); structural enforcement of “no clause silently dropped” (skip requires residue); init follows its own schema (no special case).
- Rules out: “agent decides the grid”; “operator writes from scratch”; weakening clauses below role-file defaults (only skip-with-residue, never weaken).
- Operator burden: every clause proposed at init must receive a verdict. For 4 roles × N bounded contexts × ~10 clauses per role, that’s ~40N verdicts at init. For N=3 contexts, ~120 verdicts. Auto-propose makes this manageable but not trivial.
- Refusal path (init’s own §2.4): if the project profile is low-risk, init proposes refusal rather than running through auto-propose. Operator may accept (ghyll exits) or override (residue note required).
- Brownfield divergences: residue candidates from sub-phase A
become divergence-candidates that the analyst arrow materializes
into
divergences.mdentries on its first traversal. - Init is mandatory before any other arrow (
gates.md§2 + init.md INV-1). No code path bypasses init.
See gates.md §2; specs/architecture/components/init.md;
specs/features/init.feature;
specs/architecture/operator-decisions-round-3.md D20;
specs/architecture/operator-decisions-round-4.md D41.
ADR-012: Global write-lock with FIFO amendment queue
Status: Accepted (2026-05-18; D22)
Context:
Validation-pass-2 finding #1 and #2: when ContextA’s diamond is
mid-flight and ContextB’s analyst lands a grid amendment, the
schema didn’t say whether ContextA must abort, where the amendment
originates (cell vs queue), or how concurrent amendments serialize.
ADR-008 fixed arrow identity to include grid-version; that made
the version-bump operation load-bearing for arrow identity. Two
concurrent amendments producing ambiguous v(N+1) would break arrow
identity.
The choice was between:
- Global serialization: all amendments take a project-wide lock; in-flight cells affected by the amendment abort.
- Optimistic with rebase: cells run on whatever version they started with; recording a completion against a now-stale version triggers a rebase.
- Per-context locking: amendments scoped to a context lock only that context.
Per-context locking was rejected: the integrator detects cross-context defects by definition, so amendments by definition touch at least two contexts. Optimistic-with-rebase was rejected: “rebase failure” is a new state the schema would have to define, and it pushes complexity onto every component that records a pass completion.
Decision:
Global serialization via a project-wide write-lock and a FIFO amendment queue.
When an integrator finding triggers an amendment:
- The amendment is enqueued (FIFO).
- The amendment component acquires the project-wide write-lock (per ADR-009).
- The analyst is re-engaged; the amended spec is produced; the v(N+1) arrow grid is computed.
- Dependency check against in-flight passes:
- Affected (dependency on a changed artifact, or no
dependencies declared — conservative fallback): pass is
abortedwithreason: invalidated; arrow status becomesinvalidated. - Unaffected: pass continues against vN and records completion normally.
- Affected (dependency on a changed artifact, or no
dependencies declared — conservative fallback): pass is
- Findings discovered before abort are retained, tagged with
their original
grid-versionso they’re distinguishable from findings on the new vN+1 arrow. - Grid v(N+1) is written atomically per ADR-010.
- Lock releases; next queued amendment may proceed.
The next pass on an invalidated arrow starts fresh — phases
re-run from adversarial. There is no “resume mid-phase.”
Consequences:
- Rules in: unambiguous v(N+1) per commit (FIFO ordering); serializable amendment history; “amendment took the lock for X seconds” is a meaningful telemetry signal; orphaned-lock recovery via dead-PID-check on boot (FM-27).
- Rules out: concurrent amendments racing on the same vN→vN+1 bump; per-context partial amendments that touch cross-context state; rebase semantics on pass completion.
- Performance: amendments are slow under heavy contention (FIFO blocks). Mitigated by: amendments are expected to be infrequent (require integrator-level work); queue-growth alert surfaces pathological rates (FM-28).
- Conservative-fallback signal: arrows that declared no dependencies get aborted on every amendment. The count of conservatively-invalidated arrows is a quality signal that dependency declarations are missing. Operator tooling surfaces this for triage.
- Mid-phase invalidation: if a pass is in the adversarial, remediation, or verification phase when an amendment lands, the pass aborts mid-phase. Useful signal (findings) is preserved with the grid-version tag; the partial iteration state is discarded. State-space-frame rationale (ADR-002): invalidation is an operator that resets cells.
See gates.md §7.2 “Mid-phase invalidation”;
specs/architecture/components/amendment.md;
specs/features/amendment.feature;
specs/failure-modes.md FM-25, 27, 28.
ADR-013: Add tests-pass to the catalogue (catalogue grows to 18)
Status: Accepted (2026-05-18; cold-validation pass on role files)
Context:
While implementing the v2 init component’s auto-propose loop, the
role-file parser surfaced that implementer.md clause G1 (“All test
suites pass”) referenced an informal cell — (test-runner check; per-language binding) — rather than a catalogue concept. Inspection
of the 17-concept closed catalogue confirmed the gap:
compilescovers parse/build, not test execution.mutation-scoremeasures the quality of tests that already pass; it presupposes tests run.kill-server-fails-integrationchecks that integration tests would notice a dependency outage, not that they currently pass.lint-clean,no-todo-marker,every-step-bound— none touch test results.
The catalogue genuinely lacked an abstraction for “the project’s test suite runs and is green”. Since implementer’s exit gate is the strongest gate against shipping broken behavior, leaving its G1 informal would mean the harness cannot machine-verify the most obvious quality signal it has.
ADR-005’s “Catalogue maintenance” consequence explicitly anticipates this case: “new concepts require deliberate harness changes. Adding to it is a versioned harness release.” This ADR is that deliberate addition.
Decision:
Add a single new concept, tests-pass, to the catalogue. The closed
set is now 18 concepts. The new concept’s category is
per-arrow declared at init (the same category as mutation-score,
lint-clean, etc. — not auto-applied, not auto-inserted).
Schema highlights (full schema in gates/concepts/tests-pass.yaml):
language-bound: true— each project supplies a per-language test runner binding (e.g.,tests-pass.go = go test ./...).- Arguments:
scope(path-glob of tests),language(id),timeout(duration, default 5m). - Evaluator contract:
machine. Producespass: booleanpluspassed/failed/skipped/timed-outcounts and a truncated failure list. default-cost: 2.
implementer.md G1 is updated to use the new concept:
`tests-pass`(scope, language).
Consequences:
- Rules in: test-run signal becomes a first-class machine clause; implementer’s exit gate is now fully machine-verifiable at the required-clauses tier.
- Rules out: future role files referencing “tests pass” via prose instead of the concept; the parser refuses informal cells.
- Binding obligation: every project that uses an implementer
arrow now declares a
tests-pass.<lang>binding alongside itslint-clean.<lang>,compiles.<lang>, etc. bindings. Missing binding triggers init re-entry per ADR-005 + D18. - Edge case (vacuous pass): a scope matching zero tests returns
pass=false. Vacuous truth (“we have no tests, therefore all tests pass”) was the failure mode that motivated this concept; the schema refuses it explicitly. - Flakiness deferred:
tests-passrecords the first result; it does not retry. Flake mitigation lives in a future ADR or in the binding (which may wrap a retry).
This addition does not weaken ADR-005’s closure model — the catalogue remains closed, just one entry larger. Future additions follow the same harness-versioned path.
See gates/concepts/tests-pass.yaml,
specs/architecture/roles/implementer.md G1,
docs/decisions/v2/005-concept-catalogue.md.
ADR-v4-001: Registry key shape — flat <concept>.<language> form
Status: Accepted (2026-05-25)
Context:
The runner’s evaluator Registry maps a string key to an evaluator
function. Universal (language-bound:false) concepts have one
evaluator regardless of project language; language-bound:true
concepts ship one evaluator per declared language binding (one for
compiles.go, another for compiles.rust, etc.). Two shapes were
on the table for the key:
- Flat
<concept>.<language>string, with universal concepts using the bare concept name. - Nested registry:
map[concept]map[language]Evaluator.
Decision:
Flat key. ConceptRegistryKey(c Clause) string returns:
c.Conceptverbatim forlanguage-bound:falseconcepts.c.Concept + "." + langforlanguage-bound:trueconcepts, wherelangis sourced safely fromc.Args["language"](no bare type assertion — non-string values return a sentinel that guaranteesLookupmisses cleanly).
The auto-derived classification (IsLanguageBoundConcept /
IsUniversalConcept in runner/concept_classification.go) is the
source of truth for which branch applies.
Consequences:
- One
*Registryinstance covers both kinds of concepts. No branching at the Registry layer. - The bootstrap-side
BindingKey{Concept, Language}type stays the on-disk authoring shape;BindingKey.String()produces the same flat key the Registry uses. - The grid YAML’s
language-bindings:map (<concept>.<language>: <command>) parses directly into the registry-ready keys. No schema migration today.
ADR-v4-002: Adversarial phase auto-enabled on dialect availability
Status: Accepted (2026-05-25)
Context:
The dispatcher’s §11 adversarial phase requires three LLM-backed hooks (open-sweep, depth-classify, producer-fix) plus an Adversary factory. v1 reached production with the hooks reachable only from tests; the dispatcher never instantiated them. The v4 diamond pass wires them, but the default behavior matters: auto-enable would break CI (no API keys), force-disable would leave operators expecting the cycle confused when it didn’t fire.
The v1 revision of this ADR proposed auto-enable at session start. The revised-adversarial pass (R14) flagged that this would call an LLM-backed hook in CI and break the acceptance suite.
Decision:
Auto-enable is conditional on an active dialect actually being available. At session start the engine asks the dialect router for the active dialect; if no dialect resolves (no API key configured, no model selected), the adversarial cycle defaults to disabled with a one-line operator banner:
ℹ adversarial cycle: disabled (no dialect configured; type `/adversary enable` to wire)
The CI path (no API key) sees the banner and never instantiates an
LLM-backed hook. The operator-facing toggle is the /adversary
slash command (enable / disable / bare status).
When enabled, the dispatcher partitions an arrow’s clauses on
DepthType == DepthTypeSensitive and runs the cycle on the
sensitive partition; verification runs on robust + auto-inserts
afterward.
Consequences:
- CI without API keys continues to pass without changes.
- Operators with an active dialect get the cycle for free on depth-sensitive arrows.
/adversary enablerefuses withno-dialect-configuredwhen no dialect resolves — the refusal is the operator-attestation surface, not a silent no-op.- An operator may explicitly
/adversary disable; depth-sensitive dispatches then refuse withadversarial-hooks-not-wiredrather than running verification-only.
ADR-v4-003: Amendment-driven re-register — in-memory snapshot, atomic swap, fail-before-bump
Status: Accepted (2026-05-25)
Context:
AmendmentCommitter.Commit applies an amendment to the live grid:
appends new arrows, adds new language-binding declarations, aborts
in-flight passes on the source arrow, and persists the new grid to
disk. The order of these steps determines what an observer sees if
any step fails mid-flight.
The v1 ordering (append-then-re-register) opened a window where the grid was bumped but the registry still pointed at the old bindings. The first revision moved re-register before grid-append (H5 closure) but opened a NEW asymmetric window: re-register succeeded but abort / append errored mid-loop, leaving the live registry holding new bindings against the old grid version. R10 of the v2 adversarial flagged this.
Decision:
The committer builds an in-memory snapshot registry, validates it, then atomically swaps the snapshot into the live registry only after the disk write succeeds. Step ordering:
- Acquire
committer.mu. - Validate FIFO head (R22): the amendment ID must match the queue
head; on mismatch return
ErrAmendmentCommitFIFOwith the queue intact. - Build the in-memory registry snapshot via
Registry.Snapshot()registerGridBindings(snapshot, overlay, workdir). On construction failure, abort BEFORE any mutation.
- Abort in-flight passes whose
ArrowID == amendment.SourceArrow. - Append
NewArrowsto the in-memory grid; bump grid version. - Persist the new grid via
bootstrap.Grid.Write(workdir).- 6a: atomically swap the snapshot into the live registry via
snapshot.SwapInto(rt.registry). This is the only mutation ofrt.registry; concurrent dispatchers see OLD or NEW, never partial.
- 6a: atomically swap the snapshot into the live registry via
Queue.MarkDrained(amendment.ID).- Publish
OpEventAmendmentDrainedwith typed Payload. - Release
committer.mu.
Failure at any step ≤ 6 discards the snapshot; the live registry retains the old bindings; the grid version is unchanged.
Consequences:
- The
runner.RegistrygainsSnapshot()andSwapInto()methods. AmendmentCommittergains an optionalBindingsReRegistercallback that returns a snapshot registry (the implementation lives incmd/ghyllper ADR-v4-007).- Lock order:
committer.mu → AmendmentQueue.mu → Grid.mu. The implementer MUST NOT introduce a reverse path. - Concurrent dispatch under a drain blocks on the committer’s lock
only at the swap point; reads against the registry are
lock-protected by
Registry.mu.
ADR-v4-004: Concept classification auto-derived from embedded YAMLs
Status: Accepted (2026-05-25)
Context:
The runner needs to ask “is concept X language-bound?” at dispatch
time (to compute the registry key, to know whether to expect a Go
evaluator or a BindingEvaluator). Two implementation options:
- A hand-maintained constant table in
runner/. - Auto-derive from the embedded
gates/concepts/*.yamlschemas at package init.
The hand-maintained table drifts the moment a new concept lands. v1
shipped both copies and the runner’s was out of date. The v2 first
revision proposed gates.ConceptsFS, which doesn’t exist.
Decision:
Auto-derive at package init via the existing
catalogue.LoadEmbedded() entry point (which wraps the root
ghyll.ConceptsFS embed). Two assertions hold:
len(universalConcepts) + len(languageBoundConcepts) == 18len(universalConcepts) == 11
Either invariant failure panics at startup — loud, not silent.
The 11/7 split is load-bearing per the v4 diamond contract:
- 11 universals MUST have an in-process Go evaluator in
RegisterBuiltins. - 7 language-bound concepts MUST resolve to a project-declared
BindingEvaluator(registered byregisterGridBindingsincmd/ghyll).
Consequences:
runner → catalogueis a new import edge. Verified safe:catalogueimports the rootghyllpackage only; no cycle.- Adding a new concept YAML breaks the cardinality assertions until the implementer updates them — by design, the count is a contract that travels with the runtime.
- The cardinality bands cover the load-bearing 11-universals invariant independently of the 18-total (R19 closure): a hypothetical shift of one concept from universal to language-bound would slip past a single-count check, hence the dual assertion.
ADR-v4-005: OperatorEvent typed Payload contract — outcome / reason key split
Status: Accepted (2026-05-25)
Context:
OperatorEvent.Payload map[string]string is the typed-payload
surface for subscribers (modal driver, status CLI, JSONL audit
writer). The free-text Detail field is for human reading; v1 had
subscribers parsing Detail for state, which is fragile.
The v1 contract used outcome / status / reason keys
inconsistently across event kinds. R20 of the v2 adversarial flagged
this.
Decision:
Unified contract:
outcomeis a closed enum: the terminal state of the event’s subject. Subscribersswitchon this. Values per event kind are enumerated inspecs/v4/diamond-load-bearing-revised-v2.md“OperatorBus payload contracts”.reasonis free-text: a one-line human-readable amplification. Subscribers display this; they do NOT switch on it.
Event kinds with required Payload keys (summary; full table in the spec):
| Event kind | Required Payload keys |
|---|---|
OpEventAdversarialRoundStart | arrow_id, pass_id, round, rounds_max, open_findings, tier_label |
OpEventRemediationConverged | arrow_id, pass_id, outcome ∈ {converged, converged-with-unevaluated}, rounds_used |
OpEventRemediationEscalated | arrow_id, pass_id, outcome ∈ {escalated-rounds-max, escalated-no-progress, escalated-hook-error, context-cancelled}, rounds_used, reason |
OpEventAmendmentEnqueued | arrow_id, amendment_id, source_arrow, target_role, finding_ids |
OpEventAmendmentEnqueueRefused | arrow_id, amendment_id, outcome ∈ {queue-full, duplicate-id} |
OpEventAmendmentDrained | amendment_id, source_arrow, grid_version_before, grid_version_after, arrows_added, passes_aborted, outcome ∈ {complete, partial-append-error, binding-re-register-error} |
OpEventRecoveryAmendmentsPending | count, amendment_ids |
OpEventArrowInvalidated | arrow_id, op_id, reason, timestamp |
OpEventPassClosed | arrow_id, pass_id, close_reason, arrow_status ∈ {complete, unevaluated, blocked, aborted-remediation} |
Consequences:
- A single unit test (
TestScenario_OperatorBus_PayloadContract_OutcomeKeyConsistency) enforces the contract across event kinds. - The modal driver and status CLI become simpler: a Go
switchonPayload["outcome"]covers every terminal state. - Adding a new closed-enum value to
outcomeis an additive change; subscribers that don’t recognize it fall through to a default branch and surfacereasonto the operator.
ADR-v4-006: EvaluatorWithRunner variant + two-table Registry lookup
Status: Accepted (2026-05-25)
Context:
The single-active-role-instance evaluator needs read access to the
live *PassRegistry (to count open passes on a (role, context)
tuple). The existing Evaluator = func(ctx, c Clause) (*Result, error)
signature has no path for this — passing the registry through
Clause.Args is type-unsafe and would bypass the registry’s locks.
R9 of the v2 adversarial flagged that the v1 “EvaluatorWithRunner
wraps Lookup” handwave was mechanically incorrect; Runner.Evaluate
calls Registry.Lookup(c.Concept) and the runner-typed variant
cannot be returned through that shape.
Decision:
Two-table Registry lookup:
Registry.Register(concept, Evaluator)+Registry.Lookup(concept)is the plain table (existing).Registry.RegisterWithRunner(concept, EvaluatorWithRunner)+Registry.LookupWithRunner(concept)is the runner-typed table (new).- A concept is registered in AT MOST ONE table. Registering in the
second after the first returns
ErrConceptAlreadyRegistered.
Runner.Evaluate tries LookupWithRunner first; on miss, falls back
to Lookup; on second miss, returns ErrEvaluatorUnknown.
Only one concept uses the new variant today:
single-active-role-instance. Adding a future runner-typed
evaluator means: declare the function with the new signature,
register via RegisterWithRunner.
Consequences:
Runner.passes *PassRegistryis a new unexported field;NewRunner(reg *Registry, passes *PassRegistry, tier DepthRank)is the new constructor (replaces the prior 1-arg form).- 37 call sites updated to pass the new args (
nilandDepthRankNoneare valid defaults for tests that don’t exercise the runner-typed dispatch). Registry.Count()reports the union of both tables — the cardinality assertion inRegisterBuiltinscounts 11 across the two tables.Registry.Snapshot()/SwapInto()(per ADR-v4-003) duplicate both tables in lock-step.
ADR-v4-007: Language-binding registration lives in cmd/ghyll (integration layer)
Status: Accepted (2026-05-25)
Context:
registerGridBindings(reg *runner.Registry, grid *bootstrap.Grid, workdir string) error is the operation that populates the
evaluator registry from a grid’s language-bindings: declarations.
It needs:
runner.Registry(mutates the live registry).bootstrap.Grid(source of binding declarations).runner.NewBindingEvaluator(constructs the subprocess evaluator).bootstrap.BindingKeysFromStrings(parses the<concept>.<language>keys).
The natural location would be runner/, but bootstrap already
imports runner (bootstrap/init_attestations.go references
runner.AttestationRecord). Adding the inverse edge — runner → bootstrap — creates a Go import cycle. R1 of the v2 adversarial
flagged this as a critical compile-time failure.
Decision:
registerGridBindings and its sibling helpers
(requiredBindingsFromTypedGrid, requiredBindingsFromUntypedGrid,
buildRegistryOverlay, composeBootstrapOverlay) live in
cmd/ghyll/binding_register.go (package main). Rationale:
cmd/ghyllis the integration boundary — it already imports BOTHrunnerANDbootstrap.cmd/ghyllis a leaf package — no production code imports it, so the new edges cannot cycle.- The helpers are package-local; the runtime construction site
(
openEngineWithOptionsand the amendment-driven re-register callbackbuildRegistryOverlay) lives in the same package.
Alternatives considered + rejected:
- New
wiring/package: would still need to be imported bycmd/ghyllto be called. Splits the seam without resolving the cycle. RegistryInterfaceinbootstrap/: moves binding logic AWAY fromrunnerwhere theRegistrytype lives. Worse locality.- Breaking the bootstrap → runner edge (relocating
init_attestations.go’s AttestationRecord references): large structural change far beyond v4’s scope.
Consequences:
- The integration site is the single place where binding-registration
policy lives. A future refactor that wants to make this reusable
outside
cmd/ghylllifts both helpers and their tests as a unit. - The arrow-overlay conversion
(
runner.ArrowDefinition → map[string]anyfor the in-memory bootstrap.Grid overlay) ships ascmd/ghyll/arrow_marshal.gowith a round-trip test (R3 closure). - The amendment-driven re-register callback
(
AmendmentCommitter.BindingsReRegister) is a function-typed field on the committer; the integration site wires it toengineRuntime.buildRegistryOverlay.
ADR-v4-008: Engine schema migrations via explicit ALTER TABLE
Status: Accepted (2026-05-25)
Context:
The v4 diamond adds two engine-persistence surfaces:
passes.remediation_outcome+passes.remediation_rounds_usedcolumns for the adversarial cycle’s per-pass summary.- A new
arrow_invalidationstable for the/invalidate-arrowoperator escape verb.
engine/store.go uses CREATE TABLE IF NOT EXISTS for table
construction. Pre-existing databases (operators upgrading across
v4) do NOT pick up new columns automatically — IF NOT EXISTS
won’t add columns to an already-existing table.
R8 of the v2 adversarial flagged the migration path was unflagged;
R28 added the arrow_invalidations table to the same migration
scope.
Decision:
Both schema additions ship as explicit ALTER TABLE migrations
called from engine.OpenStore after the CREATE TABLE IF NOT EXISTS block:
migrateAddRemediationColumns(db *sql.DB) error: PRAGMAtable_info(passes)to detect column existence; on miss, runALTER TABLE passes ADD COLUMN remediation_outcome TEXT NULLandALTER TABLE passes ADD COLUMN remediation_rounds_used INTEGER NULL. Idempotent (safe to call on fresh DBs).migrateAddArrowInvalidations(db *sql.DB) error: PRAGMAtable_listdetection; on miss,CREATE TABLE arrow_invalidations (arrow_id TEXT PRIMARY KEY, op_id TEXT NOT NULL, reason TEXT, invalidated_at TIMESTAMP, grid_version INTEGER).
Backfill: pre-migration passes rows have NULL in both new
columns; the cycle never ran for those passes so NULL is correct.
Consequences:
- A fresh session against a pre-v4
engine.dbmigrates on first open with no operator action. JSONLaudit records remain the authoritative attestation log per ADR-015; thepassestable extension is a search shortcut only. The cycle’s full report is logged as a JSONL record of kindadversarial-cycle-report.- The migration is the only schema-mutating path on open; a future v5 adds new tables / columns the same way.
- The
arrow_invalidationstable is read at Replay to populaterunner.Grid.Invalidations;/run-arrowconsults this map and forces re-traversal even if the cached arrow status iscomplete.
Producer:
The /invalidate-arrow <arrow-id> [--reason <text>] slash command
(implemented at cmd/ghyll/invalidate_arrow_cmd.go) is the
operator-facing producer for OpEventArrowInvalidated. The handler:
- Refuses without an active
/op-id(the row carries operator identity). - Refuses on unknown arrow-id (looked up via the live
Grid). - Publishes
OpEventArrowInvalidatedwith the typed Payload per ADR-v4-005:arrow_id,op_id,reason,source=operator.
The subscriber wired in cmd/ghyll/session_engine.go:attachJournal
fans out the publish to engine.Store.InsertArrowInvalidation
synchronously, so by the time the operator sees the confirmation
line the row is on disk. Integrator-pass I-C-1 (2026-05-25) closed
the original “consumer chain without producer” gap; the table now
receives rows from operator input, not only from direct-test
publishes.
ADR-v4-009: ReasoningContent excluded from canonical checkpoint hash
Status: Accepted (2026-06-05)
Context
The Kimi 2.5/2.6 dialect surfaces a model-side reasoning trace via
the OpenAI-compatible reasoning_content field on assistant turns.
Other dialects ignore the field; Kimi uses it for chain-of-thought
streaming. To preserve the trace across multi-turn round-trips we
add ReasoningContent string to types.Message with a
json:"reasoning_content,omitempty" tag.
Today memory.Checkpoint does NOT persist Message arrays — the
canonical hash already excludes them. But the invariant matters
prospectively: a future refactor could wire Messages onto Checkpoint
(or surface them in the JSONL audit trail under a hashed payload).
ADR-003 set the precedent for excluding fields whose cross-platform
serialization is unstable; ReasoningContent is the next member of
that class:
- Tokenizer-injected sentinel bytes vary between deployments and quantization tiers of the same model.
- Whitespace normalization is backend-dependent — vLLM, SGLang, and
llama.cpp do not agree on how to surface model-side
\r,(PDF control), and certain CJK runes inside reasoning streams. - A future cross-device verifier that hashed reasoning content would fail in exactly the same way ADR-003 said the embedding field would fail (Section “Decision” of ADR-003).
Decision
types.Message.ReasoningContent MUST follow the ADR-003 exclusion
precedent: any future canonical-hash path that ingests Message
arrays MUST omit this field from the canonical map.
Today the rule is documented (1) inline in memory/crypto.go
CanonicalHash next to the existing Embedding-exclusion comment and
(2) asserted by
TestMemory_CanonicalHash_StableAcrossReasoningSerialization in
memory/crypto_reasoning_test.go. The wire-side round-trip lives
in dialect/helpers.go’s buildOpenAIMessages, which emits
reasoning_content ONLY for assistant turns (user / tool / system
turns never carry a reasoning trace).
Consequences
- Cross-device checkpoint hash verification stays portable across Kimi backends with divergent reasoning serialization.
- The
omitemptyJSON tag keeps non-Kimi dialects’ wire surface unchanged — they continue to ignore the field entirely. - The wire round-trip is restricted to
Role == "assistant"; an upstream that smugglesreasoning_contentonto a user turn does not corrupt the assistant-turn-only contract. - The single producer for
reasoning_contentis the Kimi dialect family; if other dialects later surface their own reasoning field, they SHOULD route throughMessage.ReasoningContentand inherit the exclusion automatically. - If integrity protection over reasoning becomes necessary (e.g., for an attestation audit), use a fixed binary encoding alongside the canonical hash rather than including the JSON-serialized string — same escape hatch ADR-003 left open for embeddings.
Migration
No chain rewrite is required. Checkpoints written before this commit
have no ReasoningContent (the field did not exist on
types.Message); the field is omitempty; and memory.CanonicalHash
currently consults only the scalar Checkpoint fields — Message arrays
are never folded into the canonical map today. The addition is
purely prospective: existing chains continue to verify byte-for-byte
identical to before this commit, and VerifyChain /
VerifyCheckpoint need no special case for “pre-v4-009” entries.
If a future refactor wires Messages []types.Message into Checkpoint
(or any other hashed payload), it MUST drop ReasoningContent from
the canonical map per this ADR. The teeth-bearing assertion lives in
memory.TestMemory_CanonicalHash_StableAcrossReasoningSerialization:
it directly exercises canonicalJSON and asserts the bytes are
identical regardless of which reasoning payload was constructed
alongside, and inversely demonstrates that INCLUDING the reasoning
in the map WOULD produce divergent bytes.