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

Yupana

Yupana is an in-memory, multi-tenant code-analysis engine — the structural signal for the Bobbin × Quipu stack. It extracts precise structure from a codebase (AST, symbols, call graph, and — in later phases — control/data dependence and LSP facts), keeps it hot per tenant, and serves it over MCP (stdio and streamable-HTTP). A parallel REST HTTP API for non-MCP consumers is planned for Phase 3 (FR-27).

Bobbin holds the thread. Quipu ties the knots. Yupana keeps the working coil — live, per-tenant, ready.

The three tools

  • Yupana — extracts and serves live per-tenant structure.
  • Quipu — governs and versions the committed record (bitemporal RDF / SPARQL / SHACL).
  • Bobbin — fuses everything with its embedding and co-change signals and serves explained context over MCP.

Where to start

Status: early Phase-1 scaffold. See the phasing for what is built and what is next.

Map of all docs

Every document in this repository, grouped by what it is for, with its status. If you are new, read Installation and Quick Start first; everything else here is for when you need it.

Documents outside the book live under docs/ in the repository and open on GitHub.

Start here

documentwhat it gives you
READMEinstall, a first success, and wiring into an agent
Installation · Quick Startthe same path, in more depth
Configuration · Harness Integrationmaking it part of your workflow
Why Yupana · The Stackwhat it is for, and how it fits with Quipu and Bobbin

Reference

Everything under Reference in the book's sidebar: the CLI, configuration, MCP tools, the resident daemon, the pre-edit policy guard, golden-path conformance, the enforcement trace, and share bundles.

outside the bookstatus
Agent action certification: the yupana certify record formatcurrent

Specification and vision

documentstatus
Specification: the full build spec (requirements, architecture, phasing)living; the reference for FR numbers
Vision: Bobbin × Yupana × Quipunorth star

Design

The book's Design section summarises the addenda below and links to them.

documentstatus
Golden-path conformance guard (FR-40..42)built, first cut
Game-state and policy harness (FR-35..39)built
Work-scoped agent governancepartially implemented; the doc says which parts
Governed landing policydesign, advise tier first
Paper plan: evidence-local constraint enforcementplanned

Research and evaluation

documentstatus
Briefing retrieval evalmeasured 2026-08-15; reproducible with just e2e f1
E2E grounding evalreference

Working on Yupana

documentwhat it covers
Contributingbuild, test and the pre-push gate
Releasinghow release-plz cuts a release, and the crates.io lane

Historical

Kept for the record. They describe decisions already made, not how things work today.

documentwhat it records
Rename: hank → yupanathe 2026-08-09 rename; hank survives only as a compatibility name

Installation

Yupana is a Rust project built with just.

Source-build prerequisites

  • Rust (stable) — the project targets edition 2021.
  • A C compiler (cc/gcc) — tree-sitter grammars compile a small C parser.
  • just, and for docs mdbook, npx (markdownlint/prettier), and vale.

Build from source

git clone https://github.com/scbrown/yupana
cd yupana
just build            # or: cargo build --release

The binary is produced at target/debug/yupana (or target/release/yupana).

Install locally

just install-release 0.12.0

# Developer build from this checkout (includes uncommitted source):
just install

install-release downloads the published Linux x86_64 archive and verifies its .sha256, version, and exemplar, verifier, and verdicts capabilities. It installs the exact archive binary without compiling the checkout. This path requires curl, Python 3, flock, and Linux file utilities, but not Rust.

install builds the current checkout, including uncommitted changes. Its banner identifies the source commit and dirty state. Dirty shared checkouts with linked worktrees are refused; use your own worktree for source development.

Both paths publish atomically under an install lock to ${CARGO_HOME:-~/.cargo}/bin/yupana and install hank alongside it as a relative symlink — the same default cargo install itself uses, so this agrees with quipu, bobbin and anything else in the stack installed via plain cargo install. Set YUPANA_INSTALL_ROOT to use another prefix. To roll back a release, run just install-release with the previous version.

Install the git hooks

just setup            # installs pre-commit hooks

Quick Start

Build the binary, then point it at a source tree.

# Build the base graph for a directory and print a summary
yupana analyze src

# Find the definition sites of a symbol by name
yupana refs authenticate src

# Show the base ref, active tiers, and configuration
yupana status

# Generate shell completions
yupana completions bash > yupana.bash

Every command accepts the global flags --json, --quiet, --verbose, --tenant <id>, and --config <path>.

Call graph and blast radius

# Direct callers and callees of a symbol
yupana callers authenticate src

# Blast radius: what changing a symbol transitively affects
yupana impact authenticate src --hops 5 --json

# Reconcile the structural impact against a co-change set (from Bobbin):
# corroborated = real coupling; co-change-only = possible refactoring smell
yupana impact authenticate src --cochange cochange.json

# Intra-procedural data dependence: where a value comes from / flows to
yupana dataflow authenticate src --var token           # what `token` depends on
yupana dataflow authenticate src --var token --forward # what `token` flows into

# Export the referential structure (modules, symbols, calls, imports) as governed RDF
yupana export src --repo myrepo --format turtle

The export is the governed projection of the live graph — precise, typed referential structure in the bobbin: code ontology, not embedding chunks. It is the substrate under Phase-4 promotion into Quipu; see the Specification §5.10 and §9.

The MCP server

Built with the mcp feature, yupana serve exposes fifteen yupana_* tools over MCP — starting with yupana_status, yupana_symbols, yupana_references, and yupana_analyze (the full set is in the MCP Tools reference):

cargo run --features mcp -- serve         # stdio, for a local agent
cargo run --features mcp -- serve --http  # streamable-HTTP at :3040/mcp

See the MCP Tools reference.

What works today

analyze, refs, status, the call-graph commands callers/impact, dataflow, verify (the FR-23/FR-24 edit-buffer verdict), and the fifteen MCP tools are live. Only promote is still declared with its final shape and prints a phase notice until its engine lands — see the Specification.

Configuration

Yupana shares the stack's .bobbin/config.toml under a [yupana] table, with the same resolution order Quipu uses: compiled defaults are overlaid by the user config (~/.config/bobbin/config.toml), then the project's .bobbin/config.toml. CLI flags win over all of them.

See the full Configuration Reference for every key and its default.

[yupana]
base_ref = "main"
# Restricts `yupana analyze` to these languages.
languages = ["rust", "typescript", "python", "go", "java", "cpp"]

[yupana.serve]
bind_address = "127.0.0.1"
mcp_http_port = 3040
# When true, yupana refuses mutating operations (promotion).
read_only = false

[yupana.quipu]
enabled = false
branch_model = "qualifier"

enable_lsp, enable_cpg, and the [yupana.tenancy] limits exist but are not yet read — see the Configuration Reference, where each is marked with the phase that will honour it.

Harness Integration (Claude Code Hooks)

Yupana can plug into an agent harness so it responds to edits synchronously, the way a language server reacts to a keystroke. The agent's edit tool call is the change event; a harness hook feeds it to Yupana, and Yupana replies inline — no tool call for the agent to remember.

For Claude Code this uses hooks.

Post-edit advisory (available now)

yupana hook post-edit reads the PostToolUse payload on stdin and, when the edited file has symbols called from other files, returns a cross-file blast-radius advisory as injected context.

Wire it in .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command", "command": "yupana hook post-edit" }
        ]
      }
    ]
  }
}

After the agent edits src/auth.rs, it sees something like:

Yupana (tree-sitter): your edit to src/auth.rs touches symbol(s) with callers
elsewhere — re-check these still compile.
  authenticate <- 3 caller(s)
  verify_token <- 1 caller(s)
Impacted files: src/api/login.rs, src/api/session.rs

The advisory is emitted only when there is cross-file impact, and the hook never fails the harness (no output = nothing to say).

With the quipu feature and the work-item scope rung at advise, the same hook also delivers the out-of-ground notice as additionalContext: the edit has landed, nothing is blocked, and the agent is told — before its next action — that it stepped outside its work item's ground, with what the graph knows about that path (who worked it before, and how that went). This channel exists because a pre-edit systemMessage reaches the operator's pane, not the model's context; see the policy guard contract.

Work-item briefing (available now, quipu feature)

yupana hook session-start (SessionStart) injects the graph's knowledge of the agent's tracked work item into its context at assignment time — before the first edit, not at the first denial: the item's ground (paths prior work touched, with symbols and caller locality), the central entities around it (quipu pagerank), semantically similar past items with their outcomes so successful work gets reused, related in-flight items, the governed rules in force, and the scope posture. Silent when no plate or no quipu seam.

Candidate work-item identities are fetched in deduplicated batches of at most 32 entities, preserving each item's label and outcome without a network request per candidate. A failed batch contributes no identities and does not trigger individual retries. Search, context ranking, and the policy projection keep their existing freshness behavior; this does not cache a brief across sessions.

What lands in context is the L0 briefing: the item, its ground paths and the scope posture, plus a census of every section held back — one line each, naming how many items it holds and the real quipu_ask / quipu_context / quipu_project call that expands it. The census is the whole difference between a briefing that is small and one that is incomplete: a short briefing with no counts is indistinguishable from a graph that knew nothing. Sections with zero items are omitted entirely rather than shown as empty headings.

Division of labor with Bobbin: Bobbin's own hooks (bobbin hook install — inject-context on UserPromptSubmit, session-context/prime-context on SessionStart) own semantic code context; this briefing owns the governance/work-item context. Wire both side by side — the briefing deliberately does not nest a bobbin call, so the same context is never injected twice.

{
  "env": { "SHANTY_ROOT": "/gt", "SHANTY_AGENT": "polecat-3" },
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "yupana hook session-start", "timeout": 15 }
        ]
      }
    ]
  }
}

Pre-edit guard (available now)

yupana hook pre-edit (PreToolUse) checks an edit before it lands against the calling tenant's capability scope, and can deny it with a reason the model can act on. Blocking is opt-in and off by default.

{
  "env": { "BOBBIN_ROLE": "polecat-3" },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command", "command": "yupana hook pre-edit", "timeout": 5 }
        ]
      }
    ]
  }
}

With a scope configured for that tenant (see Configuration), an over-reaching edit comes back as:

yupana: editing `src/core.rs` reaches 4 symbols (ceiling 2) and 4 files
(ceiling 1) — beyond the blast radius allowed for tenant `polecat-demo`.
Split this into a narrower change that touches fewer callers, or ask for a
wider capability scope. (tree-sitter tier: the reach is an approximation.)

The guard always fails open: every error, timeout, and unparseable payload allows the edit, because a guard that fails closed would brick every agent the moment Yupana is unavailable. Read the full contract before wiring it into a fleet — particularly the rules that allow is silence and that Yupana never exits 2.

The action surface (pre-bash)

yupana hook pre-bash (PreToolUse on Bash) records each command's resolved (verb, target, class) for the enforcement trace, and is record-only by default: it prints nothing unless a deployment explicitly sets [yupana.policy] action_scope (off out of the box), at which point a declared scope's allow_targets / deny_targets can advise or refuse a command. Commands whose target is not unambiguous from syntax are abstentions, never violations. See the guard contract.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "yupana hook pre-bash", "timeout": 5 }
        ]
      }
    ]
  }
}

Performance note

By default the hooks build the call graph transiently on each invocation. With a running resident daemon and [yupana.serve] use_daemon = true, both hooks become thin clients of the resident graph — no per-invocation build, which is what meets the sub-100ms budget a synchronous guard needs. The daemon being down degrades differently per hook: the pre-edit guard fails open loudly, the post-edit advisory falls back silently (it is advice, not enforcement). See the specification §5.9 (FR-30/FR-31).

Why Yupana, and how it compares

Structural code intelligence is not new, and the strongest tools each prove out one signal. Yupana takes the best idea from each, then adds what none of them have: a whole team editing at once, governance, and time.

What it is for

  • 🧵 Correct while a team edits. A shared base graph plus a copy-on-write overlay per developer or agent keeps every view right while many of them edit the same code at once. See The Tenancy Model.
  • 🔀 Fusion, not one signal. Call and dataflow structure, plus historical co-change, plus embeddings. A coupling backed by a dataflow path is real; one without it is a refactoring smell, and only fusion tells them apart.
  • 🪢 Governed and time-travelable. Committed facts promote into Quipu as SHACL-validated, bitemporal RDF: a versioned source of truth, not a best-effort cache. See Promotion to Quipu.
  • 💥 Blast radius as a primitive. "What will this change break" is one query, per tenant, and it doubles as the incremental-update engine.
  • ⚡ Two-tier freshness. Tree-sitter speed for breadth and LSP precision for depth, with every fact tagged by the tier that produced it so an agent knows what it is trusting. See Tiers and Freshness.
  • 🛡️ Structure scopes the sandbox. Per-tenant blast radius bounds what an autonomous agent may touch, and can act as a guardrail on what it generates, not only as context.
  • 🪙 Token-cheap. Structural answers instead of whole files in context.

How it compares

codebase-memoryJoern (CPG)LSP / multilspyEmbeddings / co-changeYupana
Fast structural graph, low token cost✅⚠️❌✅✅
Call graph + dataflow / taint⚠️✅⚠️❌✅
Precise LSP-grade typestiered❌✅❌tiered
Incremental freshness on edit✅❌✅❌✅ (frontier-bounded)
Correct while a team edits concurrently❌❌❌❌✅ (per-tenant overlays)
Governed, versioned, time-travel record❌❌❌❌✅ (→ Quipu)
Blast radius scopes an agent trust boundary❌❌❌❌✅

Each of these proves one piece. multilspy shows that LSP facts can also guard what a model generates; Joern established the Code Property Graph and dataflow; codebase-memory is a lean standalone analyzer with content-hash incremental freshness. Yupana is closest in spirit to codebase-memory, extended with Joern-style dataflow, LSP precision, tenancy, and a governed projection into Quipu.

The advantage is not any single signal. It is fusion, governance, time and tenancy, kept correct while a whole team edits.

What is built today

  • Structure: analyze, refs, callers, impact (with --cochange reconciliation), intra-procedural dataflow, and verify, which returns a verdict on a proposed edit buffer. See the CLI reference.
  • Serving: an MCP server (yupana serve) exposing the yupana_* tools, and a resident daemon (yupana daemon) that keeps the base graph and per-tenant overlays hot. See MCP Tools and Resident Daemon.
  • Governance, around the graph: the pre-edit policy guard (scopes, structural and text rules, tripwires, and the work-item scope ladder), the record-only Bash action hook, session-start work-item briefings, the game-state harness, and the golden-path conformance guard. See Pre-Edit Policy Guard.

Rules that come from Quipu start at the advise tier: they warn, and they block only once their own enforcement gates are met. yupana exemplar drafts rule candidates from an example of what should have been caught, and yupana verifier prints the key that signs verdicts, for registering in Quipu. The full phasing is in the specification.

The Stack: three tools, one job each

        edit / save / file-watch
                 │
                 ▼
   ┌──────────────────────────┐   promote on commit/merge   ┌──────────┐
   │          YUPANA          │ ──────────────────────────► │  QUIPU   │
   │  base graph + overlays   │   (SHACL-validated Turtle)  │ EAVT log │
   │  tree-sitter + LSP + CPG │ ◄────────────────────────── │ SPARQL   │
   └────────────┬─────────────┘  SPARQL over committed code └──────────┘
                │ blast radius (per tenant)
                ▼
        ┌───────────────┐     trust boundary      ┌──────────┐
        │ Bobbin fusion │◄────────────────────────│  agents  │
        │ + serving     │────────────────────────►│          │
        └───────────────┘   explained context     └──────────┘
  • Yupana (this project) extracts and serves live, per-tenant code structure.
  • Quipu governs and versions the committed record: bitemporal RDF, queried with SPARQL, validated with SHACL.
  • Bobbin fuses everything with its statistical and embedding signals and serves explained context over MCP.

The north star is the vision document; the full build spec is the specification.

What Yupana and Quipu unlock together

Yupana holds the live structure; Quipu governs the committed record. Together they do things neither does alone:

  • Governed SPARQL over code. Query committed structure as typed, validated facts: "every public function with no test", "modules that violate the layering", "who still calls this deprecated API". Not a cache you hope is fresh.
  • Impact over history. Bitemporal facts answer what a change broke and when a coupling first appeared: blast radius that accounts for how the code got here, replayable at any point in time.
  • Ontology rules that block or steer changes. Architectural constraints are authored as rules in Quipu (SHACL over the code graph). Yupana evaluates a proposed edit against them, live and per tenant, and warns or blocks before it lands. A new rule is a graph assertion, not a new bespoke linter.
  • Per-tenant parallel worlds. A shared base plus copy-on-write overlays map onto Quipu named graphs, so a whole team edits at once without corrupting each other's view, over one always-queryable source of truth.
  • Agent trust boundaries. Per-tenant blast radius scopes what an autonomous agent may touch: the structure defines the sandbox.
  • Code linked to intent. Quipu provenance ties structural facts to the decisions and work items that produced them: "which decision does this module implement", "which tickets co-occur with this code path".
  • A decidable audit. Every enforcement decision emits a trace record derived from the constraint set, plus an ed25519-signed verdict bound to what was actually checked. quipu audit <trace> then decides mechanically whether the trace satisfied the constraints, without access to the model, its prompts, or its developers. See The Enforcement Trace for the record, and SARC Conformance for what the pair does and does not yet close.

Architecture

Yupana is a peer of Bobbin and Quipu, not a feature of either. It quarantines the heavy language toolchains (tree-sitter grammars, LSP servers, any CPG/dataflow machinery), holds a hot structural graph, and feeds three consumers: Bobbin (fusion), Quipu (promotion on commit), and the Gas Town broker/Aegis (per-tenant blast radius as a trust boundary).

   MCP (rmcp: stdio + HTTP/axum)  ·  CLI (clap)
   (a parallel REST HTTP API is Phase 3 — FR-27)
                    │
              Query / Serve layer
   refs · defs · callgraph · dataflow · blast-radius · verify
   (resolve against base + tenant overlay, tier/freshness tagged)
                    │
              Tenancy layer
   shared read-only base  +  per-tenant copy-on-write overlays
                    │
              Extraction layer
   tree-sitter (breadth) · LSP (precision) · CPG/dataflow (Phase 2)
                    │
   file-watch (notify) · git baseline · overlay cache
                    │
   Promotion boundary → Quipu (SHACL-validated Turtle)

The routing rule

Bobbin is on the request path only when fusion or ranking adds value. Multi-signal retrieval goes through Bobbin. Single-signal, analysis-only queries — edit verification, blast radius, live structure lookups — go straight to Yupana, and policy consumers like the broker read Yupana directly.

See the Specification §7 for the full component diagram and the proposed source layout.

The Tenancy Model

A team means there is no single "the AST." Each developer sits at some branch/commit plus an uncommitted working delta, and those deltas diverge. Yupana resolves this with shared base + per-tenant overlay (copy-on-write).

  • Shared base. The full structural graph is computed once at a baseline commit and held read-only in memory.
  • Per-tenant overlay. Each session gets a lightweight overlay: only touched files are re-parsed, only affected edges recomputed, layered over the base. Queries resolve against base + overlay. An overlay is invisible to other tenants — isolation is automatic.
  • Content-hash sharing. With structural sharing, N developers cost one base plus N small deltas, not N full graphs.

Blast radius is the incremental-update primitive

The overlay is not just the edited file — it is the edited file plus its frontier. When a signature changes, every reference to it (possibly in files the developer never opened) now has different facts. So the updater queries the base graph for the references and dependents of the changed symbols — the same reachability query that answers "what does this change affect?" for a consumer.

One primitive, two uses — build it once.

See the Specification §5.5 for the frontier algorithm, eviction policy, and high-fan-in handling.

Implementation state

The engine above exists as a library (yupana #2): graph::Base (shared, read-only, Arc-shared, built at a resolved commit with per-file content hashes), graph::Overlay (owned re-parses of touched files only — O(touched), never O(repo)), and graph::TenantRegistry / graph::TenantView (the per-query base + overlay composition, walked by the same FR-12 BFS as every other graph). Isolation is structural: a view composes exactly one tenant's overlay, the base is immutable, and interned parses are shared by content hash (FR-15) without sharing any view state. The tests/overlay_isolation_tests.rs suite pins §6.3 absolute isolation, masking/revert/deletion, and the cost shape.

The resident daemon wires this live: it holds the registry (base at the startup HEAD), POST /edit is the FR-30 feed (the post-edit hook calls it per save), query endpoints take tenant=, and /status reports the base commit and active overlays.

The FR-16 frontier is graph::update_frontier (yupana #3): editing a symbol perturbs its transitive callers AND callees, so the recompute is bounded to that reachable frontier — the second caller of the one reachable() BFS (FR-12), never a second traversal. The base also keeps a call-site index (callers_of_name) keyed by callee name, which is what lets an overlay-NEW symbol (one with zero base definitions) find its base callers — the case a naive per-file update misses.

On-disk edits drive this automatically (FR-17, yupana #5): a notify watcher (watch::OverlayRefresh), .gitignore-filtered and debounced, touches the tenant's overlay on the fast tier and runs update_frontier on the deferred heavy tier, tracking per-file freshness (recomputing while the frontier is pending, fresh after).

Overlay lifecycle (FR-18/§14.2, yupana #6) is the registry's job: sessions open on first touch and close explicitly (close_session), reset clears a tenant to base, and live overlays are capped at [yupana.tenancy].max_overlays — a new overlay past the cap evicts one per overlay_eviction (lru, or oldest-created as the on_session_close backstop), always logged. A symbol whose direct fan-in exceeds high_fanin_threshold has its frontier cascade clipped to one hop, so a widely-referenced signature edit cannot blow the recompute budget — also logged, never a silent truncation. This completes Phase 3.

Tiers and Freshness

Full LSP-grade resolution on every keystroke is too expensive, so Yupana serves tiered facts — and every fact it serves is tagged so a consumer never mistakes an approximation for ground truth.

Tier — how a fact was derived

  • treesitter — fast, build-free, approximate. Always-on breadth; works on a syntactically-broken buffer.
  • lsp — precise definitions/references where a build resolves. Available with the lsp feature for Rust and TypeScript/JavaScript; runtime server absence degrades to explicitly tagged tree-sitter facts (FR-2/FR-4).
  • cpg — control/data dependence from the code property graph. Planned (Phase 2, FR-7); not yet implemented or served.

Freshness — how current a fact is

  • fresh — reflects the latest observed edit.
  • stale — known to be behind a pending edit.
  • recomputing — a recompute is in flight.

Freshness names two different questions, and Yupana keeps them apart:

  • Code-fact freshness — is this structural fact current with the file it came from? The watch path tracks it per file (Recomputing → Fresh transitions in src/watch/overlay_refresh.rs), and since Phase 3 (bobbin-052) the resident daemon serves it on the tenant-scoped /symbols reply — read before the view is composed, so the tag describes the state the symbols came from. Everywhere it is unknown, the field is omitted rather than faked: the untenanted path has no tenant to key the map by, a tenant that never had an edit absorbed has no note to report, and the on-demand (non-daemon) serve path rebuilds per request so no cached code fact can be stale. Absent, never "fresh", never "unknown".
  • Projection freshness — are the governed rules a verdict enforced still the current ones? This is served today: the pre-edit guard's rule verdicts state fresh / stale (with the cache age in seconds) / recomputing against the policy projection from Quipu, and promoted verdicts carry aegis:freshness. It describes the policy registry, never a code fact.

Tree-sitter structure updates on save or debounced keystroke. Once the LSP and CPG tiers land, agents that need certainty will ask for lsp/cpg and agents that need breadth will take treesitter and know it — but today only treesitter is served, so a fact's tier tells you which of these you actually got, never a precision the build cannot provide.

Promotion to Quipu

Yupana holds the volatile, per-tenant, in-flight reality. When changes land on a shared branch (commit/merge), the corresponding facts are promoted into Quipu as a new bitemporal state — valid-time = commit time, transaction-time = when learned. Quipu holds the settled, governed, versioned record; Yupana holds what's in flight. Uncommitted churn never pollutes the governed graph.

Export — the governed projection

yupana export is the projection Yupana promotes: the precise, typed referential structure (modules, symbols, definedIn/calls/imports, and — as the markdown extractor lands — Document/Section + references), emitted as RDF Turtle in the bobbin: ontology. This is not Bobbin's chunking; it is structure for reasoning and governance.

Module dependencies (bobbin:imports, CodeModule → CodeModule) are resolved from use/mod declarations at the tree-sitter tier — best-effort by module stem, so they over-connect on shared names like any tree-sitter-tier fact; the lsp tier refines them.

yupana export src --repo myrepo --format turtle    # dump the referential graph
yupana promote --commit HEAD --to "$QUIPU_URL" .   # SHACL-validate + write it

--repo is data identity, not a label

The repository name is a segment of every entity IRI (.../code/<repo>/<path>), so it decides whether two exports of the same code describe one graph or two. --repo wins; otherwise the origin remote names the repository. With neither, promote refuses, and plain export falls back to the directory basename and says so on stderr — the same source under myrepo/ and myrepo-worktree/ shares zero entities, which a consumer imports as a parallel graph rather than as a conflict. Pass --repo for any export that is captured, shared, or imported.

yupana promote needs --features quipu; without it the binary says so rather than pretending. It emits the Turtle, SHACL-validates in-process against shapes/, and writes to /knot only if it conforms — a rejected promotion exits non-zero so a script can't read it as landed.

When promotion runs — promote_on and --trigger

Yupana installs no git hooks and owns no commit event of its own, so it does not decide when a commit happened — the caller that has the event says so, and [yupana.quipu] promote_on decides whether that event promotes:

# .git/hooks/post-commit — fires on every commit, promotes per policy
yupana promote --commit HEAD --trigger commit --to "$QUIPU_URL" .
promote_on--trigger manual (default)--trigger commit, plain--trigger commit, merge commit--trigger merge
manualpromotesskippedskippedskipped
commitpromotespromotespromotespromotes
merge (default)promotesskippedpromotespromotes

Two things worth knowing. A commit event on a merge commit counts as a merge — git knows (two or more parents) even when the hook does not — so the default policy works from the simplest possible post-commit hook. And --trigger manual always promotes: promote_on governs automation, not authorization. --to remains the only thing that authorizes a write, and serve.read_only the only thing that refuses one.

A skipped promotion exits 0 and prints SKIPPED … Wrote nothing. That is deliberate: a hook that failed on every ordinary commit gets switched off within a day, and the sentence carries the fact the exit code would have. An unrecognised promote_on value is refused, not defaulted — a typo must not be indistinguishable from the key working.

Large projections are chunked: Quipu's request body limit is ~2 MiB, so a projection over the line is split on entity-block boundaries and posted as multiple /knot writes (the output says so: ... in 3 chunks). Validation is still whole-graph and up front; a chunked write is not atomic across chunks, but IRIs are deterministic and /knot supersedes, so re-running a failed promotion converges instead of duplicating — the failure message names exactly how many chunks landed.

Commit provenance — what work touched this entity

Quipu write requests also carry structured writer headers: X-Quipu-Agent, X-Quipu-Harness, X-Quipu-Model, X-Quipu-Session, and X-Quipu-Host. The corresponding QUIPU_AGENT, QUIPU_HARNESS, QUIPU_MODEL, QUIPU_SESSION, and QUIPU_HOST environment overrides win. Otherwise active Claude/Codex sessions supply SHANTY_AGENT and SHANTY_MODEL; Codex uses CODEX_SESSION_ID before CODEX_THREAD_ID, and Claude uses CLAUDE_CODE_SESSION_ID. Without an active session, the producer names itself and uses cli (or service for daemon/MCP). Scheduled wrappers can declare QUIPU_HARNESS=cron. Host identity comes from the platform hostname command. Unknown fields are omitted, and values are sanitized to printable ASCII and limited to 128 characters. Missing session/model remains partial provenance rather than a guessed identity. Existing caller-kind and authorization headers are retained. This applies to promotion, disk observations, and share writes.

For disk observations from a harness hook, an omitted session header is filled from the hook payload's session_id. An existing declared header takes precedence. Unknown sessions remain omitted; this fallback changes request attribution only, preserving authentication and its session refusal markers.

Every promotion also writes the commit it promoted, and one edge per code module that commit changed (spec §9.7):

<…/code/myrepo/commit/2f1c…> a bobbin:GitCommit ;
    rdfs:label "myrepo@2f1c9a4b1d0e" ;
    bobbin:hash "2f1c9a4b1d0e…" ;
    bobbin:repo "myrepo" ;
    bobbin:author "Dev <dev@example.com>" ;
    bobbin:date "2026-08-25T09:14:02+00:00"^^xsd:dateTime .

<…/code/myrepo/commit/2f1c…> bobbin:modifies <…/code/myrepo/src%2Fauth.rs> .

That is the substrate for Quipu-side, deterministic work-item co-occurrence — "what work touched this, and what else did it touch" — as a SPARQL join, not a statistical mine (statistical co-change stays Bobbin's). Quipu aggregates it; Yupana only emits the raw edge.

Three things it deliberately does not do:

  • It does not emit implements (the commit → work-item half of the chain). That link needs a declared project-prefix vocabulary Yupana does not hold, and the tracker-aware ingest lane already owns it. The chain still closes: both predicates join on the commit IRI.
  • It is module-granular. "Touched" means the commit changed the file, which is exactly true; symbol-level touch would be a guess dressed as a fact.
  • It never points at an entity the same payload does not declare. Changed paths are filtered against the projection, so a changed .md, a lockfile, or a deleted file produces no edge.

A merge commit reports what the merge brought in, relative to its first parent — the case a plain git diff-tree reports as touching nothing, which matters because merges are exactly what the default promote_on policy promotes.

Branches — the qualifier fallback

[yupana.quipu] branch_model selects how promoted facts are scoped to a branch (spec §9.4). One of the two designs is implemented:

  • "qualifier" (default, implemented). Every entity the projection declares gains bobbin:onBranch "<branch>", so ?m bobbin:onBranch "main" is answerable with zero Quipu change.
  • "named_graph" (preferred by §9.4, not implemented). Needs quad support in Quipu (quipu#36). Until it lands, setting it refuses the promotion and names the blocker, rather than quietly writing under the qualifier's semantics — a config that asked for partitioned branches must never look like it got them.

Two limits worth knowing before you depend on the qualifier:

  • It answers membership, not per-branch structure. Promoted IRIs are deterministic and branch-independent by design — that is what makes a re-promotion supersede rather than fork — so two branches promoting the same module write the same subject and accumulate both branch values on it. "Which branches is this module on" works. "What did the call graph look like on feature versus main" does not, because both branches' edges land on one set of subjects with nothing to tell them apart. That gap is what quipu#36 closes.
  • An undeterminable branch is omitted, never invented. Promoting a bare SHA that is not a branch tip emits no qualifier and says so; a commit can sit on many branches and git will not pick one, so neither does yupana.

The branch is resolved from the ref you promote: --commit main (the CI shape, where the argument names the branch) or --commit HEAD on an attached checkout (the developer/hook shape).

Migrating to named graphs when quads land: the qualifier triples are additive and carry no structure of their own, so it is register the branch graphs, re-promote each branch's HEAD under branch_model = "named_graph" (deterministic IRIs supersede rather than duplicate), then retract bobbin:onBranch as one predicate sweep.

Code and docs are one referential graph (spec §5.10): code leans real-time (the live graph + edit hook), docs lean asynchronous (this export). Once in Quipu, doc rot becomes a SPARQL query — "every Document referencing a CodeSymbol that no longer exists."

  • Facts are emitted as Turtle in the existing bobbin: code ontology and SHACL-validated before write (in-process via rudof_lib, FR-20) — Yupana never writes to Quipu without passing shapes/code-edges.ttl, the compiled-in shape set. It gates the structural edges (calls, references, imports, dataDependsOn, controlDependsOn, hasTier) and the Section → references edge (§5.10), and requires the structural fields owned by Yupana. Shared CodeModule, Document, and Section labels and Section headings come from Bobbin; Yupana emits their stable IRIs, paths, depth and relationships without adding a second label or heading. CodeSymbol labels remain Yupana-owned. The local producer projection does not require the owner's heading in its partial payload. Quipu still validates the composed post-state with its full receiver shapes; a missing required owner fact can still refuse the write. This changes neither receiver policy nor historical stored labels. A real export projection is round-trip-validated against these shapes in the test suite, so the emitter cannot drift from the gate unnoticed.
  • Writes go through Quipu's existing surface (quipu_knot / POST /knot / Store::transact), honoring valid_from/valid_to, actor, and source (the commit SHA).

Querying it back — dependency and blast radius

Once promoted, the dependency graph is queryable in Quipu. Store the direct edges (bobbin:calls, bobbin:imports) and let SPARQL property paths do the transitive work — never pre-compute and store a transitive closure that goes stale. These queries are verified against live Quipu (POST /query, JSON body {"query": "…"}).

What does a symbol depend on? (one hop)

PREFIX bobbin: <http://aegis.gastown.local/ontology/>
SELECT ?dep WHERE { ?s bobbin:name "hbiw_alpha" . ?s bobbin:calls ?dep }

Blast radius — what breaks if a symbol changes? The transitive set of callers, the + property path (this is the "if X dies, what breaks?" query; assert its members, not a nonzero count):

PREFIX bobbin: <http://aegis.gastown.local/ontology/>
SELECT ?affected WHERE { ?t bobbin:name "hbiw_beta" . ?affected bobbin:calls+ ?t }

Code entities do not suffer the alias-fragmentation that afflicts the human-named infrastructure graph (a blast-radius query over fragmented nodes returns a confident subset, worse than nothing): Yupana mints one deterministic IRI per symbol (…/code/<repo>/<file>::<scope…>::<symbol>), so re-promotion updates the same node rather than minting a synonym, and the calls+ closure is complete. The scope chain — enclosing module/impl/trait/class/function names, with a trait impl written Type@Trait — is what keeps two same-named symbols in one file on distinct IRIs (without it, 42 same-name collisions across three real repos silently merged into single nodes, unioning different symbols' call edges). It is sibling-independent: adding a second run elsewhere in the file never renames the first.

Branches as named graphs

Each branch's committed facts belong in an RDF named graph, bitemporally versioned within. Quipu is a triple store today, so this is tracked as an additive, default-graph-preserving quad-store extension — scbrown/quipu#36. Until it lands, Yupana can fall back to a branch qualifier. See the Specification §9 for the ontology extension and the quad-store RFC sketch.

Commit provenance and time

Committed CLI promotion emits GitCommit → modifies edges only for touched modules present in the projection. It sends the authored git %aI timestamp as valid_from for append, snapshot and subset writes. Quipu returns a normalized UTC key, which Yupana prints as valid-from: <key>; use that returned key for valid_at queries, since a local offset can cross a UTC day boundary.

Transaction time remains server assigned, and actor="yupana" identifies the writer. The commit carries separate author, date (authored time) and committer facts. If the server does not confirm valid_from, promotion fails with an explicit warning that the write may already have landed. Upgrade the server and inspect that write before retrying.

Yupana emits raw provenance only. Work-item linking belongs to the tracker-aware ingest lane, and aggregation belongs to Quipu #37.

The Game-State Harness

FR-35..FR-39, behind the game-state Cargo feature. This is a real widening of Yupana's mandate: from a code graph analyzer to a general in-memory fact graph and policy harness. It reuses the machinery — copy-on-write overlays, multi-tenancy, the impact walk, the Selector/Predicate policy shape — over a different subject.

The driving consumer is NeuralAmplifier, an LLM brain for Alpha Centauri, but nothing here is game-specific. The design is in the addendum (docs/neuralamplifier-harness.md).

The layers

SurfaceRequirementWhat it does
yupana_ingest / POST /ingestFR-35Load span-free facts into the hot board
—FR-36The graph-pattern policy model over those facts
yupana_guard / POST /guardFR-37Check proposed orders against those policies
yupana_whatif / POST /whatifFR-38Ranked speculative impact, uncommitted
—FR-39(game_id, faction_id) tenancy as a fog-of-war boundary

A different node type, on purpose

A code node is a symbol anchored to file:start_line..end_line. A board fact ("base Alpha holds 2 garrison units") has no file and no line. Rather than widen SymbolNode with an optional anchor — which a consumer will read as present — the harness adds a second node type whose identity is an opaque id and whose provenance is an adapter id + turn + faction.

Everything it serves carries tier: "engine-state", a peer of treesitter/lsp/cpg rather than a rung above or below them. A board fact and a code fact must be equally impossible to mistake for each other, in both directions.

The tier is advertised by yupana status exactly when the game-state engine is compiled in. game-state gates src/state/, which is the ingestion path, so the flag and the implementation are the same thing.

Ingestion, and the fog-of-war routing decision

POST /ingest
{
  "game_id": "g1", "faction_id": "gaians",
  "visibility": "shared",
  "provenance": {"adapter": "smac-worldview", "turn": 42},
  "entities": [
    {"name": "base_alpha", "type": "smac:BaseState",
     "attrs": {"smac:isBorderBase": true, "smac:garrisonCount": 2}}
  ],
  "edges": [{"source": "base_alpha", "target": "base_beta", "relation": "adjacent_to"}]
}

The node and edge JSON mirrors quipu_episode (name/type/description, source/target/relation) so one adapter output feeds both stores. attrs is Yupana's addition: scalars the pattern engine can compare.

visibility has no default, and that is deliberate:

  • shared writes the game's common-knowledge base, which every faction in that game reads — the map, public treaties, observed sightings.
  • private writes only the calling faction's copy-on-write overlay — its own units and bases, unexplored fog, plans.

Guessing here means one faction's private intel silently becoming common knowledge, so a request omitting the field fails to parse rather than picking a side.

Restating a board is idempotent: node ids are unique, so the same base arriving each turn with new numbers replaces rather than accumulates. A dangling edge is refused — a pattern traversing it would bind a variable to an id with no entity behind it.

Ingest otherwise merges: a node the new request does not mention survives from the last one. That is right for a caller sending patches and wrong for a caller whose payload is the board — without a way to say so, a base razed twenty turns ago goes on matching policy selectors forever, a stale second source of board state behind a caller who believes it just stated the current one. "replace": true on the request makes that ingest the whole of the tenant's private layer rather than a patch on it. Default false, so every existing caller keeps the additive behaviour; private layer only, because the shared base is common knowledge and not one tenant's to clear.

Policies: graph-pattern, and where the line to Quipu is

A policy is the code plane's shape with three additions: a selector_lang discriminator, a boundary value of "order", and the engine-state tier. match_type, gate, effect, claim, targets and label are reused unchanged — MatchType is literally the same Rust type, so must-match cannot come to mean one thing over an AST and another over a board.

pattern := clause { '.' clause } [ '|' filter { ',' filter } ]
clause  := ?var pair { ';' pair }
pair    := ('a' | name) term
filter  := ?var ('=' | '!=' | '<' | '<=' | '>' | '>=') literal
{
  "label": "garrison-border-bases",
  "claim": "every border base retains >=1 garrison after the proposed orders apply",
  "boundary": "order", "effect": "deny",
  "selector":  {"selector_lang": "graph-pattern",
                "evidence_source": "?b a smac:BaseState ; smac:isBorderBase true"},
  "predicate": {"selector_lang": "graph-pattern", "match_type": "must-match",
                "evidence_source": "?b smac:garrisonCount ?n | ?n >= 1"}
}

Two things worth knowing before writing one:

  • No prefix expansion. smac:BaseState is matched byte for byte against what the adapter ingested. Yupana has no prefix map, and inventing one would be a second, drifting copy of Quipu's.
  • A name predicate resolves to an attribute first, an outgoing edge second. Edge traversal is outgoing only; a symmetric relation is ingested both ways.

selector_lang: "sparql" is reserved for Quipu and is refused here — not skipped, not best-effort matched. Yupana is not an RDF store. If a predicate starts wanting property paths or aggregation, that is the signal the policy belongs in Quipu, not the signal to grow this grammar. Project the datalinks it needs into the board instead.

Guarding an order set

POST /guard
{"game_id": "g1", "faction_id": "gaians",
 "policies": [ ... ],
 "orders": [{"id": "move-out", "effects": [
   {"op": "set_attr", "id": "base_alpha", "key": "smac:garrisonCount", "value": 0}]}]}

Orders carry declared effects. Yupana does not know that MOVE implies a supply change and does not try to infer it — it applies exactly the deltas the adapter states. That is what bounds FR-37's standing divergence risk: the gap is "what the adapter declared vs. what the engine will do", which its author can see and close, rather than "Yupana's reimplementation of the rules vs. the real ones", which nobody can enumerate.

The game engine remains the sole authority on legality and effects. This complements it and never replaces it: it can only subtract from, or annotate, moves that are already legal. Because the post-order board is an approximation, deny policies should be conservative — a false deny removes a legal, possibly correct move.

The report has four lists, and reading only the first is a mistake:

FieldMeaning
violationsdeny policies that fired
advisorieswarn policies that fired
unevaluatedpolicies that could not be compiled, or are not Yupana's to run
vacuouspolicies whose selector matched nothing — never asked, not passed

vacuous exists because a selector that has rotted away from the adapter's vocabulary matches zero nodes and produces zero violations, which reads exactly like a clean board. Every finding also carries pre_existing: a breach that already held before the orders blames no order ids.

What-if

POST /whatif speculates an order set onto a clone of the overlay and ranks what it reaches — nearest first, then by degree — then throws the clone away. The reply states "committed": false rather than leaving it implicit, because that is the property a caller most needs to be able to check.

The ranking is structural and domain-neutral: which entities a change reaches, how far, by which relations, over the adapter's own vocabulary. "Is this base exposed" is a graph-pattern policy over the same speculative board, not a hardcoded traversal — Yupana does not know what a supply line is, and putting a slice of one game's rules inside a general engine would hide it from everyone playing a different one.

Contrast with Quipu's quipu_impact remove=true: this is ephemeral, fast, this-turn and tactical; that is persisted, durable, cross-game and strategic. Blurring them is how the hot path acquires a database dependency it cannot afford.

Tenancy is a security boundary

The tenant is (game_id, faction_id). One shared base per game — not per process, which would make every fact in one game common knowledge in the next — plus one copy-on-write overlay per faction. A tenant reads the base plus its own overlay, never a sibling's.

When several factions in one game are LLM-driven, a leak between overlays is not a tidiness bug: it is one player reading another's private intel, and it would be invisible in results — the run just looks unusually well-informed. So isolation is asserted three ways, because none of them is sufficient alone:

  1. By construction. A view holds exactly one overlay reference, chosen from the tenant key. No API takes two keys and no method reaches a sibling, so a cross-tenant read is unrepresentable rather than merely checked.
  2. By routing. A shared ingest carrying a faction is refused and counted — the one path by which private intel could reach the layer everybody reads.
  3. By count. isolation_report() scans the shared bases for faction-stamped facts anyway. The first two are arguments; this is a measurement, and it is the only one that catches a leak arriving by a path nobody anticipated.

The tenant cap refuses rather than evicting. An evicted overlay is a faction's private intel, and dropping it mid-game would silently widen that faction's view back to the shared base — a correctness change disguised as a resource policy. The code plane can evict because a developer's overlay costs only a re-touch.

The one operational trap

A board lives in the process it was ingested into. There is no store behind these surfaces; that is what a hot graph means. Ingesting over POST /ingest and then guarding through yupana_guard in a different process guards an empty board.

So an empty board is refused, never reported as zero violations: 409 Conflict over HTTP, an error result over MCP. Ingest and guard are separate calls, and "nothing was ingested here" and "these orders are fine" would otherwise be the same success response — a green light over a dead backend.

A guard with no policies is refused for the same reason: it cannot clear an order set it never checked.

Static HTTP candidates

The opt-in library API yupana::export::http_candidates_at returns a typed JSON snapshot of committed Python FastAPI routes and explicit requests/httpx calls. It requires langs-extra; an unsupported build reports unavailable. It does not change the default Turtle export, install schemas, or ingest a graph.

Run the synthetic offline demonstration for the exact supported syntax, expected records and example exporter command. The snapshot retains source hashes, revision, extractor/profile version, occurrence and caller/handler spans, method, full literal path, and unresolved reasons. Treat every record as tree-sitter candidate evidence.

Before admitting a snapshot as current, call yupana::http_candidates::verify_current(root, repository, &snapshot). It rejects a moved HEAD, foreign repository identity, changed source bytes, mismatched committed blobs and unsafe paths. Historical snapshots do not become current by substituting a new revision.

Dynamic paths, unknown mounts/bases, ambiguous bindings and explicit generated headers remain unresolved. Unknown generated-client status never means false. The bounded profile does not cover client instances, SDKs, cross-file binding or other frameworks; the coverage manifest discloses those limits. A consumer and producer sharing a literal path do not establish a runtime dependency.

CLI Commands

USAGE:
    yupana <COMMAND>

COMMANDS:
    serve       Run the MCP server (stdio; --http for streamable-HTTP)
    daemon      Run the resident-graph daemon (FR-31; see Resident Daemon)
    analyze     Build the base graph for a path and print a summary
    refs        Find the definition sites of a symbol by name
    callers     Direct callers and callees of a symbol
    communities Densely-connected symbol clusters (deterministic Louvain, FR-9)
    impact      Blast radius; --cochange reconciles against history (FR-11)
    dataflow    Local data dependence, or opt-in Rust CPG flow across calls
    export      Emit the referential structure as Turtle (bobbin: ontology)
    hook        Harness hook adapter (post-edit / pre-edit / post-read)
    verify      Verdict on a proposed edit buffer (FR-23/FR-24)
    audit-rule  Replay an explicit structural rule on supplied source, offline
    rule-test   Run governed text rules on sample cases, no side effects [quipu feature]
    promote     Promote a commit's structural facts into Quipu    [Phase 4]
    verifier    Show the verdict-signing public key to register  [quipu feature]
    verdicts    Drain the local signed-verdict spool into quipu  [quipu feature]
    status      Show base commit, tiers, and configuration
    completions Generate shell completions
    help        Print help

GLOBAL FLAGS:
    --json      Machine-readable output
    --quiet     Suppress non-essential output
    --verbose   Raise the default log level to debug (RUST_LOG still wins)
    --tenant    Tenant/session id (default: single-tenant)
    --config    Path to config file (replaces discovery; must exist)

--config <path> replaces config discovery rather than adding to it: it loads exactly that file over the compiled defaults, and the ambient .bobbin/config.toml is not consulted (FR-29 ranks a flag above project and user config). A --config path that does not exist is a loud error, never a silent fall-back to discovery — so pointing the pre-edit guard at a scope file with a mistyped path fails visibly instead of quietly enforcing the wrong scope.

--verbose raises the default tracing level from info to debug. RUST_LOG, when set, still wins and can target individual modules, so the precedence is RUST_LOG > --verbose > the info default.

Examples

yupana analyze src
yupana analyze src --at main     # structure of the tree at a baseline commit (FR-13)
yupana refs authenticate src --json
yupana status                    # resolves base_ref to a commit SHA (in a git repo)
yupana impact authenticate src --hops 5   # lookup is by bare symbol name, not file::symbol
yupana communities src --json         # symbol clusters, largest-first
yupana verify --file src/auth.rs --buffer /tmp/edited.rs
yupana promote --dry-run                  # would this projection conform? writes nothing
yupana promote --to $QUIPU --commit HEAD  # the write; --to is what authorizes it

yupana promote — what authorizes the write

--to is the only thing that authorizes a promotion. A configured [yupana.quipu] endpoint is deliberately not enough, and a bare yupana promote refuses even where one is set — naming the endpoint it found and both remedies:

$ yupana promote
Error: refusing to promote into a DISCOVERED endpoint: http://quipu.example
  `[yupana.quipu] endpoint` is configured so the pre-edit guard can READ the rule
  catalogue. It does not authorize a write, and a promotion is a live graph write
  with no undo.
  To write there, say so:        yupana promote --to http://quipu.example
  To check it without writing:   yupana promote --dry-run

That asymmetry is the point. The endpoint key is set once, deployment-wide, so the guard can read the governed rule catalogue on every edit — a READ. Letting it double as a write target meant a bare promote from any checkout in scope of that config posted tens of thousands of triples into the live graph, and there is no undo (/episode/retract is episode-scoped and does not unwind a promotion). It was found by an operator who ran it expecting a dry run (aegis-o2h97). One config supplying both a read capability and a write capability is the defect; requiring the write to be spelled out on the command line is the fix.

The MCP yupana_promote tool keeps its own fallback to the configured endpoint: there the target comes from a server an operator deliberately started and pointed somewhere, not from whatever config happens to be ambient in an agent's shell.

Quipu write credentials

Promotion, share writes and disk-impact hook observations use the same credential order: a nonempty, trimmed QUIPU_AUTH_TOKEN, then QUIPU_AUTH_TOKEN_FILE, then ~/.config/quipu/token. An explicit file that is missing or unreadable does not fall back to another file. The former legacy token path is used only when selected explicitly. Obtain an accepted token from the service administrator; CABOODLE can install and verify it without rotating the server credential.

A missing, unreadable, invalid or rejected credential disables later writes to that server for the session. Deliberate CLI writes emit one diagnostic; disk hooks instead record a quipu_auth_failure event with its reason and scope in the metrics spool, without printing to stdout or stderr. With a harness session ID, a private marker under the Yupana state directory carries that refusal across hook processes. Without a session ID, or when the marker cannot be persisted, the diagnostic or event states that suppression is process only. Repair the credential and start a new session before retrying. Public reads remain available.

yupana status reports the credential state and suppression state without printing a token or making a write. configured_acceptance_unproven means a credential was found, not that the server accepted it; use caboodle doctor for an authenticated verification.

yupana promote --dry-run — validate without writing

--dry-run extracts the projection and runs the same SHACL gate a real promotion runs, then stops. It needs no target — validation is in-process — and reports the graph a real run would have written to:

$ yupana promote --dry-run
  DRY RUN — conforms. WROTE NOTHING.
    would post: 4041881 bytes of Turtle in 4 chunk(s)
    would target: http://quipu.example/knot

A non-conforming projection produces the identical refusal (and identical retained payload) that promote would produce, so a dry run cannot green-light something the write path would reject.

yupana promote — diagnosing a refusal

A promotion is all-or-nothing: one SHACL violation refuses the whole commit and writes nothing. So the refusal has to say enough to act on, because the projection it refused is generated on the fly and would otherwise be gone.

$ yupana promote --to $QUIPU
  REFUSED — promotion did not pass SHACL, wrote nothing:
    - MaxCount(1) not satisfied — on …/code/yupana/src%2Fmcp%2Fstate_tools.rs::StateIngestRequest (path …/symbolKind)
    payload retained at: /tmp/yupana-promote-yupana-promote-yupana-45c7b660….ttl

Two things make that actionable, and both are load-bearing:

  • The violation names its focus node and property path. A bare MaxCount(1) not satisfied is true of every maxCount shape in the file and identifies nothing.
  • The refused projection is written to disk, so you can read the document that failed. Parse errors are positional (line 8656) and the line MOVES between runs because the content is regenerated — without the payload, the one fact the error gives you is unusable.

The dump also lands on an invalid-Turtle failure and on a partial chunked write. It goes to $YUPANA_PROMOTE_DUMP_DIR, else the system temp dir.

The name carries the repo and the resolved commit, so a scheduled promotion refusing the same commit hour after hour overwrites one file. A different failing commit gets its own dump — the SHA is what tells you which projection you are holding, and reusing one name would overwrite the payload you were still reading. So the bound is distinct failing commits, not runs.

Retention is best-effort: a promotion that is already failing never fails differently because a dump could not be written.

yupana status

Reports the resolved baseline, the tiers this binary serves, and — the part a script gates on — the state of the policy rule plane.

$ yupana status
  policy      : mode=advise  scope=none for this tenant
  mode source : ~/.config/bobbin/config.toml
  tamper state: TAMPER-EVIDENT, NOT TAMPER-PROOF — a local agent can alter policy; a clean report is no evidence that tampering was prevented
  rule set    : 7 projected from quipu (0 structural, 7 text) + 0 local
  rule digest : sha256:3fb9f179b9229755 (unsigned)

rule digest answers which rule set is live, not just how many rules there are: two hosts showing different digests are enforcing different policy, which the counts alone cannot reveal. It is computed over the fields that decide enforcement (rule identity, pattern, tier, exemptions), so a rationale typo does not churn it. It is not a version — there is no signed rule set yet, and verification reports unsigned to say so rather than implying provenance yupana does not have.

mode source identifies the layer that set the effective mode. If a workspace-writable .bobbin/config.toml lowers a user policy (for example, enforce to off), status calls it out as LOWERED and exits non-zero. This does not claim that a local agent was prevented from changing a local file: the surface is deliberately tamper-evident, not tamper-proof. Read a clean report as “no evidence of tampering”, never as proof it was prevented.

Exit codes

CodeMeaning
0Rule plane loaded, empty, or off
3Rule plane degraded — the rules could not be projected
4Workspace config lowered the user's policy mode

Exit 3 is the one that matters. In that state the guard fails open on every edit: nothing is enforced, and before this had an exit code the condition printed in red and exited 0, so nothing could gate on it and a human had to happen to look. empty and off deliberately exit 0 — a graph with no rules is a true, quiet answer, and failing on it would make the code useless in every tree that has no governed policy yet.

status retries the projection three times before declaring degraded (attempts is reported, so a flap stays visible). The pre-edit hook does not retry: it runs on every edit across the fleet and its latency ceiling is the reason it exists. A status surface that went red once in ten runs from a transient blip would be routed around, and then the real red would be invisible too.

yupana refresh-projection

Fetch the complete governed-policy catalogue from the configured Quipu endpoint and atomically refresh the durable projection.json used by short-lived hooks:

yupana refresh-projection

This command always contacts Quipu, even when the existing cache is fresh. Run it from a timer outside the edit path, with a cache TTL comfortably longer than the timer interval. It works while [yupana.quipu] enabled = false, allowing an operator to bootstrap and verify the cache before enabling the guard. A failed projection leaves the prior cache unchanged and exits non-zero; success is not reported unless the new timestamp can be read back.

yupana refs

Definition sites of a symbol, by name, from the same graph callers, impact and communities read — so every language this build has a grammar for is searched, not just Rust. (Grammars beyond Rust need the langs-extra feature; yupana status lists what a given binary can parse.)

$ yupana refs derive_agents
quipu.py:1 derive_agents (function) [TreeSitter]

A zero result says which kind of nothing it is, because the two are not the same fact:

$ yupana refs no_such_symbol           # a populated graph, name genuinely absent
no definition found for no_such_symbol (searched 412 symbol(s))

$ yupana refs anything ./docs          # nothing here was parseable at all
no definition found for anything (nothing parseable under ./docs — the graph is
empty, so this is not evidence the symbol is absent)

Under --json the same distinction is count against searched_symbols, and the answer carries its tier whether or not it found anything (FR-3):

{
  "symbol": "derive_agents",
  "count": 1,
  "definitions": [
    {
      "file": "quipu.py",
      "name": "derive_agents",
      "kind": "function",
      "start_line": 1,
      "end_line": 2,
      "tier": "treesitter"
    }
  ],
  "searched_symbols": 412,
  "tier": "treesitter"
}

Resolving by position

Name lookup over-connects on common names — build, new, write. When you are reading code you know where you are, not which of the twelve it is, so point at it instead (FR-4):

$ yupana refs build                 # by name: ambiguous
a.rs:3 build (function) [TreeSitter]
a.rs:7 build (function) [TreeSitter]

$ yupana refs --at a.rs:7           # by position: the one you pointed at
a.rs:7 build (function) [TreeSitter]

--at FILE:LINE resolves to the innermost symbol enclosing that line and answers with that symbol alone — it does not re-expand to every symbol sharing its name, which would restore the ambiguity the position was given to remove. FILE is relative to the search path; with --at, the positional argument is the search path rather than a symbol name.

A column is accepted by builds carrying the lsp feature:

$ yupana refs --at a.rs:3:9
a.rs:3:4-3:9 [Lsp]

The LSP adapter supports Rust (rust-analyzer) and TypeScript/JavaScript (typescript-language-server --stdio) behind the same protocol client. Servers are optional. If no server or resolvable build is available, Yupana degrades to the name-based tree-sitter result and labels the response treesitter; it never accepts the column and quietly answers at line precision. Without the lsp build feature, FILE:LINE:COL remains a loud refusal.

Library consumers can retain extract::lsp::Session to reuse one initialized server per workspace and language. Its locations method accepts definitions, references and type definitions; hover, document_symbols and workspace_symbols preserve the standard LSP result payloads. Each returned Precise<T> includes tier: lsp. Unsupported languages return no session, while missing servers or protocol failures return errors for the caller's explicit tree-sitter fallback. No fallback is labeled LSP.

Retained sessions read saved contents on demand and send didChange before querying an edited file. Dropping the session terminates and reaps its server. They perform no work per keystroke. The one-shot CLI still starts a cold server; the warm-server performance target applies to retained sessions, not cold startup. Each capability's first request has a 30-second cold deadline because servers may initialize semantic indexes lazily; later requests have a five-second ceiling, while the measured warm p95 target remains below one second. A bounded ContentModified retry handles indexing without resetting the request deadline, and unrelated server notifications cannot extend it.

The LSP CI arm installs rust-analyzer and a pinned TypeScript language server. Real-server tests cover definitions, references, type definitions, hover, document/workspace symbols and saved-file updates. They report 20-query warm p95 measurements for definitions and references in both languages and require less than one second. Local tests report a skip when a server is absent; protocol fakes validate transport behavior, not language-server performance.

A position that resolves to nothing explains which kind of nothing it is, rather than borrowing the vocabulary of "no such symbol":

$ yupana refs --at a.rs:3
no symbol encloses a.rs:3 — it falls between definitions (a blank line, an
import, a top-level comment). `a.rs` defines: one:1-1, two:5-5

refs answers "where is this defined". For "what reaches this", use yupana callers; for the transitive form, yupana impact.

yupana verifier and yupana verdicts

Both require the quipu feature. The guard signs a verdict the moment a constraint fires and appends it locally; these are the registration and the drain. Full field list and the reasoning in The Enforcement Trace.

yupana verifier --key-path yupana-signing.pk8   # public key to register in quipu
yupana verdicts --to http://localhost:7878    # drain the spool

yupana verifier is the deliberate key-creation act — it mints the signing key if absent. The hook path never does: a signing identity materialising from an agent's edit is not something that should happen quietly.

yupana verdicts truncates the spool only when every verdict was accepted, so a partial drain leaves the remainder intact rather than losing it.

yupana verify

Checks a proposed buffer against the graph Yupana already holds and returns a boolean verdict plus violations (FR-23/FR-24). Exits non-zero when the buffer has violations, so scripts and CI can gate on it.

$ yupana verify --file src/a.rs --buffer /tmp/proposed.rs
violations src/a.rs [TreeSitter]
  ghost:2 `ghost` is called here but is defined nowhere in this buffer or the
          project graph, and is not brought into scope by a `use`.
  takes_two:2 `takes_two` is called with 1 argument(s) but is defined at line 1
          taking 2.

Only violations the edit introduces are reported: the file's current contents are the baseline, so pre-existing breakage is not blamed on this edit.

Read the unchecked list before trusting a clean verdict. At the tree-sitter tier there is no type information and no name resolution, so:

Violation (FR-23)At this tier
identifier-does-not-existfree calls only, and only ones the edit introduces
wrong-arityfree calls resolving to exactly one known definition
unresolved-importbodiless mod foo; with no sibling file
type-violationnot checked — needs the LSP tier

The bias is against false positives throughout: method calls, path-qualified calls, imports, locals, closures, and function-typed parameters are all left alone rather than guessed at. ok: true means "nothing this tier can see is wrong", not "this compiles".

yupana status resolves the configured base_ref (default main) to a concrete commit via the system git; outside a git repository the base commit shows as unresolved and Yupana falls back to the working tree.

yupana analyze --at <ref> builds the summary from the git tree at a baseline commit (FR-13) rather than the working copy — the shared read-only base the Phase-3 resident graph will hold. It reads blob content at the ref (never the working tree), and degrades to an empty result outside a repo or for an unresolved ref.

yupana communities partitions the call graph into densely-connected symbol clusters using deterministic Louvain (FR-9) — the same partition on every run, no RNG. Communities are ordered largest-first; members carry a tier tag. Quipu runs community detection over committed facts; Yupana computes it live over the hot graph.

Commands marked with a phase print a notice until their engine lands; see the Specification §12.

yupana exemplar

Drafts the raw material of a governed policy from an observed instance — policy-by-example, yupana's half. Point it at the offending text (with the file it appeared in, so the Selector can name the structural context), or at a verdict spool to read the newest denial:

yupana exemplar "ABC-123" --file src/a.rs
yupana exemplar --spool ~/.local/state/yupana/verdicts.jsonl --policy no-ticket-in-comment

The output is JSON for quipu's drafting scaffold: a Selector draft (the enclosing node kind as a tree-sitter query) and predicate candidates at each viable tier — the exact token(s) for membership (the only hard-capable tier), a generated narrowing pattern offered for human approval, and the exemplar's embedding as a similarity anchor with a suggested threshold that quipu's backtest replaces. Nothing emitted is a policy; quipu's definition-time placement check remains the refusal authority.

yupana rule-test

Runs recorded cases for governed text rules through the engine the pre-edit guard uses, textrules::evaluate_in, with the same path and repo exemptions. It exists for enforcement-readiness checks. A rule's cases have to be proven by the enforcer itself, because another regex engine can disagree with it.

echo '[{"rule":"textrule_patent-application-number","text":"filed as 63/123,456","expect":"match"},
       {"rule":"textrule_patent-application-number","text":"release 63.1","expect":"no-match"}]' \
  | yupana rule-test

Each case is {rule, text, expect, path?, repo?}. expect is match or no-match. path defaults to rule-test-case.txt, and a missing repo means unresolved, which exempts nothing. The rules come from the projection cache the hook serves (--projection overrides it), and the report states that cache's age.

The verb writes nothing: no verdict spool, no metrics line, no cache refresh and no quipu request.

Each case is pass, fail, or unknown. A case is unknown when its rule is absent from the projection or does not compile, because the guard fails open on such a rule. unknown is never a pass.

Exit codes: 0 all cases pass · 1 any case fails · 2 nothing failed but something is unknown, or there were no cases.

yupana audit-rule

Reads one JSON object from stdin: rule, path (repository relative), and source (the full file text). rule uses the structural rule fields below. An auditor supplies the blob from the exact commit it is checking; this command never reads the working tree or chooses a policy from the local projection.

printf '%s\n' '{"rule":{"name":"todo-ticket","language":"rust","query":"(line_comment) @c","match_type":"must-match","pattern":"[A-Z]+-[0-9]+","gate":"TODO"},"path":"src/example.rs","source":"// TODO fix this\nfn f() {}"}' | yupana audit-rule

The JSON response has schema_version: 1, the supplied rule name and path, verdict, violations, and errors. Exit codes are 0 for satisfied, 1 for unsatisfied, and 2 for unknown. Unsupported grammars, malformed selectors, regexes or globs, invalid source syntax, out-of-scope paths, and invalid input are unknown. A path outside the rule's language returns not_applicable and exit 0, using Yupana's existing path-to-language classifier. Requests are limited to 16 MiB. An empty selector result follows the existing structural semantics: must-exist fails, while must-match and must-not-match are satisfied.

Replay calls the existing tree-sitter rule evaluator after strict validation. It does not alter interactive hooks' best-effort behavior. This command writes no state, metrics, cache, or verdict spool and makes no network calls. It does not authenticate policy provenance, commit identity, or signed exceptions; those remain the auditor's responsibility.

Rust CPG dataflow (cpg feature)

Build with just build --features cpg. The ordinary dataflow command keeps its intra-procedural, treesitter result in every build. Opt in explicitly:

yupana dataflow main ./src --interprocedural --var input --forward --hops 32 --json
yupana dataflow main ./src --interprocedural --json

The CPG mode emits JSON (also without --json). Without --var, it returns control-dependence edges for the selected function. Each edge carries relation: "bobbin:controlDependsOn", tier: "cpg", statement IDs, and the statement/condition lines. A CFG postdominator calculation handles branches, merges, early returns, and unlabeled while/loop, break, and continue. It does not infer control dependence from lexical nesting alone. An unsupported control construct or a region unable to reach an exit omits that function's control facts with a diagnostic. Calls are modeled with normal return behavior; unwinding and diverging callees are not modeled.

With --var, the same reply adds explicit value-flow reachability across lexically resolved bare calls and unique crate::... calls between Rust free functions. Argument-to-parameter and return-to-result edges connect the functions. Call-site matching prevents the return of one invocation from contaminating a second invocation. --forward follows source to sink; the default follows dependencies backwards. Each reached value has a cpg tier, qualified function, source line, distance, and a witness path. Use $return to start from a function's return value. No implicit flow from a branch condition is added to the explicit value-flow graph; control edges are reported separately.

A simple function name must resolve uniquely. Ambiguous selectors report the exact file/scope/byte IDs to use. Bindings are declaration-scoped, so shadowed locals and same-named functions remain separate. A variable name selects all matching bindings inside the selected function; use its exact value ID to select one. IDs refer to the current source snapshot and can change after edits.

This is a bounded source approximation, not a safety verdict. Local assignments are flow-insensitive; path feasibility, heap aliases, sanitizer semantics, exceptions, macros, closures, async, traits/method dispatch and imported aliases are not resolved. Unsupported constructs, syntax errors and unresolved calls are reported in diagnostics; unknown calls do not invent argument-to-return edges. A missing path cannot prove absence of a real flow. truncated reports exhausted hop/state budgets (maximum 256 hops and 50,000 traversal states), including recursive paths. Control extraction also bounds CFG size (2,048 blocks and 10,000 AST descendants per function), reporting omissions in diagnostics. truncated: false does not remove the model's limitations.

The analysis is on-demand over Rust source, honoring source selection and ignore rules. It does not require a build or launch an external CPG engine. These CPG facts are served through the query response; the ordinary referential Turtle export and resident daemon /dataflow remain their existing tree-sitter surfaces. A build without cpg refuses --interprocedural with an explicit error.

import-scip — source-bound offline navigation

Read an existing SCIP protobuf with an indexing-time source/revision binding. See Offline SCIP navigation for the binding format, coverage and refusal behavior. This command emits JSON and does not activate an index in the daemon.

Offline SCIP navigation

yupana import-scip reads an existing local protobuf SCIP index. It never runs an indexer, downloads an index, starts LSP, or changes the resident graph. It prints JSON with a scip tier, explicit coverage, producer and protocol versions, and source-bound definition/reference occurrences.

yupana import-scip --root ./checkout --index ./index.scip --binding ./binding.json
# Restrict the output to one exact SCIP identity:
yupana import-scip --root ./checkout --index ./index.scip --binding ./binding.json \
  --symbol 'rust-analyzer cargo example 0.1.0 module/duplicate().'

The binding must be captured alongside the index at indexing time. A binding assembled from today's source bytes cannot establish that an older index was produced from them. The binding verifies byte consistency; it does not authenticate the index producer or establish compiler accuracy. Use trusted index artifacts.

{
  "version": 1,
  "repository": "https://example.com/project.git",
  "revision": "FULL_COMMIT_ID",
  "index_sha256": "LOWERCASE_SHA256_OF_PROTOBUF",
  "project_root": "file:///original/indexing/root",
  "producer": "rust-analyzer",
  "producer_version": "VERSION_FROM_INDEX_METADATA",
  "sources": {
    "src/lib.rs": "LOWERCASE_SHA256_OF_SOURCE_BYTES_AT_INDEXING_TIME"
  }
}

repository must exactly match the checkout's origin URL, and revision must exactly match its full HEAD commit. Each source hash must match both the committed blob and the checkout bytes. The source map must equal the complete indexed document set. This coverage describes that set, not the whole repository: files omitted by the producer remain outside the index's coverage. The original project-root URI is bound to index metadata, allowing a checkout to be relocated. Only UTF-8 source files and languages supported by the current build are accepted. The language declaration must match the file extension. Symbol relationships, external symbol documentation and diagnostic payloads are not imported.

Every import is all-or-nothing. Stale revision/bytes, foreign repository, changed index hash, malformed protobuf/symbol/range/role mask, a definition without a symbol, unsafe path, duplicate document, unsupported encoding/language, or incomplete coverage returns nonzero with coverage.accepted: false, a refusal reason, and no occurrences. A successful identity lookup with no occurrences means only that the identity is absent from this validated index. Local SCIP identities are qualified by document path.

Identifier ranges use zero-based lines and UTF-8 byte columns, with exclusive ends. SCIP UTF-16/UTF-32 positions are checked and normalized; positions inside a code unit or outside a line are refused. For legacy indexes omitting document position encoding, this importer uses the required UTF-8 metadata encoding. ast_body is a separate tree-sitter symbol with one-based inclusive line bounds. It is omitted when the enclosing body cannot be matched unambiguously. No SCIP identifier range replaces an AST body, and SCIP references are not interpreted as call edges. The returned object is a validated import-time snapshot, not a continuously refreshed source view.

The checked-in synthetic Rust fixture was generated with the compiler-backed rust-analyzer scip command; see its provenance. The importer uses the official SCIP protobuf bindings, independently of the initial design inspiration from infigraph. No infigraph code was copied.

Configuration Reference

Yupana reads the [yupana] table of .bobbin/config.toml. All keys are optional; unspecified keys fall back to the defaults shown below.

[yupana]
# Baseline the shared read-only graph is built at.
base_ref = "main"
# (Phase 2/3 — not yet read) Run the LSP tier for precise facts where a build resolves.
enable_lsp = true
# (Phase 2 — not yet read) Run the CPG/dataflow tier.
enable_cpg = false
# Languages to extract. RESTRICTS `yupana analyze`: a file whose language is not
# listed is not counted.
languages = ["rust", "typescript", "python", "go", "java", "cpp"]

[yupana.freshness]
# Debounce for keystroke-driven tree-sitter updates (ms).
debounce_ms = 300
# (LSP tier — not yet read) When to compute LSP facts: "save" | "on_demand".
lsp_on = "save"

[yupana.tenancy]
# Maximum concurrent per-tenant overlays over one base. A new overlay past the
# cap evicts one per `overlay_eviction` (logged, never silent).
max_overlays = 32
# Symbols whose direct fan-in exceeds this get a bounded frontier cascade
# (clipped to one hop) so a widely-referenced signature edit cannot blow the
# recompute budget (§14.2). The bounding is logged.
high_fanin_threshold = 200
# Overlay eviction at the cap: "lru" (evict least-recently-used) or
# "on_session_close" (evict on close; oldest-created is the cap backstop).
overlay_eviction = "on_session_close"

[yupana.serve]
bind_address = "127.0.0.1"
# Distinct from Bobbin's server and Quipu's 3030.
mcp_http_port = 3040
# Write guard: when true, yupana REFUSES mutating operations (promotion) with a
# distinguishable error. The served MCP/HTTP surface is read-only regardless
# today; this guards the write path and any future served write.
read_only = false
# When true, the hook and MCP graph tools consult the resident daemon at
# bind_address:mcp_http_port (see Resident Daemon) and fall back to the
# transient build when it is unusable. The guard's fallback is LOUD.
use_daemon = false

# Quipu promotion. Promotion itself IS built (`yupana promote`, SHACL-gated,
# committed-tree-only); one key here is still inert, for a reason that is NOT
# "promotion is unbuilt":
[yupana.quipu]
enabled = false
# "commit" | "merge" | "manual" — WHEN an automated promotion runs. The caller
# declares the event with `yupana promote --trigger commit|merge` (a git
# post-commit / post-merge hook, or a CI step); this key decides whether that
# event promotes. A `commit` event on a merge commit counts as a merge, so the
# default works from the simplest possible hook. `--trigger manual` (the
# default) always promotes: this governs automation, not authorization — `--to`
# is still the only thing that authorizes a write. An unrecognised value is
# REFUSED, not silently defaulted.
promote_on = "merge"
# "qualifier" | "named_graph" — how promoted facts are scoped to a branch
# (spec §9.4). "qualifier" is IMPLEMENTED: every promoted entity gains
# `bobbin:onBranch "<branch>"`, so `?m bobbin:onBranch "main"` works with zero
# Quipu change. It answers branch MEMBERSHIP, not per-branch structure — two
# branches promoting the same module write the same subject and accumulate both
# values, because promoted IRIs are branch-independent by design.
# "named_graph" is preferred by §9.4 and needs quad support in Quipu
# (scbrown/quipu#36); until that lands it REFUSES the promotion rather than
# degrading to the qualifier, so a config that asked for partitioned branches
# never looks like it got them. A branch that cannot be determined (a bare SHA
# that is not a branch tip) is OMITTED, never guessed.
branch_model = "qualifier"
# NOT READ, and cannot be honoured as written: promotion validates against
# shapes COMPILED INTO the binary (`include_str!`), deliberately, so no
# directory on disk can change what gates a write.
shapes_path = "shapes/"
# Max age (secs) the durable projection cache (projection.json) may be SERVED
# when quipu cannot be reached — a cache-served verdict is STALE and states
# its age. Past the TTL the guard fails open loudly instead. 0 disables
# serving the cache.
projection_cache_ttl_secs = 3600

# The per-request projection timeout defaults to 10 seconds. Operators can
# override it without rebuilding Yupana:
# YUPANA_PROJECTION_HTTP_TIMEOUT_SECS=15

[yupana.policy]
# "off" (inert) | "advise" (report only) | "enforce" (deny).
mode = "off"
# Wall-clock budget for the whole pre-edit guard (ms). Expiry => allow.
deadline_ms = 100
# Warn the user, once per session, when the guard fails open.
notify_on_fail_open = true
# How far to follow the call graph when sizing an edit.
max_hops = 5
# Run the FR-23 buffer verifier as an arm of the guard: reject edits that
# introduce references resolving to nothing (hallucinated identifiers, wrong
# arity, unresolved `mod` imports). Opt-in; rides inside deadline_ms.
verify = false
# The work-item scope rung (docs/work-scoped-governance.md), used when a
# tenant has NO entry in [yupana.policy.scopes.*]: the paths prior work on
# the agent's tracked item touched, projected from quipu's provenance chain.
# "off" (default) | "advise" (report out-of-scope edits, and say once per
# session when no scope applies at all) | "enforce" (deny out-of-scope
# edits). `mode` stays a ceiling; needs [yupana.quipu] and a tracker plate
# ($SHANTY_ROOT/crew/$SHANTY_AGENT/plate.json).
work_item_scope = "off"
# The ACTION surface (`yupana hook pre-bash`): "off" (record-only, default) |
# "advise" | "enforce". `mode` stays a ceiling. Checked against the declared
# allow_targets / deny_targets below; a command whose target is not
# unambiguous from syntax is an abstention, never a violation.
action_scope = "off"

# Per-tenant capability scopes, keyed by tenant/role id. A tenant with no entry
# is unconstrained. See "Pre-Edit Policy Guard" for the full contract.
[yupana.policy.scopes.polecat-3]
allow_paths = ["src/**", "tests/**"]   # empty = any path
deny_paths = ["src/config.rs"]         # beats allow_paths
max_impacted_symbols = 25
max_impacted_files = 10
# Action targets, matched as `class:target` (host | service | repo | container).
allow_targets = ["host:build-*"]       # empty = any target
deny_targets = ["service:etcd"]        # beats allow_targets

# Structural (tree-sitter-tier) rules — checks a linter finds hard or slow.
# Unlike scopes, rules are NOT per-tenant: they govern the code an edit
# introduces, for everyone. Each rule pairs a Selector (a tree-sitter .scm
# capture query) with a Predicate (a regex + a match_type). Evaluated against
# the text the edit ADDS, Mode-staged, fail-open. Use TOML literal (single-
# quote) strings so regex backslashes are not doubled.
[[yupana.policy.rules]]
name = "no-ticket-in-comment"
language = "rust"                      # the grammar the query targets
query = '(line_comment) @c'            # Selector: which nodes
match_type = "must-not-match"          # must-match | must-not-match | must-exist
pattern = '\b[A-Z]+-[0-9]+\b'          # Predicate: the regex
# gate = '\bTODO\b'                    # optional: only test captures matching this
# applies_to = ["src/**"]              # optional path globs; empty = any path
# message = "keep ticket refs in commits, not comments"  # optional override

# Tripwires — boundaries with declared effects; the edit itself is the
# crossing. See "Pre-Edit Policy Guard" § Tripwires. A misconfigured wire
# (malformed glob, unknown rule, throttle without backoff_secs, no paths and
# no rule) is a LOUD fail-open, never a silently inert control.
[[yupana.policy.tripwires]]
name = "auth-boundary"
paths = ["src/auth/**"]            # repo-relative globs; empty needs `rule`
effect = "deny"                    # warn | deny | throttle
# rule = "no-ticket-in-comment"    # optional: trip only when this rule fires inside
# backoff_secs = 300               # required for effect = "throttle"
# message = "auth changes need the security workflow"  # optional override

# What the usage spool records about each guard decision.
[yupana.metrics]
# "off" (default) | "relative" (repo-relative) | "absolute".
record_paths = "off"

An unrecognized mode is a config error, not a silently inert guard.

Auditing guard decisions ([yupana.metrics])

A guard record carries result, mode, ext, agent, tenant and ts. That is enough to count denies and not enough to review one: a record without a subject cannot confirm a rule is scoped correctly, cannot show a false positive, and cannot support an incident timeline. ext is a lossy proxy — six denies on .json tells an operator nothing actionable.

record_paths adds the subject:

ValueRecordsUse when
off (default)no pathThe deployment treats paths as sensitive.
relativesrc/auth.rsThe useful setting for a fleet — identifies the file without disclosing where the checkout lives.
absolute/srv/repo/src/auth.rsOne host, several checkouts, where a relative path is ambiguous.

It defaults to off because paths are more sensitive than extensions: a deployment opts in, and recording is never switched on beneath one.

The rule id is recorded whatever record_paths says. It names what actually fired — the matching deny_paths glob, allow_paths when a path matched no allow pattern, the exceeded ceiling, or the governed rule's name — and it is the field that makes a false positive diagnosable, since a wrongly-scoped rule and a correctly-scoped one are otherwise indistinguishable. A rule id is a name the operator wrote, not user content, so it carries none of the sensitivity that argues for gating paths.

Both fields are recorded for allow as well as deny, under the same knob. Scope that can only be inferred from the absence of denies cannot be verified at all; confirming a rule is scoped correctly needs what it let through as much as what it stopped.

{"kind":"guard","result":"deny","mode":"enforce","ext":"yaml",
 "path":".beads/config.yaml","rule":"deny_paths:.beads/**",
 "agent":"mathis","tenant":"worker","ts":1785544543}

Both fields are omitted rather than blanked when they have nothing to say (recording off, or a clean allow with no deciding rule), so a reader never has to tell "recorded as empty" from "not recorded".

yupana status reports the setting in force, so an operator can confirm from outside the process that recording is actually on — a control believed active and silently inert reads exactly like a quiet week.

$ yupana status
  audit       : record_paths=relative

Projected governed policy (Phase 4, quipu feature)

With the quipu feature and [yupana.quipu] enabled = true plus an endpoint, the guard also fetches quipu's boundary:"action" structural policies and evaluates them like any rule — a governed deny policy blocks under mode = "enforce". An unreachable quipu fails open loudly; the verdict declares whether the projection was fresh. Yupana never defines a governed policy — it only projects quipu's. See the design note "Policy edit hooks — the yupana side".

The same projection also carries quipu's entity-grounded rules (the semantic-grounded-edit-policies design): the authoritative work-item id set is projected into the hot plane alongside the rules, and the tokens of the introduced text are checked by O(1) membership — no regex. Evaluation is three-outcome: grounded (the token names a real work item), unresolvable (id-shaped but resolves to nothing — a fabricated reference, its own violation class), or no candidate. A missing grounding projection renders those rules unevaluated, loudly — never "empty set, nothing grounds, allow".

endpoint is a READ capability, not a write one. It is what the guard fetches the rule catalogue from, and that is all it grants: yupana promote will not write to a merely-configured endpoint, because setting this key deployment-wide is how every checkout in its scope came to be one bare command away from a live, un-undoable graph write (aegis-o2h97). A promotion must name its target with --to. See "yupana promote — what authorizes the write" in the CLI reference.

Docs-history advisory ([yupana.docs_history])

Product docs describe the current product. After an Edit, MultiEdit or Write to an in-scope doc, the post-edit hook asks an external judge whether the lines the edit added describe current state only. When the judge's probability is below threshold, the agent is told that the text reads as history and is asked to rewrite it as the present-tense fact. For example, "X is not supported; use Y" instead of a date or a story of what changed.

[yupana.docs_history]
enabled = false            # off unless set, and inert without `command`
# The judge's argv. Yupana appends `--state <added text> --ask <question>`
# and reads a JSON `noul` probability from stdout, as
# {"answers": {"<id>": {"noul": p}}} or {"answer": {"noul": p}}.
command = ["/path/to/judge", "noul"]
deadline_ms = 1500         # past this the judge is killed and nothing is said
threshold = 0.19           # advise when P(current state only) < threshold
include = ["README.md", "**/README.md", "CLAUDE.md", "**/CLAUDE.md",
           "AGENTS.md", "**/AGENTS.md", "**/SKILL.md", "docs/reference/**",
           "docs/services/**", "docs/book/src/reference/**"]
exclude = ["docs/runbooks/**", "**/runbooks/**"]
  • Advise only. It runs after the write and never denies. Every failure produces no advisory and leaves the edit alone. Failures include a missing command, a spawn error, a non-zero exit, the deadline, and output with no noul in 0..=1.
  • Post-edit, not pre-edit. A judge round trip takes hundreds of milliseconds, which is past the pre-edit guard's deadline (see Pre-Edit Policy Guard).
  • What is judged. Only lines present in the new text and absent from the replaced text, with blank lines dropped. Added text over 2000 characters is not judged.
  • The question is fixed. threshold is calibrated against it. Change the threshold only with new labelled measurements.
  • Paths are matched repo-relative. The repo is the nearest ancestor of the edited file that holds .git. exclude wins over include.
  • Metrics. Each judged edit writes one docs_history record with result (history, current, or the failure word), score and ms. The record includes path only when [yupana.metrics] record_paths allows it.

MCP Tools

Yupana exposes its capabilities as MCP tools, over stdio and streamable-HTTP. Tools use the yupana_* naming convention, alongside Bobbin's bobbin_* and Quipu's quipu_* on the same agent.

A parallel REST HTTP API (a per-tool endpoint for the broker and other non-MCP consumers) is served by the resident daemon (yupana daemon, FR-27/FR-31). With [yupana.serve] use_daemon = true, the graph tools here become thin clients of the same resident engine.

Build with the mcp feature and run yupana serve (stdio) or yupana serve --http (streamable-HTTP on [yupana.serve] mcp_http_port, default 3040).

cargo build --features mcp
yupana serve            # stdio, for a local agent
yupana serve --http     # streamable-HTTP at http://127.0.0.1:3040/mcp

Live tools

ToolPurpose
yupana_statusBase ref, tenant, available tiers, Quipu settings
yupana_symbolsSymbol tree for a file
yupana_referencesDefinition site(s) of a symbol, by name or by position
yupana_analyzeFiles/symbols summary for a subtree
yupana_callersDirect callers of a symbol (who calls it)
yupana_calleesDirect callees of a symbol (what it calls)
yupana_communitiesDensely-connected symbol clusters (deterministic Louvain, FR-9)
yupana_impactBlast radius — transitive callers, N hops; reconciles against a cochange set (FR-11)
yupana_dataflowLocal data dependence; interprocedural: true opts into Rust CPG
yupana_verifyVerdict on a proposed edit buffer, before you write it (FR-23/FR-24)
yupana_promotePromote a subtree's facts to Quipu — SHACL-validate, then write (needs the quipu feature)
yupana_ingestLoad generic (non-code) facts into the hot board graph (FR-35; needs the game-state feature)
yupana_guardCheck proposed orders against game-state policies (FR-37; needs the game-state feature)
yupana_whatifSpeculative, uncommitted impact of an order set over the board (FR-38; needs the game-state feature)
yupana_path_checkConformance of a plan or work-in-progress against a blessed golden path under gp-grammar/1 (FR-41/FR-42; needs the golden-path feature)

yupana_symbols enumerates the same code graph as callers and impact: the expected root-matching resident daemon when available, otherwise the transient multi-language graph. Relative and absolute paths within the analysis root are accepted, including cached files deleted from the working tree. When the graph holds no symbols for a file, the tool returns an error; that absence cannot distinguish an unindexed, unsupported or symbol-less file. It never reports that such a file is empty. Each symbol retains its tier and start line. End lines are served when known from the transient graph and omitted when the resident protocol does not carry them; the tool does not re-parse a newer file to invent extents for an older snapshot.

The last three are the game-state harness. They are registered on every build, as yupana_promote is, and refuse with a message naming the feature when it is absent — a tool that accepted a board and silently did nothing would be worse than one that is missing, because the caller would believe it had guarded. See The Game-State Harness.

yupana_references

Give it symbol or a position (at_file + at_line, plus optional at_col), not both concerns at once.

Names over-connect: a real tree has twelve builds, and asking by name gets all twelve. When you are reading code you know where you are, so point at it:

{ "at_file": "src/beta.rs", "at_line": 7 }

The position resolves to the innermost symbol enclosing that line, and the reply holds that symbol alone — it does not re-expand to every symbol sharing its name, which would restore the ambiguity the position was given to remove.

at_col is one-based and requests LSP precision. An LSP result carries complete start/end line+column spans and tier: "lsp". With no language server or resolvable build, the request degrades to the name-based tree-sitter result, which may intentionally contain multiple same-named definitions and carries tier: "treesitter". It never ignores the column while claiming precision. Passing a partial position is an error.

Position requests always take the transient build, never the resident daemon: /references does not carry the node spans a position needs.

yupana_verify

Pass the full proposed contents of a file; get back ok plus any violations. Only violations the edit introduces are reported — the file's current contents are the baseline, so pre-existing breakage is not blamed on the edit.

Always read unchecked alongside ok. At the tree-sitter tier there is no type information, so type-violation is never decided, and method calls, path-qualified calls, imports, locals, and closures are deliberately left alone rather than guessed at. ok: true means "nothing this tier can see is wrong", not "this compiles".

Every response carries a tier tag; structure-reading requests will accept a tenant parameter as multi-tenancy lands (Phase 3).

CPG dataflow opt-in

yupana_dataflow accepts interprocedural: true in a build with mcp,cpg. The function, var, forward, hops, and path selectors keep their roles; the result becomes the CPG report described in CLI reference, including source-qualified witness paths, control edges, diagnostics, and search truncation. Both the report and its facts carry tier: "cpg". Omit the flag for the existing intra-procedural response. Without cpg, requesting the flag returns an error rather than silently substituting a tree-sitter answer.

Resident Daemon

yupana daemon is the Phase-3 resident engine (FR-31): it builds the base graph once, holds it in memory, and answers queries over a local HTTP API (FR-27). The hook and the MCP graph tools become thin clients of it — the per-invocation CodeGraph build that dominates their latency disappears, which is what makes the sub-100ms guard budget reachable.

yupana daemon              # build the graph, serve on [yupana.serve] bind/port
yupana daemon --port 4050  # override the port

The daemon binds [yupana.serve] bind_address:mcp_http_port (default 127.0.0.1:3040). It refuses to start if the graph cannot be built — it never serves a graph it does not hold. SIGINT/SIGTERM drain in-flight requests and exit 0: a supervisor's stop is a clean stop, and the shutdown is announced on stderr, not silent.

Who uses it

Clients consult the daemon only when [yupana.serve] use_daemon = true (default false), and even then only after verifying via /status that the daemon serves the same root they are asking about — a daemon holding repo B must never answer for repo A.

  • yupana hook pre-edit sizes edits via POST /measure. If the daemon is down, the guard fails open and says so — the edit proceeds and the session gets a loud once-per-session notice. Down = allowed AND announced, never silently allowed, never blocked (see Pre-Edit Policy Guard).
  • yupana hook post-edit runs the FR-30 cycle in one POST /edit: the just-saved file is recorded in the session tenant's overlay (--tenant, default single-tenant) and the advisory comes back from the fresh composed view. Daemon down = transient fallback with a stderr note — silent to the model, because the advisory is advice, not enforcement, and the file on disk (not the overlay) is the system of record.
  • MCP graph tools (yupana_callers, yupana_callees, yupana_impact, yupana_references) answer from the resident graph when the request is not path-scoped; otherwise, and on any daemon failure, they fall back to the transient build. Fallback here is silent to the model (a transient answer is equally correct, just slower); absence still goes to stderr for the operator.

HTTP API

Every reply carries its provenance tier (FR-3). Facts come from the graph built at daemon startup — /status.uptime_secs says how old that snapshot is.

Code-fact freshness (the FR-3 second half) is served on the tenant-scoped /symbols reply (bobbin-052): the watch path's per-(tenant, file) note — recomputing from touch until the frontier recompute is current, then fresh — read before the view is composed, so the tag describes the state the symbols came from. The omit rule survives the wiring: the field is absent on the untenanted path (no tenant to key the map by) and for a tenant that never had an edit absorbed — never "fresh", never "unknown", because a fabricated tag is indistinguishable from a measured one. This is a different quantity from the projection freshness the verdict surface serves (how current the projected policy registry is); they answer different questions and can disagree. See Tiers and Freshness.

RouteMethodQuery/bodyAnswer
/healthGET—bare ok (the liveness probe)
/statusGET—root, node/edge counts, uptime, tiers, tenant_layer (base commit + active overlays; null = layer absent)
/callersGETsymbol, tenant?direct callers
/calleesGETsymbol, tenant?direct callees
/impactGETsymbol, hops (default 5), tenant?transitive blast radius
/referencesGETsymbol, tenant?definition sites
/symbolsGETfile (root-relative), tenant?that file's symbols, line order
/dataflowGETfunction, path?, var?, forward?, hops?intra-procedural data dependence
/measurePOST{file, rel, anchors[], max_hops?}edit blast-radius sizing for the guard
/editPOST{tenant, rel, content?}FR-30 feed-and-advise: record the edit in the tenant's overlay, return which of the file's symbols have external callers (from the fresh view), and drive the FR-16 bounded frontier recompute over that same view (the reply's frontier counts what it reached). The freshness this maintains is what tenant-scoped /symbols serves. Omitted content is read from disk, root-confined.

Feature-gated routes, mounted only on a build that can serve them:

  • game-state — POST /ingest, POST /guard, POST /whatif (FR-35, FR-37, FR-38; see The Game-State Harness).
  • golden-path — POST /path/check (FR-41/FR-42; see Golden-Path Conformance). A check that cannot be answered — no projected paths, an undeclared path, an unknown grammar version — is a 409 Conflict, never 200-with-no-findings.

The tenant layer (yupana #2)

When the root is a git repo, the daemon also holds a tenant registry: one shared read-only base built at the startup HEAD, plus a copy-on-write overlay per tenant, fed by POST /edit (the post-edit hook does this automatically when use_daemon is on). A query naming tenant= resolves against that tenant's base + overlay view; overlays are absolutely invisible to other tenants (§6.3).

Two deliberate semantics to know:

  • Tenant views compose over the COMMITTED base, not the working tree (FR-22 keeps overlay churn distinct from committed truth). An uncommitted working-tree delta present at daemon startup is visible to the un-tenanted surface (which snapshots the working tree) and absent from tenant views until a tenant touches those files.
  • Naming a tenant when the layer is absent (root is not a repo — no commit to anchor a base to) is an explicit 412, never an empty answer. /status.tenant_layer is null in that state, distinct from present-with-no-overlays.

Notes that keep the answers honest:

  • found vs empty. "The symbol is not in the graph" and "the symbol has no callers" are different answers; every reply separates them. /symbols is the one surface that cannot: files enter the graph only through their symbols, so known: false covers absent, unparseable, and symbol-less files alike — render it as "no symbols in the resident graph", never "the file is empty".
  • Root confinement. /measure names a file to read and /dataflow a subtree to build; both refuse (400) any path resolving outside the daemon's root. A localhost daemon must not become an arbitrary-file reader.
  • /dataflow is not resident. Dataflow has no resident model yet (yupana #22); the daemon computes it per request so the surface is complete rather than silently partial. The reply shape will not change when a resident model arrives.

Failure semantics

The client seam reports "daemon not reachable" as its own state that callers must handle — it can never be folded into an empty answer. What each caller does with it differs by contract:

CallerDaemon down means
pre-edit guardedit allowed + loud notice (fail-open, announced)
MCP graph toolstransient rebuild, stderr note (correct, slower)
anything parsing /statusabsent/unparseable = not a usable daemon

The guard's taxonomy keeps "the guard ran and found nothing" distinct from "the guard could not run" end to end; killing the daemon is the cheapest bypass, so its absence must always be visible.

Read-time entity legend

yupana hook post-read annotates completed reads with entity labels from the resident keyword index. It is off by default. This is advisory feedback, so a missing allocation, daemon, session ID, snapshot or response means silence. It never contacts Quipu from the hook or falls back to loading a gazetteer file. Build with quipu,mcp for the hook and resident HTTP surface.

Context allocation

The calling context pipeline must add remaining_context_bytes to the completed tool payload. It reserves prior signpost/advisory output and separators first. The legend spends at most the smaller of that remainder and 600 UTF-8 bytes, including its relation annotations. Missing or zero allocation produces no output. An independently installed hook therefore does not create another 600-byte budget.

{
  "hook_event_name": "PostToolUse",
  "session_id": "example-session",
  "tool_name": "Read",
  "tool_input": {"file_path": "notes.md"},
  "tool_response": {"file": {"content": "build-01.example runs demo-service"}},
  "remaining_context_bytes": 200
}

The adapter must reserve the actual prior bytes, invoke this stage with the remaining allocation, and combine the returned additionalContext into the existing envelope. Supplying a constant 600 to every independent hook does not establish a shared budget. Ordinary harness payloads without this adapter remain silent, even when the enable flag is set. DP signpost, prefetch, search augmentation and command routing stay in DP. Only its entity-legend stage is replaced during an approved handover; avoid enabling both entity legends.

Resident data and activation

The daemon holds first-class keywords::KeywordIndex data shared through ResidentEngine::keywords(). Label matching is compiled once per complete snapshot replacement. Atomic replacement makes new or renamed labels visible to subsequent requests without per-hook compilation.

SettingMeaning
YUPANA_READ_LEGEND=1Enable the advisory in this agent's environment
YUPANA_KEYWORDS_NAMESPACERequired ontology class namespace in the daemon environment
YUPANA_KEYWORDS_DICTPrecision word list, default /usr/share/dict/words
[yupana.quipu] enabled, endpointConfigure the daemon's background data source
[yupana.serve] use_daemon, bind_address, mcp_http_portConfigure the hook's resident connection

The hook accepts only numeric loopback bind addresses and follows no redirects. The background producer refreshes every five minutes, with a 60-second total request budget. It queries the ported governed entity classes, excluding bulk code kinds, Formula and credentials. Every class must succeed without truncation or exceeded row bounds before replacing the index. Failure retains the prior snapshot with its original timestamp; after ten minutes it is refused.

There is no startup file export or hourly cron requirement. Namespace omission leaves this producer inactive. Existing daemon policy refresh is unchanged. The keyword snapshot reports its observation time; these are projected Quipu entity facts, rather than tree-sitter code facts.

Matching and bounded output

The index preserves DP's case-insensitive ASCII boundary rules, filename-stem guard, longest-first non-overlap, precision dictionary and evidence stoplist. Multiple IRIs under one label display ambiguous N. At most five entity labels are shown. Keywords are deduplicated within a session across calls and snapshot replacements. Up to two existing directed relations between unambiguous shown entities can use the same remaining budget; co-occurrence never creates an edge.

Read responses and Bash br show or markdown cat, head, tail and sed responses are eligible. Failed or interrupted calls stay silent. The current adapter supports the Claude completed-tool envelope. Other payload schemas require their own tested translation before activation.

Shared pipeline entry

yupana hook post-read-pipeline combines DP signpost and the resident legend in one process. During an authorized handover it replaces the existing dp signpost entry; do not install it alongside that entry or an independent post-read entry. Source delivery does not install or activate it. Other DP hooks remain unchanged.

With YUPANA_READ_LEGEND=1, it disables DP_LEGEND only in its DP child, runs dp signpost, and subtracts its actual context UTF-8 bytes plus a newline separator from DP_LEGEND_MAX_BYTES (default and maximum 600). It passes that remainder to the in-process read adapter. Exhaustion adds nothing; prior output is preserved even if signpost alone exceeds the cap. Other DP envelope fields survive. Empty, malformed, unavailable or over-budget legend replies preserve DP's exact output bytes.

With the Yupana flag off, DP retains its original environment and legacy legend flag, allowing rollback without removing the pipeline entry. YUPANA_DP_BIN optionally supplies an executable path, never a shell command. --config selects Yupana's configuration as on the ordinary hook. Input is bounded to 256 KiB and accepted DP output to 64 KiB. The DP child has a one-second deadline and is killed and reaped on timeout; the resident lookup retains its 40 ms deadline. Anonymous temporary files avoid pipe deadlocks. Builds without quipu stay silent and cannot replace a working signpost entry.

The shared allocation covers these two stages. Independent harness hooks do not report their output bytes to this pipeline and are outside this allocation.

The resident POST /keywords API accepts session_id, text, reference and remaining_bytes, and returns context, shown dedup keys, raw hit count and the snapshot's generated_at. Input is bounded to 128 KiB of read text. Snapshot, match and session capacities are bounded. Saturation refuses additional output instead of evicting active dedup state and repeating annotations. Session state expires after 24 hours or daemon restart, so a restart also starts a new dedup window and must be accounted for in a continuing trial.

Measurement and handover

Successful resident decisions emit read_legend events into the Yupana metrics spool with the frozen DP scorer's timestamp, session_id, shown, bytes, budget and latency_us fields. Unavailable resident data emits a silent read_legend_unknown event. Filter this event kind before using a trial scorer; do not mix these events into action or guard denominators.

Source tests and an isolated replay establish candidate behavior. They do not establish an installed hook, delivery to an agent, or a valid continuing trial. Live handover requires a functioning context allocation adapter, durable per-agent activation, real observed delivery, and the original trial's acceptance criteria. Keep the preceding measurement available until that handover is authorized.

The Pre-Edit Policy Guard — Integration Contract

yupana hook pre-edit is a Claude Code PreToolUse hook that can deny an edit whose blast radius or target path exceeds the calling agent's capability scope (spec §5.8, FR-25/FR-30).

This page is the normative contract for harness integrators (Gas Town shantytown emits this hook per role/card). It is versioned with the binary: if any clause below changes, the change lands here in the same commit.

Blocking is opt-in and off by default. A wrong hard-deny is worse than no guard at all.

Shared path exemptions

Text rules can link to shared path selections with aegis:exemptionSelector. The target is a governed aegis:Selector with aegis:name, an aegis:evidenceSource describing repo-relative paths, and one or more aegis:exemptPathRegex strings. Several rules can share one selector, so its paths are maintained in one place.

The reader combines a rule's local exemptPathRegex values with the values on its explicitly linked selectors as regex alternatives. Selectors are read once per refresh; nested selector links are not followed. A rule with no link inherits nothing. This lets a liveness canary remain applicable everywhere while other rules share guard implementation or fixture exceptions.

A missing selector regex, invalid target IRI, empty regex, or malformed shared regex fails projection and follows the guard's existing projection-error path. It never silently becomes an exemption for every file. Deploy compatible readers before replacing local regex values with links, and verify the same paths remain governed after migration. This support does not change a rule's enforcement tier or the host's mode.

(a) Input — the payload on stdin

The hook reads one JSON object on stdin, the standard Claude Code PreToolUse payload:

{
  "session_id": "abc123",
  "transcript_path": "/home/agent/.claude/projects/.../session.jsonl",
  "cwd": "/home/agent/work/repo",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/home/agent/work/repo/src/graph/blast.rs",
    "old_string": "...",
    "new_string": "..."
  }
}

Yupana reads cwd, tool_name, and tool_input; everything else is ignored but tolerated. tool_input shape by tool:

ToolFields Yupana uses
Editfile_path, old_string, new_string
Writefile_path, content
MultiEditfile_path, edits[].old_string, edits[].new_string

Every field is optional as far as the parser is concerned. A payload Yupana cannot parse, or one naming a file in no known language, is an ALLOW — the guard only ever speaks up about edits it genuinely understands.

(b) Output — allow and deny

ALLOW: exit 0 with empty stdout. This is the overwhelmingly common path.

The guard never emits permissionDecision: "allow". That value suppresses the user's own permission prompt, and a structural guard has no business granting permission it was not asked about. The guard may only ever subtract permission, never add it. Staying silent leaves Claude Code's normal permission flow exactly as it found it.

DENY: exit 0 and print this object on stdout:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "yupana: edit to src/graph/blast.rs exceeds the blast-radius ceiling for tenant `polecat-3` (impacts 47 symbols across 12 files; ceiling is 25/10). Narrow the change, or request a wider capability scope."
  }
}

permissionDecisionReason is fed back to the model, so it is written for a model to act on: what was exceeded, by how much, and what to do instead.

Yupana never exits 2. Exit 2 is Claude Code's fail-closed channel (block, stderr to the model). Reserving it means no Yupana crash path can ever hard-block an agent — a panic exits 101, which Claude Code treats as a non-blocking error and the tool call proceeds.

That guarantee covers Yupana's own code, and argument parsing happens before any of it runs. A Yupana that predates a hook subcommand answers it with the argument parser's "invalid value" error and exit 2 — so staleness fails closed even though absence fails open. Since this version, an unparseable yupana hook … invocation degrades to a silent allow instead (exit 0, empty stdout, a loud stderr line). Binaries older than that fix cannot be repaired retroactively, which is why the invocation in (d) is written to be skew-proof on its own.

(c) Latency — the sub-100ms budget

The hook is synchronous in the agent's loop (FR-31). Yupana enforces its own wall-clock deadline, [yupana.policy] deadline_ms (default 100). When the deadline expires, the in-flight analysis is abandoned and the edit is allowed.

Do not rely on the harness timeout field for this: it is expressed in whole seconds and defaults to ten minutes — three orders of magnitude past the budget. Set it anyway as a backstop ("timeout": 5), but the real deadline is Yupana's.

100ms is a deadline, not a measured latency. Without the resident daemon (Resident Daemon, FR-31; opt-in, [yupana.serve] use_daemon defaults to false) the guard builds the call graph per invocation and will exceed 100ms on large trees — which, by the rule above, means it fails open. That is the intended degradation, not a bug: on big repos the guard has teeth only once the daemon serves it.

(d) Fail open — non-negotiable

Every failure mode allows the edit. The harness launches every crew agent through this hook; a guard that fails closed bricks the fleet the moment Yupana is unavailable.

FailureResult
yupana not on PATHexit 127 → non-blocking error → edit proceeds
yupana too old to know the subcommandexit 2 → would block; see below
Yupana panicsexit 101 → non-blocking error → edit proceeds
Deadline exceededexit 0, silent → edit proceeds
Daemon unreachable, unreadable config, unparseable payloadexit 0 + loud line → edit proceeds
quipu unprojectable, cache servableexit 0 → rules still evaluated, verdict marked STALE
quipu unprojectable, no servable cacheexit 0 + loud line → edit proceeds
Policy says denyexit 0 + deny JSON → edit blocked

Failing open is a LAST resort, not the first response to a slow quipu

Fail-open is non-negotiable, but it is not free, and for one failure it was being reached far too eagerly. The governed plane projects its rule catalogue from quipu over HTTP, and the hook is a short-lived process per edit — so before aegis-0upyu every edit by every agent issued a live /query, and any one that could not complete in the 2s budget dropped that edit straight to "allow".

Measured 2026-08-04 on this fleet: 5.2% of all pre-edit invocations, and 19% of one day's, failed open on projection timeouts alone. The failure self-interferes — quipu serves /query effectively one at a time, so heavy graph work is exactly what starves the guard that reads the graph, and the guard was least available precisely when it mattered most.

The guard now keeps a durable projection cache (projection.json, beside metrics.jsonl under $XDG_STATE_HOME/yupana, overridable with $YUPANA_PROJECTION_CACHE_PATH). Every successful projection writes it; a failed one serves it. The contract around it is what keeps a cache from becoming its own silent failure:

  • a cache-served verdict is STALE, never fresh, and states the cache's AGE in seconds — "stale" alone cannot distinguish a slow quipu from a week-old catalogue, and those warrant opposite reactions;
  • past [yupana.quipu] projection_cache_ttl_secs (default 3600, 0 disables serving) the cache is refused and the guard fails open loudly. A retired rule that keeps firing from disk is worse than no rule, because it is unfalsifiable from the outside;
  • a cache written against a different endpoint is refused outright, rather than enforcing another deployment's policy while claiming to enforce this one's;
  • served_from_cache and fail_open are different record kinds and must stay that way. One is the guard enforcing last-known policy; the other is the guard not running. Collapsing them is what made a soak count unguarded edits as clean ones.

Text-rule refresh separates catalogue membership from property retrieval. It discovers every TextRule subclass through the explicit subclass path, then reads each subject's properties directly. It reconstructs the original query's required and optional bindings before decoding, including every distinct label, rationale and exemption. This avoids expensive whole-store optional joins as the graph grows. Repository landing lookup starts from landingPolicy before checking the repository type; its selected fields and filters are unchanged.

These reads do not extend the HTTP deadline or the cache TTL. A failed or explicitly truncated text-catalogue read fails the refresh and preserves the prior cache. Like the other independently fetched policy planes, discovery and subject reads do not share a transactional snapshot; a concurrent graph edit can be reflected across successive refreshes.

Replay exposure faithfully

A guard metrics record produced by the governed plane includes exposure and repo alongside session and rule. These are the same values used for the decision and emitted on the governed record: public, internal, or unknown for a matching text rule, and n/a when exposure was not evaluated. The repo is the edited file's repository, with unresolved when no name was resolved. Other guard decisions omit both fields. Path recording remains opt-in.

The Dogwood converter preserves these fields. Replay can score rule and exposure on one session-attributed action; it must not join records by agent and timestamp or interpret a missing field in older traffic as public exposure.

Invoke it so version skew cannot block the fleet

Every row above fails open except one, and that one is not exotic: it is what you get by rolling the hook out ahead of the binary, which is the normal ordering of a deploy. Invoke the guard through this wrapper rather than bare:

out=$(yupana hook pre-edit 2>/dev/null) || exit 0
printf '%s' "$out"

|| exit 0 converts every non-zero exit — 127 absent, 2 stale, 101 panic — into an allow. Capturing first and printing only on success also means a Yupana that dies mid-write contributes nothing to stdout, so a truncated run can never be parsed as a permission decision. Emitting the command bare is safe only once every host is known to be past the skew fix; the wrapper is safe now, and stays correct afterwards.

"Loud" means systemMessage, not stderr

A hook's stderr is shown only when it exits 2; on exit 0 it goes to the debug log, where nobody looks. So a fail-open that only wrote to stderr would be silent in practice — exactly the failure this clause exists to prevent.

Yupana therefore writes the stderr line and emits a user-visible systemMessage:

{ "systemMessage": "yupana: policy guard failed open (daemon unreachable) — edits are UNGUARDED this session." }

No hookSpecificOutput accompanies it, so the edit is untouched. The message is emitted once per session_id (tracked by a marker file under the system temp directory), because a per-edit warning on a down daemon trains agents and humans alike to ignore it.

Registration

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command", "command": "yupana hook pre-edit", "timeout": 5 }
        ]
      }
    ]
  }
}

The tenant whose scope applies is resolved from --tenant, falling back to the BOBBIN_ROLE environment variable. Shantytown sets BOBBIN_ROLE per agent, so one hook registration serves every role.

Policy configuration

Policy lives in the shared .bobbin/config.toml under [yupana.policy], with the usual resolution order (project > user > defaults).

[yupana.policy]
# off     — the guard is inert (default)
# advise  — compute and report violations, but never deny
# enforce — deny violations
mode = "off"
deadline_ms = 100
notify_on_fail_open = true

# Per-tenant capability scopes, keyed by tenant/role id.
[yupana.policy.scopes.polecat-3]
allow_paths = ["src/**", "tests/**"]
deny_paths = ["src/config.rs"]
max_impacted_symbols = 25
max_impacted_files = 10

A tenant with no scope entry is unconstrained — unless the work-item scope rung is armed ([yupana.policy] work_item_scope = "advise" | "enforce"), in which case the guard falls back to the OBSERVED scope of the agent's tracked work item (the paths prior work on it touched, projected from quipu), and an unknown scope draws a once-per-session advisory instead of silence. An item with no observed ground of its own is scoped to its parent's — the derived rung, which never hard-denies at any setting (an inference must not block) and which answers Allow for an in-ground edit rather than falling through to "unguarded". See the Configuration Reference and docs/work-scoped-governance.md. deny_paths beats allow_paths. Path patterns are globs matched against the repo-relative path.

With mode = "advise" the guard reports what it would have denied via systemMessage and never blocks — run a new scope in advise for a while before promoting it to enforce.

An advise run is visible to the operator, not to the agent. systemMessage surfaces in the user's pane; it does not enter the model's context, and the tool result of an advised edit is indistinguishable from an unguarded one. Confirmed by running the same violating edit in both modes on a live pane: advise returned an ordinary success to the model, enforce returned the reason.

Two consequences when staging a scope. Agents will not self-correct during an advise run, so the violation counts you collect are the uncorrected rate — which is what you want for sizing a ceiling. And "agents behaved no differently under advise" is not evidence that they saw the advisory; they did not. Only enforce puts the reason in front of the model.

The work-item scope advise rung is the exception: its out-of-ground notice is delivered where the agent can read it — as PostToolUse additionalContext on the post-edit hook, the channel that reaches the model without blocking. The edit has landed, nothing is prevented, and the agent is told before its next action that it stepped outside its item's ground — along with what the graph knows about where it went (which work items' commits touched that path before, and how each turned out). Silent whenever it cannot speak honestly: no plate, no scope for the item, an unreachable store, or an edit inside the ground. An unknown scope is UNKNOWN, not a deviation.

Tripwires — boundaries with declared effects

A tripwire ([[yupana.policy.tripwires]]) is the local-config slice of the governance plane's Binding / Gate primitive: it attaches an effect to a boundary, and the edit itself is the crossing — nothing has to remember to check the wire. Where a rule says what text may look like everywhere it applies, a tripwire says what happens at this boundary.

# Touching the boundary at all trips the wire.
[[yupana.policy.tripwires]]
name = "auth-boundary"
paths = ["src/auth/**"]
effect = "deny"                  # warn | deny | throttle
message = "auth changes need the security workflow"   # optional

# Rule-conditioned: trips only when the named rule fires INSIDE the boundary.
# The same rule can advise repo-wide and deny here.
[[yupana.policy.tripwires]]
name = "no-tickets-in-auth"
paths = ["src/auth/**"]
rule = "no-ticket-in-comment"    # a [[yupana.policy.rules]] name
effect = "deny"

# Crossing records an expiring backoff subsequent edits surface. Never blocks.
[[yupana.policy.tripwires]]
name = "hot-file"
paths = ["src/core.rs"]
effect = "throttle"
backoff_secs = 300

Semantics, with the ambient mode a ceiling throughout:

  • deny blocks under enforce and advises under advise.
  • warn notifies and never blocks.
  • throttle records a backoff via the same machinery the Post-Action Auditor uses; the next edits surface the advisory. It is recorded even when a sibling deny wire blocks the same edit — the attempt crossed the boundary, and the crossing is recorded either way.
  • A path-only wire trips on any edit whose repo-relative path matches (a pure deletion included: the crossing is the edit's target, not its text). A rule-conditioned wire needs the introduced text and a grammar; without them it has no evidence and does not trip.
  • One decision per edit: a deny-effect trip decides it, and every tripped wire rides in the same message and is recorded as its own constraint evaluation (point PAG, tree-sitter tier, fresh — local config is authoritative).

A misconfigured wire — a malformed glob, a rule no rules entry defines, a throttle with no backoff_secs, or a wire with no paths and no rule — is a loud fail-open, never a silently inert control: the wire set names each broken entry once per session and the guard allows.

Governed tripwires (quipu feature). The same concept with quipu as the canonical store: an aegis:Policy at boundary:"action" carrying aegis:appliesTo globs and no Selector/Predicate (quipu shapes/policies/tripwire.ttl). They ride the governed plane's one registry refresh (and its durable cache — a projection failure serves last-known wires, stale and saying so), and evaluate class-first like every projected policy: a hard wire's deny blocks under enforce, a soft wire never blocks, a throttle wire's declared aegis:backoffFormula is compiled into a recorded backoff. A wire declaring verificationPoint "PAA" is skipped at the gate — quipu's placement law puts soft throttle wires there, and the PAA-side projection is that seam's sequencing step 2. Effects this seam cannot enforce (require-approval, record, …) refuse the projection loudly rather than decode into an inert wire.

The action surface (pre-bash)

yupana hook pre-bash always records resolved actions. Its target-scope arm is record-only by default and stays that way unless a deployment sets [yupana.policy] action_scope, which is off out of the box.

[yupana.policy]
action_scope = "advise"          # off | advise | enforce; `mode` is a ceiling

[yupana.policy.scopes.polecat-3]
allow_targets = ["host:build-*", "service:metrics"]
deny_targets  = ["service:etcd"]

Targets are matched as class:target against the (verb, target, class) a command resolves to — host, service, repo, container. Precedence and the empty rule match the path halves exactly: deny_targets beats allow_targets, and an empty allow_targets permits any target rather than none.

Declared only. There is no observed rung here: nothing in the graph records which hosts an item's prior work touched, so there is no record to infer from — and declared is the one provenance the trust ladder permits to hard-deny.

An abstention is never a violation. The resolver answers unknown for every command whose target is not unambiguous from syntax — a pipeline, a shell function, a script that ssh's internally, or a bare hostname with no dot. Those reach the check as no check performed. A guard that refused what it could not identify would refuse most of the shell, which is why the recognised set is deliberately small.

Stage advise first. At advise the violation goes to stderr and the spool and nothing is refused; at enforce the command is denied. The spool's action_scope records say what would have been refused, which is what a deployment should read before arming the boundary.

Governed host-memory policy

The same hook projects memory-heavy command policies from Quipu. A policy owns all of the tunable data: a live command regex in its Selector/Predicate, and a deterministic_threshold OperatingPoint whose threshold is interpreted against the declared calibration basis MemAvailable GiB. Yupana reads MemAvailable and MemTotal from /proc/meminfo; it does not substitute MemFree when the signal is absent.

A matching command below the floor reports the measured available, total, and governed threshold values. Under mode = "advise" it emits a system message and records a memory_policy metric without stopping the command. A hard policy can deny only under mode = "enforce"; changing the floor or matcher is a graph write and needs no Yupana rebuild. A missing projection, stale cache past its TTL, unreadable memory signal, or threshold larger than the host's total memory is loud fail-open.

This event is an agent-harness execution seam. Non-harness launchers must invoke the same hook contract before execution (for example from a scheduler wrapper); installing the binary alone does not interpose on arbitrary processes. Do not claim cron coverage until the cron entry itself is wired through that seam.

Quipu-backed disk-impact advisory

The pre-Bash hook also learns the disk cost of commands that commonly create large artifacts (cargo, container builds, package managers, copy/archive tools, and explicit allocation tools). It snapshots filesystem free space with df before a command and closes that sample when the next matching command arrives in the same harness session. This is intentionally a filesystem delta, not a directory walk: hardlinks, deleted-open files, caches outside the working tree, root, and tmpfs must remain visible as different budgets.

History is keyed by the governed binary:subcommand|repo:<class>|cwd:<class> signature and an opaque root|tmpfs|mount:<hash> filesystem identity. Raw argv, paths, devices, and mount points are never written to Quipu. Deltas are signed: positive values consume space and negative values free it. The guard projects the most recent 100 samples and uses their nearest-rank p90. If p90 exceeds 80% of current headroom, it emits a governed, not blocking system message. There is no enforcement branch in this first slice.

No history is UNKNOWN, never safe: the matching command is allowed without pane output, while a disk_guard_unknown spool event records why no verdict was available. Failed history writes or queries also retain their reasons in the spool. Credential failures add a quipu_auth_failure event and disable later writes for the session; public reads remain available. A governed headroom warning still speaks. Unrelated commands do not pay for a Quipu round trip and remain silent. The graph vocabulary is governed by CommandDiskImpactObservationShape; deployments must accept that shape before expecting samples to accrue.

What the guard checks

  1. Path scope (FR-25) — is the edited file inside this tenant's writable capability scope?
  2. Blast radius (FR-12/FR-25) — do the symbols the edit touches transitively affect more symbols or files than the scope permits?
  3. Buffer verification (FR-23, opt-in: [yupana.policy] verify = true) — does the proposed buffer introduce references that resolve to nothing? The same verifier yupana verify exposes, run at the blocking seam: a hallucinated identifier, a wrong-arity call, or an unresolved mod import denies under enforce and advises under advise. Rust-only today (the verifier is a tree-sitter-rust pass), scoped to what the edit introduces (pre-existing breakage is not this edit's business), and inside the same deadline_ms — a blown budget or unparseable buffer degrades to allow, never to a block.

All are computed at the tree-sitter tier against the requesting tenant's graph, and every verdict carries that tier tag (FR-3). A tree-sitter blast radius is an approximation; the ceilings should be set with that in mind.

Alongside the checks, the guard runs a denied-edit recurrence advisory (stage 1 of the semantic-grounded ordering): the verdict spool retains denied edits as a similarity corpus, and an edit near a prior denial surfaces that verdict as context — before a refusal's own text, so the refusal arrives explained, or as a plain notice on an allow. Similarity never denies; the advisory seals its cosine score, threshold, embedding-model id and corpus watermark so it can be reproduced or falsified.

Observing the guard — yupana status

A guard you cannot observe is a guard you cannot trust: enforcement that has quietly stopped looks exactly like enforcement that found nothing wrong. So the policy layer is visible in yupana status, resolved for the queried --tenant:

  policy      : mode=enforce  scope=configured (allow=1 deny=0 sym≤— files≤3)
  rule set    : none — never loaded (local config only)

Two states are reported loudly rather than left to silence:

  • enforce with no scope for the tenant — armed in appearance, inert in effect — prints a ⚠ caveat (enforcing_without_scope: true in --json). This is the disarm-that-reads-as-healthy shape behind more than one past bug.
  • No signed rule set loaded. The resident, signed rule cache does not exist yet; status reports its absence (signed_rule_set.state: "never-loaded") rather than omitting it, so the surface is present and its absence is never silent. When the cache lands it populates the live rule-set version and age into this same field.

--json emits the full policy object (mode, scope_configured, ceilings) and the signed_rule_set state for st doctor and other tooling to gate on.

Native Codex edits

Register yupana hook pre-edit on PreToolUse with matcher apply_patch. Native Codex sends a patch in tool_input.command, potentially covering several files. Yupana evaluates each file, preserves the session and tool-call IDs, and returns the strongest outcome across the patch. Native patch advisories use hookSpecificOutput.additionalContext so Codex delivers them to the model. Moves check both source and destination scopes. No files are written by the guard.

The parser reconstructs exact-context patches for verification while passing only added lines to introduced-text rules. Unchanged context is not newly introduced text. Missing or ambiguous anchors, fuzzy matches and unsupported patch syntax produce an explicit UNKNOWN metric and fail-open advisory, not a clean allow. This preserves the hook's existing availability contract; it does not certify coverage for edits performed through arbitrary shell commands.

guard records in the metrics spool measure edit evaluations. Signed landing-* certifications measure push/merge evaluations: the two populations must not be combined when assessing a landing-policy soak.

CI-map coverage

The pre-push CI advisory reads governed CiJob → LocalCommand mappings and intersects their path globs with the outgoing change. A complete map with no matching scope is quiet; an absent map is UNKNOWN. Under the existing noise policy, the latter is counted as ci_shift_unknown in the metrics spool.

Failed or truncated projections are refused before selecting commands. Invalid coverage markers, malformed globs, and blank job, label, command or scope bindings also refuse the projection. Direct callers of the selector receive UNKNOWN for an invalid or empty scope list, even when another check would match. Partial knowledge must not certify that a push is unaffected or run a partial check set.

These checks validate map integrity, not command trust or repository isolation. The current selector matches repository-relative paths; common paths can match in different repositories. Review repository identity, command authority, execution budgets and result attribution before arming a cross-repository map. Local checks supplement complete hosted CI and independent review.

Replay: measuring a rule before it refuses anything

The promotion ladder in work-scoped governance gates the move from advise to enforce on evidence, not on a date: would this rule have blocked anything that actually happened? That question turns the decision from a waiting game into a computation, and answering it needs two things — a corpus of recorded actions, and a runner that replays a candidate rule over it.

Yupana records session-grouped actions through its hooks and uses an offline runner to evaluate candidate temporal rules against those records.

The runner is Dogwood, and it is used offline only

Dogwood extends Cedar with temporal conditions that read an agent's own session event history — formerly, count_within, count_distinct_within, sum_within — plus

dogwood replay policy.dw --policy-schema schema.cedarschema --trace events.log

which prints ALLOW/DENY per event. That is the missing runner.

It is not adopted as an enforcement engine, and the reasons are worth stating rather than assuming.

Its own README says the reference interpreter is not for production, and the specific weaknesses it names are this estate's founding failure classes almost word for word: it "accepts timestamps as provided and does not validate them", it has "no authentication mechanism for events", and field inconsistencies silently weaken checks. A system built around unattributable action, unattributable message, and a control that could not fail cannot put a silently-weakening component on the path that refuses things.

Offline, that caveat costs nothing: the trace is our own spool read from our own disk, timestamp integrity is our problem either way, and no agent's edit waits on the answer.

There is a second reason and it is the deciding one. The paper plan states that a policy is never defined in yupana — authoring, SHACL validation and verdict signing stay in quipu. Adopting Dogwood as the authoring language would either break that or add a second authoring surface beside aegis:Policy. Using it to analyse records breaks nothing.

Running it

# 1. Convert the spool. Reads $YUPANA_METRICS_PATH / $XDG_STATE_HOME / $HOME
#    in the same order the emitter writes.
scripts/spool-to-dogwood.py --out /tmp/trace.log

# 2. Replay a candidate rule.
dogwood replay candidate.dw --policy-schema yupana.cedarschema --trace /tmp/trace.log

The converter maps guard records to Yupana::Action::"Edit", action records to the verb the resolver named, and audit records to Yupana::Action::"Audit". session becomes the temporal grouping key and item the work item — the two fields that make a record replayable at all.

Read the denominator

The converter prints, to stderr, how many events it wrote and how many records it dropped with a separate count per reason:

1284 event(s) written from 3011 spool line(s)
  dropped, no-session: 1502
  dropped, not-an-action: 210
  dropped, resolver-abstained: 15

That is not diagnostics; it is part of the result. A converter reporting only what it kept would let one covering 5% of traffic look identical to one covering 95%, and the false-positive rate is measured against the corpus that survived.

Two of those reasons need opposite responses, which is why they are counted separately:

  • no-session — records predating the field. Not replayable, and a rate measured without them is measured against a smaller corpus than the fleet produced. These age out as new records accumulate.
  • resolver-abstained — crate::action declined to name a verb. These are correct abstentions and must not be replayed under an invented verb: a rule derived from one would cite evidence that does not exist.

The honest limits, carried with the numbers

From work-scoped governance, and they travel with any rate this produces or the rate misleads:

Replay measures false positives only on traffic that happened. It cannot see work nobody has done yet, and it measures no false negatives at all. A seeded adversarial corpus would give FN coverage against known attacks and none against novel ones.

Governed session trajectory advice

The post-edit hook can advise when a selected command was attempted earlier in the same session. For example, a work-item creation command followed by more edits can prompt the agent to check ownership before continuing. The graph owns the command vocabulary, event ordering, notification frequency and explanation. Yupana owns the observation and evaluation mechanism.

The command observation runs in pre-bash. It proves an attempt, not a successful command or a newly created work item. Deep investigation without a matching command stays silent. Missing session identity cannot establish an ordering and stays silent as well.

Policy data

This is a separate trajectory_policies channel in the existing projection, using the existing Policy, Selector and Predicate classes. A Policy with targets = "session-trajectory" supplies:

FieldMeaning
selector / evidenceSourceJSON object with nonempty programs and verbs arrays of bare command tokens
predicate / evidenceSourcecommand-before-edit, the supported ordering
enforcementTierwarn, the supported advisory tier
oncePersession for one notice per rule/session, or edit for every subsequent edit
effect / verificationPointwarn at PAA, the supported response and placement
rdfs:commentThe full explanation delivered to the agent

The example declaration is policies/delegate-line.ttl in the repository. It is imported deliberately; the binary never seeds a fallback rule.

The normal Policy and Selector/Predicate shape requirements still apply, including claim and atom name fields. The trajectory decoder validates its additional fields, rejects unknown keys in the trigger JSON, rejects conflicting rows for one identity, and treats a missing required field as an error. Policies with incomplete fields remain visible to the query so they cannot disappear through a required SPARQL join.

A work-item selector can use:

{"programs":["br","bd"],"verbs":["create"]}

Its rationale must preserve the discriminator: comments, updates and closes are ordinary traffic on an existing item, not artifact creation. The advice should ask whether edits belong to the agent's assigned work; it must permit continuing that work. The parser retains the previous narrow behavior, including the known miss when a value-less long flag precedes the verb (br --json create). That produces a missed observation, never a claim of successful creation.

Tier and placement

This slice supports advice only. A trajectory policy marked block is refused during projection, with an error naming the missing pre-edit enforcement point. The post-edit hook cannot prevent an edit that already happened. A future pre-edit gate is a separate placement change requiring its own verification and promotion; accepting a block tier here would falsely advertise enforcement.

The deployment's existing policy mode remains the ceiling. off disables the channel. advise and enforce both deliver warn as advice.

Refresh and verification

Run yupana refresh-projection after changing graph policy. The JSON result includes a trajectory count and the durable cache includes the complete rule. Hooks read this local cache without network requests. A missing channel in an older cache is unknown, distinct from a refreshed empty catalogue; absent, expired or invalid cache data reports NOT EVALUATED rather than silently claiming no rule applies. Removing a policy and refreshing retires its advice. A changed trigger cannot inherit evidence recorded under its old definition.

Verify with a fresh isolated session:

  1. Post-edit before any selected command: silent control.
  2. Pre-bash with a selected invocation: observe the attempt.
  3. Post-edit: the graph's explanation appears.
  4. Post-edit again: silent when oncePer is session.

Also test a custom program/verb pair using only changed graph data, and confirm that a block policy refuses projection. The trajectory_advised metrics record names the rule, tier, frequency and attempted-command evidence; it never records an inferred successful work-item creation.

Read replay evidence

just session-guard session.jsonl runs a separate, offline Claude transcript replay. It reports identical successful text returned for the same requested region, only when the earlier result preceded the later request. Failed reads, missing results, images, concurrent requests, changed output, recorded edits, compaction, and known background-task output polling do not produce candidates. Overlapping but different ranges remain silent because they may contain new lines.

The output is a candidate for review, not proof of wasted work or that the harness still retains the content: eviction without a transcript marker remains unobservable. This replay is not automatically installed as a hook.

just session-guard --selftest exercises the discriminators and exits nonzero on failure. Both just test and the pre-commit/CI quality gate run this suite.

Live Read advisory

The existing yupana hook post-edit command also evaluates Claude Read completions. Its PostToolUse matcher must include Read (an all-tool matcher already does). It needs session_id, tool_use_id, transcript_path, the exact tool_input, and a successful structured text tool_response. No additional hook or background process is needed.

Only the current completed read can produce advice. The earlier structured result must precede its request on the current conversation's parent-UUID chain; abandoned branches cannot supply context. Identical returned text, actual line span and requested arguments are required. Recorded compaction, edits, malformed or missing lineage, failed/image results and task-output polling invalidate or exclude evidence. The live adapter conservatively invalidates after every shell command, since a script can write a file without naming it in the command. This is narrower than the offline replay's filename heuristic.

The advisory asks the agent to reuse earlier text if it remains in context. It does not assert that a reread was wasteful, and cannot observe eviction that the harness did not record. The existing per-session advice suppression limits repeated notices. This is a Claude structured-Read adapter; it does not claim coverage of shell reads or Codex tool output.

Transcript reads use the most recent 16 MiB, discarding a partial initial record. Large sessions can still establish a candidate from a connected pair of reads inside that window; they do not need the session root. Missing ancestry before the window cannot manufacture a clean pass when no pair is found. Missing, unreadable or unrecognized evidence produces unknown. Each Read hook evaluation emits reread_evaluated metrics with candidate, no_match, or unknown and a hash of the session/request identity. Metrics contain no paths or returned text. The evidence field classifies UNKNOWN from the same snapshot: missing current request, malformed snapshot, unavailable structured response, argument mismatch, or incomplete/unavailable ancestry. This distinguishes a hook-time evidence gap from later successful replay; it does not relax the guard. An absent current request or malformed snapshot triggers up to four 50 ms waits and fresh bounded reads. snapshot_attempts records the number of observations. Evidence still missing after the bounded retry remains UNKNOWN; the hook never synthesizes a request or treats completion order as request order. A candidate metric records evaluation, not delivery after suppression, and zero candidates cannot establish a residual false-positive rate.

Handoff advice from measured depth

The same command exposes a read-only depth evaluator:

just session-guard depth session.jsonl --harness claude \
  --session-id current-session-id --config context-policy.json

Use --harness codex for a Codex rollout. Supply the current session identity explicitly; the evaluator does not search for a likely transcript. Both guards share transcript parsing and recorded compaction boundaries.

Claude depth is the latest assistant request's input_tokens plus cache_read_input_tokens and cache_creation_input_tokens. Codex depth is the latest token_count.info.last_token_usage.input_tokens; cached input is already included, and cumulative session usage is never substituted. These are measured request depths, not a promise about growth after the measurement. A missing, invalid or unreadable latest measurement invalidates the earlier sample. A recorded compaction requires a new measurement.

The JSON config selects policy per harness under harnesses.claude or harnesses.codex. Each policy requires:

FieldContract
handoff_tokensPositive integer threshold, chosen from observed depth/compaction evidence
threshold_evidenceNonempty reference to the observations supporting that threshold
max_age_secondsPositive freshness window for accepting the measurement

There is no built-in threshold or freshness default. An observed distribution that supports a default has not yet been established; the empty config {"harnesses": {}} deliberately leaves advice unevaluated. A reference string records operator provenance; it does not validate the quality of those observations. Do not substitute transcript bytes, elapsed time, or lifetime token totals.

The evaluator always prints a JSON verdict:

StatusActionExit
UNKNOWNDO_NOT_ACT2
BELOW_THRESHOLDDO_NOT_ACT0
HANDOFF_ADVISEDCHECKPOINT_AND_HANDOFF0

Missing config, missing depth, wrong session identity, stale/future timestamps, and malformed evidence all return UNKNOWN. This is an explicit signal-loss verdict, never an instruction to cycle and never a claim that context is healthy. At or above a configured threshold, checkpoint and use your orchestrator's safe handoff procedure. The evaluator itself never clears context, stops a session, or installs a hook. A deployment must deliberately supply its signal and config; no production handoff is activated by installing this repository.

Golden-Path Conformance

FR-40..FR-42, behind the golden-path Cargo feature (in the CI matrix). A golden path is a blessed trajectory — a pruned, human-promoted record of how verified-successful work actually went, authored and governed in Quipu. This plane evaluates work against one: given a declared followsPath and a sequence of steps, it answers where the work stands — matched, deviating, hazard-adjacent — under the versioned conformance grammar shared with Quipu's backtest (gp-grammar/1).

The full design addendum, including the blessing ladder and the division of labor with camayoc and quipu, is docs/golden-path-guard.md.

Surfaces

  • yupana_path_check over MCP (registered on every build; refuses with a message naming the feature when golden-path is absent).
  • POST /path/check on the resident daemon, mounted only on a build that can serve it.

Both take the same request: the followsPath IRI the work declared, the steps as v1 signatures (action_kind + target_class), the projected paths supplied per call, a mode, and an optional deny opt-in.

Paths are supplied per call, like the board guard's StatePolicy list, because a stale resident copy would enforce yesterday's blessing while looking current. "Empty registry" is therefore a per-call property: a request declaring a followsPath that is not in the supplied set is refused, never reported clean — 409 Conflict over HTTP, an error result over MCP, so a caller gating on status alone cannot mistake "nothing was loaded" for "this plan conforms". A grammar version this build does not implement refuses the same way.

The two modes

Under gp-grammar/1 gaps are allowed, so an open trajectory never hard-deviates — a future step could always match the next pattern element. Deviation is decidable only against a complete intent, which is what splits the check in two:

  • mode: "plan" (default, FR-42) — the submitted sequence is the whole intent. The report names how much of the pattern matched and the first deviation point (which pattern step was expected, after which submitted step). This is the only mode in which deviation is decidable, and so the only mode that can ever deny.
  • mode: "progress" (FR-41) — the work is in flight. The report says how far along the path the work is, which deadEnd hazards it has brushed ("exemplars tried this; it did not help"), and which steps the grammar could not evaluate. It never denies.

Effects are capped by blessing level

Path levelEffect
advisory (L3)warn only
blessed (L4)warn by default; deny only when the caller passed deny: true and the mode is plan
constraint-backing (L5)does not even parse — an unsigned L5 cannot enforce as if it were signed; gated on verdict signing

Deviation is not failure: a deviation verdict is input to the record (quipu stores it; the promotion gates consume it as traffic), not a judgment that the agent is wrong. Paths are demoted when conformers start failing, and that evidence only exists if deviation and conformance are both recorded faithfully.

The report

An answerable check returns a PathCheckReport: the grammar version and path IRI it evaluated, the path's level and the mode, matched out of pattern_len, the first_deviation (plan mode), hazards (which submitted step matched which dead end, with its note), unevaluated_steps, the resulting effect, the path's exemplar citations (a warn must be able to say "because this concrete work succeeded this way"), and projected_at — echoed from the projection, omitted rather than faked when the projection carries none.

The Enforcement Trace

What an action tells an auditor afterwards, and the two commands that carry it into quipu.

This is the interface quipu audit consumes. If you are wiring the audit checker, this page is the contract; if you are reading a spool by hand, it is the field list. The reasoning behind the shape is in SARC Conformance.

Why the record has this shape

SARC's invariant I3 asks that a trace be derived from the specification rather than reconstructed from logs afterwards. The audit checker needs four things per action — which constraints applied, where each was evaluated, what outcome it produced, and what response was taken — and none of them can be recovered from a record that says only which rules fired.

Before this, the spool's rule field was a +-joined string of names. It answers "what fired" and cannot answer "was this evaluated at a point compatible with its class", which is exactly the placement pass of the checker. Two rules that fired identically at different points produced byte-identical records.

Where the spool lives

$YUPANA_METRICS_PATH, else $XDG_STATE_HOME/yupana/metrics.jsonl, else ~/.local/state/yupana/metrics.jsonl. One JSON object per line, appended, rotated to .jsonl.old at 64 MiB.

Writes are absolutely fail-silent — stricter than the guard's own fail-open. A metrics write must never change a guard outcome, never block an edit, never print. A bookkeeping layer that can take enforcement down with it measures negative value.

Record kinds

kindemitted bymeaning
guardyupana hook pre-edita Pre-Action Gate decision, one per edit
audityupana hook post-edita Post-Action Auditor crossing, with point: "PAA"
governedthe pre-edit guardwhich governed rules spoke, by name
fail_openanywhere on the guard paththe guard degraded, and why-kind
hosting_overclaimthe projection refreshpolicies claiming a stronger layer than the one evaluating them
commandthe CLIdeliberate tool use — the leverage signal

Every line also carries ts (unix seconds), and agent / tenant from $SHANTY_AGENT and $BOBBIN_ROLE when set.

Three fields on the guard record earn a note:

  • parsed — two-sided liveness. The record fires on every invocation, so its absence means the hook never ran; parsed: false means it ran against a payload shape it did not recognise. Without the flag that second case is indistinguishable from a clean allow, which is how a guard gets believed to be passing while it inspects nothing. (pre_bash_invoked carries the same discriminator under the same name.)
  • session and item — which session, and which tracked work item, caused this decision. They are what make the spool replayable rather than merely auditable: a windowed rule ("this session has already done N of these") has nothing to group by without them. Both are omitted, never blanked, when unresolved — these records are replayed to derive enforcement rules, so a fabricated value does not mislabel one row, it manufactures evidence for a rule that then applies to everyone.

The constraint set — constraints[]

One array rather than sibling keys, so a reader never has to reassemble which class went with which id by position:

{
  "kind": "guard", "mode": "enforce", "result": "deny",
  "policy_freshness": "fresh",
  "constraints": [
    {
      "id": "no-ticket-in-comment",
      "class": "hard",
      "verification_point": "PAG",
      "hosted_at": "orchestration",
      "outcome": "unsatisfied",
      "response": "blocked"
    }
  ]
}
fieldvaluesnotes
idstringthe constraint's stable id — the same name its verdict cites
classhard | soft | escalationomitted when undeclared, never guessed
verification_pointPAG | ATM | PAA | tool_layer | policy_layeromitted when undeclared
hosted_atorchestration | tool | policythe layer that actually evaluated it
outcomesatisfied | unsatisfied | unknownwhat the predicate concluded
responseblocked | warned | logged | escalated | no-actionwhat the runtime did

Three of those distinctions carry weight:

  • unknown is not a soft unsatisfied. A constraint that could not be evaluated has told you nothing, and collapsing the two is how an unevaluated check reads as a passing one.
  • outcome and response are separate because they answer different audit questions — what the predicate concluded, versus what the runtime did with it — and the checker's outcome pass is precisely "does the recorded response match the one the policy declared".
  • hosted_at is never the layer the policy claimed. It is stamped from the evaluating code's own constant. A field that echoed the declaration would let an overclaim survive the audit by being asserted twice (SARC I6).

policy_freshness rides alongside: how current the policy set behind those evaluations was. It is what stops a soak window counting verdicts computed against a stale projection as evidence about current policy.

The legacy rule field is still emitted, derived from the same set, because live dashboards group on it. Dropping it would silently empty every panel built on it.

The attribution tuple — who is answerable

SARC §9.6's α = ⟨P, planner, executor, tool, auth, C_eval⟩, on the gate record and the PAA record alike. Recorded on allow as well as deny: a field that appears only on refusals cannot answer "which chain has been acting here", which is the question it exists for.

fieldsourcenotes
principal_chain$YUPANA_PRINCIPAL_CHAINcomma-separated, caller-first
planner$YUPANA_PLANNERdeclared, never derived from the chain's head
executor$SHANTY_AGENTthe identity of the process that actually ran
toolthe hook payloadEdit, Write, MultiEdit
attribution_conflictcomputedemitted only when true — see below

A dispatcher that spawns a sub-agent appends itself and exports the extended chain; the sub-agent's hook then records the real chain rather than its own leaf identity:

YUPANA_PRINCIPAL_CHAIN="orchestrator,worker" \
YUPANA_PLANNER="orchestrator" \
SHANTY_AGENT="worker" \
  claude --agent worker

Every element is omitted when undeclared. Filling principal_chain with [$SHANTY_AGENT] would assert "this action had exactly one principal" to precisely the auditor the field exists for, and an undeclared chain is not a one-link chain.

planner is not derived from the chain's head. Which link deliberated and which executed is a fact about the dispatch; reading it off list position would be an inference wearing a record's clothes.

auth is deliberately absent. The effective authority is the intersection of every link's grant, those grants live in quipu, and yupana cannot read them inside a 100 ms pre-edit budget. Recording principal_chain is what lets quipu's checker derive auth from the authoritative source; a locally-guessed value would put a number in the field the grant store never agreed to.

attribution_conflict

YUPANA_PRINCIPAL_CHAIN is a declaration; $SHANTY_AGENT is what is running. When a chain is declared and its last link disagrees with the executor, the record says so rather than silently preferring one. That disagreement is the observable signature of a laundered chain — an agent acting under a dispatch record that names somebody else — and a record that resolved it by precedence would delete the only evidence of it.

It is emitted only when true. A false on every line trains a reader to skip the field, which is the opposite of what it is for.

Turn-boundary grounding evidence

NeuralAmplifier-scoped hook payloads may carry a grounding reference with scope: "na", a content-addressed grounding_id, faction_id, and worldview_sha256. Yupana resolves only the matching local cache file; it never queries Quipu on an edit or action hot path. The exact cached bytes must hash to the id, and their faction/world-view bindings must match the payload.

The result is the ordinary na-turn-grounding element in constraints[], not a second trace format. Its grounding_outcome distinguishes used, empty, transport-error, missing, unresolved, stale, and unknown-scope. Missing or unverifiable evidence is outcome: "unknown", never a fabricated success. Empty evidence is unsatisfied; fresh used evidence is satisfied.

This stage only advises. It cannot emit a blocked response, even when the ambient policy mode is enforce, and workspace configuration does not control whether a harness-supplied grounding reference is checked. Promotion to a hard response remains gated on a measured zero-false-positive advise soak.

The cache defaults to $XDG_STATE_HOME/yupana/grounding (or ~/.local/state/yupana/grounding) and can be relocated by the harness with YUPANA_GROUNDING_CACHE_DIR. Files are named <sha256>.json; the default freshness ceiling is 300 seconds and the harness may set YUPANA_GROUNDING_MAX_AGE_SECS.

Verdicts — yupana verifier and yupana verdicts

Both need the quipu feature.

A trace record is diagnostic. A verdict is an attestation: ed25519-signed, bound to an evidence hash, and verifiable against a aegis:VerifierRegistration that a human authored.

# Show the public key to register in quipu as this verifier's aegis:publicKey.
# This is the DELIBERATE key-creation act — it mints the key if absent.
yupana verifier --key-path yupana-signing.pk8

# Drain the local spool into quipu.
yupana verdicts --to http://localhost:7878

The guard signs at the moment a constraint fires and appends locally; it never promotes on the edit path. A /knot round-trip does not fit inside the guard's 100 ms deadline, and putting one there would make every agent's edit latency a function of quipu's availability — a transiently wedged quipu once held a guard for the full two minutes a caller was willing to wait.

The hook path uses an existing key only and never mints one. A signing identity materialising from an agent's edit is not something that should happen quietly; yupana verifier is where that decision is made.

The spool is truncated only when every verdict was accepted. A partial drain leaves the file intact rather than losing the remainder.

The Post-Action Auditor and throttle

yupana hook post-edit evaluates constraints declaring verificationPoint "PAA" against the file as it now stands — the completed-action state, which is the whole reason the point exists. The gate saw only the fragment an edit proposed.

It cannot prevent the action it just watched, and saying so plainly matters: a PAA presented as prevention is the false-prevented claim the enforcement gradient exists to stop. What it can do is change what happens next.

throttle is that response. A crossing records an expiring backoff, and the next edit's advisory surfaces it. The fold is purely additive by construction — it only ever turns an allow into a notice or appends to an existing one, and it cannot reach a deny. A soft constraint must not become a block by this route.

A satisfied PAA constraint is recorded, not skipped. "The rule ran and held" and "the rule never ran" are different facts, and the checker's coverage pass needs to tell them apart — an absent evaluation reads as a constraint nobody applied.

Reading a trace with quipu

quipu audit ~/.local/state/yupana/metrics.jsonl --db ops.db   # T ⊨ Σ
quipu audit replay ~/.local/state/yupana/metrics.jsonl --db ops.db
quipu audit tree ~/.local/state/yupana/metrics.jsonl
quipu audit inheritance ~/.local/state/yupana/metrics.jsonl --db ops.db

quipu audit exits non-zero when the trace contradicts the specification; an incompleteness (no principal chain, no declared class, a constraint the window never exercised) is reported and does not fail the gate. See quipu's CLI reference for the passes and what each one provably cannot decide.

Code slices as Quipu share bundles

A Quipu share bundle is a git-storable, content-addressed directory holding a canonical RDF graph, the shapes that describe it, and a lineage manifest. This page is the measured workflow for turning a Yupana code-graph slice into one, and for importing it into somebody else's store. For the consumer side — pulling a bundle somebody hands YOU into the graph Yupana reads — see Pulling a Share.

Yupana is the producer of structural code facts and nothing more. It does not write manifests, compute share ids, or canonicalize RDF: Quipu authors the bundle from the graph it holds, so a bundle built this way carries producer.name = "quipu". That is by design, not a gap — one implementation of the hashing rules means one set of rules.

The pipeline

# 1. Yupana emits the governed code projection. --repo is REQUIRED for anything shared:
#    the repo name is a segment of every entity IRI (see "Promotion to Quipu").
yupana export src --repo myrepo --format turtle > slice.ttl

# 2. Quipu ingests it and holds it.
quipu knot slice.ttl --db store.db
quipu shapes load code-entities <quipu>/shapes/code-entities.ttl --db store.db

# 3. Quipu AUTHORS the bundle. Always pass --shapes (see the warning below).
quipu share --output ./bundle \
  --construct 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }' \
  --shapes code-entities --db store.db

That writes bundle/ containing exactly:

filewhat
manifest.jsonschema …/share-manifest/v1, share_id, store_id, tx_anchor, graph_hash, shapes_hash, scope, parent_share, producer
export.ntcanonical N-Triples — the bytes graph_hash covers
shapes.ttlthe shape sets named by --shapes

A code slice selected by CONSTRUCT records scope.kind = "construct".

Import is REST-only — there is no quipu import subcommand:

curl -s http://your-quipu/import -X POST -H 'Content-Type: application/json' -d @- <<JSON
{"manifest": …, "export_ntriples": "…", "shapes_turtle": "…", "source": "yupana-code-slice:myrepo"}
JSON

The importer verifies the schema, the three payload basenames, both content hashes and the share_id before parsing any RDF, then stages into a per-source named graph. It never writes ROOT directly. A second byte-identical import returns unchanged, which is a success.

Hash the canonical form, never the producer's Turtle

yupana export serializes in filesystem traversal order. The same code, checked out twice into differently-named directories, produces the same triples in a different byte order — measured. So:

A graph_hash taken over Yupana's raw Turtle is not stable across checkouts. Taken over Quipu's canonical export.nt, it is.

Two independently-created checkouts of identical content yield byte-differing slice.ttl and an identical graph_hash. Hashing the producer's output is the obvious shortcut and it is exactly the thing that breaks; it is written down here so nobody re-derives it as an optimization.

This is also why "export twice and compare bytes" is a weak test — it passes for a canonical producer and for a path-dependent one, so it cannot tell them apart. The discriminating test is cross-condition: same content, two checkout paths, same hash required.

Local shapes decide admission — bundled shapes are evidence

The bundled shapes.ttl does not grant a bundle the right to assert its vocabulary in a stranger's store. Measured, one variable at a time:

bundle shapes.ttlconsumer's local shapesresult
presentcode-entities loadedstaged, all triples accepted
emptycode-entities loadedstaged — identical; the bundled shapes changed nothing
presentnonequarantined, 0 accepted, blocker off_vocabulary naming CodeModule/CodeSymbol
present, then adopted with quipu shapes loadnow loadedstaged

The consumer's own shapes always decide. That is deliberate: a bundle must not be able to widen a stranger's vocabulary just by shipping a file that says it may.

Receiving a quarantined slice — the recovery path is the last row: read the bundle's shapes.ttl, review what it would admit, quipu shapes load it deliberately, re-import. Quarantine is not a rejection; the triples are staged and named, and the response tells you which types blocked promotion.

⚠️ Always pass --shapes. quipu share with no --shapes writes a zero-byte shapes.ttl and exits 0. That bundle passes every integrity check and imports cleanly on any consumer that already holds the shapes — i.e. on your own team, who will never see the problem — while being the one bundle a fresh consumer can never adopt, because the recovery path above needs a file with content in it. Filed upstream against Quipu; until it refuses, passing --shapes is the whole mitigation.

Outward sharing

Every entity IRI embeds the ontology base host. That host is entity identity, not decorative metadata: rewriting or stripping it changes what the bundle asserts and breaks merging against any store holding the unrewritten form. Publishing a bundle outside the network it names is therefore a governance decision, not a scrub — do not automate it here.

Regression

The whole pipeline — cross-condition hash, bundle integrity, the four refusal arms, quarantine, and idempotence — is pinned by an end-to-end regression that stands up two independent Quipu stores and round-trips a real slice between them. Reproduce it with the commands on this page; the arms that matter are the cross-condition hash and the bare-consumer quarantine, because each fails in the reassuring direction if you only run the easy version.

Pulling a share: yupana share

Code Slices as Share Bundles is the PRODUCER half — how a Yupana code slice becomes a Quipu share bundle somebody else can hold. This page is the CONSUMER half: how a bundle somebody hands you gets into the graph Yupana reads, and what it can and cannot do once it is there.

yupana share pull    <bundle-dir|url> --to <quipu-url>   # stage it, and say where you stand
yupana share policy  <staging-graph>  [--to <quipu-url>] # what policy would this contribute?
yupana share promote <share-id>       --to <quipu-url>   # admit it (the governance decision)
yupana share align   <graph> --against <local-graph>     # which of their concepts are yours?
yupana share reshare <graph> --output <dir>              # publish it back out, as a delta

The one property to understand first

A pulled share is inert until you promote it.

Quipu stages an import into a named graph — urn:quipu:import:staging:<hash>, or …:quarantine:<hash> when the receiving store does not govern the vocabulary. Yupana's policy projection — the queries the pre-edit guard sends to build its rule catalogue — carry no GRAPH clause, so they cannot see a named graph at all.

That is what makes pull safe to run on a bundle from someone you have not audited. It is also exactly the kind of claim that deserves a measurement rather than a reassurance, so here is the measurement, taken against a real Quipu 0.3.36 with the guard's own query constant (not a paraphrase of it), before and after promoting the same share:

the guard's own unscoped text-rule queryrows
after share pull — staged, not promoted0
the identical query scoped GRAPH <staging>1
after share promote1

The middle row is the control. Two zeroes would prove nothing on their own — an endpoint that answered nothing at all would look the same — so the scoped arm establishes that the triples are present and reachable, and only the GRAPH clause separates "in the store" from "enforced".

What each verb does

share pull — always stages, never promotes

$ yupana share pull ./bundle --to http://quipu.example
quarantined: 15 triples from ./bundle
  share:   sha256:a350c7a7…
  staged:  urn:quipu:import:quarantine:a350c7a7…
  blocked: off_vocabulary
  next:    /usr/local/bin/yupana share policy urn:quipu:import:quarantine:a350c7a7… --to http://quipu.example

A quarantine is a success, and pull exits 0 for it. Pulling from a publisher whose vocabulary you do not govern is the DEFAULT case, not the exceptional one. If staging exited nonzero, every script wrapping this verb would treat the correct outcome as a failure, and the eventual "fix" for that is auto-promotion — which is the silent vocabulary widening the quarantine exists to prevent.

Nonzero is reserved for two things: a bundle that fails verification, and an endpoint that would not serve the request.

Before anything is sent, Yupana checks the manifest's SHA-256 over export.nt and shapes.ttl against the bytes it holds. Quipu's server checks them again — this is deliberate duplication, because a local refusal is a different finding: it says the bundle you were handed is not the bundle its manifest describes, which is a story about your download and not about the graph.

$ yupana share pull ./tampered --to http://quipu.example
Error: share error: share graph hash MISMATCH for export.nt: manifest declares sha256:edd17844…,
the bytes here hash to sha256:1f181001…. Nothing was sent to quipu. …
$ echo $?
1

Pulling the same bundle twice is a no-op. Quipu keys the staged graph on the share's content hash, so a byte-identical re-pull reports unchanged and writes nothing — measured, with the graph count unmoved:

outcomegraphs
first pullquarantined, 4 triples1
identical re-pullunchanged, 4 triples1

That matters because the honest response to a lost or ambiguous reply is to re-run the pull, and it must not be possible for that to duplicate anything.

share policy — what would this share contribute?

The share is staged and invisible to the guard. Before deciding whether to admit it, ask what admitting it would mean:

$ yupana share policy urn:quipu:import:staging:a350c7a7… --to http://quipu.example
policy this share would contribute to urn:quipu:import:staging:a350c7a7…:
  structural policies: 1
    - publisher rule: no TODO in comments [deny]
  text rules:          1
    - no-internal-host

This runs Yupana's own projection queries and decoders, scoped into the staging graph — not a second query written to resemble them. The catalogue's definition of "a policy" is intricate, and a hand-written preview would be a parallel definition that drifts silently, showing you a different rule set from the one that will actually be enforced. Reusing the constants means the preview is wrong only if the guard is wrong too.

One consequence worth knowing: if the publisher ships a policy whose vocabulary Yupana cannot decode, this verb fails loudly rather than reporting zero policies. A share carrying rules your tooling does not understand is a thing you want to be told about.

share policy is a read, so it falls back to the configured [yupana.quipu] endpoint. pull and promote are writes and require --to — that key is set host-wide so every agent's pre-edit guard can fetch the rule catalogue, and a read credential must not silently become a write one.

share promote — the governance decision

$ yupana share promote sha256:a350c7a7… --to http://quipu.example
promoted sha256:a350c7a7…: 15 triples admitted at tx 8

Quipu refuses to promote a share that is still blocked, and Yupana relays the refusal rather than dressing it up:

$ yupana share promote sha256:2b9610a4… --to http://quipu.example
Error: share error: … refused with 400: {"error":"no eligible staged import for sha256:2b9610a4…"}
$ echo $?
1

To clear an off_vocabulary block, the receiving store must govern the types the publisher used. That is a deliberate act on the receiving store — adopting a publisher's vocabulary is a governance change, and Yupana does not perform it as a side effect of fetching bytes. Load the shapes you intend to govern (the bundle ships its own shapes.ttl for exactly this purpose), then pull again: the same bundle that quarantined will come back staged.

share reshare — publishing it back out, as a delta

A share that came from someone else and goes back out changed is a delta, and a delta that does not name its parent has lost the only thing that makes it one. So the parent is derived, not asked for:

$ yupana share reshare urn:quipu:import:quarantine:a350c7a7… --output ./delta --to http://quipu.example
shared sha256:a146eb19…
  output: ./delta
  files:  export.nt, manifest.json, manifest.ttl, shapes.ttl
  parent: sha256:a350c7a7… (delta — derived from the pulled graph's own IRI)

Quipu names a staged graph after the share it came from, so the lineage is already written on the graph IRI and re-sharing can recover it without being told. The manifest records it as parent_share, which is where a consumer will look — not in this command's output.

The derivation is deliberately narrow. A graph that is not a pulled share yields no parent, and so do the near-misses: a truncated IRI, or one carrying the right prefix and a non-hex body. Inventing a parent for a local graph is a worse failure than omitting one, because a false lineage claim gets published in a manifest and believed downstream.

To publish a pulled graph as a new lineage, say so — and the output names what was discarded:

$ yupana share reshare urn:quipu:import:quarantine:a350c7a7… --output ./new --to … --root
  parent: NONE — published as a new lineage with --root, discarding sha256:a350c7a7…

--root and --parent conflict at parse time (exit 2, nothing written): a lineage claim cannot be both absent and specified.

Files are written verbatim, because the manifest's hashes are over those exact bytes — anything that reformats on the way through, even pretty-printing the manifest, produces a bundle whose own share_id no longer verifies.

The round trip, measured

stepresult
pull a share into receiver Aquarantined, 15 triples
reshare that staged graphnew share id, parent_share recorded in the manifest
pull the delta into a fresh receiver Bquarantined, 15 triples, exit 0
pull the delta again, byte-identicalunchanged, still 1 graph

What pull does NOT take, and why

A .qpack.db, and a bundle archive. Both are refused by name, with the command that does work:

$ yupana share pull ./graph1.qpack.db --to http://quipu.example
Error: share error: ./graph1.qpack.db is a packed graph (`.qpack.db`), and yupana cannot load it:
quipu's `unpack`/`pack` exist only on the CLI against a LOCAL store, and yupana talks to a quipu
SERVER over HTTP. Load it with the quipu CLI on the machine holding the store:
    quipu pack --verify ./graph1.qpack.db
    quipu unpack ./graph1.qpack.db --db <store.db>

This is an architectural fact rather than a missing feature. Quipu's pack/unpack have no REST route — they operate on a local store file — and Yupana has no local store: every Quipu interaction it has, including promote and the policy projection, is HTTP to an endpoint whose database lives somewhere else. There is no honest way for Yupana to unpack a pack, and a verb that accepted one and failed at the far end would be worse than one that says so up front.

The refusal names commands that run. Yupana emits no suggestion it has not made runnable: every emitted command names an absolute path to the running binary and the endpoint that just worked, and the test suite parses each one with the real CLI definition rather than asserting on its spelling.

Where the endpoint and credentials come from

resolution
endpoint--to (required for pull/promote); [yupana.quipu] endpoint for share policy
bearerQUIPU_AUTH_TOKEN, else QUIPU_AUTH_TOKEN_FILE, else ~/.config/quipu/token

/import and /import/promote are write endpoints and need Quipu's bearer; reads on the same endpoint stay open, so a working yupana impact proves nothing about whether a pull will be authorized. A 401 says so explicitly rather than surfacing as a generic failure — and a 404 is reported as "this Quipu has no /import route" rather than as a failed import, because those want opposite responses from the reader.

E2E Grounding Eval

just e2e stands up the real pair — a quipu-server on a freshly seeded store, and this repo's yupana pre-edit guard pointed at it — and proves the grounding-integrity loop end to end: governed policy projected from Quipu, hallucinated references rejected in the agent's loop at a declared tier, and every verdict returned to Quipu as a signed fact.

It needs the sibling checkouts ../quipu (the governed store) and ../camayoc (the policy pack shapes/policies/edit-grounding.ttl that the harness loads). Both sides are built in release automatically.

The eval (just e2e run)

scripts/e2e/harness.py seeds the store with the camayoc pack, two aegis:WorkItem records, and yupana's registered verifier key, then drives the guard through eight scenarios. Each maps to an aspect of the grounding cluster disclosure:

scenarioproves
clean, cited edita bounded pass — the guard does not cry wolf
hallucinated calla reference that exists nowhere is denied, tier declared
fabricated work-item citationid-shaped but resolving to nothing is its own violation class
uncited editthe must-ground discipline denies untracked work
freshness declareda live projection says verdict freshness: fresh
quipu down, cache warmlast-known policy still enforced, cache age named
quipu down, no cacheloud fail-open — never a silent allow
verdict returnspooled verdicts land in quipu signed, with tier + freshness

A second group proves work-item scope isolation — the provenance ladder of docs/work-scoped-governance.md, driven by the tracker's plate and the observed scope projected from quipu's commit-provenance chain:

scenarioproves
declared denya static [yupana.policy.scopes.*] entry hard-denies outside itself
observed in-scopethe item's own ground admits the edit silently
observed adviseout-of-scope is named (rung, item, right move) without blocking
unknown scopeno rung answers → one advisory per session, never a silent allow
observed enforcework_item_scope = "enforce" denies outside the item's boundary
enforce in-scopethe hard boundary does not over-block the item's own ground
assignment briefingsession-start injects ground, central entities, similar successful work, and rules before the first edit

Everything observable is captured under the workdir (default target/e2e): per-scenario guard stdout/stderr with RUST_LOG=debug, the metrics spool, the verdict spool, quipu's Prometheus /metrics snapshots, and a scored report.md/report.json. The run exits nonzero if any check fails.

The bench (just e2e bench)

scripts/e2e/bench.py answers the capacity question: how many concurrent agents can share one Yupana against one Quipu? Each simulated agent is what a deployment actually runs — a yupana hook pre-edit process per edit, every guard projecting policy live from the single quipu-server — under its own tenant id.

The sweep reports, per concurrency level, guard latency (p50/p95/max), aggregate edits/second, and the projection serving split:

  • live — projected from quipu on this guard
  • cached — quipu could not answer in time; the durable cache enforced last-known policy and the verdict declared its age
  • fail-open — no servable projection; the edit went unguarded, loudly

The split is the honest capacity signal: the guard degrades live → cached → fail-open by design, so "how many agents fit" has two answers — how many keep fully-live projections, and how many stay enforced at all.

The retrieval eval (just e2e f1)

scripts/e2e/eval_f1.py scores the briefing's similar/related-item retrieval against a labeled corpus organized as problem classes, so the report says not just how good the scores are but where retrieval breaks. Core classes (gated, default floor 0.9 macro-F1): a mixed composite, phrase match, term overlap, provenance-only linkage, a single-term distractor that corroboration must prune, a hub-entity trap that the provenance rung's hub-degree cap must ignore, a crowded cluster larger than the briefing cap, and a no-neighbors probe where the only correct answer is silence. Hard classes (reported, not gated — the lexical frontier): multi-term collisions and a paraphrase, the two shapes only a semantic backend separates.

The semantic arm runs when the model bundle is provisioned, which just e2e f1 does best-effort: the all-MiniLM-L6-v2 ONNX bundle from qdrant's fastembed mirror on storage.googleapis.com and the ONNX Runtime dylib extracted from the onnxruntime PyPI wheel (scripts/e2e/extract_ort.py, quipu's ort is load-dynamic via $ORT_DYLIB_PATH) — both hosts commonly allowed where HuggingFace's LFS CDN is not. With it, the briefing gains a fourth retrieval source (quipu /search, query embedded server-side, supporting hits within 80% of the query's top non-self score) and term probes retire — measured, they cost more precision on a semantic store than the recall they add. Measured macros: lexical overall 0.845; semantic overall 0.954, with the multi-term-FP and paraphrase classes both at 1.0. The full write-up — per-problem tables, the no-veto and term-probe-retirement findings, and threats to validity — is the eval note at docs/briefing-retrieval-eval.md (repo root docs, outside this book).

Each arm re-runs the shipped binary; ablations remove one retrieval source via $YUPANA_BRIEF_ABLATE — feature removal against the real code path, never a reimplementation. The gate fails the run unless the full arm's core macro-F1 clears the floor and every ablation scores strictly below it, so each retrieval source has to measurably earn its place.

Retrieval is corroboration-scored: sources vote (phrase hit 2, each term hit 1, provenance co-occurrence 2) and single-vote candidates are pruned whenever anything better exists — a lone shared term is how a lexical distractor sneaks in. Measured effect: full-arm macro-F1 0.87 → 0.94. The residual false-positive class is the multi-term lexical collision, which only a semantic backend separates: configure quipu's embeddings ([embedding] model_path/tokenizer_path, --embed-backfill) and the same probes ride the vector path with no yupana changes.

Specification

The full PRD-style build specification lives at the repository root:

It covers the problem statement and the three-tool split, personas and user stories, functional and non-functional requirements, the technical architecture and proposed source layout, the code ontology and bitemporal promotion into Quipu, the MCP/HTTP/CLI surface, technology choices (pinned to Bobbin and Quipu), phasing, risks, and open questions.

Phasing at a glance

  • Phase 1 — Yupana, single-tenant. Tree-sitter structure + LSP facts served over MCP; Bobbin fuses them.
  • Phase 2 — Dataflow & blast radius. CPG/dataflow; blast radius as a served capability and the incremental-update primitive.
  • Phase 3 — Multi-tenancy. Shared base + copy-on-write overlays, content-hash sharing, frontier-bounded incremental updates.
  • Phase 4 — Promote to Quipu. Code ontology, SHACL-validated bitemporal promotion, branches as named graphs.
  • Phase 5 — Consumption & guardrails. Per-tenant blast radius into the broker trust boundary; monitor-guided edit verification.

Vision

The north-star vision document — Bobbin × Yupana × Quipu: A Governed, Multi-Signal, Multi-Tenant Code Intelligence Layer — lives at the repository root:

The thesis: Bobbin answers "what code is relevant?" with embedding similarity and historical co-change. Neither knows the actual structure or semantics of the code. Yupana extracts and serves live per-tenant structure; Quipu governs and versions the committed record; Bobbin fuses everything — making autonomous agents better-informed, safely-bounded, and correct even when a whole team is editing at once.

Policy edit hooks — the yupana side

This documents yupana's half of the performant edit hooks for policy design. The write-time enforcement half lives in quipu and is already implemented; see quipu docs/design/policy-edit-hooks.md for the full split. This page records what yupana owns, why none of it ships yet, and the backlog.

Deciding principle: evidence locality

Evaluate a policy where its evidence is already hot.

  • Governed-fact policies — the claim reasons over quipu's committed graph (provenance, prior verdicts, workflow state). Quipu evaluates these at its pre-commit gate.
  • Structural-evidence policies — the claim reasons over the call graph, reachability, blast radius, or symbols. Yupana holds that hot, per tenant, at the boundary:"action" edit-time seam (yupana hook pre-edit + the resident daemon). Yupana evaluates these against a hot projection of quipu's canonical policies.

Canonical policy definitions, SHACL validation, verdict signing, and the human-owned VerifierRegistration root of trust stay in quipu regardless. Yupana holds only a projected read cache — a policy is never defined in yupana.

What yupana owns

  • A hot projection of quipu's boundary:"action" policies whose claims are structural, refreshed through yupana's existing watch/daemon machinery. Strictly one-directional (quipu canonical → yupana cache) with invalidation on quipu policy writes. If it diverged, yupana could allow what quipu would deny — so the projection is a cache, never a source of truth.
  • Edit-time evaluation at hook pre-edit: an edit that violates a structural deny policy is blocked at edit time, against the resident graph, with a tier-tagged verdict.
  • Verdict promotion: yupana's edit-time verdicts promote back into quipu as signed, bitemporal facts, extending the existing commit → touched promote path.

Two rule sources, one engine

Structural policies reach the guard from two sources, evaluated by the same rules engine (src/rules.rs) — a Selector (a tree-sitter .scm capture) + a Predicate (a regex + a must-match / must-not-match / must-exist direction) over the text an edit introduces:

  • Local config ([[yupana.policy.rules]]) — ships today, no quipu needed. The operational plane, the same status the capability scopes have: yupana-local, authoritative, always fresh. This is where "no ticket id in a comment" lives for a standalone yupana.
  • Projected from quipu (quipu feature) — the governed plane. Yupana fetches quipu's boundary:"action", tree-sitter-tier policies and decodes them into the same Rule shape (field-for-field congruence with aegis:Selector + aegis:Predicate), so a projected policy is just a Rule the engine already runs. Opt-in via [yupana.quipu] enabled + endpoint. Yupana never defines a governed policy — the projection is one-directional (quipu canonical → yupana cache), and a verdict declares the cache's freshness.

Field-for-field congruence includes the path scope. A local rule scopes itself with applies_to; a governed one declares aegis:appliesTo, and the projection carries it across. This is worth stating because it was missing at first: the decoder hardcoded an empty scope, so every projected policy applied to every file of its language and a policy quipu had scoped to one module was enforced repo-wide. A rule firing in more places looks like a rule that works, which is why nothing caught it. aegis:appliesTo is genuinely multi-valued, so a policy scoped to three globs returns three SPARQL rows and the decoder accumulates them — it does not collapse to whichever row arrived first, and differing scopes across rows are not treated as the conflicting-definitions error that a differing pattern or effect is. A policy that declares no scope stays unscoped, which is both the correct reading and what a quipu predating the predicate projects.

Both are Mode-staged (advise-first) and fail open: a rule set that will not compile, or a quipu that cannot be reached, is a LOUD allow, never a silent pass.

What ships now, and what remains

The cpg/lsp lesson still binds — a feature must gate real code and never advertise precision it lacks. What ships is real and tier-tagged; what does not is named honestly below.

Reframed: projection is HTTP, not a crate pin. Like promotion (POST /knot), Yupana reads policies over quipu's POST /query (W3C sparql-results+json), so --features quipu needs no quipu crate dependency — H-DEP's "pin the dep" is not a prerequisite for projection. The commented-out quipu = { git = … } line stays commented; the RDF/ureq crates the feature already pulls are enough.

Backlog

  • H-DEP ✅ (reframed) Projection + promotion run over HTTP; no quipu crate pin needed. --features quipu is in the CI matrix.
  • H-PROJECTION ✅ src/project.rs: decode quipu's structural policies into Rules, a ProjectionRegistry with sync-state freshness. Remaining: the resident/async refresh cache (FR-31) — today's projection fetches per edit, which is the daemon-side optimization, not the semantics.
  • H-EDIT-EVAL ✅ hook pre-edit evaluates projected policies; a deny-effect policy blocks under Enforce, tier- and freshness-tagged.
  • H-FRESHNESS ✅ (slice) A verdict declares its freshness: local config is fresh (evidence is the exact proposed edit); a projection reports its real sync state (fresh, or stale when a refresh fails). The full copy-on-write / frontier code-graph freshness (Phase 3) is only needed for graph-consulting structural rules, which are not shipped — the buffer-local rules are fresh by construction.
  • H-TRIPWIRE ✅ (slice) Governed tripwires: quipu's path-boundary policies (aegis:appliesTo, no Selector/Predicate — quipu shapes/policies/tripwire.ttl) project via src/project_tripwire.rs into the same Tripwire shape local config uses, ride the registry's one refresh and its durable cache, and evaluate as the governed TRIPWIRE plane of the pre-edit guard — class-first blocking, backoffFormula compiled into a recorded throttle. Remaining: PAA-declared wires are projected but skipped at the gate (the post-edit auditor's projection is sequencing step 2, per the quipu catalog), and the governed plane still skips pure deletions (its introduced_text seam), so a deletion inside a governed boundary is caught only by a local wire today.
  • H-PROMOTE-VERDICT ✅ src/verdict.rs: ed25519 signing that MIRRORS quipu's signing.rs (same ring, same canonical v1|… message, same hex encodings), a VerdictShape-conformant signed aegis:Verdict Turtle, and promote_verdict over /knot. yupana verifier prints the public key a human registers as aegis:publicKey. Remaining: wiring the trigger (promote a verdict on the post-commit commit → touched path) and key rotation — the signing + promotion primitives are done and interop-tested against the shared scheme.

Governance Plane — Policy, Workflows & Verification

Status: design — ready to implement. This describes a governance capability that layers on the Yupana × Quipu × Bobbin stack. The primitives, trust model, integration contract, and build order are settled; see Implementation phasing for the MVP and the build order, and Decisions and deferrals for the calls made. Not yet built, with one shipped slice: the Binding / Gate primitive has a local-config form in yupana — [[yupana.policy.tripwires]], a boundary (path globs and/or a rule) bound to an effect (warn / deny / throttle) at the pre-edit gate; see the policy guard reference. Scope for v1 is a single trust domain.

Motivation

The stack today is a knowledge plane — Yupana (live code structure), Quipu (governed bitemporal record), Bobbin (search / fusion / RAG). Its job is to know things about the code and feed context to agents.

Missing is a governance plane: the layer that decides what a fleet of agents should be doing, and what they are allowed to do. Today that intent lives in skills — advisory checklists an agent may freely ignore. The goal is to make it load-bearing:

  • Workflows propel the agent — soft but pervasive, always driving toward the next step.
  • Policy constrains the agent — hard but targeted, a wall at the boundaries that matter.

The governance plane uses the knowledge plane; it is not part of it. Policies query Yupana/Quipu/Bobbin for the facts they evaluate over.

The verification spine

"How is an artifact verified?" and "what is a policy?" are the same question. A policy is a verification predicate. So the plane is not three sibling concepts (artifact, workflow, policy) — it is one recursive primitive, verification, with policy as the verifier at each node.

Verification composes bottom-up:

Workflow    — verified iff its Steps are (per its order / dependencies)
  └ Step     — verified iff its required Artifacts are + its gate policy holds
     └ Artifact — verified iff its Reference, dereferenced, satisfies its Claim   ← leaf

Only the leaf touches reality — it dereferences to concrete evidence from the knowledge plane. Everything above is composition of leaf verdicts.

Primitives

The minimal vocabulary. Artifact / Step / Workflow / Policy are types of Entity; the rest are base primitives.

  • Entity — a governed, typed node in Quipu (bitemporal, provenanced).
  • Reference — an entity's handle to a concrete target: a commit, a file, a Yupana code-symbol IRI, a URL. The entity is the governed abstraction; the reference points at the real thing.
  • Claim — the verifiable assertion an entity carries (e.g. "exists and is approved").
  • Evidence — facts that bear on a claim, drawn from the knowledge plane (Yupana), the record (Quipu), or external systems (git, CI). Evidence is either canonical (read directly from a source of truth) or an attestation (a signed statement from the system that owns the fact).
  • Verifier (= Policy) — a pure predicate mapping evidence → verdict {satisfied | unsatisfied | unknown}. unknown (no evidence yet) is distinct from unsatisfied (evidence says no). Policies are external: a policy targets an entity type, the way a SHACL shape targets a class. One policy governs all entities of that type.
  • Verdict — the result, stored as a governed bitemporal fact. Carries a tier and freshness (see Tiers and Freshness), is signed, is bound to the evidence it saw, and is reproducible (see Verdict integrity).
  • Actor / Role — who performs a step. Enables fleet-level assignment and separation-of-duties policies ("the reviewer must differ from the author").
  • Binding / Gate — attaches a policy to a boundary with an effect. The boundary is either an action (pre-edit) or a transition (step completion) — the same policy can be unknown at one and decisive at the other. The policy stays pure; the effect is contextual and may be computed from risk × confidence (see Risk and confidence) rather than fixed.
  • Instance (Run) — a definition gone live for a specific run (this bead, this session), holding live state + verdicts. Distinct from the definition it instantiates.
  • Catalog leaf — the composable atoms policies are built from, of two kinds (see Catalog): a selector (evidence → a set, e.g. changed-symbols) and a predicate (element → bool, e.g. has-test). Registered, evidence-grounded, tier-tagged.
  • Parameterization — definitions are templates. References and actors are patterns (repo://{repo}/docs/design/{bead}.md, role reviewer) resolved when an Instance binds its parameters (bead, repo, agent).
  • Temporal trigger — a scheduled re-verification for verdicts that depend on elapsed time (SLAs, deadlines), which reactive fact-change invalidation does not cover.

Composition types

  • Artifact — an entity standing for a thing produced or required (design doc, test result, ADR, commit, review). Verified at the leaf.
  • Step — one unit of work: { produces: [Artifact], requires: [Artifact], gated-by: [Policy], actor: Role }.
  • Transition — a guarded edge from a step: {on: <outcome>, to: <step>}. A step may have several — approve → next, request-changes → back to implement, reject → terminal, escalate → higher role — so the workflow is a branching, possibly looping graph, not a line. The guard is a predicate over the step's verdicts / decision, evaluated by the same machinery. Loops are bounded (an iteration cap or temporal trigger) so a review ↔ changes cycle cannot spin forever.
  • Workflow — a directed graph of Steps and guarded Transitions (branches, loops, terminals).

Verdict integrity

A bare "satisfied ✓" written into the record is forgeable by anyone who can write a fact. The plane defends against the agent (including a compromised one), not the human operator — the human is the trust root. A verdict is therefore an attestation, not a claim, with four properties:

  • Authenticity — every verdict is signed by a registered verifier identity. Quipu accepts a verdict-fact only if signed by a verifier registered for that predicate. An agent cannot mint Yupana's signature.
  • Binding — the verdict names the content hash of the evidence it saw. Change the referent → hash changes → the stored verdict is automatically stale. Kills tamper-after-the-fact and replay.
  • Reproducibility — because the predicate is pure and deterministic and the evidence is content-addressed, any independent verifier can re-run it over the same evidence and must get the same result. Verdicts are checked, not trusted. (Purity is the security property, not just clean design.) The verdict retains evidence-hash + predicate-id so third parties can re-derive.
  • Evidence provenance — every evidence input is either canonical (Yupana reads the source of truth directly from its own live map — the agent's edit is the evidence, nothing to fake) or a signed attestation from the owning system (CI signs "green over hash H"; the approver signs the approval). The chain terminates at a root; nothing in it is an agent's self-report.

Architectural constraint: the verifier must run out-of-band from the agent it verifies — a service the agent calls, not a library the agent hosts — or the agent runs its own verifier over a doctored repo and signs anything. The gatekeeper sits outside the gate.

Root of trust: this reduces "trust every verdict" to "trust the verifier keys and the registry of which identity may attest which predicate." That registry is a governed, human-authored definition. The scheme does not eliminate trust; it concentrates it into a small, human-owned surface and makes everything downstream reproducible.

Risk and confidence

Fixed allow / deny gates are too blunt. Two derived signals let the plane modulate enforcement instead of hardcoding it.

Confidence — how sure a verdict is

Derived from the evidence behind it, reusing tier + freshness:

  • tier — a has-test from a tree-sitter heuristic is lower-confidence than one LSP-resolved; a tests-green from a signed CI attestation is highest.
  • freshness — a stale verdict is less trustworthy than a fresh one.
  • verifier reliability — attested-and-reproduced > attested > self-reported.

Confidence composes weakest-link: a policy is only as confident as its least-confident leaf (a selector's tier bounds the predicate quantified over it). unknown has no confidence — it is not a soft unsatisfied.

Risk — how much is at stake

An attribute of an action / change / step:

  • blast radius (Yupana) — more dependents reached, more risk.
  • criticality — a human-authored risk map (auth/**, payments/** = high). A governed artifact; sovereignty applies.
  • reversibility / sensitivity — a deploy or a data migration outranks a lint.

Risk-adaptive effect

The gate's effect becomes effect(risk, confidence), not a constant:

  • low risk + high confidence → allow silently
  • low risk + low confidence → warn
  • high risk + low confidence → require-approval / escalate
  • high risk + high confidence → allow, but record

Two consequences:

  • Confidence sufficiency scales with risk. A high-risk change can require a minimum confidence tier — "for an auth change, tests-green must be CI-tier, not a tree-sitter guess" (require-confidence ≥ X when risk ≥ Y).
  • The enforcement floor interacts with risk. On a harness that can only observe, high-risk work must be pre-authorized or routed to a hard-gating harness. Risk decides which harness may run which work.

This is the market's adaptive governance — assisted by default, promoted or blocked by measured gates — expressed as one function over signals the stack already produces.

Roles in the stack

  • Yupana — live verifier, gatekeeper, guide. Holds the code in memory, so it evaluates code-grounded claims before commit at tier=live, and that live verdict stream is the propulsion: it tells the agent which claims are not yet green. It enforces gates (via a harness adapter) and injects workflow context. It also exports its verification capabilities into the catalog — predicates like has-tests, blast-radius-within, doc-section-exists that only the live map can evaluate. It runs a co-located decision point: Quipu pushes the relevant policy bundle and Yupana evaluates the live-tier leaves locally at the edit boundary — no per-tick round-trip (the OPA-bundle pattern). Runs as a service, out-of-band from the agent.
  • Quipu — the policy / workflow engine of record. Holds the entities, references, and durable bitemporal verdicts; verifies the committed tier; enforces definition-time well-formedness via SHACL; keeps the audit trail. It owns composition, storage, committed-tier evaluation, reproducibility, and the reactive reasoner — generalizing SHACL (policy-as-schema is one policy kind) into a capability Quipu can also apply to its own data.
  • Bobbin — authoring intelligence + surfacing. Powers conversational authoring (drafting in the vocabulary of this codebase) and surfaces governance state to agents.
  • Harness adapters — propel / constrain actuation. Thin per-harness shims (Claude Code hooks; a fleet daemon; a generic governance-MCP + tool-call proxy). Enforcement strength is a gradient, tagged like a tier: prevented (harness exposes a pre-action gate) vs observed (detect, record, escalate). Never present observed as prevented.

Entities in Quipu

The primitives are RDF classes in Quipu's governed ontology — so definition-time verification is just SHACL over them, and the plane is native to Quipu, not bolted on. Where Quipu already has a governance type, reuse it:

PrimitiveQuipu classNotes
Policyextends Directivegoverned rule / intent, human provenance
Verdict / violationObservationbitemporal, signed, content-bound
Decision (human)DecisionRecordsigned approve / reject / changes
Workflow / Step / Transitionnew classesthe composition graph
Artifact / PlanStepnew classesreference concrete targets / code symbols
Selector / predicatenew classesevidence-source-bound catalog leaves

References resolve to existing stack IRIs: a code-grounded Artifact or PlanStep points at bobbin:code/{repo}/{path}::{symbol} — the same identity Yupana mints on promotion — so intent, structure, and governance reconcile on one identifier.

Each class ships a SHACL shape; a definition that violates its shape is rejected at write (definition-time verification). The governance ontology thus extends the code-entities ontology (CodeModule, CodeSymbol, Document, Section, Bundle) already in Quipu — governance facts and code facts live in one graph, queryable together in a single SPARQL query.

Integration contract (Yupana ↔ Quipu)

Four exchanges across the seam. Quipu signs bundles; Yupana signs verdicts; both verify against the shared verifier registry.

Quipu (engine of record)                         Yupana (co-located decision point)
  │  1. bundle pull ─────────────────────────────▶  cache live-evaluable policies
  │◀──── 2. signed live verdicts (at milestones) ──  evaluate live-tier leaves
  │◀──── 4. milestone promotion ──────────────────  on transition
  ▲                                                 3. yupana_plan_declare ◀── agent/harness
  1. Bundle pull (Quipu → Yupana). On instance bind — and reactively on any policy change — Quipu compiles the live-evaluable subset of the workflow's policies into a signed bundle: the catalog leaves Yupana can evaluate, their gates + effect matrix, the relevant risk-map slice, and the registry entries. A re-pushed bundle invalidates the affected cached verdicts.
  2. Live verdict envelope (Yupana → Quipu). Yupana emits signed verdicts — {predicate-id, target-ref, outcome, evidence-hash, tier, freshness, confidence, verifier, sig} — cached live in Yupana and promoted to Quipu only at milestones (the live / settled split).
  3. yupana_plan_declare (agent / harness → Yupana). {instance, targets: [symbol-ref], rationale}; Yupana binds targets to symbols, reconciles against the inferred plan, and returns the plan + an immediate coverage verdict. Companion yupana_plan_status returns the plan and per-PlanStep verdicts.
  4. Milestone promotion (Yupana / plane → Quipu). On a transition, {instance, step, transition, verdicts[], committed-evidence, actor, valid-time} is written as bitemporal facts, waking the reactive reasoner.

Tool surface: yupana_policy_check, yupana_plan_declare, yupana_plan_status join Yupana's existing yupana_* MCP tools (and REST mirror); harness adapters call these plus the pre-edit hook. The quipu Cargo feature already carries Yupana's promotion path; policy bundles ride the same channel.

Authoring

Two distinct "creations" — do not conflate:

  • Definition — authoring a workflow / policy template. Governed, human-driven, written to Quipu. This is what "authoring" means below.
  • Instance — a definition going live for a run. Automatic, runtime, held in Yupana's overlay + Quipu state. Not authored.

Creation is composition over a catalog

Underneath authoring sits a catalog of atomic, pre-verified, evidence-grounded selectors and predicates (see Catalog). Authoring is composition three layers deep, bottoming out in the catalog:

Workflow  = directed graph of Steps + guarded Transitions
  Step    = { produces, requires, gated-by: [Policy], actor: Role }
  Policy  = { targets: EntityType, claim: <composed catalog leaves> }   (external)
  Catalog leaf = selector | predicate, bound to an evidence source      (leaf)

Nobody writes raw predicates to build a workflow — they assemble named pieces. That is what makes authoring both user-oriented and safe (cf. a Semgrep rule registry, Terraform modules). The authoring modalities are interchangeable front-ends over this same composition:

  • Conversational — describe the process to a builder agent; it decomposes to a workflow + inferred policies, renders a draft, iterates. Powered by Bobbin retrieval.
  • Visual — phases as nodes, gates as policy-chips; edit rules via a guided builder. Fits Quipu's embeddable web components.
  • Guided rule builder — WHO / WHAT / WHEN (condition over facts) / THEN (effect). Never raw predicate syntax.
  • Mined — the plane observes fleet behavior in Quipu's bitemporal log and proposes a codification of a recurring sequence; a human approves.

The composition grammar

The grammar over the catalog is small: a selector yields a set, a predicate tests an element, and combinators (and / or / not) plus quantifiers (all / any) bind them. A composed policy — a fitness function — reads:

∀ s ∈ changed-public-symbols(run) : has-test(s)
      └─ selector (Yupana)             └─ predicate (Yupana)
targets Step[implement].exit-gate  ·  effect: block

Definitions are templates: references and actors are patterns (repo://{repo}/docs/design/{bead}.md, role reviewer) that an Instance resolves when it binds its parameters (bead, repo, agent). Authoring is over roles and patterns; runtime resolves them.

Dry-run against history

Because predicates are pure and reproducible, a draft policy is replayed over past evidence before promotion — "this would have blocked 3 of the last 20 merges" — via Quipu's counterfactual speculate() over the bitemporal log. You tune against reality, then promote; the same replay validates a mined proposal. No governance rule goes live blind.

Creation is itself verified

A definition is an Entity with the Claim "well-formed" — acyclic step graph, every referenced policy exists in the catalog, every produces / requires resolves. That claim is checked at write time by SHACL (definition-time verification), the same spine one altitude up:

  • Definition-time — is this workflow well-formed? (SHACL, at write, Quipu)
  • Run-time — is this workflow satisfied by reality? (stored verdicts, at execution, Yupana + Quipu)

Catalog growth and sovereignty

  • Catalog + escape hatch. 90% is composition of named predicates; an expert may author a raw predicate (a graph query / Yupana capability) and register it back into the catalog as a new named primitive. A newly registered predicate is itself an entity that must be verified/reviewed before it enters the catalog — creation-is-verified, recursively.
  • Sovereignty. Three producers — human composes, agent proposes, system mines — all emit a candidate that becomes governing only after definition-time verification and human promotion. The thing that governs the agents cannot be silently rewritten by an agent.

Catalog (initial)

A starting set — deliberately small, meant to be refined. Every entry is evidence-grounded and tier-tagged; the escape hatch grows it. run is the bound Instance context.

Selectors (evidence → set)

  • changed-files(run) — files touched — git / Yupana overlay
  • changed-symbols(run) — symbols touched — Yupana
  • changed-public-symbols(run) — exported subset of the above — Yupana
  • blast-radius(change) — dependents / frontier of a change — Yupana
  • callers(symbol) / callees(symbol) — Yupana
  • sinks-reached(source) — taint / dataflow reachable sinks — Yupana (cpg)
  • referencing-docs(symbol) / referenced-symbols(section) — doc↔code — Yupana
  • required-artifacts(step) / prior-steps(step) — Quipu (definition)

Predicates (element → bool)

Code-grounded, live tier (Yupana):

  • has-test(symbol) · symbol-exists(ref) · doc-section-exists(ref)
  • blast-radius-within(change, scope) · in-scope(action, scope)
  • sanitized-before(source, sink-class) — no unsanitized taint path

Record-grounded, committed tier (Quipu):

  • approval-exists(entity, by-role) · decision-record-exists(change)
  • different-actor-than(step-a, step-b) · artifact-verified(artifact)
  • within-duration(event, dur) — depends on a temporal trigger

Attested by external owners:

  • tests-green(artifact) · build-succeeds(commit) — CI (signed)
  • commit-exists(ref) — git · no-secrets(change) — scanner (signed)

Combinators

  • boolean: and · or · not
  • quantifiers over a selector: all · any

Execution model

Per run, the plane drives the loop through the harness adapter:

turn
  ├─ propel     inject "phase, done, next step, unmet claims"  (derived: definition × live verdicts)
  ├─ constrain  each action → policy gate → allow / ask / deny  (PreToolUse-equivalent)
  ├─ advance    observe completion → verify → advance state machine
  └─ gate       on attempted stop → exit-criteria verdicts met?
                  ├─ no  → block, return remaining steps → agent continues
                  └─ yes → transition / complete → promote run to Quipu (audit)

The injected guide content is not authored separately — it is derived from definition × current verdicts. Verdicts are derived facts: when a referent changes, the verdict goes stale and staleness propagates up the artifact → step → workflow chain (Quipu's reactive reasoner + Yupana freshness).

State: live vs settled

The state machine obeys the same tier split as the rest of the stack — so the answer to "do we write every step to Quipu?" is no.

  • Live tier (Yupana). The hot loop runs in Yupana's session runtime over the overlay: live-tier gates, the current-step pointer, plan progress, propulsion. Volatile, per-session, re-derived on demand — never written to Quipu tick-by-tick. It is ephemeral precisely because it is re-derivable, the same reason overlays are.
  • Settled tier (Quipu). Only transitions and committed verdicts are promoted — step entered/exited, gate passed, run completed — each a sparse, audit-worthy bitemporal fact. Quipu's reactive reasoner drives the settled transitions (cross-agent handoffs, temporal triggers); Yupana drives the live ones.

The Instance is a Quipu entity (identity, workflow, parameters, last durable milestone); Yupana holds a live projection of it. Agents coordinate through the Quipu record, not shared memory — a handoff is: agent A promotes a milestone → the reasoner enqueues the next step for agent B's role → agent B's Yupana rehydrates from that milestone.

Resumability

Resume is not a context reload — it is last-promoted-milestone (Quipu) + re-derived live verdicts (Yupana). Durable progress is never lost; the volatile delta since the last milestone is cheap to re-derive by re-verifying the current working tree. Because verdicts are content-bound, anything the world changed while paused surfaces as stale, so resume is precise about what shifted — which a silent checkpoint cannot be. State lives in the plane, not the context window, so a different agent (or harness) can resume the same Instance.

Intent map (tactical plan)

Yupana maps what the code is; the intent map adds what the agent intends to do to it, and binds the two. It is the middle layer between the strategic workflow (Quipu) and the raw code graph (Yupana) — and the layer that makes tactical resume and intent-conformance possible.

A PlanStep entity references a code symbol (add-sanitizer → parseInput) and belongs to the running Step / Instance. Only Yupana can bind intent to structure live, holding both the code graph and the edit overlay at once.

Sourcing the plan

Yupana infers a draft plan from the harness's plan / todo output and the agent's opening edits, binding each item to symbols. The agent may declare a plan to override or correct the inference (yupana_plan_declare), and a declared plan wins. Inference is the low-friction default; declaration is the authority. Requiring a plan before edits is itself a policy (plan-declared-before-edit).

What it unlocks — all on the existing spine

  • Plan completeness — ∀ s ∈ blast-radius(change) : s ∈ plan.targets (plan-covers-blast-radius): flags symbols in the blast radius the plan omits. Verification of the plan's coherence with the code, not the code's.
  • Tactical resume — verdicts on PlanSteps (done / pending / drifted) give resume granularity down to the individual edit.
  • Intent-conformance — ∀ edit ∈ run : edit.target ∈ plan.targets → else scope-creep. Soft by default (warn / require-revision, since re-planning is healthy); hard only when composed with a scope policy that already matters (drift into a protected module → deny).
  • Targeted context — Yupana and Bobbin inject exactly the symbol, its blast radius, and its tests for the next planned edit instead of dumping context.

Plan drift is expected: the plan is a living artifact, re-verified as it changes.

Human in the loop

Human review needs no new machinery — a human decision is just another attested evidence source, the same shape as a CI attestation. A require-approval gate (which risk-adaptive effect already produces for high-risk / low-confidence) sits unknown until a human signs a decision, which lands as a fact and satisfies the gate. Obtaining the decision is a Step assigned to a human Actor; checking it is a policy. The decision is a signed Artifact — {instance, gate, approve | reject | changes, by, rationale, valid-time}.

Asynchronous by default

A fleet of persistent agents cannot block on a synchronous human, so the flow suspends and resumes:

gate → require-approval → verdict unknown
  → Instance suspends (milestone promoted to Quipu — durable)
  → DecisionRequest dispatched to the human's queue (adapter / notification)
  → the agent is freed
  ⟳ … human responds whenever …
  → signed Decision lands as a fact
  → reactive reasoner re-evaluates → resume / branch / terminate,
     re-dispatching to whatever agent fills the role

The decision wakes the workflow, not the agent — human latency is decoupled from agent lifetime, because state lives in the plane (this is Resumability earning its rent). The operator becomes an async approver over a governance inbox (part of Quipu's web surface), each request pre-loaded with the context the plane already computed — diff, blast radius, failing verdict, risk score. The same DecisionRequest routes sync (harness-inline when present) or async (inbox) by availability.

Async-specific guarantees

  • Approvals are content-bound. The human approves hash H; if the artifact changes while the request waits, the approval is stale and does not auto-apply — re-request. This kills approve-then-sneak-in-changes, and is just verdict content-binding reused.
  • Approvals are role-restricted signed attestations under the verifier registry. An agent can no more forge or self-issue an approval than forge a Yupana verdict; separation-of-duties (different-actor-than) prevents self-approval. Sovereignty enforced cryptographically.
  • Waits are bounded by temporal triggers. No decision within the SLA → escalate to another role / auto-reject / notify.

The spectrum

One mechanism, different triggers:

  • Approval gate — sign off before a transition.
  • Review with changes — approve / reject / request-changes, the last branching the workflow back to a prior step.
  • Ambiguity escalation — triggered by low confidence (not high risk): the plane asks a human a call it cannot make.
  • Human-authored step — the work is inherently human; a policy verifies the produced artifact.
  • Break-glass override — the human is the trust root and may overrule a deny, but only via a signed, recorded override fact — never a silent bypass. Gates are overridable by default; a small non-overridable set (e.g. legal / compliance) requires N-of-M human sign-off instead.

Worked example

One feature-sdlc run, threading every primitive. Bead KUE-42 — "add rate-limiting to the login handler," repo yupana, actor agent-fix-3. Risk map: auth/** = high.

Definitions (Quipu). feature-sdlc = Steps design → implement → review → merge, guarded transitions between them. The implement exit gate binds:

plan-present               plan-declared-before-edit          (entry gate)
plan-covers-blast-radius   ∀ s ∈ blast-radius(change) : s ∈ plan.targets
all-public-tested          ∀ s ∈ changed-public-symbols(run) : has-test(s)
tests-green(artifact)                                          [CI-attested]

review binds require-approval + different-actor-than(implement).

1 — Bind. Instance created for (feature-sdlc, bead=KUE-42, repo=yupana, actor=agent-fix-3); design-doc pattern resolves to docs/design/KUE-42.md. Milestone instance started, step=design → Quipu. Quipu pushes the implement bundle to Yupana.

2 — Design. Agent writes the design doc; design-artifact-approved is an async require-approval gate → suspends; a human signs a DecisionRecord bound to the doc's hash → transition fires → step=implement.

3 — Plan (intent map). On entry, Yupana infers a plan from the harness todo → targets = {login_handler, RateLimiter}, satisfying plan-present. Agent edits both, tries to finish.

4 — Live gate (Yupana PDP). blast-radius(change) at tier=lsp (high confidence) → {login_handler, RateLimiter, session_store, auth_middleware}. plan-covers-blast-radius → unsatisfied (two omitted). Risk = high (auth). effect(high, high, unsatisfied) → deny at the transition + inject the two missing symbols. (A docs change would warn — same policy, different effect.)

5 — Adapt. Agent declares a plan override (yupana_plan_declare) covering all four, edits them, adds tests. Re-eval: plan-covers-blast-radius ✓, all-public-tested ✓ (lsp). tests-green → unknown (no CI yet) → still cannot complete; propulsion: "CI pending."

6 — Commit → milestone (Quipu). Agent commits; Yupana promotes committed structural facts; CI signs tests-green over hash H'. Quipu's engine evaluates the gate over committed evidence → all ✓, high confidence. Milestone step=implement completed → reactive reasoner fires the approve-guarded transition target... but review first needs a human, and different-actor-than forbids agent-fix-3, so it enqueues review for agent-review-2.

7 — Review (HITL). agent-review-2 runs review checks; require-approval suspends to the governance inbox. The human picks request-changes → the guarded transition branches back to implement (bounded loop). Second pass: approve → merge.

8 — Resume (if agent-fix-3 had died at step 5). A fresh agent rehydrates from the last milestone (implement in progress); Yupana re-derives live verdicts; the diff shows auth_middleware still untested → precisely the remaining work. No context reload.

Every step is a stored, bitemporal, signed fact — the run is fully auditable and reproducible after the fact.

Decisions and deferrals

Settled:

  • Home — the policy / workflow engine lives in Quipu (widely applicable, usable by Quipu on its own data); Yupana is a co-located decision point; no new repo. Working name for the capability: the loom.
  • Enforcement floor — the default floor is observed (never block real work because a harness can't gate), but a high-risk action requires a prevented boundary — a hard-gating harness or explicit pre-authorization. Risk sets the required floor (see Risk and confidence).
  • Task → workflow binding — a router selects by task type / label / files-touched, with a declared override and a repo-default fallback.
  • Catalog authority — an agent may propose a catalog predicate, but it becomes registered only on human (keeper) promotion after passing reproducibility review — creation-is-verified applied to the catalog itself.
  • Plan-before-edit — code-editing steps carry a plan-present entry gate (satisfiable by inference); the coverage gate is meaningless without it.

Deferred (v1):

  • Compensation — runs with irreversible side effects use halt-and-escalate rather than defined Saga-style undo.
  • Capability naming — the loom is a working name, not final.

Implementation phasing

Build order across the three repos. Each phase that wires a Cargo feature adds it to the CI matrix in the same change (don't ship dark), and every verdict it produces is tagged tier + freshness + confidence.

  • Phase 0 — Foundations (cross-cutting). Verifier registry + signing keys (the root of trust); content-hash evidence binding; the human-authored risk-map format. Everything downstream depends on these.
  • Phase 1 — Quipu: ontology + engine. Governance classes + SHACL shapes; definition-time validation; bitemporal verdict storage; committed-tier evaluation over the Datalog reasoner; retain evidence-hash + predicate-id for reproducibility. Extends the existing shacl + reasoner machinery.
  • Phase 2 — Yupana: policy module + live verdicts. policy/{model,eval}.rs over serve/{refs,impact,dataflow,callgraph}; bundle pull + cache; signed live verdict emission; yupana_policy_check (MCP + REST); wire the pre-edit hook to deny. Initial Yupana-grounded catalog leaves. Behind a policy feature.
  • Phase 3 — Yupana: intent map. PlanStep binding; inference + yupana_plan_declare / yupana_plan_status; plan-present, plan-covers-blast-radius, intent-conformance.
  • Phase 4 — Harness adapters. Claude Code (SessionStart / UserPromptSubmit inject, PreToolUse gate, PostToolUse advance, Stop gate); the fleet daemon; the enforcement gradient (prevented / observed).
  • Phase 5 — Orchestration + HITL. Guarded transitions, instance lifecycle, milestone promotion, reactive handoffs; DecisionRecord flow, the governance inbox (Quipu web components), temporal triggers, break-glass; risk-adaptive effects fully wired.
  • Phase 6 — Authoring. Catalog composition + escape hatch; dry-run via speculate; conversational (Bobbin), visual (Quipu web), and mined front-ends.

MVP is the worked example's spine: Phases 0–2 (minimal) + one workflow (feature-sdlc), one live gate (plan-covers-blast-radius + has-test), and the Claude Code adapter's pre-edit deny + Stop gate. That proves propel + constrain + verify end-to-end on one harness.

Scope boundary (v1): a single trust domain. Enforceable multi-tenant isolation is a known, decided-deferred gap in Quipu (group_id is provenance only); multi-operator separation is a prerequisite to lift before the plane governs across trust boundaries, not a v1 deliverable.

SARC Conformance — what yupana × quipu still needs

Status: Phases 1–6 landed. Constraint metadata and placement; the Σ-derived trace record and signed verdict emission; a real Post-Action Auditor with throttle; an Escalation Router with a bounded reversibility window; the T ⊨ Σ checker, the dispatch-graph inventory and the replay harness; authority intersection over named graphs and the attribution tuple.

Read the "as built" sections, not this line. Each ends with what its phase did not close, and four of those matter: there is no Action-Time Monitor at all (G6, untouched); the escalation queue has no server, so §5.3's W_q < τ_rev is unmeasured; θ is calibratable but not calibrated, because replay counts blocks and cannot label false positives; and no trust predicate evaluates imported content — the boundary is declared and reported, not closed. See Build order for the phases as originally scoped.

Why this document

Besanson [SARC] proposes that constraints become a first-class specification object alongside state, action space and reward (§3.1):

c = ⟨src, class, pred, verif, resp⟩  + a declared operating point θ

compiled into four named enforcement points in the agent loop (§4.1) — a Pre-Action Gate (PAG), an Action-Time Monitor (ATM), a Post-Action Auditor (PAA), and an Escalation Router (ER) — under eight runtime invariants I1–I8 (§3.5) whose joint effect is specification-trace correspondence (Definition 2, §3.6): given a specification Σ and a trace T, an auditor can mechanically decide T ⊨ Σ in O(|T|·|C|) without access to the model, its prompts, or its developers.

The stack is already most of the way there, and on the specification side it is ahead of the paper's own prototype: quipu's aegis: governance ontology is SHACL-validated and bitemporal, and verdicts are ed25519-signed against a human-owned VerifierRegistration root of trust — SARC's reference artifact is a JSON spec file and a Python checker (§3.6, §13.4).

What is missing is not the substrate. It is a specific, enumerable set of holes: the constraint objects are under-declared, three of the four enforcement points are absent or advisory-only, the trace is not derived from the specification, and nothing checks correspondence. This document names them and orders the work.

Where the stack stands

Already in place

SARC conceptStack todayWhere
Constraint specification objectaegis:Policy + aegis:Selector / aegis:Predicate atomsquipu/shapes/governance.ttl
predaegis:claim (SPARQL ASK), or selector .scm + predicate regexquipu/shapes/policies/treesitter.ttl
Pre-Action Gateyupana hook pre-edit, Mode::{Off,Advise,Enforce}src/hook/pre_edit.rs, src/policy.rs
Policy-layer reference monitorquipu pre-commit write gatequipu/src/governance/guard.rs
Verdict as attestation, not claimed25519-signed, evidence-hash-bound aegis:Verdictsrc/verdict.rs, quipu/src/signing.rs
Root of trustaegis:VerifierRegistration, human-authoredquipu/shapes/governance.ttl
Latency budget ([SARC] §5.1)policy.deadline_ms, fail-open on expirysrc/policy.rs
One-directional policy projectionquipu canonical → yupana read cachesrc/project.rs, src/hook/rule_planes.rs
Confidence inputstier ∈ {live,lsp,tree-sitter,committed,attested} + freshnessshapes, FR-3
Layer discipline (SARC I6)no rule lives in the prompt; declared, not yet verified against realityGovernance Plane
Post-Action Auditoryupana hook post-edit, soft class + throttlesrc/hook/paa.rs, src/throttle.rs
Constraint class + placementaegis:constraintClass / verificationPoint, checked at writequipu/src/governance/placement.rs
Σ-derived trace recordconstraints[] with outcome, response and placementsrc/trace.rs
Signed verdicts, both sidesyupana spools at the gate and the PAA; quipu persists the write gate'ssrc/verdict_spool.rs, quipu/src/governance/verdict_facts.rs

Governance Plane independently anticipates much of SARC — risk × confidence adaptive effect, verdict integrity, the out-of-band verifier, the prevented/observed enforcement gradient. SARC's marginal contribution on top of it is placement discipline ([SARC] §4.2, Table 3: which constraint class belongs at which enforcement point) and checkable correspondence ([SARC] §3.6: a decidable audit). SARC is explicit that this is a specification discipline layered over a policy-as-code substrate rather than a replacement for one (§2.1) — which is exactly the relationship quipu's write gate already has to yupana's projection.

The gaps

G1 — Constraint objects are incomplete. Violates I2 ([SARC] §3.5: "a constraint missing any field is not a constraint; it is a comment").

aegis:Policy carries targets, claim, boundary ∈ {action,transition} and effect ∈ {allow,warn,require-approval,deny,escalate,record}. It does not carry:

  • class ∈ {hard, soft, escalation}. effect conflates class with response, so "what kind of constraint is this" is not declarable and the class→placement rules ([SARC] Table 3) cannot be checked.
  • an operating point θ — no false-positive / false-negative tolerance.
  • a reversibility window τ_rev and on-timeout behaviour (needed by I4).
  • a per-constraint latency budget at its verification point.
  • a hosting layer (orchestration|tool|policy), needed to check I6.
  • src.type — aegis:Directive supplies optional authority/issuedBy, and the shipped catalog sets neither.

G2 — The verdict path is built but not wired. Violates I3 and I8 ([SARC] §3.5). Closed in Phase 2, both halves.

src/verdict.rs implemented signing and promote_verdict correctly — mirroring quipu's scheme so a yupana-signed verdict verifies under quipu's root of trust — and had no caller outside yupana verdict-key. A pre-edit guard decision, the exact moment a constraint fires, never became a governed fact. Symmetrically, quipu's own write-gate decision was not persisted (Q-VERDICT-PERSIST).

Both now record. yupana signs at the gate and at the PAA and spools locally, drained by yupana verdicts; quipu stages its write-gate verdicts and flushes them after the savepoint resolves, so a denial's verdict survives the rollback that denial caused.

G3 — The trace is not derived from Σ. Violates I3 ([SARC] §3.5: "the trace is generated; it is not reconstructed"), and I8 by consequence. Closed for the constraint set in Phase 2; the attribution half stays open under G7. See Structured constraint records.

src/metrics.rs emitted {kind, ts, agent, tenant, item, …} per event. There was no pre/post state, no constraints_evaluated set with outcomes, no attribution tuple. docs/work-scoped-governance.md names this precisely — "records and rules share one vocabulary" — and phases it. Its phase 1 is SARC's I3. It is designed, not built.

G4 — The Post-Action Auditor is advisory context, not a constraint site. Closed in Phase 3.

src/hook/post_edit.rs injects blast-radius context after an edit. It evaluates no constraint, emits no verdict, and cannot prevent the next action — which is what a PAA is for ([SARC] §4.1). SARC's soft class C_s has nowhere to live, and throttle — the declared PAA response responsible for the paper's entire 89.5% soft-overage reduction ([SARC] §10.3, Table 6) — is not in quipu's effect enum.

G5 — There is no Escalation Router. Violates I4 ([SARC] §3.5, §5.3).

require-approval and escalate currently fail closed at the quipu write gate with no channel to grant approval (guard.rs::effect_blocks, and the design doc says so plainly). aegis:Decision and aegis:assignsWorkflow shapes exist; there is no runtime router, no operator group, no queue, no τ_rev, no default-deny-on-timeout, no capacity model. [SARC] I4: "escalation without a bound is not human oversight; it is deferred autonomy." The queueing model that makes W_q < τ_rev measurable rather than asserted is §5.3.

G6 — There is no Action-Time Monitor.

Nothing observes an action mid-flight. Long-running Bash, MCP tool calls and sub-agent runs have no cumulative-budget monitor and no interrupt. src/hook/pre_bash.rs is deliberately record-only and prints nothing.

G7 — No attribution tuple, no authority intersection. Violates I5 ([SARC] §9.3, §9.6).

SARC's α = ⟨P, planner, executor, tool, auth, C_eval⟩ has no counterpart. The spool carries one flat agent + tenant + item. There is no principal-and-agent chain, no authority composition (all-of / any-of), no monotonic narrowing under delegation — and the trace is a sequence, not a tree, so orchestrator/worker runs are exposed to exactly the constraint-laundering and attribution-dilution failure modes of [SARC] §9.5. quipu's group_id is documented as provenance-only, and Governance Plane scopes v1 to a single trust domain. This is the deepest gap and the one with a real prerequisite.

Correction. The "real prerequisite" was described as quipu multi-tenancy, and that was wrong: named graphs are already a storage-enforced isolation substrate, and the actual gap was authorization over them. Mostly closed — see Phase 6, as built for what landed and what is still open (the trace is still a sequence, not a tree).

G8 — Enforcement completeness is unmeasured. Violates I7 ([SARC] §3.5).

docs/work-scoped-governance.md §"What this cannot reach" is an honest, explicit list of bypass surfaces: CI pipelines, cron, the far side of a remote shell, a sibling session's VCS index, a hostile agent. I7 is a property of the dispatch graph, verified by inspection against Σ — and there is no inventory of governed vs ungoverned tool-call classes, so "which actions traverse an enforcement point" is not answerable mechanically.

G9 — The aegis: vocabulary is not described, only constrained.

Found while building Phase 1, not in the original analysis. shapes/*.ttl contained zero rdfs:domain, rdfs:range, rdf:Property or owl:*Property declarations: every aegis: term was defined implicitly, by the SHACL shapes that constrain it. A shape says what is valid; it does not say what a term means or what it relates. So a reader could learn that constraintClass is one of three strings and could not learn how it differs from effect.

That matters for two consumers the stack already plans on. The authoring surface in Governance Plane §Authoring is composition over a catalog — an agent drafting a policy has nothing to read. And aligning aegis: against an external governance vocabulary (the ontology-matching problem [Agent-OM] addresses) needs property descriptions, which is precisely what was missing.

It also starved machinery already built: src/owl.rs parses rdfs:domain / rdfs:range / rdfs:subPropertyOf, and the reasoner materialises domain/range inference — fed nothing. Partly closed in Phase 1 by shapes/aegis-properties.ttl for the SARC fields; the rest of the vocabulary is Q-SARC-VOCAB.

G10 — No audit checker, no replay, no calibration.

Nothing computes T ⊨ Σ. There is no spool reader, no replay harness, no measured false-positive rate — so the advise→enforce promotion ladder in work-scoped-governance.md §Evals cannot be walked, and θ (G1) would be undeclarable-in-practice even once the field exists.

Position on the adoption ladder ([SARC] §13.3)

As first written, and where each rung now stands:

  • Level 1 (PAG, hard constraints at the tool/policy layer, structured trace emission) — was "substantially met; I3 and I8 outstanding". I3 met by the Σ-derived trace record; I8 waits on the checker (Phase 5).
  • Level 2 (PAA, soft constraints with calibrated operating points) — was "not started". PAA and throttle built, and the replay harness now measures the promotion gates. But θ is calibratable rather than calibrated: replay counts blocks and cannot label false positives, and bounds no false negatives at all. A declared θ that no measurement backs is a number, not a calibration, and the rung should not be claimed on one.
  • Level 3 (ATM, ER with declared τ_rev) — ER built with a declared window and default-deny past it. No ATM: nothing observes an action mid-flight (G6), and the escalation queue has no server, so §5.3's W_q < τ_rev is unmeasured. This rung is half met.
  • Level 4 (multi-agent) — was "blocked on quipu multi-tenancy", which misdescribed the blocker. Authority intersection, the attribution tuple, the reconstructed dispatch tree, constraint inheritance with a decidability rescue, and trust-boundary declarations are all built. Two things keep this rung from being claimed outright: the trace is emitted as a sequence, so sibling dispatches of the same principal are indistinguishable, and nothing evaluates imported content.

Decisions

  • Vocabulary: extend aegis: in place in quipu/shapes/governance.ttl rather than layering a separate sarc: overlay. One vocabulary; the existing SHACL validation and projection decode carry the new fields for free. Cost: the shipped treesitter.ttl catalog needs backfilling in the same change.
  • Escalation Router owner: quipu. The engine of record already models Decision, assignsWorkflow and the bitemporal audit trail, and require-approval already fails closed there. Yupana gets a thin client. This matches the settled "the engine lives in Quipu; yupana never originates policy" rule.
  • MVP scope: Phases 1–3 — SARC-conformant for a single agent in a single trust domain, which is exactly the v1 boundary Governance Plane already declared.

Build order

Phase 1 — Complete the constraint object

Closes G1. Prerequisite for everything else: you cannot place a constraint by class before class exists.

quipu — shapes/governance.ttl:

  • aegis:constraintClass, sh:in ("hard" "soft" "escalation"), required on boundary "action" policies.
  • aegis:verificationPoint, sh:in ("PAG" "ATM" "PAA" "tool_layer" "policy_layer"). This replaces nothing — boundary stays as the coarse action/transition split; verificationPoint is the fine placement SARC needs. tool_layer/policy_layer already appear in yupana's projected policies.
  • aegis:hostedAtLayer, sh:in ("orchestration" "tool" "policy"). Deliberately no "prompt" value. See Phase 1, as built for why the omission is defence in depth rather than the guarantee it first looked like.
  • aegis:OperatingPoint node shape + aegis:operatingPoint on Policy: falsePositiveTolerance, falseNegativeTolerance, threshold, calibrationBasis.
  • aegis:reversibilityWindowSeconds and aegis:onTimeout — the latter sh:in ("deny"), one value only, and re-checked by value in placement.rs because the shape alone does not bind on every write path. Required on escalation-class policies.
  • aegis:latencyBudgetMs on Policy.
  • "throttle" added to the aegis:effect enum, plus aegis:backoffFormula.
  • aegis:sourceType, sh:in ("regulatory" "contractual" "ethical" "operational"), and aegis:authority required on action-boundary policies.

quipu — src/governance/placement.rs (new): a class↔placement conformance pass, run at definition time alongside SHACL. hard ⇒ verificationPoint ∈ {PAG, ATM, tool_layer, policy_layer}; soft ⇒ {ATM, PAA}; escalation ⇒ {PAG, PAA} and must declare τ_rev. This is [SARC] Table 3 made mechanical, and it is what "placement discipline" means in practice. Backfill shapes/policies/treesitter.ttl (no-ticket-in-comment → hard/PAG, todo-needs-ticket → soft/PAA).

yupana:

  • project_queries::POLICY_QUERY gains ?constraintClass ?verificationPoint ?latencyBudgetMs ?fpTolerance ?fnTolerance ?reversibilityWindowSeconds as OPTIONALs. Not required — a projection that hard-required a field quipu had not yet backfilled would return zero rows, which is the exact both-sides-shipped-and-the-seam-returned-nothing failure project_queries.rs already documents in its own comments.
  • project::ProjectedPolicy gains those fields alongside effect; decode_policies reads them through the existing required/optional closures. An unrecognised constraintClass is an Error::Projection, matching how an unknown matchType is handled — never a silent drop.
  • rules::Rule gains class and verification_point, so a locally-configured [[yupana.policy.rules]] and a projected policy stay one type.
  • hook/rule_planes.rs::governed_check selects its response by declared class (hard ⇒ block under Enforce; soft ⇒ never block; escalation ⇒ route) rather than by project::effect_blocks alone. Mode::Advise keeps its ceiling: it never blocks, whatever the class.

Phase 1, as built

Shipped in quipu (shapes/governance.ttl, src/governance/placement.rs) and yupana (src/constraint.rs, src/project_decode.rs, src/hook/rule_planes.rs). Three things came out differently from the plan above, each because building it surfaced something the analysis had not:

Two fields are refused on the write path, and omitted from the vocabulary as defence in depth. aegis:hostedAtLayer has no "prompt" value and aegis:onTimeout has only "deny".

This was first described here as making the unsafe settings unrepresentable, which was an overstatement worth correcting rather than quietly fixing. A sh:in enum only binds when SHACL runs, and SHACL runs under shacl.validate_on_write — which defaults to false and validates episode ingest rather than Store::transact generally. A policy written through /knot or a direct transact could carry onTimeout "allow" and nothing would object. "The shape rejects it" and "the store cannot hold it" are different claims, and shipping the second while only the first was true is the exact failure this document catalogues elsewhere.

Both values are therefore re-checked in placement.rs, on the path that is actually on — before the action-boundary exemption, since a bad onTimeout fails just as silently on a transition-boundary policy. The vocabulary omission sits behind that as a second layer, not above it as a guarantee. Even so, the honest ceiling is "refused on quipu's write path": a raw SQL write, or a process that opens the store directly, bypasses every check quipu has.

hostedAtLayer was declared but otherwise unconsumed at this point — I6 was checked for well-formedness and not for truth, so a policy could claim tool while being enforced only in yupana's orchestration-layer hook. Spec B is now built; see H-SARC-I6, as built.

Multi-valued fields are refused, not resolved. Asserting constraintClass "hard" over an existing "soft" leaves both facts active — assertion is not replacement. The first implementation read the last SPARQL row and silently picked one, so a re-class would have landed while the old placement still validated. This was caught by the write-path test, not by the unit tests over the rule table, which is the argument for having both. A policy with two classes is now refused as ambiguous, with the retract-in-the-same-transaction remedy in the message, and a_clean_re_placement_retracting_the_old_value_lands is the recoverability half — refusing ambiguity is only safe if there is a way to legitimately move a policy.

The projection decoder got the same collapse the text decoder already had. POLICY_QUERY gained three OPTIONALs, and SPARQL returns the cross product of them: a policy carrying two rdfs:labels comes back as two rows and became two identical rules. decode_text_rules already carried a comment recording this exact failure on the live catalogue — 7 entities projecting as 11 rules, 4 duplicates, each reported twice to the model with conflicting rationales. decode_policies was exposed to it the whole time and the new fields made it likelier, so it now collapses on the policy IRI and refuses rows that disagree on a required field. Identity is the IRI, not the label: an unlabelled policy falls back to a row-indexed name, so keying on the name would give every row a distinct identity and collapse nothing.

The vocabulary had no property declarations at all. Not a SARC gap — a gap SARC's fields made visible. shapes/aegis-properties.ttl now declares the SARC properties with rdfs:domain / rdfs:range / rdfs:comment, and a test checks the two files against each other so a field added to the shape without a description fails rather than drifting. Domains are declared one property at a time, not swept in: rdfs:domain is an inference rule the reasoner materialises, so declaring it on a generically-named field like aegis:kind would silently type the first unrelated thing in the estate that used the name. The OperatingPoint fields deliberately carry range and comment only.

Two behaviour changes worth knowing about when reading a verdict:

  • The declared class outranks the governed effect. A soft policy never blocks, even with effect "deny" — that combination is contradictory, quipu's placement check now refuses to define it, and honouring what the author declared it to be is the only reading that is not a guess. A policy with no class (projected from a catalog predating the field) still behaves exactly as before: the effect decides.
  • A policy declared at the PAA does not fire at the pre-edit gate. It is skipped, not evaluated-and-ignored. Evaluating it there would tell the model to fix something its author scoped to after the fact — and until Phase 3 lands the post-edit auditor, such a policy is not evaluated at all. That is the honest state, and it is visible in the projection rather than hidden.

Mode::Advise remains a ceiling over all of it: an advise-mode deployment never blocks, whatever class a projected constraint declares. That is what makes staging a new hard constraint safe before anyone has measured its false-positive rate.

Two gaps Phase 1 exposed — and the spec to close them

Both were found by building, not by analysis, and neither is a SARC gap as such: SARC assumes a described vocabulary and takes I6 as a design rule rather than a checkable property. They are gaps in this stack's ability to back the claims it makes.

Spec A — Q-SARC-VOCAB: describe the rest of the aegis: vocabulary

The gap. G9. Outside shapes/aegis-properties.ttl (SARC fields only), no aegis: term carries rdfs:domain, rdfs:range or rdfs:comment. Terms are defined by the shapes that constrain them, which states validity and not meaning.

What to produce. One declaration per property in the aegis: namespace, in shapes/aegis-properties.ttl, each carrying:

  • a owl:DatatypeProperty or a owl:ObjectProperty — literal-valued vs IRI-valued. The distinction is load-bearing for the reasoner, not cosmetic.
  • rdfs:label — a human-readable name.
  • rdfs:range — the datatype or class. Inert for the reasoner on datatype properties (it types only IRI-valued objects), so this is for readers and matchers.
  • rdfs:comment — what the term means and how it differs from its nearest neighbour. A comment restating the label is worse than none: it passes the presence check while telling a reader nothing.
  • rdfs:domain — only where the subject class is unambiguous.

The domain rule, stated as a rule because it is a trap. rdfs:domain is an inference the reasoner materialises: declaring it asserts rdf:type on every subject carrying the property. A generically-named term (aegis:kind, aegis:threshold, aegis:name) will eventually be used by something else in the estate, and a domain would silently retype it. Declare a domain when the property's name is specific enough that no other class could reasonably carry it; otherwise range and comment only, and say so in a comment on the omission.

Sequencing. Property-at-a-time, in shape-file order, not one sweep. Each batch runs the reasoner over a store holding the shipped catalog and asserts the inferred types are the intended ones — the materialisation is the risk, so it is what the test has to exercise.

Acceptance. every_sarc_property_the_shape_constrains_is_also_described generalises from the SARC field list to every aegis: property the shape graph mentions; adding a constrained property without a declaration fails the build. A second test asserts no declaration carries a rdfs:comment that is merely its rdfs:label restated.

As built. governance_plane_properties_are_all_described in quipu/src/governance_tests.rs does exactly that, over every sh:path in governance.ttl, with a non-vacuity floor — an extractor that silently found nothing would otherwise pass over an empty set. every_description_says_more_than_its_own_label is the second test; seven declarations were rewritten to pass it honestly rather than by lowering the bar.

Two things came out differently. aegis:gate carries two meanings in this vocabulary — a Predicate's applicability condition, and the gate that produced an aegis:Decision — and the declaration records that rather than picking one, with a test asserting it still does. A reader who meets only one of the two will write code assuming it is the only one.

And the scope is the governance plane only. The ~100 estate properties in aegis-ontology.shapes.ttl (hostname, rig, park, plexId) are excluded by name: their intended subjects are not all obvious from their shapes, and asserting domains by guess materialises wrong rdf:types rather than merely documenting badly. materialising_the_declarations_types_the_shipped_catalog_correctly runs the reasoner over the shipped catalog and asserts no Selector or Predicate became a Policy — verified by mutation, since the materialisation is the risk and so is what the test has to exercise.

Why it earns its place. Two consumers this stack has already committed to. The authoring surface (Governance Plane §Authoring) is composition over a catalog, and an agent drafting a policy currently has nothing to read about the terms it is composing. And aligning aegis: against an external governance vocabulary — the ontology-matching problem [Agent-OM] addresses, with property descriptions as its primary signal — is not attemptable against a vocabulary that has none.

Spec B — H-SARC-I6: check the hosting layer against reality

The gap. aegis:hostedAtLayer is declared and unconsumed. Nothing compares it against where a constraint is actually evaluated, so a policy may claim "tool" — the layer an agent cannot route around — while being enforced solely by yupana's orchestration-layer pre-edit hook, which an agent bypasses by writing the file another way. I6 is currently checked for well-formedness and not for truth, and a false tool claim is worse than an honest orchestration one because it stops people looking.

What to produce. A layer-truth check at the projection seam, where both halves are known at once:

  1. Yupana knows what it is. A rule evaluated by yupana hook pre-edit is hosted at the orchestration layer, always — that is what the hook is. This is a constant in rule_planes.rs, not a configurable.
  2. On projection, compare each policy's declared hostedAtLayer to the layer that will actually evaluate it. A policy declaring tool or policy while yupana is its only evaluator is a mismatch.
  3. The response is a loud fail-open, not a block: yupana refusing to project a policy because its metadata overclaims would disable a rule that does still work, trading a documentation error for an enforcement gap. Report the mismatch, project the rule, evaluate it at the layer that is real.
  4. Record the layer actually used on the verdict and the trace record (hosted_at on ConstraintEvaluation), so the audit checker of Phase 5 can verify claim against record rather than taking the claim.

The asymmetry that makes it worth doing. Declaring a weaker layer than the truth is harmless — a tool-enforced constraint described as orchestration understates its own robustness. Declaring a stronger one is the failure. The check is therefore one-directional: flag when declared is more robust than actual, stay silent otherwise.

Acceptance. A projected policy declaring hostedAtLayer "tool" and evaluated by yupana produces a mismatch notice naming the policy, the claimed layer and the real one; the rule still evaluates and still blocks if it is hard. A policy declaring "orchestration" produces silence. The negative case — no declaration at all — also produces silence, since an absent claim overclaims nothing.

H-SARC-I6, as built

yupana/src/hosting.rs, quipu/src/governance/audit/passes.rs::hosting. The spec above held up; three things are worth recording about how it landed.

The check runs twice, on purpose, and they are different checks. Yupana checks at the projection seam — once per refresh, not per edit, because a metadata defect repeated on every guard line is a notice people learn to scroll past. That one compares the catalog's claim against YUPANA_HOSTS_AT, a constant rather than a configurable: a hook in the agent's loop is the orchestration layer, and making it settable would let a deployment declare itself into a robustness it does not have. Quipu checks at audit time, comparing the claim in Σ against the layer the trace says actually ran the constraint. The second is the one that cannot be fooled by yupana being wrong about itself.

The record must not echo the claim. ConstraintEvaluation::hosted_at is stamped from the evaluating code's own constant, never copied from the policy's aegis:hostedAtLayer. A field that repeated the declaration would let an overclaim survive the audit by being asserted twice, which is the failure mode the whole check exists to close.

An unknown layer is a projection error, not a None. The one value quipu's vocabulary deliberately omits is "prompt", and silently decoding it to "no claim" would turn the single claim I6 forbids into no claim at all. It fails the projection the way an unknown constraintClass does. On the audit side the same value is an incompleteness rather than a violation, because there the checker may simply be older than the runtime.

Phase 2 — Wire the verdict, derive the trace

Closes G2 and G3 — the two gaps that make I8 impossible.

  1. Call verdict::promote_verdict from the pre-edit decision path. Every fired constraint emits one signed, evidence-hash-bound aegis:Verdict.

  2. Carry the projection's real freshness into aegis:freshness. rule_verdict_message already threads it into the model-facing text; verdict_turtle currently hardcodes "fresh", which is precisely the silent-fresh-tag the tier discipline exists to prevent.

  3. Batch and buffer. Promotion must not sit on the edit's critical path — deadline_ms is 100 ms and a /knot round-trip is not. Spool locally with the existing fail-silent discipline; drain from the resident daemon.

  4. Persist quipu's write-gate verdict too (Q-VERDICT-PERSIST), so the policy-layer monitor is as auditable as the orchestration-layer one.

  5. Restructure the spool record to be derived from Σ:

    { pre_state_ref, action, post_state_ref,
      constraints_evaluated: [{ id, class, verification_point, outcome,
                                response_taken }],
      attribution, reward_components }
    

    This is work-scoped-governance.md phase 1 with [SARC] §3.6's E_i and §9.6's α_i named explicitly. Records and policies then share one vocabulary — the precondition for derive/test/explain in that document and for the checker in Phase 5.

Structured constraint records

src/trace.rs defines ConstraintEvaluation — SARC's E_i element — carrying the four things the audit checker's passes need per constraint: which one, where it was evaluated, what it concluded, and what was done about it. outcome and response are separate fields on purpose: pass (iii) is "does the recorded response match the one the policy declared", and a single collapsed field makes it unwritable. Outcome::Unknown stays distinct from Unsatisfied for the same reason it does in SARC — collapsing them makes an unevaluated check indistinguishable from a passing one.

Response::NoAction is representable and distinct from Logged. A constraint that fired and drew no response is a real state — a soft rule under a runtime with nowhere to put it, which is exactly where the stack sits before Phase 3 — and rounding it to Logged would read as a deliberate choice.

ProjectedViolation carries each rule's id, class and verification point, and the spool record names every evaluated rule. The rule field is derived from the constraint set for dashboards that group on it.

Record composition is tested through guard_recorded, which decides and composes the record; guard emits it and returns. The spool emitter is covered separately by the metrics tests. This keeps test setup within the crate's unsafe_code = "deny" policy.

Verdict emission (G2) closed. src/verdict_spool.rs signs one verdict per evaluated constraint at the moment the constraint fires, and appends it locally; yupana verdicts promotes the spool to quipu. It is a spool and not a direct call because the guard runs inside PreToolUse under a deadline_ms that defaults to 100 ms, and a /knot round-trip is not that — the projection path already records what an unbounded quipu call does here (a wedged quipu held the guard for two minutes). Promotion on the edit path would make every agent's edit latency a function of quipu's availability, to record a fact nobody needs until an audit. Signing is microseconds; the verdict is durable at the moment of decision and stays bound to the evidence hash it was signed over, so the delay costs nothing.

Four judgement calls in it worth naming:

  • The guard never mints a signing key. verdict::load_or_generate creates one when absent, and a keypair materialising as a side effect of an agent's edit should not happen quietly. On the hook path only an existing key signs; yupana verifier is the deliberate act that creates one.
  • The verdict records what the PREDICATE concluded, not what the guard did. A constraint can be unsatisfied while the mode declined to block. Conflating them would make an advise-mode fleet indistinguishable from a compliant one in the governed record; the response lives in the trace record beside it.
  • unknown spools nothing. An unknown verdict asserts "there was no evidence", and a constraint yupana evaluated had evidence by construction. Minting satisfied or unsatisfied for it would be a signed claim about something that concluded neither.
  • A rejected verdict is retained, never dropped. The drain truncates only when every line was accepted. A rejection is a fact about the verdict — a shape violation, an unregistered verifier — and truncating past it would erase exactly the record worth investigating.

Freshness stopped being a lie in two places. verdict_turtle hardcoded aegis:freshness "fresh", so every verdict yupana could have promoted would have claimed currency it never checked. It now takes the real value, and Decision carries the currency of the policy set that produced it — a parameter rather than a defaulted field, because a default would be Fresh and every caller who forgot it would rebuild the same defect. The trace record carries it as policy_freshness, which is what stops a soak window counting verdicts computed against a stale projection as evidence about current policy.

Recomputing maps to stale on the verdict and stays itself on the trace record: aegis:freshness admits only fresh/stale and the conservative reading is the only one that cannot overstate, while a trace record is diagnostic and the distinction is real. Two audiences, two mappings, each stated where it applies.

The attribution tuple, since closed. Phase 2 shipped α partial: yupana supplied tool, executor and C_eval, and left the principal chain P and the intersected authority auth absent rather than filled with the single agent id — a one-element chain asserted where a real chain belongs reads as "this action had one principal" to exactly the auditor the field exists for. The stated blocker was multi-tenancy quipu did not have. That reading was wrong about quipu and is now wrong about yupana too; see Phase 6 for what named graphs already provided, what authority intersection added, and src/attribution.rs for the five elements yupana now records. auth is still not one of them, and that is deliberate rather than pending: it is the intersection of grants quipu owns, so yupana records P and the checker derives auth from the authoritative source.

Phase 3 — Make the PAA a real enforcement point

Closes G4.

Give hook/post_edit.rs a constraint-evaluation path alongside its advisory context: evaluate verificationPoint "PAA" policies against (pre, post, action, obs), emit verdicts, and implement the throttle response — a declared backoff applied to subsequent actions once a soft window is crossed. This is the single highest-leverage mechanism in the [SARC] evaluation and the stack has no equivalent.

Soft constraints stay non-blocking by construction. The PAA's "prevents the next, not the just-completed" semantics must be explicit in the module doc, because presenting it otherwise is the false-prevented claim the enforcement gradient exists to stop.

Phase 3, as built

src/hook/paa.rs evaluates the constraints that DECLARE verificationPoint "PAA" against the completed file, records them in the same trace vocabulary the gate uses, and spools their verdicts.

The two points partition the rule set; they do not overlap. A rule declaring PAA is skipped at the gate and judged here; one declaring PAG, or declaring nothing at all, is judged at the gate exactly as before the field existed. So a constraint is evaluated once and its verdict says where — auditing at both would record two verdicts for one action and make the coverage pass ambiguous.

A satisfied constraint is recorded, not skipped. "The rule ran and held" and "the rule never ran" are different facts, and an absent evaluation reads as a constraint nobody applied.

The auditor never blocks, under any mode. Enforce and Advise behave identically here; only Off disarms it. That is not an oversight — it is what makes the soft class mean something, and quipu's placement check already refuses to let a hard constraint declare itself at this point.

throttle, and what it honestly is. A declared backoffFormula records an expiring, repo-scoped backoff (src/throttle.rs) that the next edit's advisory surfaces. It is observed, not prevented: an agent that ignores it proceeds. There is no timer and no sleeping — a throttle is state with an expiry, read by whatever hook runs next — so it is deliberately not described as rate limiting, which it cannot enforce. What it does buy is that a crossing is bounded, recorded, and visible to the next action instead of dissolving into a log line.

Three judgement calls worth naming:

  • An unparsed backoff formula applies no throttle and says so, recording Response::NoAction. A default would be a backoff nobody declared, under a constraint whose entire point is that its cost was declared — and silently warning instead would report the crossing as handled.
  • Throttles are scoped to the repo root. State is one file per user, not per checkout. The first implementation was unscoped, and the test suite caught it the way it deserved to be caught: tests passed alone and failed together, because a throttle written by one leaked into another. In production the same bug means a window crossed in one repo advises an agent editing an unrelated one — an advisory that is true about something and false about the work in front of it, which is how agents learn to ignore advisories.
  • An unscoped record matches nothing rather than everything. The permissive reading of a legacy line would advise every repo on the host.

The audit runs independently of the blast-radius advisory. advisory_for bails early on a non-Rust file, a file with no symbols, or an edit landing outside every symbol body. The auditor must not inherit those exits, or a constraint's coverage would depend on a property of the file with nothing to do with the rule.

Phase 4 — Escalation Router (quipu, yupana client)

Closes G5. Turns require-approval from fail-closed-with-no-channel into bounded human oversight.

  • aegis:OperatorGroup shape: capacity model (M/M/c — c, mean_service_s), hours, after-hours mode, fallback_if_unavailable "deny".
  • Router service in quipu: accept a suspended action, dispatch a DecisionRequest to the group's queue, hold until τ_rev, default-deny on timeout. A ruling that modifies the action must re-enter the gate (SARC Algorithm 1's goto PagCheck) — re-validation, not trust.
  • Decisions stay content-bound to the evidence hash (already the shape's contract), so approve-then-change goes stale automatically.
  • Yupana gets a thin ER client at the pre-edit seam. When the router is unreachable the escalation-class response is deny, and the fail-open notice says so loudly.
  • Emit queue-depth / wait / utilisation metrics, because the operative claim of [SARC] §5.3 is that W_q < τ_rev is a measurable property of an M/M/c queue, not an assertion.

Phase 4, as built

quipu/src/governance/router.rs, plus aegis:OperatorGroupShape and aegis:DecisionRequestShape. Three things came out differently from the sketch above, each because the sketch described something that could not be true.

The router does not hold the transaction open. A write gate is synchronous; "hold until τ_rev" would convert an approval gate into a lock on the store, and a store you cannot write to while a human is at lunch is an outage wearing governance's clothes. What actually happens: the refused attempt mints a DecisionRequest naming the policy, the target, the group that can rule, an evidence hash and an expiresAt derived from the constraint's reversibility window; a human signs an aegis:Decision bound to the same hash; the next attempt finds it and proceeds. The hold is the agent retrying, not the engine waiting. §5.3's window still governs — past expiresAt an unserviced request is Expired, which is a denial, and Ruling::permits returns true for Approved alone so neither Pending nor Expired can read as a pass.

A rejection outranks an approval. When both are bound to the same evidence, two humans have disagreed, and resolving that by row order would make the outcome depend on storage layout. The safe reading of a disagreement about whether to permit something is no.

The request is staged, not written in place. The gate runs inside the savepoint the refusal is about to roll back, so a request written there would vanish with it — leaving an operator a refusal and no request to act on, exactly the state the router exists to end. Requests stage on the Store and flush after the savepoint resolves either way, the same mechanism verdicts use rather than a second one invented for decisions.

What it is not: there is no scheduler and no notification. routedTo records which group should rule; delivering the request to them is a consumer of the record. The queue-depth and utilisation metrics of §5.3 need a queue with a server attached, and claiming W_q < τ_rev from a store that only records requests would be the dashboard anti-pattern with extra steps. That measurement stays open, and it is named here rather than quietly dropped.

Phase 5 — Audit checker and enforcement inventory

Closes G8 and G10 — the "auditable by construction" claim itself.

  • quipu_audit_check(Σ, T): four passes per [SARC] Definition 2 (§3.6) — coverage, class-placement compatibility, outcome consistency, attribution completeness — returning a structured discrepancy report. Deterministic, predicate-language-agnostic, and explicitly not an LLM call: [SARC] §5.1's design rule, which is the same O(ℓ_tool) budget discipline yupana already applies to its own guard.
  • A dispatch-graph inventory for I7: enumerate every tool-call class the harness exposes, mark governed/ungoverned, fail the check when an executable class traverses no compatible enforcement point. Seed it from work-scoped-governance.md §"What this cannot reach", so the known-unreachable surfaces become data rather than prose.
  • The replay/eval harness from work-scoped-governance.md §Evals — liveness, both-outcomes, non-vacuity, recoverability, replay. This is what makes the Phase-1 operating point θ an honest number rather than a declared one, and it gates advise→enforce promotion per rule.

All three built; see Phase 5, as built, including what replay measures and what it provably cannot.

Phase 5, as built

quipu/src/governance/audit.rs (+ audit/passes.rs, audit_spec.rs), inventory.rs, replay.rs, and quipu audit <trace>|inventory|replay <trace>. All three pieces landed. What is worth recording is where each one stops.

The checker

Four passes, each a comparison between two declared values and never a model call. Coverage, class-placement compatibility, outcome consistency, attribution completeness — plus the I6 claim-versus-record check described above.

Two severities, kept apart deliberately. A violation is the trace contradicting Σ: a soft constraint that blocked, a declared deny that only warned under enforce, a record whose declared chain disagrees with the process that ran. An incompleteness is the trace not saying enough to decide: no principal chain, no declared class, a constraint Σ declares that this window never exercised. Collapsing them would break the checker in the direction that matters — report everything as a violation and an operator learns to ignore the output; report everything as an incompleteness and a soft constraint blocking an edit reads as a formatting note. Only violations change the exit code.

The outcome pass is mode-aware, and has to be. advise has a declared ceiling, so a hard deny constraint that only warned is correct under advise and a violation under enforce. A check that ignored the mode would have to pick one of those two records to be wrong about.

The placement pass reuses placement::points_for rather than carrying its own copy of Table 3. Two copies would eventually disagree, and the disagreement would be between the definition-time check and the audit-time one — the two places that must not.

What it cannot check, stated in the module doc rather than papered over. SARC's coverage pass asks whether every constraint that applied to an action was evaluated. Deciding applicability means re-running the selector against the file as it stood, and quipu has neither the file nor the parser. What is checkable is the converse — nothing is cited that Σ does not define — plus vacuity. A checker reporting "coverage: pass" while testing something weaker would be the more dangerous artifact.

Unreadable lines are counted, never skipped, and the count is in every summary even at zero. Conformance over a window that was only partly read is not conformance.

The dispatch inventory (I7)

shapes/dispatch-inventory.ttl turns docs/work-scoped-governance.md §"What this cannot reach" from prose into aegis:ToolClass facts. Prose goes stale the first time a harness adds a tool; nothing recomputes, and the list quietly becomes a description of last year's deployment.

The distinction the whole thing exists for: an executable class traversing nothing with no stated reason is an unknown hole (violation); the same class with an aegis:ungovernedReason is an acknowledged bypass surface (incompleteness). Neither is "governed", and the checker never reports one as the other — without that split an operator cannot tell a decision from an oversight. Every acknowledged surface is reported on every run, because one an operator has stopped seeing is one they have stopped weighing.

Plus the cross-check the other direction: a constraint placed at a point no executable class traverses can never fire. It reads as governance in the catalog and is inert in the deployment — the failure hardest to see from either side alone. An empty inventory is an incompleteness, never a pass: an unwritten dispatch graph is not an empty one.

The seed file says in its own comment that nothing derives it from the harness's tool registry, so it can drift the way the prose did. The difference is that a drifted declaration is a wrong answer to a question something asks, rather than a paragraph nobody re-reads.

The replay harness

Five gates per rule — liveness, both-outcomes, in-spec, recoverability, and new-blocks (not a gate; the number an operator is actually deciding about). Recoverability walks the trace in order: a target cleared before its refusal proves nothing about whether anyone got past the rule, and counting it would make every rule that ever allowed anything look recoverable.

Nothing is re-evaluated. The predicate needed the file as it stood and that file is gone, so replay is deterministic arithmetic over records rather than a simulation.

Three limits ride in the summary rather than in a footnote, because a promotion number read without them is read as a safety claim:

  1. It measures only traffic that happened. A rule that would block a kind of edit nobody attempted shows zero new blocks and is not therefore safe.
  2. It counts false-positive candidates, never false positives. A block is wrong only if the action was legitimate, and no record carries that judgement — it needs a human. Reporting new_blocks as an FP rate would be the number-shaped fiction the operating point exists to replace.
  3. It bounds no false negatives at all. Actions a rule let through without firing look exactly like actions it correctly approved.

So θ is now measurable in one direction and still undeclared in the other, and the doc should not be read as saying the operating points are calibrated. They are calibratable.

Still open in Phase 5

There is no scheduler behind the Escalation Router, so §5.3's W_q < τ_rev remains unmeasured: the queue-depth and utilisation metrics need a queue with a server attached, and claiming the inequality from a store that only records requests would be the dashboard anti-pattern with extra steps.

Phase 6 — Multi-agent

Closes G7. Originally written as "gated on quipu enforceable multi-tenancy — do not start before it". That gate was misdescribed; see Phase 6, as built.

  • Attribution tuple α on every trace record; the trace stored as a tree, worker subtrees attached to their dispatch node, never summarised.
  • Authority intersection along the call chain, monotonically non-increasing; an empty intersection fails safe.
  • Constraint inheritance with decidability rescue: evaluate an inherited constraint at the deepest layer where it remains decidable, or escalate — never silently drop it, which is the constraint-laundering path.
  • Trust-boundary tagging on imported state (Bobbin retrieval results, MCP tool output, sub-agent responses) with a PAA trust predicate. The zero-trust agent gateway of [SARC] §9.5, expressed in the existing constraint vocabulary rather than as a separate perimeter layer.

Phase 6, as built

The gate was described wrongly

"Blocked on quipu multi-tenancy" was the framing above, and in G7, and in the Phase 2 note about the attribution tuple. It was wrong, and it was wrong in a way that cost real work: it made a whole phase look unreachable when the substrate was already there.

Quipu's named graphs are not a provenance label. graphs is a registry with an enforced committed|overlay class and a bind-once parent_branch, and writes, retractions and idempotency are all graph-scoped in SQL (store/overlays.rs, store/ops.rs). Partitioning was never the missing piece. What was missing is authorization: http_auth::authorize is one global bearer token, all-or-nothing, and nothing checked whether a principal may write to graph N. "Multi-tenancy is unbuilt" and "multi-tenancy has no access-control layer" are different claims, and only the second was true. quipu's docs/design/group-isolation.md carried the older framing and has been corrected in place, because a doc that is being read as a blocker is not a neutral inaccuracy.

Authority intersection — quipu/src/governance/authority.rs

§9.3's rule, auth_k = ⋂ authority(p_i) for i in 0..=k, as code:

  • Authority::intersect is monotonically non-increasing, so adding a delegate can only narrow. That is the defence against authority escalation via tool capability (§9.5) — a sub-agent whose own credentials are broader cannot use them, because the effective authority is the intersection and not the executor's own.
  • The wildcard is the identity, not a widening. A principal may hold *, which is how a single-tenant deployment keeps working unchanged; * intersected with a narrow authority is the narrow one, so a wildcard-holding orchestrator delegating to a scoped worker yields the worker's scope.
  • An empty intersection fails safe. A chain narrowed to nothing cannot act, and that is a refusal rather than a fallback to the principal's authority — the fallback would be precisely the escalation the rule exists to stop.
  • An empty chain is none(), not any(). "Nobody said who is acting" must not mean "anybody may act".
  • An undeclared principal holds nothing. Reading an absent grant as permission is how an access-control layer becomes decorative.

Enforcement runs in Store::enforce_graph_authority, called before the savepoint in transact_to_graph — the real write path, not a helper a caller can route around. It is gated by enforce_authority (default off) and is inert without a principal chain, so every existing caller is untouched: the flag makes a supplied chain binding rather than making attribution a hard requirement beneath a running deployment. The refusal names the chain, the graph, and what the chain actually holds, because a refusal that says only "denied" leaves an operator guessing which link narrowed it.

The attribution tuple — yupana/src/attribution.rs

α = ⟨P, planner, executor, tool, auth, C_eval⟩, on the gate record and the PAA record alike. Five of the six:

  • P from YUPANA_PRINCIPAL_CHAIN, comma-separated and caller-first. A dispatcher that spawns a sub-agent appends itself and exports the extended chain. Absent when undeclared — an undeclared chain is not a one-link chain, and this is the same distinction that kept the field out of the record in Phase 2.
  • planner from YUPANA_PLANNER, declared and never derived from the chain's head. Which link deliberated and which executed is a fact about the dispatch; reading it off list position would be an inference wearing a record's clothes.
  • executor from $SHANTY_AGENT — the identity of the process that actually ran, which is ground truth rather than a claim.
  • tool from the hook payload.
  • C_eval is the constraints array Phase 2 already emits.

auth is deliberately not recorded by yupana, and this is a settled decision rather than a pending one: the effective authority is the intersection of grants that live in quipu, yupana cannot read them inside a 100 ms pre-edit budget, and a locally-guessed value would put a number in the field the grant store never agreed to. Recording P is what lets the checker derive auth from the authoritative source. The tuple is completed by the audit, not faked by the hook.

The conflict flag. YUPANA_PRINCIPAL_CHAIN is a declaration; $SHANTY_AGENT is what is running. When a chain is declared and its tail disagrees with the executor, the record says attribution_conflict rather than silently preferring one. That disagreement is the observable signature of a laundered chain — an agent acting under a dispatch record naming somebody else — and a record that resolved it by precedence would delete the only evidence of it. It is emitted only when true: a false on every line trains a reader to skip the field, which is the opposite of what it is for.

The attribution tree — quipu/src/governance/tree.rs

quipu audit tree <trace.jsonl> reassembles the dispatch forest from the principal chains. The trace is still emitted as a sequence, so this is reconstruction rather than structure, and the output says so in three places rather than presenting a tree as a guarantee:

  • Unattributed records are not placed. Attaching one to whichever root happened to be first would invent an answer to the question the tree exists to answer.
  • Implied dispatch nodes are flagged. A chain [orchestrator, worker] proves an orchestrator exists; it does not prove the orchestrator's own actions are in the window. "Did nothing" and "was not recorded" are different facts.
  • Collapsed nodes get a note. Two dispatches of the same worker by the same caller produce the same chain and land on one node — attribution dilution reappearing at the reader instead of at the record.

Constraint inheritance — quipu/src/governance/inheritance.rs

aegis:inheritedByDelegates declares that a constraint binds the whole subtree under a dispatch. aegis:onUndecidable admits only "escalate" — the same shape as onTimeout admitting only "deny" — which is the decidability rescue: evaluate at the deepest layer where the constraint still decides, or hand it to a human. A constraint that silently stops applying where it cannot be checked is one an agent escapes by dispatching into a context where the evidence is absent.

Two findings, at deliberately different severities because one has evidence and the other has a question. A constraint that decided on a target and is absent from a deeper action on the same target is a violation — it proved it could decide there. A constraint evaluated at a dispatch node and never in its subtree is an incompleteness: that might be laundering and might be a selector that matched nothing deeper, and deciding needs the files as they stood. Reporting both as violations would make the strong one unfindable.

is_below is a strict prefix test. A sibling branch is not a delegation of this one, and a shallower action after a deeper one is returning, not delegating — either read as laundering would flag every parallel run.

Trust boundaries — declared on the ToolClass

aegis:importsUntrustedState and aegis:untrustedOrigin, on the dispatch inventory rather than on a policy, because the tool class is the thing that actually imports. Reported by quipu audit inventory whether or not the class is governed: governedAt says a class's own actions traverse a point and says nothing about what it returned. A class that imports and declares no origin is a violation — an import channel nobody can describe is one nobody can weigh.

shapes/dispatch-inventory.ttl declares this stack's three real channels: a sub-agent's response text, MCP tool output, and retrieved documents.

Still open in Phase 6

There is no trust predicate. The vocabulary now names the channels and the inventory reports them on every run, but nothing evaluates imported content. The PAA judges the file after an edit, not the material the edit was based on, and building a predicate over sub-agent responses needs a producer that records them — which no part of this stack does today. So the boundary is declared and open, not closed; the honest claim is that it is now visible on every run rather than absent from the model.

The trace is emitted as a sequence. Making it structurally a tree means the harness emitting a dispatch id per spawn, which is a change in the harness, not in yupana. Until then, sibling dispatches of the same principal are indistinguishable — and quipu audit tree says so per node instead of leaving a reader to assume otherwise.

Files this touches

quipu

  • shapes/governance.ttl — the constraint-object extensions (Phase 1)
  • shapes/policies/treesitter.ttl — backfill the shipped catalog (Phase 1)
  • src/governance/guard.rs — class-aware effects, verdict persistence
  • src/governance/placement.rs (new) — class↔placement conformance pass
  • src/governance/router.rs (new) — escalation router (Phase 4)
  • src/governance/audit.rs (new) — the T ⊨ Σ checker (Phase 5)
  • src/signing.rs — reused unchanged; it is already the root of trust
  • docs/design/policy-edit-hooks.md — the SARC gaps land in its backlog table

yupana

  • src/project_queries.rs, src/project.rs — projection of the new fields
  • src/rules.rs — Rule gains class + verification point
  • src/hook/rule_planes.rs — response selection by declared class
  • src/hook/pre_edit.rs — verdict emission on decision
  • src/hook/post_edit.rs — PAA constraint evaluation + throttle (Phase 3)
  • src/metrics.rs, src/audit.rs — Σ-derived trace record (Phase 2)
  • src/verdict.rs — real freshness and batching; the signing scheme is correct
  • src/daemon/ — verdict drain, ER client (Phase 4)
  • docs/work-scoped-governance.md — reconcile its phasing with this one

Verification

Beyond each repo's normal gate:

  • quipu — cargo test governance: the extended catalog must still conform; new tests that a class↔placement mismatch is rejected at write (the definition-time half of the discipline), and that an escalation-class policy without τ_rev fails validation.
  • yupana — just check && just test, plus two-sided fixtures through the real hook binary per work-scoped-governance.md §Evals: a RED case and a GREEN case per new constraint class, and a non-vacuity mutation check.
  • End to end — a policy authored in quipu, projected into yupana, fired at pre-edit, promoted back as a signed verdict, and accepted by quipu_audit_check against the same Σ. That round trip is the decidable-audit property of [SARC] Property 1, and it is the acceptance test for Phases 1–2.

One caveat travels with any number this produces: replay measures false positives only on traffic that actually happened, and measures no false negatives at all. A seeded adversarial corpus gives coverage against known attacks and none against novel ones.

Out of scope — and why

Five of the six sources this analysis was drawn from bear on how Σ gets authored and kept aligned with its sources, not on how it is enforced at runtime. That distinction matters: [SARC] §6 is explicit that the arrow from obligation to predicate is an institutional process, not a technical step, and that the framework "presupposes rather than resolves" it. So the authoring work is real, and it is a different piece of work.

Where each one lands, should we pick that thread up:

  • [Raji & Bashir] surveys the governance requirements Σ has to encode, and is the clearest statement of the principal-agent problem behind G7 — who is the principal, and what is the agent authorized to do on their behalf. Its Singapore MGF summary ("assess and bound the risks upfront", "make humans meaningfully accountable") maps onto the risk map and the ER respectively.
  • [Informatica/Deloitte] argues the semantic-layer case Quipu already embodies. Useful as external corroboration for why the governed graph is the right home for policy, not as a source of requirements.
  • [Agent-OM] is directly applicable to keeping aegis: aligned with external vocabularies as they drift — the maintenance half of the translation layer, and the thing that stops predicates silently decaying away from their source obligations ([SARC] §6, "the translation layer as a governed control surface").
  • [Peshevski et al.] is the agent-driven ontology-construction pattern behind the "mined" authoring modality in Governance Plane §Authoring.
  • [Olivares-Alarcos et al.] grounds explanation generation in an ontology while keeping the reasoning sound — relevant to making a refusal legible, which is a stated requirement of the recoverability eval in docs/work-scoped-governance.md §Evals ("every refusal names the command that satisfies it").

None of them changes the runtime gap list above, which is why they are named here rather than folded into it.

Sources

  • [SARC] — Besanson, G. (2026). SARC: A Governance-by-Architecture Framework for Agentic AI Systems: Compiling Regulatory Obligations into Runtime Constraints. Working paper, Universidad Torcuato Di Tella. arXiv:2605.07728v1 [cs.SE]. Reference artifacts: https://github.com/besanson/sarc-governance. All section, table, definition and invariant references in this document are to this paper.
  • [Raji & Bashir] — Raji, M. & Bashir, M. (2026). Towards Agentic AI Governance: A Preliminary Assessment. AIR-RES 2026, Springer Nature. arXiv:2607.07612v1.
  • [Informatica/Deloitte] — Beierschoder, M., Andrensek, J. & Rebele, T. Building the Semantic Data Layer for Agentic AI. Informatica / Deloitte whitepaper (5340en).
  • [Agent-OM] — Qiang, Z., Wang, W. & Taylor, K. (2024). Agent-OM: Leveraging LLM Agents for Ontology Matching. PVLDB 18(3), 516–529. doi:10.14778/3712221.3712222.
  • [Peshevski et al.] — Peshevski, D., Stojanov, R. & Trajanov, D. (2025). AI Agent-Driven Framework for Automated Product Knowledge Graph Construction in E-Commerce. arXiv:2511.11017v1 [cs.AI].
  • [Olivares-Alarcos et al.] — Olivares-Alarcos, A., Ahsan, M., Sanjaya, S., Lin, H.-I. & Alenyà, G. Ontological grounding for sound and natural robot explanations via large language models. arXiv:2602.13800v1.

Internal design documents this analysis builds on, all in-tree:

  • Governance Plane — the verification spine, verdict integrity, risk × confidence, the Yupana↔Quipu integration contract.
  • Policy edit hooks — evidence locality, the quipu pre-commit gate, the yupana projection, and the Q-* / H-* backlog the Q-SARC-* beads extend.
  • Tiers and Freshness — the confidence inputs SARC's operating point composes over.
  • docs/work-scoped-governance.md — the trace taxonomy, the five eval properties, and the per-rule promotion ladder. Out of the book by design; cited by path.

Governed Relations — Yupana ⋈ Quipu

Yupana knows the live structure of the code. Quipu knows the governed record of everything related to it — docs, work items, commits, policies, decisions. They share one IRI scheme, so Yupana can tell you, at the moment you touch a symbol, everything the governed graph already knows about what you changed.

The idea

Yupana exports its structure in the same bobbin: code ontology that Quipu stores (FR-20; src/export.rs mirrors Quipu's namespace.rs). A CodeSymbol IRI in Yupana's live graph is the same node in Quipu's governed graph. That shared IRI is a free join key: Yupana's live blast radius on one side, Quipu's committed neighborhood on the other, joined by identity.

This turns a single question into a general one. "Which docs reference this symbol?" stops being a special case and becomes one filter over "what governed facts relate to these IRIs?"

The primitive

Given a symbol — or the impacted set from an edit — ask Quipu for the governed neighborhood of those IRIs, and fold it back tagged as the past.

Yupana keeps ownership of the live structural answer (blast radius, dataflow). The neighborhood is Quipu's governed answer. Yupana does the join and annotates its own output — it never re-derives or re-owns Quipu's edges.

impacted set (Yupana, live)          governed neighborhood (Quipu, committed)
  foo::bar  ─────────────┐         Section --references--> foo::bar   (docs/x.md#y)
  foo::baz  ─────────────┼──⋈──►   GitCommit --modifies--> foo::bar   (bead st-42)
  ...                    │         Policy --targets--> <type of foo::bar>
                         └──────►  Decision --targetRef--> foo::bar    (prior HITL)

The join key: one ontology

  • Yupana promotes / exports CodeModule / CodeSymbol with definedIn / calls / imports in the bobbin: ontology (FR-19/FR-20, yupana export).
  • Quipu's shapes/code-entities.ttl defines the same CodeModule / CodeSymbol / Document / Section, and the governance plane (shapes/governance.ttl) extends that model in the same namespace.
  • Because both sides mint the same IRIs, the join is identity — no mapping table, no fuzzy match.

Tiering: this is the past, and it says so

Every fact Yupana serves carries a tier and freshness (FR-3). Governed relations come back tagged committed (or attested for signed verdicts) — never mixed into the live treesitter / lsp structural tier. This is the present-vs-past split (§9) made legible at the fact level: Yupana annotates the present (its live edit) with the past (Quipu's settled record), and the consumer always knows which is which.

Relation types this unlocks

One query, different predicate filters:

Governed edge (Quipu)Surfaced as
Section --references--> CodeSymboldoc-staleness — "docs/x.md#y may be stale"
GitCommit --modifies-->, Bead --implements-->traceability — "traces to bead st-42 / epic E-3"
Policy --targets--> <type>, PlanStep --references-->governance — "a policy gates this; a plan step depends on it"
Decision / Verdict --targetRef-->staleness of intent — "a prior decision was bound to this code"
cooccurrence / PageRank neighborhood"these entities usually move together"

Principles

  • Borrow, don't derive (§9.6). Quipu is the single source of truth for governed relations. Yupana reads them and tags them committed; it never promotes them back or treats them as its own structural facts. The day Yupana re-owns a Quipu edge is the day there are two sources of truth.
  • Optional, and it degrades. Gate the enrichment behind the existing quipu feature. If Quipu is unreachable, the enrichment is simply absent — the code blast radius still answers. "Could not look" is not "nothing there" (the tier discipline, one layer up).
  • Latency by surface. The advisory post-edit hook can afford a best-effort synchronous Quipu query. The blocking pre-edit guard cannot — it stays on the live graph (or a startup-loaded cache), never a synchronous Quipu round-trip (FR-31's sub-100ms budget).

Surfaces

  • yupana relations <symbol> / yupana_relations (MCP) — the governed neighborhood of a symbol, with an optional predicate filter, tier-tagged committed.
  • Folded into yupana impact — the impact response gains a governed_relations section for the impacted set.
  • Folded into the post-edit hook — the advisory gains a "governed relations touching your change" block.

Worked example: doc-staleness

Doc-staleness is the references filter over the primitive. On an edit to foo::bar, Yupana's blast radius yields the affected symbols; the join asks Quipu for Section --references--> edges into that set; the advisory says:

governed relations (committed):
  docs/architecture.md#data-flow  references foo::bar  — may be stale

Once the referential graph is in Quipu, the same fact is auditable over time as a SPARQL query — "every Document that references a CodeSymbol which no longer exists" (§5.10). The live hook is the in-the-moment view; the query is the durable one.

Status

  • Exists today: yupana export --format turtle emits the code side (CodeModule / CodeSymbol + definedIn / calls / imports) and, via src/docref.rs, the doc→code Section --references--> CodeSymbol edges.
  • This design adds: the read direction — Yupana querying Quipu by shared IRI to enrich its live output, tier-tagged committed, behind the quipu feature, exposed as yupana_relations and folded into impact / post-edit.
  • Boundary held: Yupana owns structural references; embeddings and semantic retrieval stay in Bobbin, the governed record stays in Quipu (§5.10).

Workflow-Gated Edits — process around a change

Some edits need a process around them — "changing this file requires a demo of its effect on the UI," "this module needs a security review." That process need not happen at edit time, or by the agent that made the edit. This design is how a governed edit assigns a workflow asynchronously, across the Yupana × Quipu × Shantytown stack, without any tool growing a job that isn't its.

The shared currency

A governed aegis:Workflow (Steps + guarded Transitions, defined in Quipu's shapes/governance.ttl) is the unit passed between tools. Each tool touches it at exactly one point:

ToolOne job
YupanaRecord structure — promote the entity change to Quipu (FR-19). Structure-only: it does not evaluate policy or orchestrate workflows. It informs the acting agent in-loop via the post-edit hook (configurable).
QuipuOwn the rule and the record — the Policy (targets + assignsWorkflow), the Workflow / Step / Transition, the resulting Decision / Verdict — and act as the event source (bitemporal transaction log).
ShantytownSubscribe and route — watch Quipu's entity events, evaluate the policy at the subscription boundary, deliver the required workflow to the administrator, who acts.

The loop (async-first)

agent (via Yupana) edits src/ui/widget.rs
 → Yupana promotes the entity change to Quipu               (async; a new transaction)
 → Shantytown subscribes to Quipu entity events
      sees the change → asks Quipu /policy/check "is a workflow required?"
 → yes: aegis:Workflow "UI-demo-required" → route to the ADMINISTRATOR
 → admin ACTS: creates a bead "demo UI effects of the widget change"
      (assignable to anyone, anytime — not necessarily now, not necessarily that agent)
 → Yupana's post-edit hook informs the ORIGINAL agent in-loop (configurable, FR-30)

The requirement surfaces as a Quipu entity event, not a synchronous block. That matches the domain: "requires a UI demo" is inherently decoupled — it does not belong to the edit or the editor.

Why async is the right default

  • It fits the work. The process can be done later, by someone else. A synchronous guard would have to fake that.
  • It stays inside "not a mayor." The administrator reacts to an event by creating work — a bead — assignable to anyone, anytime. No agent is auto-assigned; no hierarchy reorganizes. Event-driven is not autonomous: the system surfaces, a human acts.
  • Workflows are just one event kind. The same subscription carries verdicts, policy effects, and doc-staleness. Gating an edit is one filter over it.

The secondary path: the synchronous guard

For hard trust-boundary cases (capability-scoped polecats), the opt-in pre-edit guard (FR-30) still evaluates a boundary=action Policy and may deny before the edit lands. On require-approval, its verdict names the workflow rather than blocking:

{
  "verdict": "require-approval",
  "effect": "require-approval",
  "required_workflow": "aegis:workflow/ui-demo-required",
  "reason": "edit touches a UI surface governed by ui-demo policy",
  "tier": "committed"
}

Even here Yupana only references the workflow — it names the requirement; it does not run it. Blocking guards stay opt-in, because a wrong hard-deny is worse than none (FR-30).

Boundary discipline

  • Yupana records structure and (guard only) references a workflow. It never evaluates policy on the hot path or orchestrates. Policy evaluation and routing live in Shantytown; the record lives in Quipu.
  • Quipu owns the rule. "This entity type requires this workflow" is a governed aegis:Policy with targets + assignsWorkflow — SHACL-validated at write, so a malformed rule is rejected at definition time.
  • Shantytown owns delivery. It is already the tool that routes work to agents and injects prompts into terminals; the assigned workflow is one more thing it routes — to the administrator, for a human decision.

The pieces this needs

  • Quipu — aegis:assignsWorkflow (Policy → Workflow) in governance.ttl, so a require-approval / escalate effect can name the workflow to run.
  • Quipu — a transactions cursor (GET /transactions?since=<tx>&limit=<n>) so Shantytown's subscription poll is O(new events), not O(whole log).
  • Shantytown — a QuipuEvents subscriber over the transaction log, filtered to the kinds it cares about, with the four-state liveness discipline (a stalled watermark on a busy graph is surfaced, never silently green).

Worked example: "changing this file requires a UI demo"

  1. A ui-demo aegis:Policy in Quipu targets the src/ui/** module type and assignsWorkflow the ui-demo-required aegis:Workflow.
  2. An agent edits src/ui/widget.rs. Yupana promotes the entity change; the agent gets the in-loop post-edit advisory.
  3. Shantytown's subscription sees the change, asks /policy/check, learns a workflow is required, and routes it to the administrator.
  4. The administrator creates a bead — demo the UI effects of the widget change — assignable to any agent, at any time. The edit is not blocked; the process is captured and owned.

Status

  • Exists today: yupana hook post-edit (the in-loop agent advisory); Quipu's governance plane (Workflow / Step / Policy / Verdict) and tool_policy_check.
  • This design adds: aegis:assignsWorkflow and the transactions cursor in Quipu; the QuipuEvents subscriber and admin routing in Shantytown; the optional required_workflow field on the pre-edit verdict in Yupana.
  • See also: Governed Relations — the Yupana ⋈ Quipu join this builds on.

Game-State & Policy Harness (addendum)

The FR-35..FR-39 addendum — the net-new capabilities the NeuralAmplifier project needs from Yupana — lives at the repository root:

Status: BUILT, behind the game-state Cargo feature. The code is src/state/; what shipped, as opposed to what was designed, is described in The Game-State Harness.

Yupana ingests facts an engine adapter stated — a game board, not source — and serves them at the engine-state tier, a peer of the code tiers rather than a rung above or below them. Nothing there is span-anchored, so a consumer that read an engine-state fact as if it pointed at a file:line would be wrong in exactly the way FR-3 exists to prevent.

One spelling trap the addendum fixes and this page repeats because it is a wire value: game-state is the Cargo feature; engine-state is the tier. Two names for two things, and a consumer discriminating on the wrong string matches nothing, silently.

Golden-Path Conformance Guard (addendum)

The FR-40..FR-42 addendum — blessed-trajectory projections and plan/progress conformance under gp-grammar/1 — lives at the repository root:

Status: BUILT (first cut), behind the golden-path Cargo feature. The code is src/goldenpath/; the surfaces are yupana_path_check over MCP and POST /path/check on the daemon, both feature-gated. The operational reference is Golden-Path Conformance.

Two choices the implementation made that the design left open, since they change how the guard is used:

  • Projected paths are supplied per call, like StatePolicy, rather than held resident — a stale resident copy would enforce yesterday's blessing.
  • Deviation is decidable only against a complete plan, so FR-41's per-action flow reports progress and hazards rather than hard deviations.

Contributing

See CONTRIBUTING.md and AGENTS.md at the repository root.

The essentials:

  • Use just, never raw cargo.
  • Run just check and just test before every push — both must pass.
  • Clippy is -D warnings; the lint policy mirrors Quipu's.
  • Keep source files small (warn at 400 lines, fail at 500).
  • Tag every served fact with a tier and freshness.
  • No Cargo feature ships dark — add it to the CI matrix in the same change.
  • Work is not complete until it is pushed and CI is green.