Building the Governed Write Path
Part 1 set the case: a procurement team with 8,000 supplier disclosures a quarter and six analysts, a knowledge graph that is the system of record for supplier risk, and an LLM agent asked to turn the first into updates to the second. We watched the naive one-hop pipeline read Acme Speciality Chemicals’ Q3 disclosure and draft five claims, four of them wrong, and commit all five silently.
This post builds the thing that stops that. Same document, same five candidate facts, component by component through the architecture, with the code that makes each one real. At the end both pipelines run side by side so the difference is visible rather than asserted.
The stack, and where the trust boundary sits
Concrete choices first, because “knowledge graph” is not a deployable artifact.
pgvector. Used for evidence retrieval only, never as a source of truth.
Tool server
A Python MCP server via the official SDK's FastMCP. Any MCP-speaking agent framework can then use it unchanged.
Now the part that actually does the enforcing, and it is not code:
/query for reads and /update for writes. The MCP server holds the credentials for /update. The agent process holds none, and its network egress cannot reach that endpoint at all. This is what makes "writes narrow" an enforced property rather than a request. If the only thing standing between your model and your database is a tool description asking it politely, you have written documentation, not a control.
Everything below assumes that boundary. The gate is only a gate if there is no path around it.
The evidence layer: where RAG actually fits
The most common question about this architecture is where retrieval belongs. The answer is narrower than people expect.
Retrieval is not the governance mechanism. It is how the agent gathers the evidence it will be held to. Two retrieval jobs run before any drafting happens:
query_graph: what do we already know about this supplier, its relationships, its current ratings?Retrieval feeds drafting. It has no authority over what gets committed.
That second job is the one teams skip, and it is why so many extraction agents produce duplicates. An agent that does not first ask “what does the graph already say about Acme’s risk rating?” cannot know it is about to assert a second one. The read path is generous precisely so the write path can be narrow.
The mechanics of building that retrieval layer, chunk-to-entity joins, hybrid dense-plus-sparse search, and time-aware filtering, are a subject of their own, covered in Giving RAG a sense of time. The entity-linking side, resolving “Acme Speciality Chemicals BV” to proc:Supplier_Acme rather than creating a duplicate node, is in the entity linking flywheel. This post takes both as given and starts where the agent has a draft in hand.
The evidence contract
One design decision here does a surprising amount of work later. Every proposal must carry a verbatim span from the source document, not a summary of it:
@dataclass(frozen=True)
class Evidence:
document_id: str # "ASC-2026-Q3"
locator: str # "§4.1" - section, page, or char offset
quote: str # MUST appear verbatim in the source
Because the quote must be verbatim, you can verify it mechanically, with no model in the loop:
def evidence_is_grounded(evidence: Evidence, corpus: DocumentStore) -> bool:
"""A quote either appears in the source document or it does not.
This is a string containment check, not a judgement call. It costs
microseconds and it is the cheapest anti-hallucination control in
the entire system.
"""
source = corpus.raw_text(evidence.document_id)
return normalise(evidence.quote) in normalise(source)
Remember claim 5 from Part 1, the fabricated “conflict minerals compliant” assertion that no SHACL shape could catch because it was structurally perfect. It fails here. The document never mentions conflict minerals, so there is no span to quote, and any quote the model invents fails containment. A claim inferred from silence cannot produce evidence, and this check is what turns that observation into a control.
The boundary: two tools, one asymmetry
Two tools. The asymmetry between them is the entire design.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("governed-graph")
@mcp.tool()
def query_graph(sparql: str) -> list[dict]:
"""Run a read-only SPARQL SELECT against the supplier graph.
Explore freely: relationships, history, current ratings, provenance.
"""
reject_if_not_select(sparql) # parse, don't regex
return read_endpoint.select(sparql, timeout_s=10)
Two details in that short function matter more than they look.
Parse, do not pattern-match. A reject_if_not_select that greps for the string INSERT is defeated by comments, casing, and encoding. Parse the query with rdflib.plugins.sparql.prepareQuery and check the resulting algebra’s type. The agent controls this string; treat it as hostile input, because that is exactly what it is when a prompt injection lands in one of your 8,000 documents.
Scope the read. In a real deployment read_endpoint applies tenant and ACL filters before the agent’s query ever runs. “Reads generous” means generous within the agent’s authority, not unrestricted.
Now the write tool. Note the signature: it takes far more than a triple, and every extra argument is there to make a downstream decision possible.
@mcp.tool()
def propose_fact(
subject: str,
predicate: str,
obj: str,
evidence: Evidence,
confidence: float,
) -> ProposalResult:
"""Propose a fact. It becomes graph truth only if it survives the gate.
This tool does not write. It proposes. Three independent checks decide
what happens next, and the caller is told which one it failed.
"""
# ① Is the claim grounded in a real span of a real document?
if not evidence_is_grounded(evidence, corpus):
return ProposalResult(
status="rejected",
reason="ungrounded_evidence",
detail=f"No verbatim match for quote in {evidence.document_id}.",
)
# ② Is the assertion structurally legal, given the rest of the graph?
report = validate_candidate(subject, predicate, obj)
if not report.conforms:
return ProposalResult(
status="rejected",
reason="shape_violation",
detail=explain(report), # the agent can act on this
)
# ③ Is it sufficiently supported to land unattended?
if confidence < AUTO_COMMIT_THRESHOLD:
queue.enqueue(subject, predicate, obj, evidence, confidence)
return ProposalResult(status="queued_for_review")
assertion_id = commit_with_provenance(
subject, predicate, obj, evidence, confidence,
)
return ProposalResult(status="committed", assertion_id=assertion_id)
Three checks, three different questions, four possible fates:
The independence matters. A system with only the shape check catches hallucinated entities and waves unfounded claims straight through; a system with only confidence scoring trusts a number the model produced about its own output. Each check also fails distinctly, which is what makes the metrics at the end of this post meaningful.
The gate: validating a candidate that isn’t in the graph yet
This is the genuinely tricky part of the implementation, and it is where naive versions break.
To know whether proc:SR-4417 proc:suppliesPlant proc:Plant_Rotterdam is valid, SHACL needs the candidate triple and enough of the live graph to judge it: the object’s type (for sh:class), and every existing value of that property on that subject (for sh:maxCount). But you cannot commit the triple first and validate afterwards, because then invalid data has been in your system of record, however briefly, and concurrent readers may have seen it.
The obvious fix is to copy the graph, add the candidate, and validate the copy. That works on a toy graph and is unusable on a real one; you are not snapshotting forty million triples per proposal.
The workable approach is to build a validation context: the smallest subgraph that can change the verdict.
CONTEXT_QUERY = """
CONSTRUCT {
?s ?p ?o . # everything already asserted about the subject
?target a ?type . # the type of the proposed object
}
WHERE {
{ ?s ?p ?o . }
UNION
{ ?target a ?type . }
}
"""
def validate_candidate(subject: str, predicate: str, obj: str) -> Report:
"""Validate the candidate against just enough of the live graph.
Cardinality shapes need the subject's existing triples; sh:class shapes
need the object's type. Nothing else in the graph can change the verdict,
so nothing else gets copied.
"""
context = Graph()
context += read_endpoint.construct(
CONTEXT_QUERY,
# Bound as terms, never interpolated: the agent controls these IRIs.
init_bindings={"s": URIRef(subject), "target": URIRef(obj)},
)
context.add((URIRef(subject), URIRef(predicate), coerce_term(obj)))
conforms, results_graph, text = pyshacl.validate(
context,
shacl_graph=SHAPES, # loaded from the versioned policy repo
ont_graph=ONTOLOGY, # class hierarchy, for sh:class subsumption
advanced=True, # enable sh:sparql constraints
)
return Report(conforms, results_graph, text)
subject and obj values originate from a language model that just read an untrusted document. Interpolating them into a query template is SPARQL injection with extra steps. Bind them as terms.
Two honest caveats about this approach.
The context query has to track your shapes. If you add a shape whose verdict depends on something outside the subject and object, a sh:sparql rule spanning three hops, for instance, the context above is no longer sufficient and will produce false conformance. The safeguard is to derive the context from the shapes rather than hand-writing it, or to widen it and accept the cost. If your store has server-side SHACL, this problem goes away: validate inside a transaction and roll back on failure.
There is a race. Between building the context and committing, another writer can change the subject. Under concurrency you need the store’s transaction support or an optimistic-locking retry keyed on the subject. Worth knowing before you find out in production.
Making the gate a teacher
A bare "rejected" teaches the model nothing and burns a retry. A SHACL validation report is a structured RDF graph, and the useful move is to render it as instruction:
def explain(report: Report) -> list[dict]:
"""Turn a SHACL results graph into feedback an agent can act on."""
return [
{
"path": str(r.path), # proc:riskRating
"constraint": local_name(r.component), # InConstraintComponent
"offending_value": str(r.value), # proc:Catastrophic
"message": str(r.message), # from sh:message
}
for r in report.violations()
]
Which is why shapes should carry sh:message. Policy that explains itself costs one line and saves a retry:
proc:SupplierShape a sh:NodeShape ;
sh:targetClass proc:Supplier ;
sh:property [
sh:path proc:riskRating ;
sh:in ( proc:Low proc:Medium proc:High ) ;
sh:maxCount 1 ;
sh:severity sh:Violation ;
sh:message "riskRating must be exactly one of proc:Low, proc:Medium, proc:High. Map qualitative language to the nearest permitted level; do not invent new levels." ;
] .
Now the rejection the agent receives is actionable:
{
"status": "rejected",
"reason": "shape_violation",
"detail": [{
"path": "proc:riskRating",
"constraint": "InConstraintComponent",
"offending_value": "proc:Catastrophic",
"message": "riskRating must be exactly one of proc:Low, proc:Medium, proc:High. Map qualitative language to the nearest permitted level; do not invent new levels."
}]
}
The agent retries with proc:High and the proposal lands. In practice most shape rejections resolve in a single governed retry, which is the difference between a gate that improves throughput and a gate everyone routes around. Severity levels give you a second dimension here, sh:Warning for advisory constraints that annotate rather than block; the full treatment is in Continuous Semantic Validation.
The ledger: provenance, so mistakes are survivable
A committed fact needs a receipt. The clean way to attach one in RDF is to put each assertion in its own named graph and describe that graph with PROV-O, the W3C vocabulary for describing how things came to be.
A named graph is simply a labelled subset of the store. That label is the trick: it gives you something to hang statements about those triples on, which plain RDF otherwise makes awkward, since a triple has no identity of its own to point at.
# The assertion itself, isolated in its own graph.
GRAPH proc:assertion_7f3a91 {
proc:CERT-8871 proc:validUntil "2026-06-30"^^xsd:date .
}
# Everything we know about how that assertion came to exist.
proc:assertion_7f3a91
a prov:Entity ;
prov:wasGeneratedBy proc:activity_7f3a91 ;
prov:wasDerivedFrom proc:doc_ASC-2026-Q3 ;
proc:confidence 0.94 ;
proc:evidenceLocator "§4.1" ;
proc:evidenceQuote "Our ISO 14001 certificate (reg. 14001-NL-8871) lapsed on 30 June 2026." .
proc:activity_7f3a91
a prov:Activity ;
prov:startedAtTime "2026-07-02T09:14:22Z"^^xsd:dateTime ;
prov:wasAssociatedWith proc:agent_kgwriter_1_4 ;
proc:shapesVersion "proc-shapes-3.2.0" . # which policy approved it
proc:agent_kgwriter_1_4
a prov:SoftwareAgent ;
proc:baseModel "vendor-model-4.6" ; # pinned, as reported
proc:promptVersion "extract-supplier-risk-v7" .
Note what is recorded beyond the usual timestamp: the shapes version that approved the write, and the prompt version that produced it. Both change independently of the model, and when quality moves you will want to know which of the three it was.
The bad Tuesday
This is what the provenance is for. A model upgrade ships on a Tuesday and by Thursday someone notices risk ratings drifting upward across the portfolio. The question is not “is the new model worse?” It is “which facts do we now distrust?”, and it is a query:
PREFIX prov: <http://www.w3.org/ns/prov#>
PREFIX proc: <https://w3id.org/proc/>
SELECT ?assertion ?s ?p ?o ?confidence WHERE {
?assertion prov:wasGeneratedBy ?activity ;
proc:confidence ?confidence .
?activity prov:wasAssociatedWith ?agent ;
prov:startedAtTime ?t .
?agent proc:baseModel "vendor-model-4.7" .
FILTER (?t >= "2026-07-14T00:00:00Z"^^xsd:dateTime)
GRAPH ?assertion { ?s ?p ?o }
}
Scoped, complete, and answerable in seconds. Without provenance, the equivalent question is an archaeology project across the whole graph whose honest answer is “we don’t know.”
DROP GRAPH. Resist it. Deleting a bad assertion also deletes the record that you ever made it, which is precisely the thing an auditor will ask about. Mark it revoked, with who revoked it and why, and filter revoked assertions out at read time. The audit trail must survive the correction, otherwise your provenance layer has a hole exactly where the interesting events are.
The router and the queue
The threshold is not a model parameter. It is a statement about what a wrong fact costs and how much curation capacity exists, and it belongs in the same review process as the shapes:
AUTO_COMMIT_THRESHOLD = 0.90 # compliance context: wrong facts are expensive
REVIEW_FLOOR = 0.55 # below this, discard rather than queue
That second constant matters as much as the first. Without a floor, every low-quality guess becomes a queue item, the backlog grows without bound, and the reviewers stop trusting the queue. A queue nobody works is worse than no queue, because it looks like a control while functioning as a landfill.
And the queue is not overhead, it is the flywheel. Every human decision, approve, correct, reject, is labelled data of the scarcest kind: hard cases from your own domain, adjudicated by people who know the answer. Fold them back three ways:
proc:High in your policy rather than in general English.
Regression tests
Every rejection becomes a test case for the shapes. If a future shape edit would let it through, CI fails. This is the SHACL test pyramid pointed at agent output.
Evaluation sets
Adjudicated hard cases are what you evaluate the next model against before promoting it, which is what turns "the upgrade seems fine" into a number.
Both pipelines, same document
Here is the Acme Q3 disclosure through each path.
Claim 5 is the one that justifies the whole three-check design. It is the only one SHACL alone would have let through: right subject, right predicate, right datatype, structurally flawless. Evidence grounding is what caught it. A system built with only a shape gate would have committed a fabricated compliance clearance with a completely straight face, and that is the assertion in this document most likely to end up in front of a regulator.
And the sign-off
The procurement manager asks the question from Part 1. The naive system answers in fluent prose citing a chunk, gives a different answer next week, and offers no way to tell that four of the five facts underneath it are wrong.
The governed system answers:
Acme Speciality Chemicals · NOT CLEARED for Q4 build Blocking: ISO 14001 certification lapsed 2026-06-30. Source: ASC-2026-Q3 §4.1 · asserted 2026-07-02 by
kg-writer-agent v1.4· confidence 0.94 · shapesproc-shapes v3.21 item awaiting review · [conflict-minerals status, unevidenced]
One blocking fact, traceable to a section of a named document, reproducible tomorrow, with the open question shown as open rather than silently resolved. That last line is doing quiet work: the manager can see the boundary of what the system knows, which is the difference between a tool that supports a signature and one that merely sounds confident.
The gate is also your instrument panel
Because every proposal crosses one boundary, that boundary is where the system becomes observable. Five numbers worth a dashboard:
sh:message text has stopped being useful, or the policy changed and nobody told the prompt.
Evidence grounding failures
The direct hallucination counter. Unlike the others, this one has no benign explanation.
Auto-commit rate
Drifting up quietly means your threshold is no longer calibrated to the model currently behind it.
Queue depth and age
Capacity, not quality. Growing without bound means the thresholds are writing checks the team cannot cash.
What this does not solve
Four limits worth stating plainly, because a governance architecture that oversells itself is its own risk.
A well-evidenced false fact still lands. If the disclosure itself is wrong, or the model quotes a real sentence and draws the wrong conclusion from it, every check passes. This system governs the path from document to graph. It does not verify the document.
Shapes encode the policy you thought to write. Nothing catches a category of error nobody anticipated. Shapes need the same review discipline as the ontology, which is why they live in git and get versioned like an API.
Curation capacity is the real ceiling. Thresholds only move work between “committed” and “queued.” If the queue outgrows the reviewers, tuning the threshold does not create capacity, it just changes which failure you get.
The gate costs latency and money. Every proposal is a context query, a validation, and possibly a retry. That is a real per-document cost, and it is the price of the write being defensible. In compliance the trade is easy. Not every domain is compliance.
The shape of it
Three sentences carry the architecture, and they are worth more than the code:
The pattern generalises past supplier risk to anything where an LLM writes into a store that other systems trust: clinical intake, KYC onboarding, incident records, master data. What changes is the ontology and the shapes. The three checks, the enforced boundary, and the receipt on every fact stay the same.
A minimal open wireframe of this, one MCP server, query_graph plus propose_fact, pySHACL at the gate, is at SHACL-Gated Agent Writes. The scope is deliberately small, because the point is the shape of the architecture, not the size of it.
Part 1 of this series, When an AI Agent Writes to Your System of Record, sets up the case study and argues why prompting and structured outputs cannot close this gap. The retrieval half of the same pattern is GraphRAG with temporal knowledge graphs; the construction half is the entity-linking flywheel; the ontology and shapes underneath come from the Agile Ontology Engineering series.