Skip to main content

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:

Tierclearance_rank
UNCLASSIFIED0
INTERNAL1
RESTRICTED2
CONFIDENTIAL3
SECRET4
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:

  1. 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.
  2. 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.

PolicyTableCommandWhere the clearance comparison sits
pages_tenant_readwiki.pagesSELECTUSING
pages_tenant_writewiki.pagesINSERTWITH CHECK
pages_tenant_updatewiki.pagesUPDATEboth USING and WITH CHECK
pages_tenant_deletewiki.pagesDELETEUSING
attachments_tenant_readwiki.attachmentsSELECTUSING
attachments_tenant_insertwiki.attachmentsINSERTWITH CHECK
attachments_tenant_updatewiki.attachmentsUPDATEboth USING and WITH CHECK
attachments_tenant_deletewiki.attachmentsDELETEUSING

The two arms of pages_tenant_update do different jobs:

  • USING decides which existing rows the update can reach. You cannot edit a page above your clearance, because the update never finds it.
  • WITH CHECK decides 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.spaces is 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 clearance claim 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.

  • Auditing a reclassification. A tier change is written as an audit-critical wiki.page.reclassified entry, 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 UNCLASSIFIED pages only. Raising a nominated page above that tier withdraws its nomination. See Lint.