Classification and clearance
Every wiki page and attachment carries a classification, and every request runs at a clearance. A row is visible to a request, and writable by it, only when the row's classification ranks no higher than the request's clearance. This page describes the ladder, where that comparison is enforced, and where the clearance comes from.
The ladder
The platform ladder has five tiers:
| Tier | clearance_rank |
|---|---|
UNCLASSIFIED | 0 |
INTERNAL | 1 |
RESTRICTED | 2 |
CONFIDENTIAL | 3 |
SECRET | 4 |
| anything else | −1 |
The ladder is declared in two places, and a test holds them equal: ClearanceLevels /
CLEARANCE_RANK in @constellation-platform/auth-core, and the SQL function
public.clearance_rank() (platform-db migration 012_clearance_rank_internal.sql). If this table
ever disagrees with them, they are right. The convention for adding a classified column is
ADR-007.
Tiers are compared by rank, never as strings. 'CONFIDENTIAL' < 'RESTRICTED' is true
lexically and false on the ladder, so every policy, query and UI ordering goes through
clearance_rank() or CLEARANCE_RANK. A value outside the ladder ranks −1. As a classification
it is therefore below every clearance, but the wiki.classification domain refuses to store one.
As a clearance it is below every classification, so it admits nothing.
INTERNAL was inserted at rank 1 by PLT-497. The four older tiers kept their relative order, and
ranks are computed rather than stored, so no existing row or user clearance changed meaning.
Reading and writing a tier
The wiki validates a classification it reads and one it writes with two separate schemas,
ReadClassificationSchema and WriteClassificationSchema. Since
PLT-977 both hold all five tiers, and
the wiki.classification domain admits all five, so a caller with enough clearance can create,
reclassify and upload at any tier, INTERNAL included.
The split is kept on purpose: it is what lets a new tier reach the wiki consumer-first, across
two releases. That is how INTERNAL arrived, and
ADR-032
records why:
- The first release widens everything that reads a classification — API responses, list and search filters, the editor, the event validators — while the domain and the write schema still refuse the new tier.
- The second release widens what writes it.
During a deploy two builds serve at once. If one release both produced and read the new tier, the
newer build could create a page the older one cannot parse. Separating the two releases means that
by the time any build can write the tier, every serving build can already read it. With a single
schema this intermediate state could not be expressed, which is why the two stay separate even
while they hold the same values. For INTERNAL the two releases were PLT-502 and PLT-977. ADR-032
does not bind a future tier: that rollout decides its own posture, and widening a required event
enum then goes through the constitutional route.
Operators of Dedicated Cloud and On-Prem deployments must install those two releases in order: see the upgrade prerequisite.
Where the rule is enforced
For an ordinary query, the boundary is row-level security in the database. Each policy below
compares clearance_rank(classification) with clearance_rank(wiki.session_clearance()). That is
the clearance the request's transaction runs at, from the app.clearance setting, and it is
UNCLASSIFIED when the setting is missing or empty.
| Policy | Table | Command | Where the clearance comparison sits |
|---|---|---|---|
pages_tenant_read | wiki.pages | SELECT | USING |
pages_tenant_write | wiki.pages | INSERT | WITH CHECK |
pages_tenant_update | wiki.pages | UPDATE | both USING and WITH CHECK |
pages_tenant_delete | wiki.pages | DELETE | USING |
attachments_tenant_read | wiki.attachments | SELECT | USING |
attachments_tenant_insert | wiki.attachments | INSERT | WITH CHECK |
attachments_tenant_update | wiki.attachments | UPDATE | both USING and WITH CHECK |
attachments_tenant_delete | wiki.attachments | DELETE | USING |
The two arms of pages_tenant_update do different jobs:
USINGdecides which existing rows the update can reach. You cannot edit a page above your clearance, because the update never finds it.WITH CHECKdecides what the row may look like after the update. You cannot reclassify a page you can see to a tier above your clearance, because the new row is rejected.
Every wiki table with a tenant_id also carries a <table>_tenant_isolation policy (PLT-857). It
scopes rows to the tenant only, not to clearance, and is restrictive wherever the table has other
permissive policies. The wiki's runtime database role cannot bypass RLS,
and the app refuses to start on one that can, so these policies apply to its requests.
SECURITY DEFINER functions are the other place the rule lives. A definer runs with its
owner's privileges instead of the caller's. That does not switch RLS off by itself: the policies
above still filter its reads unless the owner is a role that bypasses RLS. A definer that can read
past them must apply clearance itself: wiki.restore_page, for example, takes the caller's
clearance as an argument and consults no row above it. A definer that deliberately reads
past the caller's clearance writes an audit entry on every call, and the definer census is checked
by two pre-push gates (check:wiki-definer-grants, check:wiki-definer-crossing).
Two layers sit above the database, and neither is the boundary:
- The tool layer refuses first. Creating a page, reclassifying one and uploading an attachment
are each checked against the caller's clearance before anything is written, and a tier above it
is refused with
403. That check gives a readable error. It is not what stops the write: RLS would still refuse a write that reached the database. A space's default classification is the exception:wiki.spacesis scoped by tenant only, so the application check is the whole of that rule (see Default classification). - The editor control is presentation. The classification control in the page editor and in the new-page dialog (PLT-556) shows the tiers in ladder order and disables those above your clearance, so it offers only what the database will accept. A client that is not the editor — the REST API, the MCP tools — never sees it, and loses nothing by not seeing it. One exception is deliberate: the tier a page already holds stays selectable even when it is above your clearance (after a downgrade, for example), because choosing it sends no classification change and makes an accidental click reversible.
A page above your clearance does not exist, as far as your request can tell. Reads filter it
out, so fetching it by id answers 404 rather than 403, and lists and search results omit it
without saying that anything was left out. Several features read past clearance by design: the
nomination-policy walk, the folder-run governance lock and the classification lint. Each of them
records an audit entry every time it runs. See
Classification auditing for the first two, and the
classification types under Check types for the lint.
Where a request's clearance comes from
The canonical clearance is a column, identity.users.clearance, owned by Directory. It is
NOT NULL with a default of UNCLASSIFIED.
A session token carries no clearance claim. Nothing writes one: the Supabase session's
app_metadata has no clearance, the mock login used in local development mints none, and
Directory's API-key issuance does not set one.
Since PLT-1010 the column fills
that gap. When a request enters a tenant, withValidatedTenant resolves the caller's membership
once. The same lookup now returns identity.users.clearance, and that value is written onto
ctx.user.clearance. The wiki's tools then open each transaction with that clearance, so it
becomes app.clearance and RLS compares against it. Two rules apply:
- A claim that is present wins. A signed token that deliberately carries a valid
clearanceclaim keeps it, and the column is not consulted. This is how a credential can be issued narrower than its user, and how the wiki's integration tests set a clearance. Today no production path issues such a token. - Each source fails closed. A token claim or forwarded clearance header outside the ladder is
dropped rather than passed on, and the Directory value then answers. A Directory value that is
missing, unreadable or unrecognised becomes
UNCLASSIFIED, never a higher tier.
The wiki's GET /api/auth/me reports the same clearance, so the browser is told the tier its
requests actually run at. The editor control and the client-side cache are keyed on it.
A changed clearance takes effect on the next request. The server reads it per request, so a
reload always shows exactly what you may read now. A tab that is already open learns of the change
when it next re-reads /api/auth/me; what it does then is described in
When a reader's clearance is lowered in an open tab.
Related behaviour
- Auditing a reclassification. A tier change is written as an audit-critical
wiki.page.reclassifiedentry, filed under the higher of the two tiers so that a downgrade is not dropped by a SIEM threshold. See Classification auditing. - A space's default tier. A page created without a classification inherits its space's
defaultClassification. Changing that default is gated on the caller's clearance in both directions. See Default classification. - Slugs are unique per classification. Two pages may share a slug at different tiers, so a write never collides with a page you cannot read (ADR-058). See Slug collisions on save.
- The knowledge base holds
UNCLASSIFIEDpages only. Raising a nominated page above that tier withdraws its nomination. See Lint.