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
- New here? Read Architecture, then The Tenancy Model.
- Want to run it? Installation and Quick Start.
- Want the whole design? The Specification.
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
| document | what it gives you |
|---|---|
| README | install, a first success, and wiring into an agent |
| Installation · Quick Start | the same path, in more depth |
| Configuration · Harness Integration | making it part of your workflow |
| Why Yupana · The Stack | what 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 book | status |
|---|---|
Agent action certification: the yupana certify record format | current |
Specification and vision
| document | status |
|---|---|
| Specification: the full build spec (requirements, architecture, phasing) | living; the reference for FR numbers |
| Vision: Bobbin × Yupana × Quipu | north star |
Design
The book's Design section summarises the addenda below and links to them.
| document | status |
|---|---|
| Golden-path conformance guard (FR-40..42) | built, first cut |
| Game-state and policy harness (FR-35..39) | built |
| Work-scoped agent governance | partially implemented; the doc says which parts |
| Governed landing policy | design, advise tier first |
| Paper plan: evidence-local constraint enforcement | planned |
Research and evaluation
| document | status |
|---|---|
| Briefing retrieval eval | measured 2026-08-15; reproducible with just e2e f1 |
| E2E grounding eval | reference |
Working on Yupana
| document | what it covers |
|---|---|
| Contributing | build, test and the pre-push gate |
| Releasing | how 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.
| document | what it records |
|---|---|
| Rename: hank → yupana | the 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 docsmdbook,npx(markdownlint/prettier), andvale.
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-memory | Joern (CPG) | LSP / multilspy | Embeddings / co-change | Yupana | |
|---|---|---|---|---|---|
| Fast structural graph, low token cost | ✅ | ⚠️ | ❌ | ✅ | ✅ |
| Call graph + dataflow / taint | ⚠️ | ✅ | ⚠️ | ❌ | ✅ |
| Precise LSP-grade types | tiered | ❌ | ✅ | ❌ | 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--cochangereconciliation), intra-proceduraldataflow, andverify, which returns a verdict on a proposed edit buffer. See the CLI reference. - Serving: an MCP server (
yupana serve) exposing theyupana_*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 thelspfeature 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 → Freshtransitions insrc/watch/overlay_refresh.rs), and since Phase 3 (bobbin-052) the resident daemon serves it on the tenant-scoped/symbolsreply — 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) /recomputingagainst the policy projection from Quipu, and promoted verdicts carryaegis: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 |
|---|---|---|---|---|
manual | promotes | skipped | skipped | skipped |
commit | promotes | promotes | promotes | promotes |
merge (default) | promotes | skipped | promotes | promotes |
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 gainsbobbin: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
featureversusmain" 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 viarudof_lib, FR-20) — Yupana never writes to Quipu without passingshapes/code-edges.ttl, the compiled-in shape set. It gates the structural edges (calls,references,imports,dataDependsOn,controlDependsOn,hasTier) and theSection → referencesedge (§5.10), and requires the structural fields owned by Yupana. SharedCodeModule,Document, andSectionlabels and Section headings come from Bobbin; Yupana emits their stable IRIs, paths, depth and relationships without adding a second label or heading.CodeSymbollabels 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 realexportprojection 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), honoringvalid_from/valid_to,actor, andsource(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
| Surface | Requirement | What it does |
|---|---|---|
yupana_ingest / POST /ingest | FR-35 | Load span-free facts into the hot board |
| — | FR-36 | The graph-pattern policy model over those facts |
yupana_guard / POST /guard | FR-37 | Check proposed orders against those policies |
yupana_whatif / POST /whatif | FR-38 | Ranked 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:
sharedwrites the game's common-knowledge base, which every faction in that game reads — the map, public treaties, observed sightings.privatewrites 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:BaseStateis 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
namepredicate 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:
| Field | Meaning |
|---|---|
violations | deny policies that fired |
advisories | warn policies that fired |
unevaluated | policies that could not be compiled, or are not Yupana's to run |
vacuous | policies 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:
- 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.
- By routing. A
sharedingest carrying a faction is refused and counted — the one path by which private intel could reach the layer everybody reads. - 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 satisfiedis true of everymaxCountshape 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
| Code | Meaning |
|---|---|
0 | Rule plane loaded, empty, or off |
3 | Rule plane degraded — the rules could not be projected |
4 | Workspace 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-exist | free calls only, and only ones the edit introduces |
wrong-arity | free calls resolving to exactly one known definition |
unresolved-import | bodiless mod foo; with no sibling file |
type-violation | not 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:
| Value | Records | Use when |
|---|---|---|
off (default) | no path | The deployment treats paths as sensitive. |
relative | src/auth.rs | The useful setting for a fleet — identifies the file without disclosing where the checkout lives. |
absolute | /srv/repo/src/auth.rs | One 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
noulin 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.
thresholdis 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.excludewins overinclude. - Metrics. Each judged edit writes one
docs_historyrecord withresult(history,current, or the failure word),scoreandms. The record includespathonly when[yupana.metrics] record_pathsallows 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
| Tool | Purpose |
|---|---|
yupana_status | Base ref, tenant, available tiers, Quipu settings |
yupana_symbols | Symbol tree for a file |
yupana_references | Definition site(s) of a symbol, by name or by position |
yupana_analyze | Files/symbols summary for a subtree |
yupana_callers | Direct callers of a symbol (who calls it) |
yupana_callees | Direct callees of a symbol (what it calls) |
yupana_communities | Densely-connected symbol clusters (deterministic Louvain, FR-9) |
yupana_impact | Blast radius — transitive callers, N hops; reconciles against a cochange set (FR-11) |
yupana_dataflow | Local data dependence; interprocedural: true opts into Rust CPG |
yupana_verify | Verdict on a proposed edit buffer, before you write it (FR-23/FR-24) |
yupana_promote | Promote a subtree's facts to Quipu — SHACL-validate, then write (needs the quipu feature) |
yupana_ingest | Load generic (non-code) facts into the hot board graph (FR-35; needs the game-state feature) |
yupana_guard | Check proposed orders against game-state policies (FR-37; needs the game-state feature) |
yupana_whatif | Speculative, uncommitted impact of an order set over the board (FR-38; needs the game-state feature) |
yupana_path_check | Conformance 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-editsizes edits viaPOST /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-editruns the FR-30 cycle in onePOST /edit: the just-saved file is recorded in the session tenant's overlay (--tenant, defaultsingle-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 notpath-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.
| Route | Method | Query/body | Answer |
|---|---|---|---|
/health | GET | — | bare ok (the liveness probe) |
/status | GET | — | root, node/edge counts, uptime, tiers, tenant_layer (base commit + active overlays; null = layer absent) |
/callers | GET | symbol, tenant? | direct callers |
/callees | GET | symbol, tenant? | direct callees |
/impact | GET | symbol, hops (default 5), tenant? | transitive blast radius |
/references | GET | symbol, tenant? | definition sites |
/symbols | GET | file (root-relative), tenant? | that file's symbols, line order |
/dataflow | GET | function, path?, var?, forward?, hops? | intra-procedural data dependence |
/measure | POST | {file, rel, anchors[], max_hops?} | edit blast-radius sizing for the guard |
/edit | POST | {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 a409 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_layerisnullin that state, distinct from present-with-no-overlays.
Notes that keep the answers honest:
foundvs empty. "The symbol is not in the graph" and "the symbol has no callers" are different answers; every reply separates them./symbolsis the one surface that cannot: files enter the graph only through their symbols, soknown: falsecovers absent, unparseable, and symbol-less files alike — render it as "no symbols in the resident graph", never "the file is empty".- Root confinement.
/measurenames a file to read and/dataflowa subtree to build; both refuse (400) any path resolving outside the daemon's root. A localhost daemon must not become an arbitrary-file reader. /dataflowis 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:
| Caller | Daemon down means |
|---|---|
| pre-edit guard | edit allowed + loud notice (fail-open, announced) |
| MCP graph tools | transient rebuild, stderr note (correct, slower) |
anything parsing /status | absent/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.
| Setting | Meaning |
|---|---|
YUPANA_READ_LEGEND=1 | Enable the advisory in this agent's environment |
YUPANA_KEYWORDS_NAMESPACE | Required ontology class namespace in the daemon environment |
YUPANA_KEYWORDS_DICT | Precision word list, default /usr/share/dict/words |
[yupana.quipu] enabled, endpoint | Configure the daemon's background data source |
[yupana.serve] use_daemon, bind_address, mcp_http_port | Configure 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:
| Tool | Fields Yupana uses |
|---|---|
Edit | file_path, old_string, new_string |
Write | file_path, content |
MultiEdit | file_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.
| Failure | Result |
|---|---|
yupana not on PATH | exit 127 → non-blocking error → edit proceeds |
yupana too old to know the subcommand | exit 2 → would block; see below |
| Yupana panics | exit 101 → non-blocking error → edit proceeds |
| Deadline exceeded | exit 0, silent → edit proceeds |
| Daemon unreachable, unreadable config, unparseable payload | exit 0 + loud line → edit proceeds |
| quipu unprojectable, cache servable | exit 0 → rules still evaluated, verdict marked STALE |
| quipu unprojectable, no servable cache | exit 0 + loud line → edit proceeds |
| Policy says deny | exit 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,0disables 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_cacheandfail_openare 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:
denyblocks underenforceand advises underadvise.warnnotifies and never blocks.throttlerecords a backoff via the same machinery the Post-Action Auditor uses; the next edits surface the advisory. It is recorded even when a siblingdenywire 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
- Path scope (FR-25) — is the edited file inside this tenant's writable capability scope?
- Blast radius (FR-12/FR-25) — do the symbols the edit touches transitively affect more symbols or files than the scope permits?
- Buffer verification (FR-23, opt-in:
[yupana.policy] verify = true) — does the proposed buffer introduce references that resolve to nothing? The same verifieryupana verifyexposes, run at the blocking seam: a hallucinated identifier, a wrong-arity call, or an unresolvedmodimport denies underenforceand advises underadvise. 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 samedeadline_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:
enforcewith no scope for the tenant — armed in appearance, inert in effect — prints a⚠caveat (enforcing_without_scope: truein--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;
statusreports 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::actiondeclined 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:
| Field | Meaning |
|---|---|
selector / evidenceSource | JSON object with nonempty programs and verbs arrays of bare command tokens |
predicate / evidenceSource | command-before-edit, the supported ordering |
enforcementTier | warn, the supported advisory tier |
oncePer | session for one notice per rule/session, or edit for every subsequent edit |
effect / verificationPoint | warn at PAA, the supported response and placement |
rdfs:comment | The 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:
- Post-edit before any selected command: silent control.
- Pre-bash with a selected invocation: observe the attempt.
- Post-edit: the graph's explanation appears.
- Post-edit again: silent when
oncePerissession.
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:
| Field | Contract |
|---|---|
handoff_tokens | Positive integer threshold, chosen from observed depth/compaction evidence |
threshold_evidence | Nonempty reference to the observations supporting that threshold |
max_age_seconds | Positive 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:
| Status | Action | Exit |
|---|---|---|
UNKNOWN | DO_NOT_ACT | 2 |
BELOW_THRESHOLD | DO_NOT_ACT | 0 |
HANDOFF_ADVISED | CHECKPOINT_AND_HANDOFF | 0 |
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_checkover MCP (registered on every build; refuses with a message naming the feature whengolden-pathis absent).POST /path/checkon 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, whichdeadEndhazards 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 level | Effect |
|---|---|
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
kind | emitted by | meaning |
|---|---|---|
guard | yupana hook pre-edit | a Pre-Action Gate decision, one per edit |
audit | yupana hook post-edit | a Post-Action Auditor crossing, with point: "PAA" |
governed | the pre-edit guard | which governed rules spoke, by name |
fail_open | anywhere on the guard path | the guard degraded, and why-kind |
hosting_overclaim | the projection refresh | policies claiming a stronger layer than the one evaluating them |
command | the CLI | deliberate 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: falsemeans 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_invokedcarries the same discriminator under the same name.)sessionanditem— 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"
}
]
}
| field | values | notes |
|---|---|---|
id | string | the constraint's stable id — the same name its verdict cites |
class | hard | soft | escalation | omitted when undeclared, never guessed |
verification_point | PAG | ATM | PAA | tool_layer | policy_layer | omitted when undeclared |
hosted_at | orchestration | tool | policy | the layer that actually evaluated it |
outcome | satisfied | unsatisfied | unknown | what the predicate concluded |
response | blocked | warned | logged | escalated | no-action | what the runtime did |
Three of those distinctions carry weight:
unknownis not a softunsatisfied. 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.outcomeandresponseare 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_atis 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.
| field | source | notes |
|---|---|---|
principal_chain | $YUPANA_PRINCIPAL_CHAIN | comma-separated, caller-first |
planner | $YUPANA_PLANNER | declared, never derived from the chain's head |
executor | $SHANTY_AGENT | the identity of the process that actually ran |
tool | the hook payload | Edit, Write, MultiEdit |
attribution_conflict | computed | emitted 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:
| file | what |
|---|---|
manifest.json | schema …/share-manifest/v1, share_id, store_id, tx_anchor, graph_hash, shapes_hash, scope, parent_share, producer |
export.nt | canonical N-Triples — the bytes graph_hash covers |
shapes.ttl | the 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_hashtaken over Yupana's raw Turtle is not stable across checkouts. Taken over Quipu's canonicalexport.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.ttl | consumer's local shapes | result |
|---|---|---|
| present | code-entities loaded | staged, all triples accepted |
| empty | code-entities loaded | staged — identical; the bundled shapes changed nothing |
| present | none | quarantined, 0 accepted, blocker off_vocabulary naming CodeModule/CodeSymbol |
present, then adopted with quipu shapes load | now loaded | staged |
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 sharewith no--shapeswrites a zero-byteshapes.ttland 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--shapesis 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 query | rows |
|---|---|
after share pull — staged, not promoted | 0 |
the identical query scoped GRAPH <staging> | 1 |
after share promote | 1 |
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:
| outcome | graphs | |
|---|---|---|
| first pull | quarantined, 4 triples | 1 |
| identical re-pull | unchanged, 4 triples | 1 |
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
| step | result |
|---|---|
| pull a share into receiver A | quarantined, 15 triples |
reshare that staged graph | new share id, parent_share recorded in the manifest |
| pull the delta into a fresh receiver B | quarantined, 15 triples, exit 0 |
| pull the delta again, byte-identical | unchanged, 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 |
| bearer | QUIPU_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:
| scenario | proves |
|---|---|
| clean, cited edit | a bounded pass — the guard does not cry wolf |
| hallucinated call | a reference that exists nowhere is denied, tier declared |
| fabricated work-item citation | id-shaped but resolving to nothing is its own violation class |
| uncited edit | the must-ground discipline denies untracked work |
| freshness declared | a live projection says verdict freshness: fresh |
| quipu down, cache warm | last-known policy still enforced, cache age named |
| quipu down, no cache | loud fail-open — never a silent allow |
| verdict return | spooled 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:
| scenario | proves |
|---|---|
| declared deny | a static [yupana.policy.scopes.*] entry hard-denies outside itself |
| observed in-scope | the item's own ground admits the edit silently |
| observed advise | out-of-scope is named (rung, item, right move) without blocking |
| unknown scope | no rung answers → one advisory per session, never a silent allow |
| observed enforce | work_item_scope = "enforce" denies outside the item's boundary |
| enforce in-scope | the hard boundary does not over-block the item's own ground |
| assignment briefing | session-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 structuraldenypolicy 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 → touchedpromote 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 capabilityscopeshave: yupana-local, authoritative, always fresh. This is where "no ticket id in a comment" lives for a standalone yupana. - Projected from quipu (
quipufeature) — the governed plane. Yupana fetches quipu'sboundary:"action",tree-sitter-tier policies and decodes them into the sameRuleshape (field-for-field congruence withaegis:Selector+aegis:Predicate), so a projected policy is just aRulethe 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 quipuis in the CI matrix. - H-PROJECTION ✅
src/project.rs: decode quipu's structural policies intoRules, aProjectionRegistrywith 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-editevaluates projected policies; adeny-effect policy blocks underEnforce, 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, orstalewhen 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 — quipushapes/policies/tripwire.ttl) project viasrc/project_tripwire.rsinto the sameTripwireshape 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,backoffFormulacompiled 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 (itsintroduced_textseam), 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'ssigning.rs(samering, same canonicalv1|…message, same hex encodings), aVerdictShape-conformant signedaegis:VerdictTurtle, andpromote_verdictover/knot.yupana verifierprints the public key a human registers asaegis:publicKey. Remaining: wiring the trigger (promote a verdict on the post-commitcommit → touchedpath) 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 fromunsatisfied(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
tierandfreshness(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
unknownat 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, rolereviewer) 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-idso 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-testfrom a tree-sitter heuristic is lower-confidence than one LSP-resolved; atests-greenfrom a signed CI attestation is highest. - freshness — a
staleverdict is less trustworthy than afreshone. - 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
authchange,tests-greenmust 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 likehas-tests,blast-radius-within,doc-section-existsthat 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) vsobserved(detect, record, escalate). Never presentobservedasprevented.
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:
| Primitive | Quipu class | Notes |
|---|---|---|
| Policy | extends Directive | governed rule / intent, human provenance |
| Verdict / violation | Observation | bitemporal, signed, content-bound |
| Decision (human) | DecisionRecord | signed approve / reject / changes |
| Workflow / Step / Transition | new classes | the composition graph |
| Artifact / PlanStep | new classes | reference concrete targets / code symbols |
| Selector / predicate | new classes | evidence-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
- 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.
- 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). 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. Companionyupana_plan_statusreturns the plan and per-PlanStepverdicts.- 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 overlaychanged-symbols(run)— symbols touched — Yupanachanged-public-symbols(run)— exported subset of the above — Yupanablast-radius(change)— dependents / frontier of a change — Yupanacallers(symbol)/callees(symbol)— Yupanasinks-reached(source)— taint / dataflow reachable sinks — Yupana (cpg)referencing-docs(symbol)/referenced-symbols(section)— doc↔code — Yupanarequired-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 smallnon-overridableset (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 apreventedboundary — 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-presententry 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-idfor reproducibility. Extends the existingshacl+ reasoner machinery. - Phase 2 — Yupana: policy module + live verdicts.
policy/{model,eval}.rsoverserve/{refs,impact,dataflow,callgraph}; bundle pull + cache; signed live verdict emission;yupana_policy_check(MCP + REST); wire the pre-edit hook todeny. Initial Yupana-grounded catalog leaves. Behind apolicyfeature. - Phase 3 — Yupana: intent map.
PlanStepbinding; inference +yupana_plan_declare/yupana_plan_status;plan-present,plan-covers-blast-radius, intent-conformance. - Phase 4 — Harness adapters. Claude Code (
SessionStart/UserPromptSubmitinject,PreToolUsegate,PostToolUseadvance,Stopgate); the fleet daemon; the enforcement gradient (prevented/observed). - Phase 5 — Orchestration + HITL. Guarded transitions, instance lifecycle,
milestone promotion, reactive handoffs;
DecisionRecordflow, 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 concept | Stack today | Where |
|---|---|---|
| Constraint specification object | aegis:Policy + aegis:Selector / aegis:Predicate atoms | quipu/shapes/governance.ttl |
pred | aegis:claim (SPARQL ASK), or selector .scm + predicate regex | quipu/shapes/policies/treesitter.ttl |
| Pre-Action Gate | yupana hook pre-edit, Mode::{Off,Advise,Enforce} | src/hook/pre_edit.rs, src/policy.rs |
| Policy-layer reference monitor | quipu pre-commit write gate | quipu/src/governance/guard.rs |
| Verdict as attestation, not claim | ed25519-signed, evidence-hash-bound aegis:Verdict | src/verdict.rs, quipu/src/signing.rs |
| Root of trust | aegis:VerifierRegistration, human-authored | quipu/shapes/governance.ttl |
| Latency budget ([SARC] §5.1) | policy.deadline_ms, fail-open on expiry | src/policy.rs |
| One-directional policy projection | quipu canonical → yupana read cache | src/project.rs, src/hook/rule_planes.rs |
| Confidence inputs | tier ∈ {live,lsp,tree-sitter,committed,attested} + freshness | shapes, FR-3 |
| Layer discipline (SARC I6) | no rule lives in the prompt; declared, not yet verified against reality | Governance Plane |
| Post-Action Auditor | yupana hook post-edit, soft class + throttle | src/hook/paa.rs, src/throttle.rs |
| Constraint class + placement | aegis:constraintClass / verificationPoint, checked at write | quipu/src/governance/placement.rs |
| Σ-derived trace record | constraints[] with outcome, response and placement | src/trace.rs |
| Signed verdicts, both sides | yupana spools at the gate and the PAA; quipu persists the write gate's | src/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}.effectconflates 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:Directivesupplies optionalauthority/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
throttlebuilt, 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 < τ_revis 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 inquipu/shapes/governance.ttlrather than layering a separatesarc:overlay. One vocabulary; the existing SHACL validation and projection decode carry the new fields for free. Cost: the shippedtreesitter.ttlcatalog needs backfilling in the same change. - Escalation Router owner: quipu. The engine of record already models
Decision,assignsWorkflowand the bitemporal audit trail, andrequire-approvalalready 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 onboundary "action"policies.aegis:verificationPoint,sh:in ("PAG" "ATM" "PAA" "tool_layer" "policy_layer"). This replaces nothing —boundarystays as the coarse action/transition split;verificationPointis the fine placement SARC needs.tool_layer/policy_layeralready 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:OperatingPointnode shape +aegis:operatingPointonPolicy:falsePositiveTolerance,falseNegativeTolerance,threshold,calibrationBasis.aegis:reversibilityWindowSecondsandaegis:onTimeout— the lattersh:in ("deny"), one value only, and re-checked by value inplacement.rsbecause the shape alone does not bind on every write path. Required on escalation-class policies.aegis:latencyBudgetMsonPolicy."throttle"added to theaegis:effectenum, plusaegis:backoffFormula.aegis:sourceType,sh:in ("regulatory" "contractual" "ethical" "operational"), andaegis:authorityrequired 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_QUERYgains?constraintClass ?verificationPoint ?latencyBudgetMs ?fpTolerance ?fnTolerance ?reversibilityWindowSecondsas 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 failureproject_queries.rsalready documents in its own comments.project::ProjectedPolicygains those fields alongsideeffect;decode_policiesreads them through the existingrequired/optionalclosures. An unrecognisedconstraintClassis anError::Projection, matching how an unknownmatchTypeis handled — never a silent drop.rules::Rulegainsclassandverification_point, so a locally-configured[[yupana.policy.rules]]and a projected policy stay one type.hook/rule_planes.rs::governed_checkselects its response by declared class (hard ⇒ block underEnforce; soft ⇒ never block; escalation ⇒ route) rather than byproject::effect_blocksalone.Mode::Advisekeeps 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
softpolicy never blocks, even witheffect "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:DatatypePropertyora 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:
- Yupana knows what it is. A rule evaluated by
yupana hook pre-editis hosted at the orchestration layer, always — that is what the hook is. This is a constant inrule_planes.rs, not a configurable. - On projection, compare each policy's declared
hostedAtLayerto the layer that will actually evaluate it. A policy declaringtoolorpolicywhile yupana is its only evaluator is a mismatch. - 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.
- Record the layer actually used on the verdict and the trace record
(
hosted_atonConstraintEvaluation), 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.
-
Call
verdict::promote_verdictfrom the pre-edit decision path. Every fired constraint emits one signed, evidence-hash-boundaegis:Verdict. -
Carry the projection's real freshness into
aegis:freshness.rule_verdict_messagealready threads it into the model-facing text;verdict_turtlecurrently hardcodes"fresh", which is precisely the silent-fresh-tag the tier discipline exists to prevent. -
Batch and buffer. Promotion must not sit on the edit's critical path —
deadline_msis 100 ms and a/knotround-trip is not. Spool locally with the existing fail-silent discipline; drain from the resident daemon. -
Persist quipu's write-gate verdict too (
Q-VERDICT-PERSIST), so the policy-layer monitor is as auditable as the orchestration-layer one. -
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.mdphase 1 with [SARC] §3.6'sE_iand §9.6'sα_inamed 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_generatecreates 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 verifieris 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.
unknownspools 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:OperatorGroupshape: 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
DecisionRequestto 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'sgoto 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 < τ_revis 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 sameO(ℓ_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:
- 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.
- 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_blocksas an FP rate would be the number-shaped fiction the operating point exists to replace. - 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::intersectis 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(), notany(). "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:
PfromYUPANA_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.plannerfromYUPANA_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.executorfrom$SHANTY_AGENT— the identity of the process that actually ran, which is ground truth rather than a claim.toolfrom the hook payload.C_evalis theconstraintsarray 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 persistencesrc/governance/placement.rs(new) — class↔placement conformance passsrc/governance/router.rs(new) — escalation router (Phase 4)src/governance/audit.rs(new) — theT ⊨ Σchecker (Phase 5)src/signing.rs— reused unchanged; it is already the root of trustdocs/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 fieldssrc/rules.rs—Rulegains class + verification pointsrc/hook/rule_planes.rs— response selection by declared classsrc/hook/pre_edit.rs— verdict emission on decisionsrc/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 correctsrc/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 perwork-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_checkagainst 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 theQ-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/CodeSymbolwithdefinedIn/calls/importsin thebobbin:ontology (FR-19/FR-20,yupana export). - Quipu's
shapes/code-entities.ttldefines the sameCodeModule/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--> CodeSymbol | doc-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
quipufeature. 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-edithook can afford a best-effort synchronous Quipu query. The blockingpre-editguard 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-taggedcommitted.- Folded into
yupana impact— the impact response gains agoverned_relationssection for the impacted set. - Folded into the
post-edithook — 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 turtleemits the code side (CodeModule/CodeSymbol+definedIn/calls/imports) and, viasrc/docref.rs, the doc→codeSection --references--> CodeSymboledges. - This design adds: the read direction — Yupana querying Quipu by shared IRI
to enrich its live output, tier-tagged
committed, behind thequipufeature, exposed asyupana_relationsand folded intoimpact/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:
| Tool | One job |
|---|---|
| Yupana | Record 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). |
| Quipu | Own 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). |
| Shantytown | Subscribe 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:Policywithtargets+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) ingovernance.ttl, so arequire-approval/escalateeffect 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
QuipuEventssubscriber 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"
- A
ui-demoaegis:Policyin Quiputargetsthesrc/ui/**module type andassignsWorkflowtheui-demo-requiredaegis:Workflow. - An agent edits
src/ui/widget.rs. Yupana promotes the entity change; the agent gets the in-looppost-editadvisory. - Shantytown's subscription sees the change, asks
/policy/check, learns a workflow is required, and routes it to the administrator. - 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) andtool_policy_check. - This design adds:
aegis:assignsWorkflowand the transactions cursor in Quipu; theQuipuEventssubscriber and admin routing in Shantytown; the optionalrequired_workflowfield on thepre-editverdict 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 rawcargo. - Run
just checkandjust testbefore 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
tierandfreshness. - 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.