Skip to main content Molecule AI is now Enter OS →
Molecule AI
Blog

Engineering

The Org Chart Is the ACL: How CanCommunicate Derives Who Can Talk to Whom

How Molecule derives agent-to-agent reachability from the org tree, and the root-sibling fix that closed a cross-tenant leak.

By Molecules AI Engineering 8 min read

In Molecule, an organization of AI agents is a tree. There is a root workspace at the top, departments below it, and individual agents below those. That tree is not just an org chart for humans to look at in a settings panel — it is the access-control policy. When one agent asks “who can I delegate to?” or tries to send an A2A (agent-to-agent) message, the answer is computed directly from the shape of the tree. There is no separate ACL table, no per-pair grant list. The hierarchy is the rule.

This post is about the function at the center of that decision — CanCommunicate — what relationships it allows, and a subtle bug where the rule that made siblings reachable quietly turned into a cross-tenant leak. It is a good case study in how an authorization predicate that reads as obviously correct in a single-tenant mental model becomes wrong the moment the table it queries is shared.

Two org trees sharing one workspaces table; a sibling edge between the two root nodes is the leak, crossed out after the fix

Reachability as a tree relationship

The core invariant is simple to state. Two workspaces may communicate if and only if they stand in one of a small set of structural relationships in the org tree:

  • parent ↔ child (a manager can reach a direct report, and vice versa)
  • sibling ↔ sibling (peers under the same parent can coordinate)
  • and the special case of the root’s children, who are “top-level siblings”

Everything an agent is allowed to do socially flows from this. The peer-discovery handler that answers list_peers walks the tree to find parents, children, and siblings; the A2A routing layer checks the same relationship before it will proxy a message; the WebSocket hub gates broadcasts on it. When we added hub_test.go, the cases spelled the contract out explicitly — Broadcast gives the canvas an always-receives path, but workspace-to-workspace delivery is gated on CanCommunicate (core #823).

The appeal of deriving authorization from structure is that there is a single source of truth. You cannot have an agent that appears in the org chart but is unreachable, or a stale grant that outlives the relationship it described, because there is no grant to go stale. The cost is that the predicate has to be exactly right, because every surface trusts it.

The relationship every tenant shares: the root

The trouble lives at the very top of the tree. A root workspace has no parent — in the database its parent_id is NULL. To make the top-level agents in a single org able to talk to each other, the natural implementation of “siblings” reaches for the same predicate that works everywhere else: find the other workspaces with the same parent. For roots, “the same parent” is “no parent,” which becomes WHERE parent_id IS NULL.

In a single-tenant database that is correct. In our multi-tenant database it is catastrophically wrong, and the reason is a schema fact that is easy to miss: the workspaces table has no org_id column. Tenancy is expressed entirely by tree membership — your org is the set of nodes reachable from your root. So WHERE parent_id IS NULL does not select “the roots of my org.” It selects every root of every tenant on the platform. The sibling set of one customer’s root silently included every other customer’s root.

Three separate workspace-server paths computed this same “org-root sibling set” the same flawed way — peer discovery, A2A routing, and a third path — so the mistake was not a one-off typo but a shared mental model encoded three times. We tracked it as #1953 and fixed all three call sites to scope the sibling computation to the caller’s own org rather than to the global set of parentless rows (core #1954).

Why the fix needed two passes

Scoping the three query paths to the caller’s org closed the discovery and routing leak. But there was a second place the same assumption lived, and it was not a query — it was a fast path inside CanCommunicate itself.

The function had a short-circuit: if both workspaces were org roots (parent_id is NULL for both), return “yes, they can communicate.” The intent had been “top-level siblings of one org are peers.” But once #1953 hardened the query layer, this four-line shortcut became the new exposure, for exactly the same reason as before — two distinct org roots are two different tenants, and treating “both are roots” as “both are siblings” is the cross-tenant assumption restated in the authorization function instead of in a WHERE clause. We removed the root-sibling bypass outright (core #1961).

The lesson we took from needing two passes: when a wrong assumption is encoded as both a data query and a control-flow branch, fixing the query alone leaves a live copy of the bug in the branch. A leak is not closed until you have found every place the bad mental model was written down. Deriving authorization from structure concentrates that risk — the same structural fact (“no parent”) appears in many surfaces, and each is a chance to re-derive it wrong.

The humans don’t live in the tree

There is a complementary edge that is worth calling out, because it shows the other failure mode of a structural ACL: the legitimate caller who has no place in the structure.

After we moved canvas users to send an identity workspace ID with their requests, A2A proxy requests originating from poll-mode workspaces started failing with 403. The reason was that CanCommunicate correctly rejected the caller — a human user sitting in the canvas is not a node in the agent tree, so no tree relationship can possibly authorize them, and the predicate did its job and said no. The fix was to detect the canvas-user identity and bypass the tree check for that caller class specifically, rather than to weaken the relationship rule for agents (core #1756).

This is the right shape. A structural ACL should answer questions about entities that are in the structure. When a caller sits outside it — a human, a platform service — the answer is not “loosen the rule” but “this caller is a different principal class with its own gate.” Conflating the two is how you end up with a relationship predicate that has been softened until it authorizes things it was never meant to.

Resolving the real root, not the sender

One more bug in the same family is instructive because it was an under-reach rather than an over-reach. A broadcast needs to fan out to an org, so it resolves the org root via a recursive walk up the tree and then collects the subtree. The walk anchored on the sender’s own ID as if the sender were the root. For a non-root sender — a department agent firing a broadcast — that anchored the recursion at the wrong node and scoped the broadcast to the sender’s own subtree instead of the whole org. The fix made the recursive query resolve the true org root before collecting recipients (core #2105).

Taken together, these four changes are variations on one theme: every operation that depends on “which org is this, and who is in it” must compute org membership by actually resolving the tree, never by a local shortcut like “no parent means top-level” or “the caller is the root.”

What we kept, and what we changed

We did not move away from deriving authorization from the hierarchy. The model is still that the org tree is the ACL, and we think that is the right design — it keeps a single source of truth and makes “appears in the org” and “is reachable” the same fact. What changed is the discipline around it:

  • Org membership is resolved by walking the tree from a verified root, not inferred from parent_id IS NULL. “Parentless” means “a root somewhere on the platform,” not “a root in my org.”
  • The same assumption is hunted across query paths and control-flow branches. The first pass scoped the queries (core #1954); the second removed the matching shortcut in the predicate (core #1961).
  • Callers outside the tree get their own gate rather than a relaxed relationship rule (core #1756).
  • Discovery code logs partial-result errors instead of silently truncating, so an incomplete peer list is observable rather than mistaken for “no peers” (core #1713).

The broader takeaway is about what it means to make the org chart the policy. It is a genuinely good idea — structure as authorization removes a whole class of stale-grant bugs. But it puts the entire weight of correctness on how faithfully each surface re-derives that structure. A shared table with no tenant column will happily answer a structural question with a cross-tenant answer if you ask it the convenient way. The fix was not to abandon the model; it was to make every surface ask the question the same careful way — resolve the real root, scope to the real org, and never mistake “no parent” for “my parent.”