Sitemap
Lali Devamanthri

Lali Devamanthri

Building a Governance Knowledge Graph for AI Agents

19 min read4 days ago

--

Signed human mandates, a fail-closed policy gate, and an RDF graph that explains every agent action but can never authorize one. Tested against 10,000 forged requests.

The graph explains the decision. Signed authority and deterministic policy make it.

In April 2026 the Cloud Security Alliance asked enterprises about the AI agents running inside them. 82% had found agents nobody knew about, and 65% had already suffered at least one agent-related security incident in the previous twelve months. The same survey found that the two signals organisations most want to govern agents by are the risk of the action (63%) and human authorization (53%).

The incidents are not hypothetical. In July 2025 a coding agent on Replit deleted a live production database during an explicit code freeze, then produced output that did not match the state of the system. OWASP’s new Top 10 for Agentic Applications gives the pattern a name, ASI03 Identity and Privilege Abuse: an agent inherits credentials that exceed its task, and the resulting actions are technically “authorized”, so nothing alarms.

Regulators have noticed too. Article 12 of the EU AI Act requires high-risk systems to automatically record events over their lifetime, with enough traceability to reconstruct how a decision was reached. The Digital Omnibus moved that deadline from 2 August 2026 to 2 December 2027; it did not remove it.

So here is the question this article is about.

At 12:00 a customer-service agent asks a banking system to issue a $420 refund. It succeeds. At 12:04 the human who delegated that power revokes it. At 12:05 the same agent tries again and is denied. Six months later an auditor asks:

Who authorized the first refund, under which policy version, against which verified customer case, and how do we prove the answer was true at 12:00, not reconstructed from today’s records?

Most organisations hold fragments. The identity provider knows the human. The agent registry knows the workload. IAM knows some permissions. A ticketing system knows the exception. An audit service knows an API was called. Each system is internally sensible. Together they are an expensive investigation.

I wanted to know whether an ontology-driven governance graph, kept strictly separate from a deterministic authorization plane, could make that question answerable, and whether the separation survives an attacker. We build it and test it:

  • An audit question, turned into seven requirements and nine design claims, each graded against an experiment.
  • A nine-type governance ontology in Turtle, with SHACL shapes that refuse malformed decisions.
  • Signed human mandates (Ed25519) and a fail-closed gate with twelve distinct deny reasons.
  • A signed, hash-chained receipt log, attacked seven ways.
  • A named-graph projection and SPARQL queries that answer “who authorized this?” and “what was true at 12:00?”
  • An adversarial fuzzer with an independent oracle: 10,000 mutated requests.
  • A scaling run to 10,000 decisions and 190,011 triples, and a claim-by-claim verdict.

By the end: 0 unauthorized permits in 10,000 mutated requests, 23 passing tests, a truncation attack that beats the hash chain and is caught by a two-field checkpoint, and a full audit scan that costs 416× more than looking up one action.

This is an original design and a small reproducible proof of concept. The banking data is synthetic. Every number below is a local functional result on one 2.1 GHz core, not a production benchmark or a compliance claim.

Where The Idea Comes From

At QCon London in March 2026, Netflix engineers Prasanna Vijayanathan and Renzo Sanchez-Silva described ontology-driven observability: a knowledge graph over a shared vocabulary that lets agents correlate telemetry across services, deployments and on-call chat. Fareed Khan’s independent rebuild on public data went further into named-graph provenance, shape validation and honest measurement, and is the format this article borrows. Neither of those projects is this one, and nothing here is Netflix’s deployment.

Governance has the same integration problem with one difference that changes the architecture. An observability graph may suggest a root cause. A governance graph must never infer that an agent is authorized because its relationships look plausible.

Press enter or click to view image in full size
Figure 1 — Enforcement is synchronous, trusted and fail-closed. The graph is asynchronous, provenance-aware and read-only.

These are the files we write:

ontology_governance_reference/
├── ontology/
│ ├── governance.ttl # nine classes, their properties and ranges
│ └── shapes.ttl # what a Decision must look like before it is published
├── src/
│ ├── governance.py # mandates, gateway, receipt chain, graph projection, SPARQL
│ ├── experiments.py # fuzz, tamper, as-of, SHACL, scale, injection
│ └── bench.py # the original microbenchmark
├── tests/ # 23 functional and adversarial tests
├── results/ # every output quoted in this article

The Audit Question

The question defines the requirements, not the database:

Why was agent spiffe://acme.example/agent/refund allowed to call refund.create for $420 on case-77 at 12:00, whose human mandate covered it, and which evidence proves the policy decision?

Press enter or click to view image in full size

The design makes nine testable claims, most of them stated in my first write-up of this architecture; the last section grades each one against an experiment. The design is successful if the graph can explain the actual decision without being able to create a new permission.

Governance Ontology

The vocabulary has nine types: Human, Mandate, Agent, Action, Policy, Decision, Evidence, Risk and Control. The relationships carry the meaning:

Press enter or click to view image in full size
Figure 2 — One audit trail across facts that normally live in six systems. Control mappings are not legal compliance claims.

It lives in git as Turtle, and it says up front what it is not:

# ontology/governance.ttl
# This ontology describes observed governance evidence, NOT runtime authorization grants.
gov:Human a owl:Class ; rdfs:subClassOf prov:Agent .
gov:Agent a owl:Class ; rdfs:subClassOf prov:Agent .
gov:Mandate a owl:Class ; rdfs:subClassOf prov:Entity .
gov:Action a owl:Class ; rdfs:subClassOf prov:Activity .
gov:Decision a owl:Class ; rdfs:subClassOf prov:Activity .
gov:Evidence a owl:Class ; rdfs:subClassOf prov:Entity .

gov:requestedBy a owl:ObjectProperty ; rdfs:domain gov:Action ; rdfs:range gov:Agent .
gov:underMandate a owl:ObjectProperty ; rdfs:domain gov:Action ; rdfs:range gov:Mandate .
gov:issuedBy a owl:ObjectProperty ; rdfs:domain gov:Mandate ; rdfs:range gov:Human .
gov:documentedBy a owl:ObjectProperty ; rdfs:domain gov:Decision ; rdfs:range gov:Evidence .

A triple such as mandate-101 gov:delegatesTo refundAgent is a record of what happened. It is not a permission token. It may have come from an untrusted extractor, a stale snapshot or a compromised graph writer. So each identity class gets one authority of record and one canonical key:

Press enter or click to view image in full size

A model may suggest that two aliases are the same person. Only a designated source may assert it. This is the governance equivalent of a canonical application catalogue, and it is where I would spend the most effort: a graph can be syntactically valid and semantically wrong if identities are joined badly.

Signed Mandate

Three questions are often conflated:

  • Authentication: which workload is calling? In production, an mTLS or token-based identity, never a caller-supplied SPIFFE string.
  • Delegation: which authorized human issued a scoped, signed mandate for that workload, and is it still valid?
  • Authorization: does this exact action satisfy the mandate and current policy and business state?

The mandate is a small JSON document:

{
"id": "mandate-101",
"human_id": "h-alex",
"agent_id": "spiffe://acme.example/agent/refund",
"tenant": "bank-a",
"actions": ["refund.create"],
"purpose": "customer-remediation",
"currency": "USD",
"max_amount_cents": 50000,
"not_before": "2026-11-04T08:00:00Z",
"expires_at": "2026-11-06T08:00:00Z"
}

Its canonical bytes are signed with Ed25519. Canonical means sorted keys and no whitespace, so the same mandate always hashes the same way:

# src/governance.py
def canonical(obj) -> bytes:
return json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
def digest(obj) -> str:
return hashlib.sha256(canonical(obj)).hexdigest()
def sign(private: Ed25519PrivateKey, data: dict) -> str:
return b64(private.sign(canonical(data)))
def valid_sig(public: Ed25519PublicKey, data: dict, signature: str) -> bool:
try:
public.verify(unb64(signature), canonical(data))
return True
except (ValueError, InvalidSignature):
return False
#### OUTPUT ####
mandate_sha256 cb903f106b646f51b5bea40f8bb920942209443720afa93e1f84f30b1c299805
policy_sha256 76f7fb500d3c2844539b0ca6280bc676780c83387a58cdbc89ad6525a0cc4b60

Those two digests travel in every receipt. The gate verifies the signature against a trusted key registry, then separately checks that the issuer holds the role allowed to delegate refunds. A valid signature from a human who is not a refund manager is still a denial.

Decision Gate

The whole authorization rule fits on a card:

PERMIT = authenticated_agent
AND valid_human_signature
AND authorized_delegator
AND active_mandate
AND mandate_scope_matches
AND current_policy_allows
AND trusted_business_state_allows
AND evidence_commit_succeeds

A knowledge-graph query is deliberately absent. The implementation is an ordered chain of checks, each reading trusted context (an identity registry, a role register, a revocation set, a case system), never a graph edge:

# src/governance.py
def decide(self, request, signed_mandate, *, at):
moment, mandate = iso(at), signed_mandate['mandate']
issuer_key = self.trusted.trusted_human_keys.get(mandate['human_id'])
reason = 'PERMIT'
if request['request_id'] in self.seen_requests: reason = 'REPLAYED_REQUEST'
elif request['agent_id'] not in self.trusted.agent_allowlist: reason = 'UNKNOWN_WORKLOAD'
elif issuer_key is None or not valid_sig(issuer_key, mandate, signed_mandate['signature']):
reason = 'INVALID_MANDATE_SIGNATURE'
elif 'refund-manager' not in self.trusted.human_roles.get(mandate['human_id'], set()):
reason = 'ISSUER_NOT_AUTHORIZED'
elif mandate['id'] in self.trusted.revoked_mandates: reason = 'REVOKED_MANDATE'
elif not iso(mandate['not_before']) <= moment < iso(mandate['expires_at']):
reason = 'MANDATE_OUTSIDE_VALIDITY'
elif request['agent_id'] != mandate['agent_id'] or request['tenant'] != mandate['tenant']:
reason = 'WRONG_AGENT_OR_TENANT'
# ... action, purpose, currency, amount, then the trusted case record
self.seen_requests.add(request['request_id'])
return self.ledger.append({... 'decision': 'PERMIT' if reason == 'PERMIT' else 'DENY', 'reason': reason ...})

Seven scripted scenarios, run with PYTHONPATH=. python -m src.governance results:

#### OUTPUT ####
valid PERMIT PERMIT
over_limit DENY AMOUNT_OUT_OF_SCOPE
unverified_case DENY CASE_NOT_VERIFIED_OR_MISMATCH
forged_mandate DENY INVALID_MANDATE_SIGNATURE
wrong_tenant DENY WRONG_AGENT_OR_TENANT
revoked DENY REVOKED_MANDATE
replay DENY REPLAYED_REQUEST
Press enter or click to view image in full size
Figure 3 — One permitted request and six denied, from the local run.

The forged-mandate case matters most. The attacker took a genuinely signed mandate and raised max_amount_cents to 99,999,999. The human's signature no longer matches the bytes, so the gate never reaches the amount check. Scripted scenarios are not evidence of robustness, though; the fuzzer below is.

In this reference the identity and case systems are in-memory stand-ins. That makes the tests deterministic. It does not mean the code performs SPIFFE attestation, calls a real Cedar policy point, or writes to a strongly consistent store.

Receipt Chain

Every decision, permitted or denied, produces a receipt. The receipt is appended to a log whose entries are signed with a separate service key and linked by hash:

# src/governance.py
@dataclass
class Ledger:
signing_key: Ed25519PrivateKey
records: list[dict] = field(default_factory=list)
    def append(self, payload: dict) -> dict:
"""Signed hash chain. NOT a tamper-proof or externally anchored event store."""
entry = {'sequence': len(self.records) + 1,
'previous_hash': self.records[-1]['entry_hash'] if self.records else 'GENESIS',
'payload': deepcopy(payload)}
signature = sign(self.signing_key, entry)
result = {**entry, 'signature': signature,
'entry_hash': digest({'entry': entry, 'signature': signature})}
self.records.append(result)
return result

The first receipt from the run, trimmed:

{
"sequence": 1,
"previous_hash": "GENESIS",
"payload": {
"request": {"request_id": "req-001", "amount_cents": 42000, "case_id": "case-77", "...": "..."},
"mandate_id": "mandate-101",
"mandate_sha256": "cb903f106b646f51b5bea40f8bb920942209443720afa93e1f84f30b1c299805",
"human_id": "h-alex",
"policy_id": "REFUND-POLICY-1.0",
"policy_sha256": "76f7fb500d3c2844539b0ca6280bc676780c83387a58cdbc89ad6525a0cc4b60",
"revocation_revision": 0,
"authorized_at": "2026-11-04T12:00:00+00:00",
"decision": "PERMIT",
"reason": "PERMIT",
"control_ids": ["CTRL-AUTH-01", "CTRL-EVID-02"]
},
"signature": "LIj3F7saQLnrFfthFdizFKTkFymeQ087wOljjAjL4FhG8pDIxtgR3CjyBORprSMPGrYE2vdg19Mup3Z59n8HAw",
"entry_hash": "c99e7087b7b98fee6b63084890b9db8e70e2f1b20a12690d9b439e69194ffcc9"
}

Notice what it pins: not the policy name but its digest, and the revocation revision observed at decision time. Both become essential below.

What the receipt does not prove: that the refund API completed the action. Production needs a separate, correlated tool-execution outcome from the target service with its own idempotency key and transaction ID. “Cryptographically verifiable” should mean exactly this: a verifier can check signatures and hashes of specific artifacts. It does not mean every real-world event was captured.

Tamper Test

A signed hash chain is often described as tamper-proof. I wanted to see precisely what it catches, so experiments.py builds a five-record log (record 3 is an over-limit DENY) and attacks a copy seven ways:

# src/experiments.py
def checkpoint(ledger: Ledger) -> dict:
"""What an independent anchor would hold: count and head hash, nothing more."""
return {'count': len(ledger.records), 'head': ledger.records[-1]['entry_hash']}

def verify_with_checkpoint(ledger: Ledger, cp: dict) -> bool:
return ledger.verify() and checkpoint(ledger) == cp
#### OUTPUT ####
attack chain +checkpoint projection
untouched log ok ok projected
flip DENY->PERMIT in record 3 BROKEN BROKEN refused
raise amount in record 2 BROKEN BROKEN refused
swap records 2 and 4 BROKEN BROKEN refused
re-sign record 3 with attacker key BROKEN BROKEN refused
drop the last record (truncation) ok BROKEN projected
drop the last 2 records ok BROKEN projected
Press enter or click to view image in full size
Figure 4 — Four content edits break the chain and are refused at projection. Truncation passes the chain and is caught only by a checkpoint.

Every edit to a retained record is caught, and the graph projection refuses to build from a log that fails verification. But an administrator who simply deletes the tail presents a prefix that verifies perfectly. A hash chain proves the records you kept are consistent with each other; it says nothing about records you threw away.

Get Lali Devamanthri’s stories in your inbox

Join Medium for free to get updates from this writer.

The fix is small and external: a checkpoint of {count, head_hash} written somewhere the log's custodian cannot alter, such as a transparency log, a second account, or an object-lock bucket. Two fields turned "intact" into "BROKEN" for both truncations. The lesson is not that hash chains are weak; it is that completeness and integrity are different properties, and an auditor should ask for both.

Time And Revocation

Press enter or click to view image in full size
Figure 5 — The 12:00 decision stays historically valid although a fresh request at 12:05 must be denied.

The wrong audit technique is to ask the graph whether the mandate is valid today and infer the answer for yesterday. The receipt carries authorized_at and revocation_revision, so an as-of query over the projected graph can answer what the gate knew at the moment:

SELECT ?action ?outcome ?reason ?at ?rev WHERE {
GRAPH ?g {
?action gov:underMandate <urn:mandate:mandate-101> ; gov:hasDecision ?d .
?d gov:outcome ?outcome ; gov:reason ?reason ;
gov:authorizedAt ?at ; gov:revocationRevision ?rev .
}
FILTER(?at <= "2026-11-04T12:03:00+00:00"^^xsd:dateTime)
} ORDER BY ?at
#### OUTPUT ####
as of 12:03: 1 decision(s) visible
12:00 req-001 PERMIT PERMIT revocation revision 0
as of 12:06: 2 decision(s) visible
12:00 req-001 PERMIT PERMIT revocation revision 0
12:05 req-006 DENY REVOKED_MANDATE revocation revision 1
today: mandate-101 revoked = True, current revision 1
wrong method: "is the mandate valid today?" -> no -> conclude 12:00 was unauthorized
right method: replay the 12:00 receipt -> PERMIT at revision 0, policy digest 76f7fb500d3c...

In a serious implementation I would track two timestamps on important assertions: valid time (when the source says the relationship applied) and transaction time (when the platform learned it). That stops a report written tomorrow from leaking into a replay of today’s decision. The graph is a good temporal investigation tool; the signed receipt and the immutable policy digest remain the primary evidence.

Graph Projection

Once receipts verify, each one becomes its own named graph, so every fact knows which receipt it came from:

# src/governance.py
def build_graph(ledger: Ledger) -> Dataset:
"""Read-side projection. A graph fact is never a runtime permission grant."""
if not ledger.verify():
raise ValueError('cannot project unverified receipts')
ds = Dataset()
for rec in ledger.records:
p, request = rec['payload'], rec['payload']['request']
g = ds.graph(URIRef(f'urn:governance:receipt:{rec["sequence"]}'))
act, decision = URIRef(f'urn:action:{request["request_id"]}'), URIRef(f'urn:decision:{request["request_id"]}')
g.add((act, GOV.requestedBy, URIRef(f'urn:agent:{request["agent_id"]}')))
g.add((act, GOV.underMandate, URIRef(f'urn:mandate:{p["mandate_id"]}')))
g.add((act, GOV.hasDecision, decision))
g.add((decision, GOV.outcome, Literal(p['decision'])))
g.add((decision, GOV.policyDigest, Literal(p['policy_sha256'])))
g.add((decision, GOV.revocationRevision, Literal(p['revocation_revision'], datatype=XSD.integer)))
g.add((decision, GOV.documentedBy, URIRef(f'urn:receipt:{rec["entry_hash"]}')))
# ... human, policy, risk and control triples
return ds

The audit question becomes one traversal instead of six system queries:

PREFIX gov: <https://example.org/governance#>
SELECT ?action ?agent ?human ?mandate ?outcome ?reason ?policy ?receipt
WHERE {
GRAPH ?source {
?action gov:requestedBy ?agent ; gov:underMandate ?mandate ; gov:hasDecision ?dec .
?mandate gov:issuedBy ?human .
?dec gov:outcome ?outcome ; gov:reason ?reason ;
gov:usedPolicy ?policy ; gov:documentedBy ?receipt .
}
}
ORDER BY ?action
#### OUTPUT ####
action urn:action:req-001
agent urn:agent:spiffe://acme.example/agent/refund
human urn:human:h-alex
decision PERMIT (PERMIT)
policy urn:policy:REFUND-POLICY-1.0
receipt urn:receipt:c99e7087b7b98fee6b63084890b9db8e70e2f1b20a12690d9b439e69194ffcc9

The graph joins and explains. An independent verifier still fetches the receipt behind that urn:receipt: and checks its signature, policy digest and checkpoint before accepting the claim as proven.

One production note: Neptune’s RDF default graph can be the union of named graphs, so named graphs are not a tenant boundary. Tenant isolation belongs at the query-service layer.

Graph Injection

The central claim is that nothing written into the graph can create a permission. So I revoked the mandate, then wrote three forged triples into an untrusted named graph, including a PERMIT outcome for the exact action about to be requested:

# src/experiments.py
g = ds.graph(URIRef('urn:untrusted:llm-enricher'))
g.add((URIRef('urn:action:req-001'), GOV.hasDecision, URIRef('urn:decision:fake')))
g.add((URIRef('urn:decision:fake'), GOV.outcome, Literal('PERMIT')))
g.add((URIRef('urn:mandate:mandate-101'), GOV.delegatesTo, URIRef('urn:agent:spiffe://acme.example/agent/refund')))
rec = gw.decide(sample_request(id='req-001'), token, at=NOW)
#### OUTPUT ####
3 forged triples written to an untrusted named graph, including outcome "PERMIT"
gateway decision for the same action: DENY (REVOKED_MANDATE)

This is trivially true of the code because decide() never imports the graph. That is the point. The property is architectural, and it should be enforced by review and by a test that fails the day someone adds a convenient graph lookup to the hot path.

Shapes

The ontology says what may exist. SHACL shapes say what a fact must look like before it is published. The reference shapes.ttl holds one shape, and I wanted to see whether it does anything:

# ontology/shapes.ttl
gov:DecisionShape a sh:NodeShape ;
sh:targetClass gov:Decision ;
sh:property [ sh:path gov:outcome ; sh:minCount 1 ; sh:maxCount 1 ; sh:in ( "PERMIT" "DENY" ) ] ;
sh:property [ sh:path gov:authorizedAt ; sh:minCount 1 ; sh:datatype xsd:dateTime ] ;
sh:property [ sh:path gov:revocationRevision ; sh:minCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 0 ] ;
sh:property [ sh:path gov:documentedBy ; sh:minCount 1 ; sh:class gov:Evidence ] ;
sh:property [ sh:path gov:usedPolicy ; sh:minCount 1 ; sh:class gov:Policy ] .

I validated the projection of three signed receipts with pySHACL, then planted three decisions of the kind a buggy or hostile enricher would write: an outcome of "MAYBE", a decision with no evidence link, and a revision stored as the text "zero":

#### OUTPUT ####
projected graph from 3 signed receipts: conforms = True
after planting 3 broken decisions: conforms = False, 4 violations
planted-1 outcome In
planted-2 documentedBy MinCount
planted-3 revocationRevision Datatype
planted-3 revocationRevision MinInclusive

All three are caught. The third one is the quiet killer: a number stored as text passes most code paths and silently breaks a numeric filter later. Shape validity is still not authority validity. SHACL can reject a badly shaped fact; it cannot decide whether the human named in it was empowered to approve a payment. I would store a source class on every assertion, AUTHORITATIVE, DERIVED or PROPOSED, and let a model write only into the last.

Adversarial Fuzz

Seven scripted scenarios prove nothing about the cases nobody scripted. So the fuzzer starts from the one legitimate request and applies one to three mutations drawn from a menu of attacks and ordinary edits: forged mandate bodies, attacker-signed mandates, a stripped delegator role, an unknown workload, revocation, expiry, replay, cross-tenant requests, wrong cases, wrong currencies, over-cap amounts, and type confusion ("42000", True, None, [42000]).

The important part is the oracle: a second implementation of the policy, written from the policy text rather than from the gateway code, so the two can disagree:

# src/experiments.py
def oracle_should_permit(req, mandate, *, revoked, role_ok, sig_ok, agent_known, at, cases, seen) -> bool:
case, moment = cases.get(req['case_id']), iso(at)
return (req['request_id'] not in seen and agent_known and sig_ok and role_ok and not revoked
and iso(mandate['not_before']) <= moment < iso(mandate['expires_at'])
and req['agent_id'] == mandate['agent_id'] and req['tenant'] == mandate['tenant']
and req['action'] == 'refund.create' and req['action'] in mandate['actions']
and req['purpose'] == 'customer-remediation' == mandate['purpose']
and req['currency'] == 'USD' == mandate['currency']
and isinstance(req['amount_cents'], int) and not isinstance(req['amount_cents'], bool)
and 0 < req['amount_cents'] <= mandate['max_amount_cents']
and case is not None and case['tenant'] == req['tenant']
and case['customer_id'] == req['customer_id'] and case['state'] == 'verified'
and case['currency'] == req['currency'] and req['amount_cents'] <= case['max_refund_cents'])

Ten thousand cases, seed 42, about six seconds:

#### OUTPUT ####
10,000 mutated requests, seed 42
out-of-scope cases 9,501 unauthorized PERMITs 0
in-scope cases 499 legitimate DENYs 0
deny reasons, most common first:
UNKNOWN_WORKLOAD 2063
INVALID_MANDATE_SIGNATURE 1306
REPLAYED_REQUEST 1166
ISSUER_NOT_AUTHORIZED 784
REVOKED_MANDATE 675
ACTION_NOT_IN_SCOPE 573
WRONG_AGENT_OR_TENANT 568
CASE_NOT_VERIFIED_OR_MISMATCH 540
AMOUNT_OUT_OF_SCOPE 526
PERMIT 499
PURPOSE_MISMATCH 454
MANDATE_OUTSIDE_VALIDITY 434
CURRENCY_MISMATCH 412
Press enter or click to view image in full size
Figure 6 — Every out-of-scope request denied, every in-scope request permitted. The bars show the first check that failed.

Two things in that table are worth more than the zero.

First, the 499 legitimate permits. A gate that denies everything also scores zero unauthorized permits. The in-scope arm exists so that a fail-closed design is also shown to be a working one.

Second, the first run of this fuzzer reported 94 unauthorized permits. All 94 had one mutation, forge_mandate, and in every one the "forgery" had set tenant or agent_id to the value it already had. The bytes never changed, so the human's signature was still valid, and the gate was right to permit. The bug was in my oracle, which had marked the signature invalid because a forgery was attempted. I mention it because it is exactly the kind of false alarm an audit function must be able to distinguish from a real breach, and because a result section that only ever reports zero should make you suspicious.

Scan Versus Lookup

The original microbenchmark stopped at 1,000 decisions. I extended it to 10,000 and added one measurement, the time to explain one specific action, next to the time to scan every decision. Medians of three in-process runs, RDFLib 7.6, one 2.1 GHz core:

#### OUTPUT ####
decisions sign+log verify project full scan one action triples
100 0.023s 0.015s 0.040s 0.026s 7.58 ms 1,911
1,000 0.216s 0.135s 0.443s 0.896s 27.80 ms 19,011
5,000 1.043s 0.755s 3.006s 25.055s 91.14 ms 95,011
10,000 2.661s 1.406s 5.492s 112.666s 270.56 ms 190,011
Press enter or click to view image in full size
Figure 7 — Log scales. The full scan grows super-linearly, the single-action lookup stays in milliseconds.

Signing and logging held between roughly 3,800 and 4,800 decisions per second in single-threaded Python, which is a reminder that Ed25519 is cheap and that the expensive parts of a real gate will be network calls to identity, policy and state. The audit scan is another story: at 10,000 decisions it costs 113 seconds against 271 milliseconds for one action, about 416×. RDFLib’s in-memory store is not Amazon Neptune, and I would not quote these absolute numbers for anything. The shape is what matters, wide audit queries belong in a batch or analytics path with their own indexes, and nothing on the authorization path should ever wait for one.

Claim Verdicts

Figure 8 — Nine design claims, each graded against an experiment, seven held, one failed, one open.
Press enter or click to view image in full size

Mapping To A Cloud

Press enter or click to view image in full size
Figure 9 — Candidate AWS services behind a portable identity, policy, evidence and ontology contract.

An AWS implementation could use EKS or Bedrock AgentCore for the agent runtime with identities verified at the gate; AgentCore Gateway or a custom controlled API gateway for interception; Cedar through Amazon Verified Permissions for policy, with mandate validation and fresh revocation checks as explicit responsibilities; DynamoDB for mandates, revocations, idempotency and decision commitments; KMS for the two separated signing domains; a transactional decision log plus S3 with Object Lock for evidence; ingestion jobs with RDF/SHACL contracts feeding Neptune Database when SPARQL is needed (Neptune Analytics targets openCypher and is not a drop-in); and an authenticated query service that joins graph results to receipt verification.

The order of operations is the contract, and it is portable to Google Cloud or Azure without changing what a mandate or a receipt means:

attest workload identity
→ verify human-signed mandate and the issuer's right to delegate
→ check current policy, revocation and case state
→ durably commit the signed decision receipt
→ invoke the tool only on PERMIT
→ capture the tool's own outcome
→ archive, then project evidence into the governance graph
→ query relationships and verify proofs during an audit

There is no global transaction across a policy service, a refund API, S3 and a graph database. On an ambiguous tool timeout the orchestrator queries operation status or reconciles by idempotency key. It never blindly retries a financial action.

When Not To Build This

Not every governance system needs a graph. One application, one policy service and a few thousand records are better served by relational joins and structured logs. I would adopt the graph when the organisation repeatedly asks variable-length, multi-domain questions: which agents still depend on a revoked human mandate, which controls have no execution evidence, which incidents touched a given policy revision, which third-party agents transitively reach a regulated dataset.

Press enter or click to view image in full size

A first pilot should freeze 30–50 pre-agreed audit questions and measure answer correctness against source systems, median and p95 investigation time versus the manual process, evidence coverage per decision, false joins and temporal leakage, zero unauthorized permits under an adversarial suite, and cost per ingested decision and historical query. Hold back unseen cases for the final evaluation. A handful of successful demo queries is not an ROI claim.

Failure Modes That Are Not Graph Problems

  • Bypass paths: a service calls the refund API without crossing the gate. Enforce at the resource, not through a voluntary SDK.
  • Stale revocation: a cache outlives a revocation. Define a freshness bound or fail closed when state is unavailable.
  • Mutable policy references: a receipt that names a policy without its digest. Store both.
  • Incomplete evidence: a failed ingestion job mistaken for an absence of risky actions. Record coverage states.
  • Cross-tenant disclosure: named graphs mistaken for access-control domains.
  • Confused deputy: a valid agent identity reused for a different human or purpose. Bind mandate, principal, action, tenant and purpose to one request.
  • Evidence laundering: a model-generated relationship presented as a verified fact. Keep source class, reviewer and validation status.
  • Signing-key compromise: correct signatures from the wrong controller. Protect, rotate, revoke and audit keys and delegator roles.
  • Truncation: the one this article measured. Anchor a checkpoint outside the custodian’s reach.

Limits

This study ran on one laptop-class core with synthetic data, and the figure lists what it leaves open.

  • Identity is simulated. Agents are allow-listed strings; there is no SPIFFE attestation or mTLS.
  • No external systems. No Verified Permissions, KMS, DynamoDB, S3, Neptune or refund API was called. The commit-before-execute protocol, idempotent side effects and concurrent contention were not exercised.
  • RDFLib is not a production store. The scan-versus-lookup curve shows a shape, not a capacity.
  • The oracle shares my blind spots. It was written independently of the gateway but by the same person. A second author, or property-based generation of the policy itself, would be a stronger check.
  • One policy, one mandate shape. Real estates have policy hierarchies, multi-step delegations and exceptions with expiry conditions. Modelling the rejected alternatives behind a human exception is the next most valuable thing to add, because a final APPROVED state hides the most useful governance context.
  • No AI in the loop yet. The read-side flywheel (observe, enrich, infer, adapt) is designed but not built. When it is, models propose into a PROPOSED namespace, a named owner approves, and changes ship through policy-as-code CI with separation of duties.

What I Would Build Next

The first milestone is not a graph cluster. It is one critical agent action with a complete, independently verifiable authority chain.

M1 — Proof of authority: one tool path, live identity, signed delegation, policy enforcement, revocation, idempotency, signed receipts with an external checkpoint. Run the fuzzer against the real gate.

M2 — Proof of explanation: map authority records into the versioned ontology, validate the projection with shapes, implement as-of investigation and tenant-safe queries. Compare every answer against source systems.

M3 — Governance intelligence: enrichment and specialist agents that propose control gaps, missing evidence and policy changes, kept outside the runtime path, with accountable human approval for any change.

The destination is not another dashboard. It is a machine-readable model that gives every AI agent action a reconstructable chain of human accountability, scoped permission, deterministic decision and verifiable evidence.

The ontology provides context. The graph makes relationships navigable. The cryptographic and policy controls establish what can be trusted. Keeping those three apart is what makes an ontology-driven approach safe to use for AI governance.

References

--

--