ACL Registration Convergence — Write-Time Registration + Read-Time Fallback¶
Pillar 2 — Epistemic Knowledge Graph · Concepts:
KG-2.6(Company Brain),AU-KG.backend.company-brain-write-guard,AU-KG.compute.data-is-private-its
The defect¶
secured_reads.permit() is default-deny for any node with no registered ACL —
DataLevelPermissions.check_permission (company_brain.py)
hardcodes if acl is None: return allowed=False, reason="No ACL defined — default
deny", with no owner/tenant fallback and no privileged-actor exception. That is
correct, intended behaviour for a security boundary.
The bug is the other half: nothing on the generic write path ever registered an
ACL. classify_node()/set_acl() were called nowhere in production code except
two narrow, independently-discovered call sites (sessions.py::_persist_goal,
engine_ingestion.py::ingest_mcp_server, both from fix/green-slice-7, not yet on
main at the time of this convergence). Consequence: data was writable but
permanently unreadable — even by its own creator.
Four lanes hit this independently in one night and wrote four partial, overlapping fixes. This document is the converged, canonical design; the sections below say precisely what each fix contributed, what is superseded, and what remains open.
The four inputs¶
| # | Branch | Contribution | Status at convergence time |
|---|---|---|---|
| 1 | fix/green-slice-3 |
Read-time synthesis: _hydrate_missing_acls falls back to a private, owner-readable ACL from _owner_id/tenant_id when no external_access descriptor exists. Also proposed write-time governance stamping at GraphComputeEngine.add_node and IntelligenceGraphEngine._upsert_node, LadybugDB _GOVERNANCE_COLUMNS schema declarations, and a Ladybug execute_read transient-close-ordering fix. |
Not yet on main. |
| 2 | fix/w2-w2-tail |
Right-store read: _durable_access_rows reads via active.backend.execute_read(...) instead of active.graph_compute (which is only the same store as the backend in the single-process EpistemicGraphBackend topology). |
Already merged to main (commit 063acc07). |
| 3 | fix/green-slice-7 |
Write-time per-call-site registration: explicit classify_node(INTERNAL, data_owner=...) in _persist_goal, and a _classify_mcp_node() helper (PUBLIC) in ingest_mcp_server. Its own deferred item (D-GS27-5) flags this as narrow and not swept. |
Not yet on main. |
| 4 | fix/green-slice-3's D-GS3-2 |
Names a second split-store variant: nodes whose write only reaches _upsert_node (backend) and never graph_compute (delete_memory, ingest_mcp_server, ingest_a2a_agent_card, ingest_agent_skill). |
Resolved as a side effect of #2 (see below) — the read side no longer depends on graph_compute having a copy at all. |
Checking claim #2 vs #1: they are complementary, not competing, and the
branch author's framing is correct. #2 fixes "an ACL exists but the hydrator
read the wrong store to find it" (a node the write path already wrote
external_access/classification onto, in the durable backend, was
invisible because the read went to a different, unwritten compute
scratchpad). #1 fixes "no durable ACL material exists for this node at all"
(first-party nodes never carry external_access). Both must be true for a
first-party write to become readable: the data has to actually be in the
store the read queries (#2), and that data has to carry ACL material for the
read to synthesize from (#1 — and, per this convergence, the write side that
actually stamps that material, which #1 assumed but did not add).
Canonical design: defence in depth, not one layer¶
Register an ACL at write time, at the write chokepoints every node write funnels through, so correctness does not depend on lazy hydration — and keep a read-time fallback for nodes written before this fix, or through a path that still bypasses every chokepoint.
This is deliberately not a single-layer fix:
- Write-time only (à la slice-7's per-call-site
classify_node()calls) does not scale:classify_node()/set_acl()are called nowhere in the generic write path, and the grep foradd_node/_upsert_nodecall sites in this sweep turned up ~150 files calling some form ofadd_nodeand ~55 files calling_upsert_nodedirectly. Sprinkling explicitclassify_node()calls at each one is unreviewable, will drift the moment a new call site is added, and is exactly what D-GS27-5 (slice-7's own deferred item) already concluded: "classify_node() is called nowhere else in the entire agent_utilities production tree... A systemic fix belongs in BrainGuardedBackend's write guard... or IntelligenceGraphEngine.add_node itself." Per-call-site classification remains the right tool for the a small number of cases where the call site has semantic knowledge a bare node label cannot express (see Classification Policy below) — but as the primary mechanism it is the wrong shape. - Read-time-only (à la slice-3's original
_hydrate_missing_aclsfallback) leaves every write until the next read cold-starting the ACL from whatever happens to be in_owner_id/tenant_id/classification— but nothing durable stampedclassificationat all before this convergence, so the fallback had nothing to synthesize besides a hardcoded guess. It is also silently unable to recover once_owner_idis missing (system/background writes) — there is no way to reconstruct authorial intent after the fact. Read-time synthesis is the right safety net, not the right primary mechanism.
Together: the write-time stamp guarantees every future write is self-describing (carries its own owner/tenant/classification durably, so a future process restart or a different reader can reconstruct the ACL without guessing), and the read-time fallback guarantees the millions of rows written before this fix shipped — and any path this sweep didn't find — degrade to "owner can read it, no one else gains anything" instead of staying permanently denied.
Mechanism¶
Write time — tenant_sharing.stamp_classification()¶
A new sibling to the existing tenant_sharing.stamp_ownership() (which stamps
tenant_id/_owner_id/_shared_scope). stamp_classification(properties,
label) stamps a durable classification property in place:
labelin a short, explicitPUBLIC_CATALOG_LABELSset (ToolMetadata,CallableResource) →PUBLIC.- everything else →
CONFIDENTIAL(private-by-default; combined withstamp_ownership's_owner_id, this is what makes the node readable by its own creator — the exact defect being fixed — without granting anyone else anything new).
Existing classification values are never overwritten (setdefault
semantics) — a re-write or an explicit reclassification is never silently
reset.
Write-time chokepoints (four found and fixed)¶
Both stamp_ownership and stamp_classification are now called, best-effort
(caught PermissionError for unauthenticated/system/background writes —
identical no-op to pre-fix behaviour for that class of write), at every write
seam this sweep found that reaches durable storage without going through
another already-stamped seam:
IntelligenceGraphEngine._upsert_node(engine.py) — the seam ~55 call sites across the codebase call directly (engine_memory.py,engine_ingestion.py,engine_ahe.py,core/registry/kg_adapter.py,security/policy_ingestor.py,security/rule_ingestor.py, …), and thatIntelligenceGraphEngine.add_node()itself calls for its Tier-1 (backend-first) write.GraphComputeEngine.add_node(graph_compute.py) —self.graphandself.graph_computename the same native graph authority in the standard (EpistemicGraphBackend) topology (the class's own docstring); dozens of ingestion/pipeline modules (kb/ingestion.py,kb/x_ingestion.py,pipeline/document_*.py,security/*_ingestor.py,kb/entity_claim_extractor.py, …) callself.graph.add_node(...)directly, bypassing_upsert_nodeentirely.BrainGuardedBackend.add_node/_add_node_level(brain_guarded_backend.py) — the transparent proxy that "activates the dormant Company Brain on the write path without editing the dozens of writers" (its own docstring); already stamped_owner_id/tenant_idviastamp_ownershipbut neverclassification. Reached directly byadaptation/feedback.py,enrichment/pipeline.py,core/engine_tasks.py, and others that callself.backend.add_node(...)._BatchedBackend.add_node(enrichment/pipeline.py) — the buffered bulk-RPC path for the KG-2.9g code-symbol ingest (tens of thousands of nodes per repo); flushes throughgraph.bulk_mutate/batch_update, bypassing all three chokepoints above.
Also required (without these, the stamped properties silently vanished or the write itself errored on the default Ladybug/Kuzu backend):
materialization._GOVERNANCE_COLUMNS/schema_valid_keys— declarestenant_id/_owner_id/_shared_scope/classificationas valid columns so a schema-backed backend's SET-clause filter keeps them instead of folding them into themetadataJSON catch-all.ladybug_backend.py_GOVERNANCE_COLUMNS— Kuzu is strict-schema; an undeclared column reference raises a Binder exception rather than silently dropping the value. Declared on every node table (initialCREATE+ best-effortALTER TABLEmigration for pre-existing tables) and on the generic auto-created table for unknown labels.ladybug_backend.py::execute_readtransient-close-ordering fix — the transient/test-mode connection close ran before the lazyrows_as_dict()read, raisingRuntimeError: Query result is closedon every transient-modeexecute_readcall.secured_reads._durable_access_rows(the read-time fallback below) depends onexecute_read— this fix is a load-bearing dependency for the fallback to function at all in the test/transient topology, not a tangential cleanup.IntelligenceGraphEngine._upsert_nodealso gainedprepared.setdefault("id", node_id)— the raw-CypherMERGE (n:{label} {id: $id})branch binds$idfrom the params dict; a caller whosedatanever included an"id"key (e.g.skill_workflow_ingest.ingest_runnable_skill) sent$idin the query text with nothing to bind it to, which the native parser rejects outright.
Read time — secured_reads._hydrate_missing_acls¶
Generalized (not left as slice-3's hardcoded-CONFIDENTIAL version) to
respect whatever the write-time stamp actually recorded:
external_accesspresent as a dict → unchanged connector-sourced path (sync_access).- No
external_access, but a durableclassification/_owner_id(now added to the_durable_access_rowsprojection) → classificationPUBLICregisters in either case of owner; otherwise an owner-present node registers aNodeACLwith the stamped classification (falling back toCONFIDENTIALfor legacy data written before this fix, matching slice-3's original, narrower intent) anddata_owner=<owner_id>. - Neither
external_accessnor an owner norPUBLIC→ nothing is synthesized; the node stays denied. This is unchanged, fail-closed behaviour — unowned/system data with no explicit grant was never made readable by anyone, and still isn't.
Classification policy — deliberate, not blanket¶
The mandate is explicit that classification must be deliberate per node class, not a single default slapped on everything. The policy actually implemented:
- A short, evidence-backed
PUBLIClist (ToolMetadata,CallableResource) for labels that are, by their own design intent, a discoverable capability catalog — the self-describing tool registry (CONCEPT:AU-ECO.toolkit.self-describing-registry). This was slice-7's reasoning foringest_mcp_server's two labels; the chokepoint generalizes it to every writer of those labels for free —kg_adapter.register_callable_resource("discoverable through the same graph query" — its own docstring),ingest_a2a_agent_card, andskill_workflow_ingest.ingest_runnable_skill'sCallableResourcenode are now covered without any code at those sites. - A conservative
CONFIDENTIAL+ owner default for everything else — not because every other node class is equally sensitive, but because the chokepoint cannot know, from a bare label alone, whether aMemorynode holds a shopping list or a legal privilege review. Defaulting to "readable by its creator only" is the narrowest fix that resolves the reported defect (data unreadable by its own creator) without guessing at broader visibility. This is the same "data is private, its owner" default the siblingstamp_ownershipalready applies — the two stamps are deliberately symmetric. - Explicit per-call-site refinement stays available and is used once:
sessions.py::_persist_goalcallsclassify_node(INTERNAL, data_owner=...)after the chokepoint has already run, because the label it writes under (Concept) is overloaded across the codebase and only the call site knows "this particular Concept is a user's goal." This does not change whether the actor can read it (the chokepoint default already guarantees that) — only the classification label used for entailment-propagation strictness ordering (secured_reads.inherit_inferred_acl) and RESTRICTED-tier audit posture. Per-call-siteclassify_node()remains the right tool for this narrow class of "the label lies, the call site knows better" cases — it is wrong only as the primary, unswept mechanism.
Why not blanket-PUBLIC or blanket-INTERNAL: check_permission's only
special-cased classification is PUBLIC (unconditional read allow); every
other classification (INTERNAL/CONFIDENTIAL/RESTRICTED) is functionally
identical in the current implementation (owner/actor/role gated) except for
RESTRICTED's audit_on_access flag and inherit_inferred_acl's strictness
ordering. Given that, blanket-assigning PUBLIC to fix the defect will be a
straightforward tenant-isolation-preserving-but-actor-isolation-breaking data
exposure vulnerability (every actor in the tenant can read every node), and
is exactly what the task's "line the operator must not cross" forbids.
What this does not touch (explicitly out of scope)¶
DataLevelPermissions.check_permission/secured_reads.permit()are unmodified. Still hardcodesif acl is None: return allowed=False. No owner/tenant fallback, no privileged-actor bypass was added there — the fallback lives entirely in_hydrate_missing_acls, which only ever registers an ACL (through the sameclassify_node()used everywhere else); it never changes how a registered ACL is evaluated.company_brain.TenancyManager.scope_cypher_query(the tenant-isolation predicate,KG-2.6) is untouched. Bothfix/green-slice-3andfix/green-slice-7independently proposed changes here (atenant_id IS NULL OR tenant_id = ''commons-fallback, matchingPostgreSQLBackend.rls_statements's documented RLS policy) — that is a real, confirmed inconsistency between the Cypher-level scoping and the SQL RLS layer, but it is a different security mechanism (scope(), tenant isolation) from the one in this mandate (permit(), per-node ACL), already owned byfix/w2-tenant-predicate(merged tomain), and risked an unreviewed conflict with that lane under time pressure. Filed asD-ACL-3(see Deferred).- The external-source materialization path (
enrichment/registry.write_batch→core/materialization.write_entities, used bymaterialize_source/ingest_graph_slicefor the whole connector fleet) stampsstamp_source(provenance) but neitherstamp_ownershipnorstamp_classification. Connector-sourced entities largely carry their ownexternal_accessdescriptor stamped at the connector layer (checked:rest.py,ard.py,web.py,filesystem.py,database.py,mcp_tool.py, … all stampexternal_access=), which is the pre-existing, already-working branch of_hydrate_missing_acls— butwrite_batch's own docstring says internal, non-connector callers ("finance/synthesize" batches) passsource=Noneand stay untagged, with noexternal_accessand now noclassification/_owner_idfallback either. This is a materially different, fleet-wide surface (the source-connector/external-ingestion path once documented by the now-retiredagent-utilities-source-integrationskill, whose packaged skill guide moved to graph-os) that deserves its own dedicated audit rather than an unverified blind stamp under this lane's time budget. Filed asD-ACL-4.
Cross-tenant safety¶
The fallback is gated by the pre-existing tenant check in
_hydrate_missing_acls (if str(properties.get("tenant_id") or "") !=
actor.tenant_id: continue) — unchanged. A node stamped with a different
tenant's id is never even considered for ACL synthesis, in either case of
classification/_owner_id. See
tests/unit/knowledge_graph/test_secured_reads.py::test_owner_fallback_widens_only_the_owner_not_other_tenant_or_other_actor
for the proof: the owner gains read access; a same-tenant non-owner and a
cross-tenant actor (even one that happens to share the same actor_id) are
both still denied.