Facade directions — two trust planes
Parent: structure · Kin: facade-parity · owner-facade-from-registry · acl-and-quota-granularity
facade-parity made every facade a projection of one registry. That model had no direction axis — its only base reaches were OwnerRead() / OwnerAction(), and it wired exactly two facades, both owner-facing. This adds the missing axis, and the reason it had to be added before anything outward touched the mechanism is worth stating plainly:
Registering an outward facade naively would have made every OwnerRead() op "missing" on it — the ratchet would have demanded exposing the admin console's reads to strangers. A parity mechanism with no notion of direction does not merely fail to protect; it actively pushes the wrong way.
The model
A facade's identity is the actor it serves. Parity groups facades one level up, by trust plane — actors holding the same grant level. There are exactly two.
| Plane | Actors | Grant | Parity semantics |
|---|---|---|---|
| owner | admin console, the owner's own AI over MCP; later Electron, IM ingest | login session / keypair | completeness — every owner op on every owner facade |
| outward | visitor humans (code or BYOAI), visitor programs (API key), anonymous readers and crawlers | a role, resolved from the grant | role-consistency — a role-grantable capability renders on every outward facade unless its class excepts it |
"Public" is not a plane
The anonymous visitor is the degenerate grant: their role is the owner's public role. That is already what BYOAI scopes to and what published gates on the reader pages. So within the outward plane everything reduces to one line:
reachable(caller, cap) =
facade-renderable(cap) # can this facade physically carry it
∧ opened(cap) # owner made it an API candidate (API facade only)
∧ role(grant).grants(cap) # code role / key role / public role
− denials(grant, cap) # per-code / per-key deny rows
The dial runs anonymous → keyed → coded on one axis. The payoff of collapsing public into ACL: the owner could one day grant a capability to the public role itself — anonymous read-only search, say — with zero new machinery.
The leak invariant, enforced twice
An owner-plane op may never render on any outward facade. This is why direction has to be structural rather than a convention:
- Manifest level — a
leakviolation kind: any exposure containing an op whose plane differs from the facade's is a hard red, checked both ways, failing at boot. Not "not required" — forbidden. - Registry level — owner-only capabilities already return
ErrHiddenfrom their visitor binding, and the outward facades assemble through that same path, so such a capability is structurally incapable of appearing. A conformance test injects a fake owner op into an outward exposure and asserts red (a-gate-that-cannot-go-red).
Two mechanisms for one invariant is deliberate: the manifest catches a wrong declaration, the registry catches a wrong assembly, and neither can cover the other's mistake.
The fourth facade: the API key
What the direction axis was built to admit. Before it, an outsider could reach the instance only through visitor chat (access code, an LLM in the loop, metered by turns) or the anonymous public pages — there was no way for a program to call the owner's capabilities directly. The API-key facade is that seat: like an access code, minus the brain and the gas — the same role-scoped grant, but endpoint-in / structured-data-out, throttled by rate limits instead of turn and session quotas.
It is outward, non-agentic, role-scoped, and it earns its keep only because the plane model already answers what it may carry. Shipped 2026-07-10 (b42a15e7f): backend/internal/routes/pubapi/pubapi.go — only non-agentic outward tools render, a per-key fixed-window limiter (rateWindow) does the throttling, and the candidacy gate (opened(cap) above) ratcheted to zero; 2df6b795b (2026-08-20) let a booking made through a key say whose it is (on_behalf_of.go).