← All postsGoverned Agent Writes · Part 2

Building the Governed Write Path

Synergy of AI and Knowledge GraphsAI AgentsMCPGovernanceSHACLPROV-OLLMs

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.

Graph store Any SPARQL 1.1 endpoint. Apache Jena Fuseki for self-hosted, GraphDB or Stardog commercially, Amazon Neptune or Oxigraph if you prefer managed or embedded. The architecture cares that it speaks SPARQL and supports named graphs, not which one you pick. Validation pySHACL in-process, or the store's built-in SHACL engine (GraphDB and Stardog both have one) if you want validation to run server-side. Vector index Qdrant, Milvus, or plain 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:

🔒
The agent never holds write credentials A SPARQL store exposes two endpoints: /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:

Document retrieval
A disclosure can be 80 pages. Chunk, embed, and pull the sections that plausibly carry risk facts.
+
Graph retrieval
query_graph: what do we already know about this supplier, its relationships, its current ratings?
Draft
Candidate assertions, each carrying a verbatim quote and a locator.

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:

Proposal triple + evidence + confidence Grounded? verbatim quote found in the source document Legal? SHACL shapes, against the live graph Supported? confidence above the threshold Committed written with a provenance receipt yes yes yes no no no REJECTED ungrounded_evidence the claim has no source REJECTED shape_violation report → agent retries QUEUED a human decides and the decision is kept
Three independent questions in sequence. Only a proposal that answers yes to all three reaches the graph, and each failure produces a different, logged signal.

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)
⚠️
Never string-interpolate agent-supplied IRIs into SPARQL The 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.

🔁
Cap the retries Two attempts, then route to the curation queue. An agent that cannot satisfy a shape in two tries is not going to satisfy it in nine, and an uncapped repair loop is an unbounded bill attached to a document nobody is reading.

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.”

Revoke by asserting, not by deleting The instinct is 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:

Few-shot examples Corrected proposals become drafting examples, which is how the agent learns that "significant concerns" maps to 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.

ONE DOCUMENT · FIVE DRAFTED CLAIMS 1 · Certificate lapsed validUntil 2026-06-30 TRUE & MATERIAL 2 · Risk rating proc:Catastrophic INVENTED VALUE 3 · Second rating old Medium still live CONTRADICTION 4 · Supplies plant Plant_Rotterdam NO SUCH PLANT 5 · Conflict minerals declared free = true UNEVIDENCED WITHOUT THE GATE ALL FIVE COMMITTED four of them wrong · zero errors raised detection latency: unbounded WITH THE GATE the same five claims, through the same three checks 1 2 3 4 5 COMMITTED claim 1 grounded ✓ legal ✓ 0.94 + provenance receipt CORRECTED claim 2 sh:in violation → retry landed as proc:High BLOCKED claims 3 & 4 sh:maxCount · sh:class never reached the graph ESCALATED claim 5 no verbatim evidence waiting on a human 2 facts in the graph, both correct · 2 blocked · 1 escalated every outcome logged, attributable, and reproducible
The same document, the same model, the same five drafted claims. The only difference is what stands between the draft and the system of record.

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 · shapes proc-shapes v3.2 1 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:

Rejection rate by shape Which constraint the agent fights most. A spike after a prompt or model change is a regression signal that arrives before any bad data does. Retry resolution rate Share of rejections fixed within the retry cap. Falling means your 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:

Agents draft. Shapes decide. Provenance remembers. Neural fluency is not reliability. You do not fix it with a better prompt; you give the model a symbolic backbone it cannot argue past.

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.