Skip to content

Scope sets in depth

Scopes explains the idea, that a memory carries a set of organizations and you read it only if you belong to every one of them. This page is the same idea at the level of the column, the policy expression and the classmethods, and it assumes you have read that page. Where the values come from is on The Logto boundary.

Every scoped table gets two columns from the Scoped mixin in src/aizk/store/mixins/scoped.py.

created_by = sql.Field(UUID5, index=True)
scopes = sql.Field(list[UUID5], min_length=1, sa_type=ARRAY(Uuid()))

The array is nonempty and stored sorted. Nonempty is enforced twice, by min_length=1 in the model and by cardinality(scopes) > 0 inside the policy itself, because a zero-length array would be trivially contained in any set and would read as visible to everyone.

Sorted is a convention that every writer follows, and you will see sorted(scopes) or sorted(scopes, key=str) at each write site in src/aizk/artifacts/, src/aizk/graph/ and src/aizk/extract/ingest.py. It exists so that two different callers who name the same two organizations produce the same array, which makes exact-set equality a plain = comparison. In Python the same value is a frozenset[UUID5], aliased as Scopes in src/aizk/types.py.

A join table is the obvious alternative and it is the wrong one here, for two reasons.

The first is the policy. The read check is one array containment test, scopes <@ readable, which the active SQL backend evaluates per row against transaction-local authority. With a join table every policy on every table would become a correlated subquery with a grouped HAVING, evaluated inside row security, on every read.

The second is that a scope set is a value, not a relationship. Background work partitions by exact scope set and asks questions like Fact.Claim.scopes == sorted(scopes). That is an equality on one indexed column. Expressed relationally it is set equality, which is an aggregate comparison and cannot be an index lookup.

Scoped.__rls__ compiles the policies. The caller’s standing reaches the transaction through the database adapter. RLSAlchemy flattens the nested context into transaction-local app.scopes.read, app.scopes.write and app.scopes.public arrays on both PostgreSQL and CockroachDB. _authority reads those native UUID arrays for the containment operators.

read a row?
├── cardinality(scopes) = 0 ? ──▶ no
├── scopes <@ caller.read ? ──▶ yes
├── cardinality(scopes) = 1
│ AND scopes <@ caller.public ? ──▶ yes
└── otherwise ──▶ no
write a row? nonempty AND scopes <@ caller.write

That cardinality guard on the public branch is the whole reason public organizations stay harmless. A public organization opens exactly the rows scoped to it alone. A row scoped to {public_org, private_org} is an intersection, and an intersection is narrower than either side, so it must not fall out of the public branch. Requiring cardinality 1 there is what keeps it from doing so. The tests that cover this still carry the older name for the shape, in tests/store/test_rls.py::test_scope_intersections_and_public_access_follow_the_lattice.

Scoped emits rls_select and rls_insert always, adds rls_update when the model declares mutable and rls_delete when it declares deletable, all bound to settings.app_role. Setting read_through replaces the child’s read predicate with “my parent is visible”, and adds to its write predicate that (parent_id, scopes) must match a visible parent row exactly. That pairing is what stops a chunk from being written with a wider array than the document it belongs to.

User.scopes is a ScopeTable, three frozensets named read, write and public. Three classmethods build one.

Constructor Read Write
User.authorized(id, read=, write=, public=) as given as given
User.private(id) just {id} just {id}
User.system(scopes) exactly scopes exactly scopes

authorized is called only after an authentication boundary has already verified every set, and it is where OrganizationStanding.writable gets stamped from the write set. private is the auth-off local identity. system is background authority and is covered on Background work.

Three ways to run a statement as that caller, all of which write the same setting.

async with user as session opens one short app-role transaction. Nested uses are tracked on a ContextVar stack, so concurrent tasks cannot pop one another’s transaction.

await user.exec[Model](statement, **binds) runs exactly one statement in its own transaction and validates every row into Model. Binds the statement names are filled from settings first and then overridden by the keywords, so a changed setting takes effect on the next call.

user.owner opens an RLS-bypassing transaction on the owner engine and raises PermissionError unless the caller is settings.system_user_id. It is for migrations, the roster read and maintenance, never for a request.

Read. You may read a row only when every scope in its array is in your read set. Nothing else grants a read.

Write. You may insert, and update or delete a mutable or deletable row, only when every scope in its array is in your write set. Before SQL is reached, User.write_scope(names) already refuses an organization name you do not hold or cannot write to, raising ScopeNotFoundError.

Retrieval. Find takes no scope argument. It runs under the caller’s whole read set, so one question spans private memory, every organization, and every intersection at once, and the returned evidence is labeled by User.scope_labels as Private, the organization name, or Shared.

Provenance. created_by says who and scopes says where, and they are independent. Content rows carry no scopes at all and inherit visibility from the claims that point at them, which The data model explains.

Public. A public organization grants read to any signed-in caller, only for rows whose array is exactly that one scope, and never grants write. The anonymous fallback identity gets a public read set and empty read and write sets.