Data Sovereignty Is an Access-Control Pattern (and It Should Fail Closed)
“The community owns its data” is a principle. SELECT * FROM alerts is a query. The distance between the two is where most data platforms quietly fail — they state a governance value in the README and then enforce nothing in the code path that actually returns rows. This post is about closing that gap: how to make data sovereignty a concrete access-control pattern, and why the pattern has to fail closed.
I recently built a small Governed Data-Access Viewer — a runnable sketch over a sample territorial-monitoring dataset (deforestation and change-detection alerts, each one belonging to a specific community’s territory). The domain comes from Indigenous land-monitoring platforms, but the pattern is general: any system where the subject of the data, not the operator of the platform, holds the authority to decide who sees it. Health records, union membership, legal discovery, community-owned environmental data — same shape.
The one rule: default deny
Most access-control bugs are additive mistakes. Someone adds a role, a feature flag, an “admin can see everything” shortcut, and the set of things a user can see grows a little past what anyone intended. The fix is to invert the default. In the viewer, the access engine has exactly one public contract:
The default answer is DENY. Access is granted only when a specific, positive rule matches.
An unknown user? Deny. A record that doesn’t exist? Deny. A grant that expired yesterday? Deny. An unexpected state the author didn’t foresee? Deny — because “I didn’t foresee it” resolves to the default, and the default is no. There is no code path to a silent allow. That’s what fail closed means: when something is ambiguous or broken, the safe answer is the restrictive one.
Concretely, the whole decision lives in one function, and it reads like a list of the only ways to say yes:
def decide(user, record):
if user is None: return Deny("unknown actor")
if record is None: return Deny("record does not exist")
# (1) A community can always read its own data.
if user.community == record.governing_community:
return Allow("actor belongs to the owning community")
# (2) Cross-community reads require an explicit, active, in-scope grant
# issued BY the owning community.
for g in grants_from(record.governing_community):
if g.grantee == user.community and not expired(g) and in_scope(g, record):
return Allow(f"active grant from {g.grantor}")
return Deny("no active grant — denied by default")
Everything not on that short list is a denial. When you want to audit “how can someone see this record,” you read one function, not a graph of middleware.
Sharing is the owner’s decision, and it expires
Sovereignty isn’t “nobody else can ever see it” — that would make a collaboration platform useless. It’s that the owner decides. So the only route to cross-community access is a Grant: issued by the data owner, to a specific other community or partner org, with a scope (all data, or just one alert type) and an expiry. Grants are never open-ended. A partner who had access last quarter and wasn’t renewed silently loses it — because the engine checks the expiry every single time, and an expired grant is just another way to reach the default deny.
This maps directly onto the CARE Principles for Indigenous Data Governance — specifically “Authority to Control.” CARE was written as a complement to the FAIR data principles precisely because “make data as open as possible” is the wrong default when the data is about a community that has historically had its information taken and used against it. “Authority to control” is a governance sentence. for g in grants_from(owner): ... is that sentence compiled.
There is no super-role
Here’s the part that trips up most implementations. Somewhere, someone adds a platform_admin who can see everything “for support.” The moment that role exists, the sovereignty claim is false — the platform operator is now the real owner of the data, and the community’s control is a UI convenience.
So the viewer has a platform_operator role, and it is deliberately powerless. An operator belongs to an ops org that holds no grants, so the engine denies them every community record — same code path as any stranger. The test suite asserts it explicitly: iterate every record, assert the operator is denied every one. If someone later adds a backdoor, that test goes red. The absence of a super-role is not a policy in a doc; it’s an executable invariant.
This is the technical expression of the OCAP® framework’s Possession: the data lives on infrastructure the community controls, and even the people running the software can’t read it unless they were granted access. Self-hosting makes that physically true; a fail-closed engine with no super-role makes it true in the application layer too.
Every decision is audited — including the denials
Access to governed data should never be invisible. The viewer writes an append-only audit row for every decision — allows and denies. That second half matters more than it looks: a denied attempt to enumerate another community’s territory is exactly the event you want a record of, and it’s the one most systems throw away because “nothing happened.” Something happened. Someone tried. The row says who, what record, which owning community, the decision, and a human-readable reason.
Append-only is a design choice, not a comment. The audit table is only ever INSERTed into — no UPDATE, no DELETE — so the log is a durable artifact rather than a mutable convenience. When a community asks “who has looked at our data,” the answer is a query, not a shrug.
Where it belongs in production
The viewer keeps the whole model in one readable Python engine so the pattern is legible. In production, you’d push it down a layer: the same rules become PostgreSQL row-level security policies backed by roles and grants, so the database itself refuses to return another community’s rows even if an app-layer bug tries to ask. That’s not redundancy for its own sake — it’s defense in depth. The application decides and audits; the database is the backstop that doesn’t trust the application to be bug-free. CREATE POLICY is where “authority to control” stops being something you promise and starts being something the storage engine enforces.
The takeaway
Data sovereignty reads like ethics, and it is — but on a platform it cashes out as four unglamorous engineering decisions: default deny, owner-granted, expiring sharing, no super-role, and an append-only audit of every decision. None of them are hard individually. What’s hard is committing to them as invariants — writing the tests that go red when someone adds the convenient backdoor, and keeping the decision in one place you can actually read. Do that, and “the community owns its data” stops being a line in the README and becomes the behavior of the system.