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.