Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. 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.

  2. User Guide — the operator path: install, configure, run.

  3. 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.
  4. Internals — the implementation details: dialect modules, context management, drift detection, injection detection, tool execution, the workflow system, and sub-agents.

  5. 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 checkout or inventory. This is NOT the LLM context window. The LLM-context-window meaning still applies in docs/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 unevaluated status doesn’t auto-derive to pass → the operator sees it.
  • The arrow’s pass count, lock status, and last verdict are persisted → forensic reconstruction is mechanical.

Reference


3. Clauses are typed on two axes

The decision

Each clause carries two type tags:

  • Evaluation typemachine or attested.
    • 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 typedepth-robust or depth-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-robust check. Any model can run make test and 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


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


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


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

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/memory orphan 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 checkout or inventory. This is NOT the LLM context window. The LLM-context-window meaning still applies in docs/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:

  1. Scans the project for bounded contexts (auto-declares a default context if none detected).
  2. Walks the four role pairs (init → analyst → architect → implementer → integrator) using the role contracts embedded in the binary.
  3. Loads the closed concept catalogue (also embedded — gates/concepts/*.yaml).
  4. Builds a proposal for each (role-pair, context) tuple.
  5. Auto-accepts proposed clauses; clauses missing required catalogue args are skipped with a residue note for a future amendment.
  6. Writes .ghyll/grid.v1.yaml atomically.

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?”.

ArtifactWhere it livesHow 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 templateembedded 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.yamlwritten by ghyll init; immutable after write
Engine DB (passes, findings, attestations index)per-project at .ghyll/engine.dbsqlite, opened on session start
Attestation auditper-project at .ghyll/attestations.jsonl plus the per-pass tree under .ghyll/attestations/appended on every verdict, fsync’d before accept
Checkpointsglobal at ~/.ghyll/memory.db and the ghyll/memory git orphan branchsqlite + 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:

CommandEffect
/list-arrowsShow 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-amendmentsFIFO-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).
/deepTemporarily force the deep tier (GLM-5).
/fastRestore auto-routing.
/planEnter plan mode (deeper reasoning).
/statusShow active model, lock state, deep/plan flags, turn count, and tool depth.
/exitEnd 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. confirm unit, 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’s residue-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

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):

  1. 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] becomes GHYLL_API_KEY_CSCS_GLM5.
  2. GHYLL_API_KEY — global fallback. Use this for single-tenant hosts; the scoped form for multi-endpoint setups (CSCS gateway + a local dev endpoint).
  3. cfg.Models[name].api_key from 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 show prints api_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 failed message; 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:

FieldDefault
routing.default_model"m25"
routing.context_depth_threshold32000
routing.tool_depth_threshold5
routing.gate_floor_escalate_at_rank2 (MOCKED)
memory.branch"ghyll/memory"
memory.sync_interval_seconds60
memory.checkpoint_interval_turns5
memory.drift_threshold0.7
tools.bash_timeout_seconds30
tools.file_timeout_seconds5
tools.web_timeout_seconds30
tools.web_max_response_tokens10000
sub_agent.max_turns20
sub_agent.token_budget50000
sub_agent.timeout_seconds300
workflow.instruction_budget_tokens2000
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 --force to 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_sha256 alongside 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>.tmp then renames on success. A partial download (network drop, server error, size-cap hit) leaves any existing file untouched.
  • Size cap: 1 GiB. Misconfigured model_url pointing 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_path accepts 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

CommandEffect
/deepTemporarily switch to the deep-tier model. Refused when --model was passed.
/fastRestore auto-routing and clear plan mode.
/planEnter plan mode (deeper reasoning, higher tier preference). All tools remain available.
/statusShow active model, lock state, deep/plan flags, turn count, tool depth.
/exitEnd the session cleanly. Cancels any in-flight modal read; creates a final checkpoint.
/quitREPL alias for /exit.
/op-id <id>Declare the operator identity for this session. Required before /attest.
/op-idShow 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.
/passesList currently-open passes from the PassRegistry. /passes <pass-id> shows one pass’s full state.
/list-arrowsRender 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-amendmentsFIFO-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:

PathPurpose
~/.ghyll/config.tomlConfiguration
~/.ghyll/memory.dbCheckpoint store (SQLite)
~/.ghyll/keys/Ed25519 signing keys
~/.ghyll/models/ONNX embedding model
~/.ghyll/instructions.mdGlobal workflow instructions
~/.ghyll/commands/Global slash commands
<repo>/.ghyll/Project workflow (instructions + commands; roles/ here is ignored — see ADR-008)
<repo>/.ghyll.lockSession 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:

LayerSourceExample
1GHYLL_API_KEY_<MODEL> env varGHYLL_API_KEY_CSCS_GLM5=sk-...
2GHYLL_API_KEY env var (global fallback)GHYLL_API_KEY=sk-...
3api_key = "..." under [models.<name>] in TOMLapi_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 run calls the config bootstrap. When ~/.ghyll/config.toml is missing, the embedded config/example.toml template 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 init runs the four-stage bootstrap pipeline end-to-end (C-1):

    1. Profile the project directory (greenfield vs brownfield, bounded-context detection, language detection).
    2. Load the embedded concept catalogue (H-1).
    3. 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 of specs/architecture/roles/ on disk).
    4. 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.
    5. Persist .ghyll/grid.v1.yaml atomically (temp → fsync → rename, ADR-010). The grid file is immutable after write; subsequent amendments produce grid.v2.yaml, grid.v3.yaml, etc.

    If .ghyll/grid.v1.yaml already exists, ghyll init refuses to clobber it. If profiling finds no bounded contexts (typical for a greenfield repo), a single default context 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-arrows to see what’s been declared and /run-arrow <id> to drive a specific arrow.
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:

CommandEffect
/deepTemporarily switch to the deep-tier model. Refused when --model was passed.
/fastRestore auto-routing and clear plan mode.
/planEnter plan mode (deeper reasoning, higher tier preference).
/statusShow active model, lock state, deep/plan flags, turn count, tool depth.
/exitEnd the session cleanly. Cancels any in-flight modal read; creates a final checkpoint.
/quitREPL alias for /exit.
/op-id <id>Declare the operator identity for this session. Required before /attest.
/op-idShow 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.
/passesList currently-open passes from the PassRegistry.
/passes <pass-id>Show one pass’s full state.
/list-arrowsRender 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-amendmentsFIFO-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 / pconfirm unit, no payload.
  • fail / f — prompts for inspected locations (record-locations-inspected unit; CSV).
  • insufficient-basis / ib — prompts for a residue note (write-residue-note unit; capped by the grid’s residue-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 familyValuesWhere you see it
Clause statusunevaluated, pass, fail, insufficient-basis, skippedghyll arrow show per-clause line; verdict modal
Pass statusopen, closed:ok, closed:failed, aborted/passes listing; pass-opened / pass-closed events
Arrow statusunevaluated, 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 open and recovered_at is stamped.
  • evaluation_runs flipped — clauses with end_status=running AND a verdict in the JSONL audit log; end_status is reconciled to match the verdict (passpass, failfail, insufficient-basisrunning so 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.

SubcommandEffect
ghyll memory search <query>Vector-search past checkpoints by semantic similarity.
ghyll memory logShow the local checkpoint chain (hash, parent, turn, summary).
ghyll memory syncManual 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

VariableEffect
GHYLL_REQUIRE_SANDBOX1/true/yes/on: refuse to start outside a recognized sandbox. Unset: warn only.
GHYLL_SANDBOX_ASSUME_SAFEBypass the sandbox check with the given reason string (audited in the warning).
GHYLL_LOG_LEVELdebug/info/warn (default)/error. Routes diagnostics.
GHYLL_LOG_FORMATtext (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:

  1. The runtime AttestationStore rejects a Record call where AttestedByRole is either endpoint, case-insensitive + trimmed. The slash command always uses operator as the role, which bypasses the conflict by design.
  2. 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

SymptomLook 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 lostThe 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 verdictAttestations 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 wrongghyll 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):

SandboxHow to wrap
Docker / PodmanRun ghyll inside a container with bind-mounted project dir
bubblewrapbwrap --bind ~ /home/me --bind /tmp /tmp ghyll run .
sandbox-exec (macOS)sandbox-exec -f profile.sb ghyll run .
Firejailfirejail --noprofile ghyll run .
KubernetesKUBERNETES_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 with GHYLL_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.

  • 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 spec
  • docs/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/memory orphan branch.
  • Vault API — HTTP API exposed by ghyll-vault for team memory search.
  • Error Types — typed errors per package, sentinel patterns, error-flow across boundaries.

See also

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 Record returns to the CLI handler. Per ADR-010 and the operator-attestation spec, the JSONL line is fsync’d before /attest prints 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 validate BEFORE any write. A self-cert attempt errors out with ErrAttestationSelfCert; 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-basis on the same clause emit OpEventInsufficientBasisRoundsExceeded on 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 used flag 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 MarkDrainedAmendmentEventDrain → journal observer → UPDATE. Without this, the amendment re-replays as pending on next session start.
  • status=complete|partial-append-error on 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-id swap 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).
  • /exit cancels 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 reach filepath.Join.

Where the diagrams live in code

FlowPrimary 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 cyclerunner/orchestrator.go, runner/producer_fix.go, runner/adversarial.go
Amendment commitrunner/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 import bootstrap/, engine/, catalogue/, dialect/, or cmd/.... It is the runtime kernel; callers wire it. It depends only on internal/pathglob and internal/skipdirs plus the standard library.
  • engine/ imports runner/ (the persistent store consumes runner record types) but runner/ does NOT import engine/. The Journal observer pattern keeps the direction one-way.
  • bootstrap/ imports runner/ and catalogue/ to populate the grid and validate clauses; nothing imports bootstrap/ except cmd/ghyll.
  • catalogue/ embeds gates/concepts/*.yaml via //go:embed. The gates/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

PackageImport pathPurposeDepends on
cmd/ghyllghyll/cmd/ghyllCLI entry, session loop, repl, all v2 subcommands, callback wiringbootstrap, catalogue, cmd/ghyll/modal, config, context, dialect, engine, memory, runner, stream, tool, types, ui, workflow
cmd/ghyll/modalghyll/cmd/ghyll/modalTier 2 verdict modal (TermModal, LineReader, sanitize)runner
cmd/ghyll-vaultghyll/cmd/ghyll-vaultVault server entryconfig, memory, ui, vault

v2 gate-and-arrow surface

PackageImport pathPurposeDepends on
runnerghyll/runnerGate-and-arrow runtime: clause evaluation, dispatcher, RunArrow, InsufficientBasisTracker, OperatorBus, AttestationStore, adversarial orchestrator, amendment queueinternal/pathglob, internal/skipdirs
engineghyll/enginesqlite-backed persistent store + Journal observer fanout + Replay (sessionstart load) + Recovery (crash reconciliation)runner
bootstrapghyll/bootstrapghyll init: auto-propose, modify rules, orphan-symbol extraction, role-clause parsing, session registry, grid buildcatalogue, internal/pathglob, internal/safefile, internal/skipdirs, runner, project root (for embedded role specs)
catalogueghyll/catalogueClosed concept vocabulary (per-language bindings); embeds gates/concepts/*.yamlproject root (for embedded YAML)

(gates/concepts/ ships YAML schemas only; it is not a Go package.)

v1 substrate

PackageImport pathPurposeDepends on
typesghyll/typesShared types: Message, ToolCall, ToolResult(none – leaf)
configghyll/configTOML loader, model/endpoint mapping, validationinternal/safefile
toolghyll/toolDirect OS operations: bash, file, git, grep, edit, glob, webinternal/safefile, types
memoryghyll/memoryCheckpoint store, embedder, hash chain, sync, vault clientinternal/safefile
dialectghyll/dialectModel-specific functions, router (incl. v2 gate-floor), handoffconfig, memory, types
streamghyll/streamSSE client, response assembly, terminal renderertypes
contextghyll/contextContext manager, compactor, drift detector, injection detectortypes
vaultghyll/vaultTeam memory HTTP server, searchengine, memory

Support packages

PackageImport pathPurposeDepends on
uighyll/uiUser-facing terminal output (CLI); slog handles diagnostics(none – stdlib only)
workflowghyll/workflowProject instructions + slash commands loader (reads .ghyll/, .claude/)internal/safefile
internal/pathglobghyll/internal/pathglobPath-glob matcher used by bootstrap modify rules and the runner orphan scan(none – leaf)
internal/safefileghyll/internal/safefileSafe file open / read helpers (path-traversal-resistant)(none – leaf)
internal/skipdirsghyll/internal/skipdirsDefault 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 a runner.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/*.yaml via //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:

InputSourceType
context_depthcontext/manager (token count)int
tool_depthcontext/manager (sequential tool calls)int
model_lockedcmd/ghyll (–model flag)bool
deep_overridecmd/ghyll (/deep command)bool
active_modelrouting statestring
backfill_triggeredcontext/driftbool
context_depth_thresholdconfig (default 32000)int
tool_depth_thresholdconfig (default 5)int
context_compacted_belowcontext/manager (post-compaction depth)int
gate_floorrunner (RoutingRequirement.MinTier of the traversing arrow, encoded as a depth rank 0..3)int
gate_floor_escalate_at_rankconfig (routing.gate_floor_escalate_at_rank, default 2)int
gate_floor_disabledconfig (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.

#ConditionDecisionTargetNeeds CompactionNote
1gate_floor out of range (< 0 or > 3)invalid(current)noProgrammer/config error. RejectedFloor is set.
2model_locked AND gate_floor activegate_locked_conflict(locked)no–model lock collides with §7.1. Session must route to operator attestation.
3model_lockednone(locked)noThe –model flag is absolute. No routing changes occur.
4gate_floor active AND no deep_model configuredgate_unsatisfiable(current)no§7.1 unsatisfiable. Session must NOT silently dispatch on the insufficient tier.
5gate_floor active AND active != deep_modelescalate (gate-floor)deep_modelnoThe arrow’s MinTier exceeds gate_floor_escalate_at_rank; authoritative over /deep, context-depth, tool-depth.
6deep_override AND active == default_modelescalatedeep_modelnoUser requested /deep. Temporary override.
7backfill_triggered AND active == default_modelescalatedeep_modelnoDrift detected, additional context loaded.
8context_depth > threshold AND active == default_modelescalatedeep_modelyesContext too large for fast tier. Compact first.
9tool_depth > tool_threshold AND active == default_modelescalatedeep_modelnoComplex multi-tool chain detected.
10context_compacted_below < threshold AND active == deep_model AND NOT deep_override AND NOT gate_floor activede-escalatedefault_modelnoContext reduced enough to return to fast tier; blocked while a gate floor is active.
11(none of the above)none(current)noSteady 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:

  1. Create a checkpoint on the current model (captures pre-switch state).
  2. If escalating due to context depth: compact first on the current model.
  3. Call the target dialect’s HandoffSummary(checkpoint, recentTurns) to format context for the new model.
  4. Replace the context window with the handoff summary.
  5. Update routing state (active model, deep override flag).
  6. 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/handoff to 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:

  1. Load configuration (config/).
  2. Load or generate the device ed25519 key (memory/).
  3. Open the SQLite checkpoint store (memory/).
  4. Initialize the ONNX embedder if available (memory/).
  5. Set up the git worktree if needed (memory/sync).
  6. Acquire the repo lockfile (<repo>/.ghyll.lock) – exit if already held.
  7. Start the background sync goroutine (memory/sync).
  8. Pull remote checkpoints and public keys (memory/sync).
  9. Resolve the active model from config and the --model flag.
  10. Build initial routing state (plan mode = off).
  11. Load workflow from .ghyll/ (or fallback .claude/). Merge global + project instructions.
  12. Build the system prompt via the active dialect + workflow instructions.
  13. If --resume: load previous session checkpoint, inject summary as backfill, restore plan mode.
  14. Initialize the context manager (context/).
  15. Initialize the stream client (stream/).
  16. 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).
  • AllTiersDown or ModelLocked: 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).

  1. Select turns to summarize (all except the last N).
  2. Get the compaction prompt from the active dialect.
  3. Build a compaction request (context/).
  4. Send the compaction request to the active model – this is a separate API call.
  5. Replace old turns with the summary (context/manager).
  6. Create a compaction checkpoint (memory/).
  7. Measure drift post-compaction (context/).
  8. Recalculate the token count.
  9. 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.

  1. If triggered by context depth: run COMPACT first.
  2. Create a handoff checkpoint on the current model.
  3. Format context for the target model: dialect.HandoffSummary(checkpoint, recentTurns).
  4. Replace the context window (context/manager).
  5. Update routing state (active model, clear/set deep override).
  6. Update the stream client endpoint.
  7. Display: switched to glm5, loaded from checkpoint N.
  8. 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.

  1. Search local checkpoints by embedding similarity (memory/).
  2. If local results are insufficient and vault is configured, search the vault (memory/vault_client).
  3. Verify signatures on all candidate checkpoints (memory/).
  4. Discard unverified checkpoints with a warning.
  5. Select top-k verified checkpoints within the token budget.
  6. Inject summaries into context (context/manager) – this is additive, never replacing existing context.
  7. Display: backfill from checkpoints X, Y.
  8. If backfill added significant context, re-evaluate routing (may escalate to deep tier).
  9. Return to TURN.

SHUTDOWN

  1. Create a final checkpoint if the session had activity.
  2. Final sync push (blocking, with timeout).
  3. Release the repo lockfile.
  4. Close the SQLite store.
  5. Close the ONNX session.
  6. 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:

  1. Take all fields except hash and sig.
  2. Serialize as JSON with keys sorted alphabetically.
  3. No whitespace, UTF-8 encoding.
  4. 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:

  1. Hash verification – recompute the canonical hash from the checkpoint content and compare it with the stored hash. A mismatch indicates content tampering.
  2. 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].ParentHash must equal checkpoint[i-1].Hash.
  • The first checkpoint in a chain has ParentHash set 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:

  1. Write <hash>.json to <worktree>/repos/<repo-hash>/checkpoints/.
  2. Append a chain entry to <worktree>/repos/<repo-hash>/chains/<device-id>.jsonl.
  3. 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:

  1. Fetch the latest state: git -C <worktree> fetch origin ghyll/memory
  2. Fast-forward merge: git -C <worktree> merge --ff-only origin/ghyll/memory
  3. Scan for new checkpoint files not already in the local SQLite store.
  4. 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_hash matches the previous checkpoint’s hash).
    • Verify signatures against devices/<device-id>.pub.
    • Insert into SQLite with verified=1 (or verified=0 if signature verification fails).
  5. 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-only always succeeds after a git 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 Authorization header. The token is configured in config.toml. Requests with a missing or invalid token receive a 401 Unauthorized response.
  • 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:

  1. Verify the checkpoint signature against known public keys.
  2. Verify the hash matches the content (recompute the canonical hash).
  3. If valid, store the checkpoint.
  4. If invalid, reject with 403 Forbidden and 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:

FunctionPurpose
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):

  1. Create dialect/kimi.go with all six functions
  2. Add the dialect name to resolveDialect() in cmd/ghyll/session.go
  3. Add model config in config.toml
  4. 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 message
  • Messages() — get a copy of the current window
  • PreTurnCheck() — run proactive compaction if needed
  • ReactiveCompact() — compact in response to model rejection
  • ApplyBackfill() — prepend checkpoint summaries

Compaction

When the context window exceeds 90% of the active model’s limit, compaction triggers:

  1. Split messages into “to summarize” and “preserved” (last 3 turns)
  2. Send the turns-to-summarize to the model as a separate API call with the dialect’s compaction prompt
  3. Replace old turns with the model’s summary
  4. 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:

  1. Embeds the current context window using the ONNX embedding model
  2. Retrieves the most recent checkpoint’s embedding
  3. Computes cosine similarity between the two
  4. 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:

  1. Search local checkpoints by embedding similarity
  2. If local results are insufficient and vault is configured, search team memory
  3. Verify signatures on all candidates
  4. Select top-k within token budget
  5. 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

PatternExamples
instruction_override“ignore previous instructions”, “you are now”, “act as if”
sensitive_path~/.ssh/, /etc/shadow, .env, id_rsa, private_key
base64_payloadLong 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:

  1. The signal is recorded in the checkpoint’s injections field
  2. A warning is displayed: checkpoint 3: injection signal in turn 7
  3. The checkpoint is still created (detection, not prevention)
  4. 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)

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

  1. Load global instructions from ~/.ghyll/instructions.md.
  2. Load global commands from ~/.ghyll/commands/.
  3. Check for <repo>/.ghyll/ — if found, load project instructions and commands (overriding global on name conflict).
  4. If .ghyll/ absent, try fallback folders (default: .claude/). Maps CLAUDE.md to instructions; commands/ loaded identically.
  5. 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/ pathTreated as
CLAUDE.mdinstructions.md (if no instructions.md exists)
instructions.mdinstructions.md (takes precedence over CLAUDE.md)
commands/commands/ (identical)
roles/Intentionally ignored (per ADR-008)

See also

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

  1. The parent model calls agent(task: "...").
  2. ghyll creates a fresh context with the dialect’s system prompt + workflow instructions (no plan mode).
  3. A mini turn-loop runs: send to model, parse tool calls, execute tools, repeat.
  4. 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 GlobalInstructions and ProjectInstructions are appended to the sub-agent’s system prompt (cmd/ghyll/subagent.go lines 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

LimitDefaultConfig key
Max turns20sub_agent.max_turns
Token budget50,000sub_agent.token_budget
Wall-clock timeout300ssub_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:

AvailableExcluded
bash, read_file, write_file, edit_file, git, grep, glob, web_fetch, web_searchagent, 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 Message is a single-package change
  • types/ must remain a leaf — no dependencies allowed
  • Slight indirection: types.Message instead of context.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:

  1. The dialect’s compaction prompt
  2. 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 CompactionRequest struct; cmd/ghyll wires 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:

  1. Routing tier — fast vs. deep, determined by context depth, tool depth, and user override.
  2. Model identity — the user-chosen name for a configured endpoint (e.g., "m27", "glm51").
  3. 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:

FieldMeaningExample
routing.default_modelFast tier model"m27"
routing.deep_modelDeep 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:

FamilyAPI contractFiles
"minimax"OpenAI-compatible, Lightning Attention characteristicsdialect/minimax.go
"glm"OpenAI-compatible via SGLang, DSA attention characteristicsdialect/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" and dialect = "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_model at 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:

  1. 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.
  2. 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 Roles field from workflow.Workflow; workflow.Load() no longer reads .claude/roles/*.md or .ghyll/roles/*.md.
  • Session.wf.Roles, Session.activeRole, and Session.SwitchRole() from cmd/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.md and .ghyll/CLAUDE.md). These compose into the system prompt per the invariants in specs/architecture/data-structures.md lines 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 /ultrareview in .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:

  1. Adversarial — separate instance attacks the upstream artifact.
  2. Remediation — bounded loop fixing or accepting-risk findings.
  3. 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.FallbackFolders no longer used for role discovery (still used for CLAUDE.md discovery).
  • The Workflow.Roles field is removed.
  • Operators who relied on custom roles in .claude/roles/*.md must 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.md encoded domain knowledge, that becomes arrow clauses in your project’s grid.
  • If your .claude/roles/X.md encoded prompts, that becomes a slash command.
  • If your .claude/roles/X.md encoded 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

  1. Keep both layers. Rejected: the gate system loses its enforcement guarantee the moment a role can argue with it.
  2. 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.
  3. Defer to a future release. Rejected: every release that ships with both surfaces compounds the v1↔v2 split this consolidation exists to close.
  • 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 → architect on-the-spot arrow: implementer or integrator may attest.
  • For an architect → implementer arrow: analyst or integrator may attest.
  • For an implementer → integrator arrow: analyst or architect may 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 ErrSelfCertImpossible and the operator must amend the workflow before retry.

Code

  • runner.ResolveOnTheSpot already enforces the role check.
  • Add sentinel ErrSelfCertImpossible for 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

  1. Source role only. Narrower reading of “producing”. Rejected: target-role self-cert leaves the beneficiary deciding whether the contract is acceptable, defeating the gate.
  2. 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.
  3. 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.
  • specs/architecture/gates.md §5.4, §12.2
  • runner/onthespot.go ResolveOnTheSpot
  • specs/invariants.md inv 27 (producers cannot self-certify)
  • specs/failure-modes.md FM-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’s DepthType / MinDepthTier assignment 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 in runner.FindingsStore.TransitionByOperator and the finding_transitions engine 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:

  1. Engine (sqlite) — authoritative, persistent. A new attestations table on engine.Store. Records are immutable once written.
  2. Runner (in-memory)runner.AttestationStore is 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 attestations table back into the cache before evaluation begins).

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: AttestationStore type with Record(rec AttestationRecord), Lookup(ref string) (AttestationRecord, bool), Observe(o AttestationObserver). Record validates 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.Replay loads attestation rows into runner.AttestationStore BEFORE grid replay.
  • New file runner/attestation_jsonl.go: AttestationJSONLWriter reads from journal events.

Test

  • Unit tests on runner/attestationstore.go for 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 DepthTypeAttestationRef resolve through the new store, removing the matching @deferred tags.

Alternatives considered

  1. Attestations on FindingsStore. Overloads a defect-tracking surface with verdict records. Rejected (different lifecycle).
  2. 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.
  3. Autoincrement attestation IDs. Simpler ID scheme but introduces the replay-ordering chicken-egg. Rejected.
  4. 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.
  5. JSONL writer reads from runtime cache. In-memory only; cache contents not durable. Rejected — audit trail must survive crash.
  • runner/runner.go:152DepthTypeAttestationRef field
  • engine/journal.go, engine/records.go, engine/queries.go
  • engine/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.Evaluate for 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 *ErrRoleContextBusy on 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 different passID has re-acquired the lock is a no-op (does NOT clobber the new holder). Silent no-op is the right semantic because defer 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, use TryAcquire and handle the busy error.
  • ExpireOlderThan(now time.Time) (expired int) — sweeps entries whose expiresAt is past now. Zero-expiresAt entries 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 > 0expiresAt = now + ttl. If the entry is still in the table after expiresAt, the next TryAcquire on 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 after ttl elapses 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: RoleContextLockTable and friends.
  • New sentinel ErrRoleContextBusy carrying the conflicting passID, role, context, and acquire-time.
  • Dispatcher integration in cmd/ghyll (and in BDD step helpers that simulate the dispatcher).
  • Engine status CLI surfaces InspectHolder output if any (role, context) tuple is held — informational, no decision-making.

Tests

  • Concurrent TryAcquire from two goroutines on the same key — one succeeds, one gets ErrRoleContextBusy.
  • 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

  1. Lock per Runner.Evaluate call. Rejected: wrong granularity. Two passes on the same (role, context) could interleave clause evaluations.
  2. sync.Map per key with one-shot atomic CAS. Simpler API but loses holder identity on contention. Rejected: ErrRoleContextBusy needs the holding passID.
  3. 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.
  4. 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.
  • specs/invariants.md inv 19, inv 23
  • specs/failure-modes.md FM-23
  • docs/decisions/v2/009-three-locks.md — three-lock topology
  • cmd/ghyll/lockfile.go — process-level single-session enforcement
  • runner/runner.goRunner.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.Pass publishes pass-opened / pass-closed.
  • runner.AmendmentCommitter publishes amendment-drained.
  • runner.AdversarialOrchestrator publishes adversarial-round-start / producer-fix-signal / remediation-converged / remediation-escalated.
  • runner.InsufficientBasisTracker publishes insufficient-basis-rounds-exceeded.
  • runner.AttestationJSONLWriter / AttestationTreeWriter publish attestation-audit-durability-failed.
  • runner.ProducerFixHarness publishes 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

  1. 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.

  2. 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.

  3. 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.

  • runner/operatorbus.go — implementation
  • runner/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 AmendmentCommitter needs 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 in Close / Abort.
  • Identity (passID, role, context, arrowID).
  • Lifecycle state (Open / Closed / Aborted).
  • Open-at and close-at timestamps + close reason.
  • An optional OperatorBus for 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

  1. 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.

  2. 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.

  3. 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.

  • 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:

  1. The adversary attacks the upstream artifact (clause falsification, open sweep, depth classification).
  2. Findings raised this round trigger a producer-fix signal.
  3. The producer attempts remediation (transitions findings, re-emits artifacts).
  4. The next round re-attacks the entire artifact.
  5. After remediation-rounds-max rounds 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.
  • 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:

  1. Adversary fires; reports any newly-raised findings.
  2. If no open-above-threshold findings AND no new findings this round → OutcomeRemediationConverged (early exit).
  3. ProducerRemediate runs (if set).
  4. Re-check open-above-threshold; if zero → OutcomeRemediationConverged (mid-round exit).
  5. 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

  1. 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.

  2. 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.

  3. 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.

  • 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:

  1. runner.Pass is in-memory only. The engine has tables for findings, arrows, amendments, attestations, evaluation_runs — but no passes table. A crash mid-pass loses pass state.
  2. Crash recovery semantics are under-specified. Today state-machine.md F-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.
  3. 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:

  1. Persist Pass state in v2 engine sqlite (new passes table).
  2. Crash recovery preserves passes with attestation-pending clauses; all other open passes become aborted:crash.
  3. JSONL becomes the source of truth for attestations. The engine attestations table 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 /passes shows 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.Record calls the JSONL observer synchronously inside the same critical section, before the store’s byID map mutates and before any other observer fires.

  • Replay: engine.Replay for attestations reads the JSONL file at session start, parses each line, and INSERTs into the engine attestations table (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_status to match the verdict (Part D).

  • JSONL state semantics (F-5):

    • Missing JSONL AND engine has zero attestation rows → fresh project, treat as empty stream. loadFromJSONL returns (loaded=0, truncated=false, err=nil). The file is created on the first successful Record.
    • Missing JSONL AND engine has attestation rowsErrAttestationAuditLost. Operator must restore or run ghyll attestations rebuild --force (separate escape hatch).
    • JSONL exists but unreadable / corrupt headerErrAttestationAuditLost.
    • JSONL exists but trailing line truncated (F-6) → lenient mode. loadFromJSONL returns (loaded int, truncated=true, err=nil), stops at the last complete record. Session.Open emits OpEventAttestationAuditDurabilityFailed with offset detail. The writer truncates the file at the last complete offset on the next successful Record so the bad bytes are overwritten cleanly.
  • JSONL observer special-casing for error channel (N-5): AttestationObserver func(event AttestationEvent) has no error return; observers can’t fail the Record call. 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 within Record’s critical section. If it fails, Record returns ErrAttestationAuditWriteFailed and the in-memory map is not mutated; other observers never fire. The AttestationObserver signature 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:

  1. OpEventAttestationRequested has zero publishers in the codebase today — the bus was a false detection signal.
  2. The depth_type_attestation_ref carried on evaluation_runs IS the persistent attestation-request signal: it lands when the runner persists the clause run via the existing Journal.handleRun path, 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 @deferred BDD 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: add passes to the CREATE TABLE block.
  • engine/records.go: add PassRecord type + UpsertPass function.
  • runner/projectstatus.go (PassRegistry): add Observe / emit mirroring FindingsStore.
  • runner/pass.go: OpenPass calls observer with kind=open; closeWith calls observer with kind=closed/aborted.
  • engine/journal.go: AttachPasses registers observer; handlePass routes to UpsertPass.
  • engine/replay.go: passes load step; calls Recovery after.
  • engine/recovery.go: new file, implements the five-step scan.
  • runner/attestationstore.go: Record calls 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 call Recovery after Replay, before attachJournal.

References

  • specs/architecture/components/pass-persistence.md (analyst output)
  • specs/architecture/components/state-machine.md F-4, F-6
  • specs/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:

  1. The verdict UI is an interactive modal in the chat REPL — blocks the turn until the operator answers, with a skip option for deferral.
  2. 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.
  3. 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 with ErrAttestationPassIDEmpty (gate-1 F-6).
  • context, stratum: stamped by the dispatcher at record- construction time so EncodeAttestationPath doesn’t need a Grid lookup (gate-1 F-2).
  • adversary_role: empty for normal records; populated by runner/adversarial.go when a verdict is captured during an adversary-phase pass (gate-1 F-3). Self-cert check extends to forbid adversary_role equal to source_role or target_role.
  • unit: one of confirm / record-locations-inspected / write-residue-note. Empty for pre-Tier-2 rows.
  • unit_payload_json: JSON object whose shape depends on unit. '{}' for confirm; {"inspected": [...]} for record-locations-inspected; {"residue": "..."} for write-residue-note.
  • hint_json: the dispatcher-synthesized hint shown to the operator. Default '{}' (NOT empty string) so the verifier’s json.Unmarshal parses pre-Tier-2 rows cleanly (gate-1 F-25). Shape: {"arrow_id": "", "clause_id": "", "concept": "", "attestation_ref": ""}. Tier 3 adds locations, 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) error method that mirrors AttestationJSONLWriter.PrimaryWriter(). No *Grid argument (gate-1 F-2 / F-19). Inline marshal + write
    • fsync; returns the error inline so AttestationStore.Record fails closed if the tree write fails.
  • The file is opened O_RDWR | O_APPEND (gate-1 F-11) so the writer can ReadAt for TruncateTrailingPartial.
  • A new TruncateTrailingPartialAll(root) error method that walks every <root>/v*/<ctx>/stratum-*/<role-pair>/<pass-id>.jsonl and 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 (NOT attestation_id) for the file name.
    • Reads context, stratum, source_role, adversary_role, target_role directly from rec. 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=true AND the PrimaryWriter appends path-truncated:<segment> to AttestationRecord.Reason AND publishes ErrPathComponentTooLong via 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).

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 @deferred scenarios 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 baseline CREATE TABLE attestations. (Pre-prod the v2→v5 ALTER chain was collapsed into a single fresh schema.)
  • engine/records.go: new fields on AttestationRecord (in the engine variant — runner’s runner.AttestationRecord mirrors).
  • runner/attestationstore.go: AttestationRecord gains PassID, Unit, UnitPayload, HintJSON. Validate(rec) calls ValidateUnitPayload.
  • runner/attestation_tree.go:
    • File name is <sanitized(rec.PassID)>.jsonl (was <rec.ID>.jsonl).
    • contextFromArrow + stratumFromArrow use the grid lookup; placeholder removed.
    • Add PrimaryWriter() method.
    • Implement EncodeAttestationPath.
  • runner/dispatcher.go: SynthesizeHint(c, def) + inline in the Publish(OpEventAttestationRequested) call.
  • cmd/ghyll/session.go: subscribe modalDriver; new DrainPending pre-turn step.
  • cmd/ghyll/modal/ (new package): OperatorModalPrompt interface + TermModal impl + StubModal for tests.
  • tests/acceptance/: BDD step bindings use StubModal / ScriptedModal to 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:

  1. Cached aggregatorProjectStatus lives 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.
  2. Pure read functionCaptureProjectStatus(sources) walks the stores at call time, returns a value. Surface reads are O(N stores). Cost: each call duplicates work.
  3. 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.go defines ProjectStatus, PassSnapshot, FindingStatusCounts, StatusSources, and CaptureProjectStatus.
  • The engine runtime exposes a ProjectStatus() accessor that builds StatusSources from its embedded stores and calls CaptureProjectStatus.
  • The /status REPL command + ghyll engine status CLI both render the snapshot via Render(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:

  1. Clause-falsification (try to make declared depth-sensitive clauses fail).
  2. Open sweep (find defects no clause names).
  3. 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.md or auditor.md as 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 synthetic adversary role-id per ADR-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-idUse
initProducer for the initialization arrow’s attested clauses. Bound to a fresh harness instance at project start.
adversaryProducer 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 path
  • analyst__adversary__architect — the analyst→architect arrow’s adversarial-phase path
  • implementer__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.md or adversary.md role 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]any blob 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.md open question 3).
  • Type system: common arg types — path-glob, artifact-ref, language-id, severity, pass-result — are reused across concepts (defined in concepts.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 frequently invalidated is a sign the operator should upgrade those dependencies to section or clause-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 invalidated against 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 becomes invalidated; 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 an insufficient-basis clause upstream after N rounds (ADR-009
    • gates.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 running passes.
  • 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 of grid-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.

LockOwnerScopeUsed by
Per-clause transition lockState Machine engine(pass-id, clause-id)All callers proposing clause-status transitions
Per-(role, context) lockRunner(role-id, bounded-context-id); expires on pass terminationEnforces single-active-role-instance at pre-spawn
Project-wide grid write-lockAmendment componentProjectHeld 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:

  1. State Machine engine recovers first — reads checkpoint log, reconstructs running-pass state, marks orphan running passes as aborted with reason: crash.
  2. Amendment component recovers second — validates .ghyll/grid.current matches an existing grid file; alerts on divergence; cleans up orphaned temp files; releases any crashed-while-held lock.
  3. 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.current is a small pointer file. One line: v<N> naming the active version.
  • Atomic update sequence on amendment / init write:
    1. Write content to .ghyll/grid.v(N+1).yaml.tmp.
    2. fsync() the temp file.
    3. fsync() the containing directory.
    4. rename(.tmp, grid.v(N+1).yaml) (atomic).
    5. fsync() the directory.
    6. Write .ghyll/grid.current.tmp containing v<N+1>.
    7. rename(grid.current.tmp, grid.current) (atomic).
  • Init’s first write produces grid.v1.yaml and grid.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 see grid.v(N+1).yaml intact 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.current points at missing file → engine refuses to start; operator must restore or re-point.
    • grid.current corrupted → engine refuses with grid-current-malformed.
    • Multiple grid versions but grid.current absent → 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:

  1. Auto-propose: harness drafts each clause from roles/<role>.md’s exit-gate table.
  2. 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.
  3. 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 adversary instance 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.md entries 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:

  1. The amendment is enqueued (FIFO).
  2. The amendment component acquires the project-wide write-lock (per ADR-009).
  3. The analyst is re-engaged; the amended spec is produced; the v(N+1) arrow grid is computed.
  4. Dependency check against in-flight passes:
    • Affected (dependency on a changed artifact, or no dependencies declared — conservative fallback): pass is aborted with reason: invalidated; arrow status becomes invalidated.
    • Unaffected: pass continues against vN and records completion normally.
  5. Findings discovered before abort are retained, tagged with their original grid-version so they’re distinguishable from findings on the new vN+1 arrow.
  6. Grid v(N+1) is written atomically per ADR-010.
  7. 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:

  • compiles covers parse/build, not test execution.
  • mutation-score measures the quality of tests that already pass; it presupposes tests run.
  • kill-server-fails-integration checks 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. Produces pass: boolean plus passed/failed/skipped/timed-out counts 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 its lint-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-pass records 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:

  1. Flat <concept>.<language> string, with universal concepts using the bare concept name.
  2. Nested registry: map[concept]map[language]Evaluator.

Decision:

Flat key. ConceptRegistryKey(c Clause) string returns:

  • c.Concept verbatim for language-bound:false concepts.
  • c.Concept + "." + lang for language-bound:true concepts, where lang is sourced safely from c.Args["language"] (no bare type assertion — non-string values return a sentinel that guarantees Lookup misses cleanly).

The auto-derived classification (IsLanguageBoundConcept / IsUniversalConcept in runner/concept_classification.go) is the source of truth for which branch applies.

Consequences:

  • One *Registry instance 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 enable refuses with no-dialect-configured when 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 with adversarial-hooks-not-wired rather 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:

  1. Acquire committer.mu.
  2. Validate FIFO head (R22): the amendment ID must match the queue head; on mismatch return ErrAmendmentCommitFIFO with the queue intact.
  3. Build the in-memory registry snapshot via Registry.Snapshot()
    • registerGridBindings(snapshot, overlay, workdir). On construction failure, abort BEFORE any mutation.
  4. Abort in-flight passes whose ArrowID == amendment.SourceArrow.
  5. Append NewArrows to the in-memory grid; bump grid version.
  6. 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 of rt.registry; concurrent dispatchers see OLD or NEW, never partial.
  7. Queue.MarkDrained(amendment.ID).
  8. Publish OpEventAmendmentDrained with typed Payload.
  9. 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.Registry gains Snapshot() and SwapInto() methods.
  • AmendmentCommitter gains an optional BindingsReRegister callback that returns a snapshot registry (the implementation lives in cmd/ghyll per 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:

  1. A hand-maintained constant table in runner/.
  2. Auto-derive from the embedded gates/concepts/*.yaml schemas 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) == 18
  • len(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 by registerGridBindings in cmd/ghyll).

Consequences:

  • runner → catalogue is a new import edge. Verified safe: catalogue imports the root ghyll package 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:

  • outcome is a closed enum: the terminal state of the event’s subject. Subscribers switch on this. Values per event kind are enumerated in specs/v4/diamond-load-bearing-revised-v2.md “OperatorBus payload contracts”.
  • reason is 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 kindRequired Payload keys
OpEventAdversarialRoundStartarrow_id, pass_id, round, rounds_max, open_findings, tier_label
OpEventRemediationConvergedarrow_id, pass_id, outcome ∈ {converged, converged-with-unevaluated}, rounds_used
OpEventRemediationEscalatedarrow_id, pass_id, outcome ∈ {escalated-rounds-max, escalated-no-progress, escalated-hook-error, context-cancelled}, rounds_used, reason
OpEventAmendmentEnqueuedarrow_id, amendment_id, source_arrow, target_role, finding_ids
OpEventAmendmentEnqueueRefusedarrow_id, amendment_id, outcome ∈ {queue-full, duplicate-id}
OpEventAmendmentDrainedamendment_id, source_arrow, grid_version_before, grid_version_after, arrows_added, passes_aborted, outcome ∈ {complete, partial-append-error, binding-re-register-error}
OpEventRecoveryAmendmentsPendingcount, amendment_ids
OpEventArrowInvalidatedarrow_id, op_id, reason, timestamp
OpEventPassClosedarrow_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 switch on Payload["outcome"] covers every terminal state.
  • Adding a new closed-enum value to outcome is an additive change; subscribers that don’t recognize it fall through to a default branch and surface reason to 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 *PassRegistry is 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 (nil and DepthRankNone are valid defaults for tests that don’t exercise the runner-typed dispatch).
  • Registry.Count() reports the union of both tables — the cardinality assertion in RegisterBuiltins counts 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/ghyll is the integration boundary — it already imports BOTH runner AND bootstrap.
  • cmd/ghyll is a leaf package — no production code imports it, so the new edges cannot cycle.
  • The helpers are package-local; the runtime construction site (openEngineWithOptions and the amendment-driven re-register callback buildRegistryOverlay) lives in the same package.

Alternatives considered + rejected:

  • New wiring/ package: would still need to be imported by cmd/ghyll to be called. Splits the seam without resolving the cycle.
  • RegistryInterface in bootstrap/: moves binding logic AWAY from runner where the Registry type 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/ghyll lifts both helpers and their tests as a unit.
  • The arrow-overlay conversion (runner.ArrowDefinition → map[string]any for the in-memory bootstrap.Grid overlay) ships as cmd/ghyll/arrow_marshal.go with 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 to engineRuntime.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:

  1. passes.remediation_outcome + passes.remediation_rounds_used columns for the adversarial cycle’s per-pass summary.
  2. A new arrow_invalidations table for the /invalidate-arrow operator 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: PRAGMA table_info(passes) to detect column existence; on miss, run ALTER TABLE passes ADD COLUMN remediation_outcome TEXT NULL and ALTER TABLE passes ADD COLUMN remediation_rounds_used INTEGER NULL. Idempotent (safe to call on fresh DBs).
  • migrateAddArrowInvalidations(db *sql.DB) error: PRAGMA table_list detection; 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.db migrates on first open with no operator action.
  • JSONL audit records remain the authoritative attestation log per ADR-015; the passes table extension is a search shortcut only. The cycle’s full report is logged as a JSONL record of kind adversarial-cycle-report.
  • The migration is the only schema-mutating path on open; a future v5 adds new tables / columns the same way.
  • The arrow_invalidations table is read at Replay to populate runner.Grid.Invalidations; /run-arrow consults this map and forces re-traversal even if the cached arrow status is complete.

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:

  1. Refuses without an active /op-id (the row carries operator identity).
  2. Refuses on unknown arrow-id (looked up via the live Grid).
  3. Publishes OpEventArrowInvalidated with 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 omitempty JSON 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 smuggles reasoning_content onto a user turn does not corrupt the assistant-turn-only contract.
  • The single producer for reasoning_content is the Kimi dialect family; if other dialects later surface their own reasoning field, they SHOULD route through Message.ReasoningContent and 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.