Governance & Review¶
Full source:
examples/governance_and_review.py
This tutorial walks through why AI principals can't write directly, and the full review lifecycle a proposal goes through when it doesn't auto-accept.
sequenceDiagram
autonumber
actor Agent as scout (ai, propose)
participant KB as Ontology
participant Policy as PolicyStrategy (pure)
actor Reviewer as alice (human, review)
participant Store as StorageBackend
Agent->>KB: propose(subject, predicate, value, source, confidence, model)
KB->>Policy: evaluate(proposal, principal, read-only view)
alt AutoAccept
Policy-->>KB: AutoAccept
KB->>Store: apply via conflict routing, one transaction
KB-->>Agent: auto_accepted
else RequireReview (the default for every AI proposal)
Policy-->>KB: RequireReview(reviewers, reason)
KB-->>Agent: require_review
opt Reviewer wants a better source
Reviewer->>KB: request_changes(reason)
Agent->>KB: resubmit() replays the original payload
KB->>Policy: fresh evaluation
Policy-->>KB: RequireReview
end
Reviewer->>KB: accept_proposal() or reject_proposal(reason)
KB->>Store: on accept, apply via conflict routing, one transaction
KB-->>Agent: accepted or rejected
else Reject
Policy-->>KB: Reject(reason)
KB-->>Agent: rejected, nothing written
end
Note over KB,Store: Every review action is stored as a proposal_event and shows up in provenance.
Why AI proposals always require review¶
Ontolith's capability lattice (read < propose < write < review < admin)
caps AI principals at propose by default — they submit proposals, they
never write directly. But even a propose-capability principal's proposal
could auto-accept under a permissive policy. The default
ThresholdPolicy (ADR-0003) deliberately doesn't allow that for AI authors:
every AI-authored proposal requires human review, regardless of trust
level.
reviewer = kb.create_principal(
"reviewer@example.com", kind="human", default_capability="admin", trust_level=8
)
researcher_bot = kb.create_principal(
"researcher-bot", kind="ai", owner=reviewer.id,
default_capability="propose", trust_level=6,
)
A human's write auto-accepts:
proposal, decision = kb.propose(
subject=acme.id, predicate="Company.headquarters", value="Springfield",
value_type="Text", author=reviewer.id, source="internal records", confidence=1.0,
)
# decision is an AutoAccept; proposal.state == "auto_accepted"
The AI's proposal — even from a well-trusted principal — does not:
proposal, decision = kb.propose(
subject=acme.id, predicate="Company.headquarters", value="Metropolis",
value_type="Text", author=researcher_bot.id, source="a news article",
confidence=0.7, model="example-model-2026-01", # required for AI authors
)
# decision is a RequireReview; proposal.state == "require_review"
model (the AI model family+version) is required provenance for any
AI-authored assertion — SPEC §7.4.
The review lifecycle¶
A pending proposal can go three ways. This tutorial exercises all three:
# A reviewer isn't convinced by the source and asks for a better one.
proposal = kb.request_changes(
proposal.id, reviewer=reviewer.id,
reason="Need a primary source, not a news article.",
)
# proposal.state == "changes_requested"
# The author (or a delegate) resubmits. resubmit() replays the ORIGINAL
# payload through a FRESH policy evaluation -- it doesn't let the author
# quietly swap in a different value while resubmitting.
proposal, decision = kb.resubmit(proposal.id, author=researcher_bot.id)
# The reviewer accepts it this time.
proposal = kb.accept_proposal(proposal.id, reviewer=reviewer.id)
# proposal.state == "accepted"
The third path ends a proposal terminally with no assertion ever written —
useful when a proposal shouldn't just be revised, it should be refused
outright. It needs a fresh, still-pending proposal (the one above is
already accepted by now — reject_proposal() only works on a proposal
still awaiting review):
proposal, decision = kb.propose(
subject=acme.id, predicate="Company.name", value="Extremely Legitimate Business Inc",
value_type="Text", author=researcher_bot.id, source="an anonymous forum post",
confidence=0.2, model="example-model-2026-01",
)
proposal = kb.reject_proposal(proposal.id, reviewer=reviewer.id, reason="Not a credible source.")
# proposal.state == "rejected"
What happens to the assertion itself¶
Company.headquarters is a static property by default (temporality
isn't declared, so it defaults to static). The first assertion said
"Springfield"; the accepted AI proposal says "Metropolis" — a genuine
disagreement between sources on a fact that isn't supposed to change. SPEC
§10.3's routing kicks in: both values are flagged as a contradiction,
neither silently wins.
active = kb.assertions(subject=acme.id, predicate="Company.headquarters", status="active")
flagged = kb.assertions(subject=acme.id, predicate="Company.headquarters", status="flagged")
# active == []
# flagged == [<"Springfield">, <"Metropolis">]
kb.assertions() defaults to status="active" — flagged (disputed)
assertions are deliberately excluded from the default result set (SPEC
§10.3: "MUST be excluded from default retrieval unless explicitly
requested"). A review-or-above, non-AI principal resolves the dispute
explicitly with resolve_contradiction(); nothing here auto-resolves.
Who can resolve this specific contradiction
Not reviewer in this example, despite being admin capability —
reviewer authored one of the two contradicting values
("Springfield"), and a party to a contradiction can never resolve it
themselves, regardless of capability (KI-026). Resolving this one
needs a different, uninvolved review-or-above, non-AI principal.
If Company.headquarters had instead been declared temporality=
"time_varying" (see the Bitemporal Queries tutorial),
the second value would have superseded the first instead of
contradicting it — the whole point of declaring temporality up front is
telling Ontolith which of these two behaviors you want.