Governance: Policies, Verdicts & the Write Gate
Implementation status (2026-08-12): ✅ Built — the Phase-A write gate (
src/governance/guard.rs, wired throughstage_and_guardinsrc/store/ops.rs), the seven governance/overlay MCP tools (src/mcp/governance.rs), v1 verdict signing (src/signing.rs,src/governance/verdict_facts.rs), authority intersection, and the audit/replay machinery. ⬜ Not built: the hank-side structural-policy projection (Phase B),boundary:"transition"enforcement, the workflow half ofrequire-approval, and everything from §5 of the signing-plane design (shared signing crate, bitemporal key registry, task signing).
Quipu carries a declarative governance vocabulary and an engine that binds it to the write path. A policy is a fact in the graph like any other; whether it enforces is a runtime decision, and every enforced decision leaves a signed, auditable verdict behind.
The vocabulary
An aegis:Policy (shapes/governance.ttl) names four things: what it governs,
what compliance means, where it binds, and what happens on failure.
@prefix aegis: <http://aegis.gastown.local/ontology/> .
aegis:todo-needs-ticket a aegis:Policy ;
aegis:targets "aegis:CodeComment" ;
aegis:claim "ASK { $target aegis:citesTicket ?t }" ;
aegis:boundary "action" ;
aegis:effect "deny" .
targets— the entity type the policy applies to.claim— a SPARQL ASK stating the compliant condition;$targetis bound to the entity under evaluation. Satisfied = good.boundary—action(pre-edit/pre-write) ortransition(workflow step; declared but not yet enforced).effect—allow | warn | require-approval | deny | escalate | record.appliesTo(optional) — repo-relative path globs scoping where an action-boundary policy binds. Genuinely multi-valued: a policy scoped to three globs carries three values, and a consumer accumulates them rather than keeping whichever arrived first. Absent means unscoped. Declared withrdfs:rangeonly — nordfs:domain, so the same term reads identically on a futureTextRuleorDirective.
An optional aegis:evidenceProbe (another ASK: “does evidence exist yet?”)
lets the evaluator distinguish unknown from unsatisfied — no evidence is a
different fact from failing evidence, and neither is collapsed into the other.
Tripwires: path-boundary policies
A policy carrying aegis:appliesTo and no selector or predicate is a
tripwire: touching the path is the crossing, so the claim needs no
evidence beyond the action’s own target. shapes/policies/tripwire.ttl ships
the catalog — the governed twin of yupana’s local
[[yupana.policy.tripwires]], with quipu as the canonical store and yupana
holding only a projected cache. Placement follows SARC Table 3, not
convenience: the deny wire is hard at the PAG (admissibility is decided
before dispatch — the edit must never land), and the throttle wire is
soft at the PAA with a declared aegis:backoffFormula (it prices a
completed crossing and backs off the actions after it, never the crossing
itself). That formula is not optional decoration: under validate_placement
the write gate refuses any policy declaring effect "throttle" without an
aegis:backoffFormula — a throttle with no backoff is a response nobody can
compile, so the consumer records the crossing and applies no throttle, an
armed-looking wire that prices nothing. There is no soft-at-the-gate wire — a soft constraint has nothing to
price before the action lands. Re-scoping a wire is amending the policy:
the write gate treats an appliesTo write as governance-defining and
invalidates the cached policy registry.
The write-path gate
The single write choke point is Store::transact_to_graph. Inside the open
savepoint — datums staged, nothing committed — stage_and_guard
(src/store/ops.rs) hands the pending post-state to the policy guard
(PolicyRegistry::build + evaluate_write, src/governance/guard.rs). On a
blocking verdict the savepoint rolls back and the write never lands; the store
is byte-identical to before the call.
The registry is built once and cached: every active boundary:"action" policy,
indexed by target type. A write’s touched entities are intersected with the
governed-type set first, so a write touching no governed type runs zero
ASKs; the cache invalidates itself when a transaction writes a
governance-defining fact. Effects split into blocking — deny,
require-approval, escalate block when the claim is unsatisfied (a gate with
no approval channel fails closed rather than passing silently; escalate
additionally mints an aegis:DecisionRequest for the escalation router) — and
advisory — allow, warn, record never block.
Enforcement is opt-in:
[quipu.governance]
enforce_on_write = false # the default
Default off: existing deployments are unchanged, and turning the gate on
is a deliberate configuration act, not a side effect of upgrading. validate_placement (definition-time class↔placement
conformance) and enforce_authority (below) are separate flags with the same
opt-in posture.
On-demand evaluation: quipu_policy_check
The read-only half. Given a policy IRI (or an inline claim) and a target,
it evaluates the claim over the committed graph and returns a Verdict:
{
"predicate_id": "aegis:todo-needs-ticket",
"target_ref": "ex:comment-42",
"outcome": "unsatisfied",
"evidence_hash": "fnv1a:9c2a41d07be3f118",
"tier": "committed",
"verifier": "quipu",
"verifier_authorized": true,
"signed": true,
"signature": "…hex…"
}
outcome is satisfied | unsatisfied | unknown, bound to a reproducible
evidence_hash over (predicate, target, valid_at, bound claim) — any
verifier re-runs the same ASK over the same committed evidence and must get the
same verdict. Checked, not trusted. valid_at/tx make the evaluation as-of.
Verdict signing
Verdicts are attestations, not claims. If the store holds a signing identity
(ed25519 via ring, host-file key custody — explicitly v1), it signs the
canonical message v1|predicate|target|outcome|evidenceHash|tier|verifier
(src/signing.rs). No signing identity means no persisted verdict, never an
unsigned one — a bare satisfied fact is forgeable by anyone who can write.
Write-gate verdicts are persisted as bitemporal aegis:Verdict facts
(src/governance/verdict_facts.rs), staged during evaluation and flushed after
the savepoint resolves — so a denied write still records its verdict, and
unknown is recorded rather than skipped. Their evidence hash seals
attribution: sha256 over predicate|target|outcome|writer|chain, binding the
aegis:attributedWriter and aegis:principalChain into the signed seal. It is
deliberately not a hash of graph state, which has no stable serialisation.
Linking a verdict to the judged transaction
The shipped governance shape permits an optional aegis:gatedTx: one
xsd:integer greater than or equal to one. It identifies the committed
transaction whose delta was judged, not the later transaction that persists
the verdict. Transaction IDs are local to their originating store; cross-store
audits must retain that store’s provenance.
Refused or rolled-back attempts omit gatedTx. Their attempted transaction row
is rolled back, so its provisional ID is not a durable identity and must never
be predicted or reused as the link. Use a durable write.refused event identity
for a refusal correlation when the producer provides one. This shape does not
introduce that event link. Historical verdicts may also omit gatedTx; absence
means the transaction join is unavailable, not that the verdict was a refusal.
Do not guess a backfill from timestamps or matching predicate/target pairs.
This is the schema contract, not automatic producer support. A producer adding
gatedTx must bind it into the signed evidence and verdict identity: changing
the judged transaction must change the seal and identity. Otherwise identical
outcomes can collapse to one verdict with several transaction IDs, and the link
itself is not authenticated. SHACL checks cardinality and datatype; it does not
prove that the referenced transaction exists or that the signature binds it.
Replaying a refusal: the denial quarantine
A denied write is rolled back, so its verdict survives and the evidence
behind it does not. From the governed graph alone a refusal can only be
attested — the rules it cites were in force at the instant — never
re-derived, because the post-state the gate judged is gone. The denial
quarantine (src/governance/quarantine.rs) keeps that evidence outside
the governed graph: two plain tables, not facts, that no SPARQL pattern,
search, or fact read can reach. Refused content still never enters the
graph.
For each verdict of a refused write, the gate records — while its savepoint still holds the post-state — a hash of the attempted delta (the writer’s datums, canonical, IRIs rather than term ids), a digest of the post-state it evaluated, a digest of the rule set in force, and the transaction the pre-state ended at. The entry is keyed by the verdict and sealed with the store’s signing key. That is the default, digest-only: nothing refused is retained. A graph opted into full retention also keeps the attempted delta itself, sealed.
[quipu.governance.quarantine]
enabled = true # the default
retain_full = ["urn:quipu:graph:root"] # or ["*"]; default []
quipu audit replay <verdict> rebuilds the store as of the refusal in a
throwaway in-memory copy, applies the attempt — the sealed one, or one
presented with --delta and checked against the digest first — and runs
the real write path with the gate on. A refusal re-derives when the
write is refused again with the same outcome, under the same rule-set
digest, over the same post-state digest. A later rule change does not move
the answer: the replay reads the rules as of the refusal, not today’s. A
verdict whose write committed replays from that write’s transaction.
quipu audit quarantine purge erases sealed deltas by graph or by age and
keeps each entry’s digests and seal, so the audit can still say “refused,
content purged” and can still verify a delta someone presents. Full packs
carry the digests (they back verdicts that travel) and never the sealed
deltas.
Limits, stated. The post-state digest is over this store’s term ids, so a respaced copy replays its refusals as “post-state differs”. A refusal inside a multi-graph batch judged a post-state that held the batch’s earlier graphs, which no entry carries; its replay reports the divergence. The digest costs one scan of the live facts per denial (never per write) — about 2.4 s per million facts in a debug build.
The Phase-0 root of trust
Trust concentrates in a small, human-owned surface: aegis:VerifierRegistration
facts carry each verifier’s name, hex aegis:publicKey, and the predicates it
aegis:attests. A human registers a verifier; quipu never self-registers.
quipu_verifier_authorized— may this verifier attest this predicate?quipu_verdict_verify— verify a signed verdict’s fields against the registry. Returnssignature_valid,verifier_registered,verifier_authorized, andtrusted=signature_validANDverifier_authorized— the one property a consumer should gate on.
Verification is currently latest-only; the bitemporal key registry (rotation with as-of re-verification) is designed but not built.
Transition signatures at the write gate
Shuttle signs every workflow transition with the performing agent’s ed25519
key over the canonical message
shuttle-transition-v1|{run}|{step}|{from}|{to}|{at}|{agent} and exports the
signature as an ordinary aegis:signature fact on the
aegis:TransitionEvent — re-derivable from the exported facts alone, so
consumers can re-check it (shuttle verify). Under
[quipu.governance] verify_transitions (default off), quipu re-checks it
at the write gate (src/governance/transition.rs): a write landing a
TransitionEvent that is unsigned, signed by an agent with no
aegis:VerifierRegistration, or whose signature does not verify over the
staged fields under any of the agent’s registered keys is refused before it
commits — forged and tampered transitions become unwritable rather than
merely detectable. The registry read spans every graph (shuttle’s convention
keeps registrations in a never-frozen dataKind=identity named graph);
protecting the registry itself from unauthorized writes is enforce_authority’s
job, exactly as for signed decisions.
Authority over graphs
aegis:Principal facts hold aegis:authorityOver graph IRIs (or *). A call
chain’s effective authority is the intersection of every link’s, so
delegation only narrows and an empty intersection refuses
(src/governance/authority.rs). Gated by enforce_authority (default off) and
inert for callers that present no principal chain. This gates writes only —
it is not a read-side confidentiality boundary.
The authority reader accepts both legacy and Quechua vocabulary spellings for
Principal, principalId, and authorityOver, including mixed spellings.
Identical aliases collapse; conflicting identity values grant no authority.
Graph grants remain set-valued, and the reader retains its committed root-graph
scope. This compatibility does not change principal identities or grant writers.
Audit & replay
The rest of src/governance/ closes the loop after the fact:
quipu_audit_check (audit.rs) mechanically checks a recorded trace against
the policy spec — coverage, class↔placement, outcome consistency, attribution —
deterministically, never an LLM call. denial_replay.rs re-derives one recorded
gate decision as of its transaction (see the quarantine above). replay.rs measures whether an advisory
rule is ready for promotion to enforcement (liveness, both outcomes,
recoverability). router.rs queues require-approval escalations as
DecisionRequests with expiry (only an approval permits; a rejection outranks
an approval); tree.rs, inventory.rs, and inheritance.rs reconstruct
attribution, check dispatch-graph coverage, and detect constraint laundering
under delegation.
See also
- MCP tools reference — the seven governance and overlay tools.
- REST API reference — the mirrored HTTP routes.
docs/design/policy-edit-hooks.md— the write-gate design and its backlog.docs/design/signing-plane.md— where signing goes next (proposed).
Post-commit reactions and firing records
shapes/aegis-ontology.shapes.ttl defines aegis:Reaction and
aegis:ReactionFiring for Chaski. These describe post-commit or scheduled
reactions. Loading the schema does not start an evaluator, install a graph
sink, or deliver an alert.
A reaction has one rdfs:label, triggerKind (event or schedule),
condition (SPARQL SELECT ?focus), severity (info, warning, critical
or page), and string owner. It requests one or more action values:
alert, page, bead, or webhook. The existing string properties
severity, owner, and schedule are reused.
Event reactions use a comma-separated eventTypes string; scheduled reactions
use an ISO-8601 duration string in schedule, such as PT5M. Chaski’s current
consumer supports time durations expressed with hours, minutes and seconds.
enabled is an optional boolean; Chaski treats omission as enabled. The graph
does not assert that default. A single rdfs:comment can explain the rule.
SHACL checks the declared field types, cardinalities and enums. The consumer
must also validate the trigger-specific configuration and query: the shape
alone does not parse SPARQL or duration strings.
A firing records a transition, never each evaluation. It has exactly one
firedBy link to a typed Reaction, one IRI focus, and one xsd:dateTime
startedAt. Its stable IRI identifies the machine-emitted record; it does
not require an rdfs:label. The rule and its durable firing record are BFO
continuants, separate from the execution that produced the record.
resolvedAt is absent while active and set once via /set when
the condition resolves. A failed or timed-out query is unknown and must not
resolve an existing firing. A later firing after resolution is a new
occurrence. SHACL does not enforce these temporal rules or timestamp ordering.
Stable delivery identity
ReactionFiring optionally carries one string eventId. When present, derive
the firing IRI deterministically from (reaction label, eventId) using an
unambiguous encoding, for example a hash of a JSON array containing both
strings. The event ID identifies an item and transition; including the stable
reaction label separates reactions receiving the same event. If an adapter’s
event ID does not uniquely identify its focus, it must include that focus in
the identity input too. Do not substitute a send-time random ID.
For a Chaski outbox event, take startedAt from the frozen payload’s
observed_at, never the retry time. Persist the graph-sink payload before its
first send and reuse it unchanged after a crash. Stable identity alone does
not provide idempotence when timestamps or descriptions change between
attempts. Verify receiver state after an uncertain write; an unchanged
response alone is not a presence check. Test send-then-crash replay at the sink.
This schema does not itself implement an idempotent receiver or guarantee
exactly-once external actions.
For an adapter that evaluates whether work has become unblocked, condition
selects candidate WorkItems with aegis:blockedOn; the adapter decides their
verdict. State that interpretation in the reaction’s rdfs:comment. Ordinary
scheduled transition firings may omit eventId and retain a durable occurrence
identity for each transition of the (reaction, focus) pair.