ObjectStackObjectStack

Authorization Architecture

The one-page map of ObjectStack authorization — the enforcement chain, combination semantics, package provenance, lifecycle coverage, and the CI governance — with its limits — behind "declared" equals "enforced". Stitches ADR-0049/0054/0056/0057/0066/0068/0069/0078/0086 into a single narrative.

This page is the consolidated overview of how authorization works across the platform. Every decision summarized here is owned by an ADR (index at the bottom) — this page adds no new decisions; it exists so you don't have to read nine ADRs to hold the model in your head.

TL;DR — A request passes through six gates, each fail-closed at its own layer: anonymous deny → declaration-derived public-form grant → object CRUD (permission-set union) → OWD/sharing → row-level security → field-level security. Grants union most-permissively; hard prerequisites (AND-gates) and fail-closed defaults are the only implicit denies. Enforcement is tracked in a conformance matrix whose CI ratchet covers a curated set of HTTP/transport entry points: a new ungated route there, or a deleted guard on one already pinned, fails the build. For a primitive enforced by a predicate inside a resolver the matrix is a hand-maintained ledger, not a checked one.


The three-way separation (ADR-0066)

Authorization splits into three concerns that stay decoupled:

  1. Capabilitywhat can be done (manage_users, export_data). Defined by the platform (curated PLATFORM_CAPABILITIES) or by a package via defineCapability; extended by admins in Setup. A capability is not a contract and has no inputs — a resource merely references one by name (see Requirement, below).
  2. Assignmentwho holds it — permission sets / positions / user bindings (sys_permission_set, sys_position, sys_user_permission_set, sys_position_permission_set, sys_user_position). Runtime records, maintained by admins in Setup — and, since ADR-0090 D12, a governed surface: writing them requires tenant-level administration or a covering delegated adminScope, never just CRUD on the tables.
  3. Requirementwhat a resource needs — an object / field / action references a capability as a contract. A resource never bakes in "who", only "what is required".

The enforcement chain

Every data request traverses these gates in order. Each names its enforcement site — the file you read when behavior surprises you.

#GateWhat it decidesEnforcement siteFailure direction
1Anonymous denyNo identity → HTTP 401. Uniform across every HTTP surface that reaches object data (#2567): REST /data and the metadata endpoints (/meta) — the raw-hono standard /data routes left the matrix when that duplicate surface was deleted in v17 (#4073), and the dispatcher GraphQL endpoint left when the GraphQL surface was removed (/graphql now 404s) — one shared decision, so a caller denied on /data can't read the same rows through a sibling door. Unconditional (#3963, closing out ADR-0056 D2): the api.requireAuth: false opt-out was retired in v17 — the key is tombstoned, so authoring it is a parse error, and anonymous callers are denied on every data surface with no deployment-level escape hatch. Narrower public surfaces do not need it — each derives its own authorization from a declaration rather than from the deployment posture: control plane (/auth, /health, /discovery) is allow-listed; public form submission carries a publicFormGrant (ADR-0056 Option A); share-links validate their token then read as SYSTEM; and an anonymous GET of the book/doc read surface is admitted so book.audience: 'public' works under the secure default, with the ADR-0046 §6.7 audience gate — 'public' only, fail-closed — doing the authorizing (#3963).packages/core/src/security/anonymous-deny.ts shouldDenyAnonymous — called by rest-server.ts enforceAuth and the dispatcher handleMetadata/handleAI (default in packages/spec/src/api/rest-server.zod.ts); a ratchet in authz-conformance.test.ts enumerates these entry points from source across a curated file list, and fails CI if a new surface ships ungated in one of those filesfail-closed
2Public-form grantAn anonymous form submission carries a declaration-derived publicFormGrant authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the guest position).packages/plugins/plugin-security/src/security-plugin.ts (ObjectQL middleware)scope-limited allow
3Object CRUDallowRead/Create/Edit/Delete (+ the destructive lifecycle class allowTransfer/Restore/Purge, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets.packages/plugins/plugin-security/src/permission-evaluator.ts checkObjectPermissionfail-closed 403
4OWD / sharingOrg-wide default (private / public_read / public_read_write / controlled_by_parent; unset or unknown ⇒ private, fail-closed — ADR-0090 D1) plus the external dial (externalSharingModel, ADR-0090 D11), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — Sharing Rules), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on sys_business_unit, not positions).packages/plugins/plugin-sharing/src/sharing-service.ts + sharing-rule-service.tsfail-closed to owner-only
5Row-level securityCEL predicates (using read filter, check write post-image) compiled into the query. If applicable policies exist but none of them compiles, the result is a deny-all sentinel (fail-closed); an object with no applicable policy is simply unfiltered at this layer — RLS narrows what the earlier gates already allowed, it never denies on its own. An uncompilable policy alongside compilable ones is excluded from the OR-union with a logged warning — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top.packages/plugins/plugin-security/src/rls-compiler.ts + security-plugin.tsfail-closed
6Field-level securityRead mask (strip non-readable fields) + write deny per fields rules. Caller queries that filter, sort, group, or aggregate by a non-readable field are rejected outright (HTTP 403, field_predicate_denied) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like owner_id).packages/plugins/plugin-security/src/field-masker.ts + predicate-guard.tsfail-closed on predicates; see posture note below

Two orthogonal identity-layer gates run before all of this: the ADR-0069 authentication-policy gate (password expiry / enforced MFA blocks a gated session from protected resources while keeping remediation reachable), and anti-escalation — RBAC tables are read-only for organization_admin, and since ADR-0090 D12 every RBAC-table write is additionally checked by the delegated-admin gate: tenant admins pass, delegates are confined to their adminScope (BU subtree + assignable-set allowlist, no self-escalation, strict containment for scope grants), and everyone else is denied. The everyone/guest audience-anchor bindings reject high-privilege sets at the data layer for every caller.

The MCP execution surface (ADR-0096 / ADR-0101)

The serve-side MCP server (@objectstack/mcp) is a first-class execution surface, not a side door around the chain above. Whatever an agent does over MCP reaches object data only through the same gates 3–6 as a REST request: a per-request, principal-bound bridge runs every tool through callData with the caller's ExecutionContext, so object CRUD, OWD/sharing, RLS, and FLS apply identically. Identity is admitted fail-closed at each transport boundary, and both transports hold an enforced row in the conformance matrix (mcp-http-identity, mcp-stdio-authority), so a fail-open regression breaks CI like any other surface.

  • HTTP (/api/v1/mcp, default-on — ADR-0096). The dispatcher resolves the same ExecutionContext a REST call would; no userId and not isSystem ⇒ 401 (advertising RFC 9728 OAuth metadata in WWW-Authenticate when the OAuth track is live, else a plain 401). An OAuth token additionally narrows the exposed tool families to its granted scopes (403 on none). Enforcement: packages/runtime/src/http-dispatcher.ts handleMcp + buildMcpBridge(context).
  • stdio (opt-in — ADR-0101). The long-lived local transport has no ambient identity: it mints its principal solely from OS_MCP_STDIO_API_KEY, resolved through the same resolveAuthzContext chain and re-resolved per call (revocation takes effect live). It is fail-closed — a missing / unknown / revoked / expired / owner-less key refuses to start the transport — with no system bypass (full authority means minting an admin or service key). Enforcement: packages/mcp/src/plugin.ts start().

Combination semantics (the fixed order)

From ADR-0066 Precedence / combination semantics — the contract, not an implementation detail:

  1. AND-gates first (hard prerequisites). A resource's requiredPermissions and a private object posture must ALL clear before any grant is consulted. Missing one denies regardless of everything else.
  2. Grants union (most-permissive). Object CRUD and field grants combine across all of the caller's permission sets — any set that allows, wins. Keys never collide across packages because object api names are package-namespaced.
  3. RLS: OR within an object, AND with tenant-global. Multiple row policies for the same object/operation OR-combine; the tenant wall (Layer 0, ADR-0095 D1) is a separate always-first AND conjunct, not an OR-mergeable policy. viewAllRecords / modifyAllRecords (super-user bypass, posture-gated) short-circuit the object's business RLS. Crossing the tenant wall, though, requires the PLATFORM_ADMIN posture (ADR-0099 D1). That posture is derived at one site from two anchors, either of which is sufficient: the deployment's configured administrator list — an address declared in OS_PLATFORM_OWNER_EMAIL, matched against the caller's own stored and email-verified sys_user row — and an unscoped admin_full_access grant row. See who holds admin_full_access for both. A scoped grant, or piecemeal platform capabilities (studio.access, manage_users, …), grant Studio/admin functions but never widen the tenant data boundary. A tenant organization_admin never crosses it (invariant I1).
  4. Explicit deny — reserved. There is no deny layer yet; the only implicit denies are the AND-gates in (1) and fail-closed defaults. Permission-set groups + subtractive muting (Salesforce-style) are the planned step 4 (ADR-0066 ⑦) — when they land they must cover field grants too (ADR-0066 ⑧).

FLS posture (ADR-0066 ⑧): runtime field security is block-list-shaped — an undeclared field is visible by default, and field grants union (one set's readable: true out-votes another's false). Until the muting layer lands, protect sensitive fields by granting them only in the sets that need them, and treat "sensitive field on a public object" as a review smell.

Package provenance & composition (ADR-0086)

The metadata↔config boundary follows one line: definitions travel with the package (metadata); subject bindings and env-specific values stay as config.

  • A package ships its own permission sets (Shape B), recorded with managedBy: 'package' + owning packageId on sys_permission_set (package_id / managed_by columns). bootstrapDeclaredPermissions (packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts) seeds stack.permissions at boot — idempotent, re-seeded on upgrade, and it never clobbers env-authored (platform/user/legacy) rows. A package never writes into a foreign record.
  • The environment admin assigns sets to positions/users; the runtime unions them. One shared set with hand-picked cross-package grants remains an env-admin-only construct.
  • This is what makes package uninstall well-defined (drop the package's own sets) and the objectui Access matrix scopable to { packageId }.
  • Declared positions and sharing rules seed the same way (bootstrapDeclaredPositions, ADR-0057 D6) — a declarable-but-never-seeded array is exactly the inert-metadata smell ADR-0078 prohibits.

Package capability declaration (ADR-0066 D1)

A package DEFINES its own authorization capabilities with defineCapability, collected on the stack's capabilities array — the declaration-side counterpart of the platform's curated PLATFORM_CAPABILITIES:

import { defineCapability, defineStack } from '@objectstack/spec';

export const ExportDataCapability = defineCapability({
  name: 'export_data',
  label: 'Export Data',
  description: 'Bulk-export records to CSV/XLSX.',
  scope: 'org', // 'platform' (global) | 'org' (scoped to the caller's org)
});

export default defineStack({
  manifest: { namespace: 'billing' },
  capabilities: [ExportDataCapability],   // ← DEFINE
  permissions: [{ name: 'billing_admin', systemPermissions: ['export_data'] }], // ← GRANT
  // a resource REQUIRES it:  requiredPermissions: ['export_data']
});

At boot bootstrapDeclaredCapabilities (packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts) seeds each declaration into sys_capability with managed_by: 'package' + package_id provenance — idempotent, re-seeded on upgrade, and it never clobbers admin-authored rows, refuses to hijack a curated platform capability, and refuses to write into another package's capability.

This replaces the implicit back-door where a capability existed only as an untitled placeholder derived from whatever a permission set happened to reference in systemPermissions[]. That derivation still runs for back-compat (a reference with no declaration keeps resolving as a managed_by:'platform' placeholder), but an explicit defineCapability takes precedence and — for a pre-existing derived placeholder — claims it, upgrading the row to package provenance with the authored label/description/scope. This is the ADR-0066 D1 direction: retire the implicit managed_by-guessing back-doors in favour of explicit, attributable declarations.

Remember the three-way separation: a capability is not a contract. You DEFINE it here, GRANT it via a permission set's systemPermissions, and REQUIRE it on a resource via requiredPermissions. There is no inputs.

Two doors, one metadata (ADR-0086 D6/D7)

The managedBy provenance axis is not just descriptive — the platform populates and gates on it, so editing a permission set flows through exactly one of two doors, each writing only what it owns:

  • Package door (studio /studio/:packageId/access) — a package's own set is metadata, so edits are saved as a draft stamped with the packageId (saveMetaItem mode:'draft') and go live with the package's atomic Publish, exactly like Data and Interfaces. On publish, a registered materializer (registerPublishMaterializer) projects the published body into sys_permission_set as managedBy:'package' + packageId, reusing the same upsert as the boot seeder (upsertPackagePermissionSet). Enforcement is unaffected while a set is still a draft (drafts never enter the active resolve).
  • Environment-admin door (metadata-admin) — the cross-package all-objects matrix plus subject assignment (sys_user_permission_set, sys_position_permission_set), edited live (config). It owns env-authored sets (managedBy platform/user) and assignments — not package sets. Since ADR-0094 the set definition itself has one authoritative store — the metadata layer: an env-door write to sys_permission_set (the Setup CRUD) is transparently redirected into an env-scope metadata save (saveMetaItem), and the data record is a pure projection the platform re-derives on every metadata mutation (awaited — no staleness window) and at boot. Deleting an artifact-backed set through this door resets it to its declared body rather than removing it; renaming through the data door is rejected (the name is the metadata identity — clone instead). Subject assignments remain plain config rows, unchanged.
  • Data-layer gate (evolved by ADR-0094) — the security middleware still refuses any payload that forges package provenance (insert or update, single or array) and the lifecycle ops with no overlay translation (transfer/restore/purge) on package rows, failing closed ahead of the CRUD check — even a modifyAllRecords super-user is blocked. An ordinary admin-door edit of a set whose definition ships as a code artifact is refused with 403 not_overridable, loudly, at the moment of the write: the ADR-0094 write-through still translates the edit into a metadata save, but permission declares allowOrgOverride: false (ADR-0005's security row — overlays of the authorization surface would create silent privilege drift), so the tier gate refuses it and no env-scope overlay is minted. The supported channel is the one ADR-0086 two-doors always named: edit the package and re-publish. (ADR-0094 D5's 2026-07-14 direction — translate such an edit into a first-class ADR-0005 env-scope overlay — was retired on 2026-08-09; see ADR-0094 D5-R.) A set authored through the data door, whose definition lives only in sys_metadata, rides the still-open allowRuntimeCreate tier and stays editable. A "delete" of a packaged set through this door still degrades to a reset to the shipped declaration — the admin door can never remove a packaged definition. System / boot writes carry isSystem and bypass it, so the seeder and materializer are never self-blocked. That bypass is not local to this gate — the full set of behaviours the flag changes is catalogued in System Context (isSystem).

Lifecycle coverage (five stages)

StageWhat holds todayOwned by
1 · Package developmentZod-validated authoring; positions / sharingRules / permissions seeded at boot with provenanceADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates
2 · Distribution / install / upgrade / uninstallInstall-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-definedADR-0025/0028/0048/0086
3 · Environment composition / assignmentPlatform-owned assignment records (sys_user_position etc.); anti-escalation; union semanticsADR-0057 D4
4 · Runtime enforcementThe six-gate chain above; each enforced primitive holds a row naming its enforcement site — the matrix file is the authority on the count and on which rows CI can checkADR-0056 D10 matrix
5 · Production / enterpriseADR-0056 D8 dispositions settled (2026-07): compliance configs, data masking, the global RLSConfig, and agent visibility were removed (never enforced — for visibility, correct owner/org enforcement is undesigned, so it was dropped rather than carried; re-introduce under #1901); field encryption stays honestly [EXPERIMENTAL] (roadmap — stable schema shape). Enterprise authentication hardening staged per ADR-0069ADR-0049/0056 D8, ADR-0069

Explaining a decision (ADR-0090 D6)

The security kernel service exposes explain(request, callerContext) — the first-class answer to "why can 张三 PATCH 李四's leave_request?". It walks the SAME code paths the middleware enforces with (shared set resolution, evaluator, FLS mask, RLS composition — explained by construction) and reports every pipeline layer in order:

principal → required_permissions → object_crud → fls → owd_baseline
         → depth → sharing → vama_bypass → rls

Each layer carries a verdict (grants / denies / narrows / widens / neutral / not_applicable), a human explanation, and contributor attribution — which permission set granted, reached via which position / additive baseline / direct grant. For reads, the decision includes the composed row filter as the machine artifact.

The same report is reachable over REST as GET/POST /api/v1/security/explain (object, operation, optional userId; validated against ExplainRequestSchema). The endpoint is authenticated-only and delegates to the service, so both surfaces share one authorization rule: explaining another user requires the manage_users capability or a delegated adminScope whose business-unit subtree covers that user (ADR-0090 D12) — an administrator who can rewire a user's grants may read why they resolve as they do. Studio's Access pillar ships a "why can this user access?" panel on top of this endpoint.

Full request/response walkthrough, layer vocabulary, and caller-authorization details: Explain Engine.

Grant lifecycle: validity windows (ADR-0091 L1)

Every user-grant row (sys_user_position, sys_user_permission_set) carries optional effective-dating columnsvalid_from / valid_until (half-open [from, until), UTC; null = unbounded).

ADR-0091 D1 declared four further nullable columns on both grant tables: reason, delegated_from, last_certified_at, certified_by. Three of the four are still declared on both; delegated_from now exists only on sys_user_position — it was retired from sys_user_permission_set under ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): the runtime delegation gate is structurally scoped to the position table, so on the permission-set table the column was writable provenance no runtime consumer ever read. The columns are declared together but enforced separately, so they are listed here one by one rather than as one set of audit columns — what a value in any of them is worth depends on the column and on which grant table it sits on. Access recertification is a compliance surface (SOX / ISO 27001 access review), where "the platform maintains this column" and "the platform stores what you write here" are very different statements:

ColumnOn sys_user_positionOn sys_user_permission_set
reasonEnforced at runtime. The D3 delegation gate rejects a delegation insert whose row carries no non-empty reason — the dual-audit half described below.Written by the platform, read by nothing. The org-admin grant auto-derived from a membership grade stamps its own provenance here; no gate, resolver or lint reads the value back.
delegated_fromEnforced at runtime, and load-bearing. Stamping it is what makes a write a delegation: the gate requires it to name the writer and refuses to re-delegate a row that itself arrived by delegation, and the explain engine attributes the position "via delegation from X, until Y".Not declared — retired. Removed under ADR-0049 enforce-or-remove (maintainer ruling 2026-08-18): both runtime readers opposite are guarded on sys_user_position, so here the column was provenance an author could record and nothing checked or acted on. A row written with the key today is refused as an undeclared field (400 INVALID_FIELD). If permission-set-granularity delegation is ever wanted, the column returns together with a runtime reader in the same change.
last_certified_at / certified_byInert — the ADR-0091 D5 recertification substrate, storage and nothing more.Inert — identically.

The D5 pair is worth spelling out, because it is the pair a compliance reader is likeliest to over-read: no framework code writes either column and none reads either one, on either table — no resolution path, gate or lint consults them, and nothing derives "never certified" or "certification stale" from them. A null therefore means the recertification workflow does not exist here, not that the grant went unreviewed; a value means some client wrote one, and the platform checked nothing about it. Their field descriptions on both objects say the same in the same words, and ADR-0091 D5 is where that split was decided: framework ships the substrate, cloud ships the campaign.

Correctness lives in resolution-time filtering, fail-closed (ADR-0091 D2): a row outside its window simply stops resolving — in resolveAuthzContext, the explain engine, sharing-rule position expansion, and (transitively) the delegated-admin gate's held-scope resolution. No background cleanup job is involved (ADR-0049); the clock is checked on every resolution. An expired unscoped admin_full_access grant no longer derives platform_admin — a statement about the grant anchor. The configured-administrator anchor is not a stored grant row at all, so it carries no validity window and nothing here dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated contributor state ("held until 2026-08-01 — expired"), so "why did access disappear" is self-answering. The state member is one shared "held but not resolving, because X" vocabulary: a closed enumeration of reasons (expired here; deactivated for the ADR-0049 active switch below), extended only deliberately — every lifecycle control that silently drops a held grant answers through the same member rather than growing its own shape. Two authoring lint rules cover seed grants: one mirrors D2 — a seed grant whose valid_until is already past (or unparseable) is dead on arrival (error) — and one mirrors the D3 dual audit: a delegation row (delegated_from) without reason is an error. The first runs on both grant tables; the second runs on sys_user_position only, the one table that declares delegated_from — on sys_user_permission_set this lint used to be the retired column's only enforcement, which is precisely why the column is gone rather than still linted.

Delegation of duty (职务代理, ADR-0091 D3) builds on this substrate and is enforced today. A position opts in with delegatable: true; a holder may then self-service assign it to a delegate WITHOUT being an administrator — the D12 gate grows a branch that approves a sys_user_position insert iff it is a well-formed delegation: delegated_from = the writer, a mandatory valid_until within the 30-day ceiling, a mandatory reason, and the writer holds the position directly (a grant that itself arrived via delegation is not re-delegatable — chains are cut). Delegation is insert-only, so a "temporary" grant can't be silently rolled forever (no self-renewal); continuing past expiry needs a fresh delegation, leaving a new audit record. A delegatable position may never distribute an adminScope-carrying set — administration is never self-delegated (that would bypass the D12 containment). The write is dual-audited (granted_by = writer, delegated_from = authority source) and the explain engine attributes a delegated hat "via delegation from X, until Y".

Break-glass activation and recertification campaigns remain enterprise product — see ADR-0091 D4–D7 for the open-core line; their community shapes (a time-boxed direct grant with a reason; certification stamps) are the L1 substrate above.

Grant lifecycle: the active switch (ADR-0049)

Validity windows above date a user's grant row. The second lifecycle control dates the catalogue row itself: sys_permission_set.active and sys_position.active, the switch behind the Deactivate action on both objects. It answers a different question — "switch this grant off for everyone, without deleting it or unwinding the assignments" — and it is enforced in the same place, by the same discipline: resolution-time filtering, fail-closed, in resolveAuthzContext, with no cleanup job involved.

  • A deactivated permission set contributes nothing: not its name, not its system_permissions, not its tab_permissions. As with an expired grant, a deactivated unscoped admin_full_access no longer derives platform_admin — the flag is applied before the posture is derived, not after. A configured administrator is judged elsewhere: that anchor reads the deployment's configuration and the caller's own sys_user row, never the catalogue row, so the flag does not decide their standing.
  • A deactivated position stops carrying its permission sets, and its name stops appearing in positions, so a permission set that merely shares the position's name cannot resolve through it either.
  • Assignments are untouched. sys_user_position and sys_user_permission_set rows stay exactly as they were, and re-activating the catalogue row restores every grant it carried, at the next resolution.

The explain engine reports a deactivated-but-held row with the same dedicated contributor state the expired case gets (state: 'deactivated', "held — deactivated") — the shared vocabulary above. This matters more here than for an expiry: deactivation is an incident-response control, installation-wide, and there is no date on the user's own grant row to notice — without the state, the admin diagnosing "why did access disappear" would get the same answer as for a user who never held the grant, with no pointer to the catalogue row somebody switched off. Reported for the grant rows explain already walks: sys_user_position rows and direct sys_user_permission_set grants (a deactivated set reached only through an active position's linkage is not re-derived — that would replicate the resolver's aggregation).

Absent is ACTIVE. Only a stored value that really reads false takes a grant away, so a row that predates the column keeps granting. The same predicate (isRowActive) is used by every reader, including the last-administrator guard's simulation — a guard that modelled "deactivated" differently from the resolver would permit exactly the write it exists to refuse.

Deactivating the break-glass set is refused. Switching admin_full_access off un-makes every platform admin who holds it through a grant row, in one write — and re-activating it needs the permission just lost. That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while it would leave the environment with no administrator who can sign in. The enumeration behind that judgement counts both anchors, so an environment whose administrators are configured rather than granted is not empty, and the write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does not touch is deliberate. Administration surfaces keep listing and editing a deactivated position — an admin must still be able to unbind and clean up what they just switched off — and the write gates that judge audience-anchor bindings and a delegated administrator's blast radius keep reading every row, deactivated included: dropping rows there would make a refused binding permitted and a delegate's boundary narrower, which is the opposite of switching access off.

Governance: how "declared = enforced" is kept true

Five mechanisms — four CI-time, one runtime — make the security posture a checked artifact rather than a belief:

  • Security publish linter (ADR-0090 D7, validateSecurityPosture in @objectstack/lint, gating os compile — and, since the #7891 rollout completed with #8310, the runtime publish door for object / permission / book / seed writes, where a gating finding refuses the save with 422 INVALID_METADATA and the rule id in issues): unset OWD on custom objects (security-owd-unset — an object publish with no authored sharingModel is refused; absence is not a decision, at the CLI and at the runtime door alike), retired OWD aliases, an external dial wider than internal (security-external-wider-than-internal), '*' wildcards carrying View/Modify All outside the platform admin set, high-privilege isDefault (everyone-suggested) sets, the reserved word "role" in security identifiers, a controlled_by_parent object with no relation the platform can derive access from (ADR-0055: no required master_detail, no master_detail at all, and no required lookup — so the runtime denies every read and refuses every write), and the ADR-0091 grant-lifecycle rules (a seed grant already expired at authoring time; a delegation row missing its mandatory reason) — every error rule mirrors a runtime gate.

  • Runtime OWD posture gate (#3050, objectPostureGate in @objectstack/plugin-security, registered on the metadata protocol's pre-persistence registerAuthoringGate seam): the packaged-baseline rule no lint rule can judge, enforced on every runtime-authored object body — Studio drafts, REST saves, AI builders. An environment overlay of a packaged object may only tighten sharingModel / externalSharingModel, never widen them beyond the packaged declaration (403 owd_widening_forbidden — widen it in the package source and publish instead; this closes the OS_METADATA_WRITABLE=object escape hatch as an unvalidated widening path, ADR-0086 D1). Write-path only: stored metadata keeps loading unchanged. The gate's former second rule (403 owd_external_wider, external ≤ internal) was retired as a duplicate when the lint block crossed to the runtime door (#8310 maintainer ruling): the 422 lint door answers first for external-wider and for unauthored-OWD bodies, and the 403 gate remains for packaged-baseline widening only.

  • Access-matrix snapshot (ADR-0090 D6, buildAccessMatrix / diffAccessMatrix): with access-matrix.json committed next to the config, os compile fails on any capability drift with semantic lines ('crm_admin' gains delete on 'crm_lead') until the snapshot is updated via --update-access-matrix — the snapshot's git diff is the review artifact. Opt-in, format, and workflow: Access-Matrix Snapshot Gate.

  • Conformance matrix (packages/qa/dogfood/test/authz-conformance.matrix.ts, ADR-0056 D10): every authorization primitive sits in exactly one honest state — enforced (must name its enforcement site; high-risk rows must reference an end-to-end dogfood proof), experimental, or removed. CI checks the row shape, that every cited proof file exists, and that the row ↔ proof pairing is mutual in both directions (#7976) — a cited proof must name the rows it proves via a header // authz-row: <id> line, so a row cannot cite a test that exercises a neighbouring primitive, and a shared proof file has to say which rows it covers. A surface ratchet additionally enumerates HTTP/transport entry points from source over a curated file list: a new ungated route in one of those files fails CI as an unclassified surface, and deleting a guard that a row pins makes the pinned key vanish from source, so the row goes stale and the build goes red. Most keys pinned this way name the enforcement call rather than merely a function name, which is what makes them anti-regression — removing the check breaks CI, not just renaming something.

    What CI does not check is "one row per primitive" itself. A primitive enforced by a predicate inside an existing resolver adds no HTTP entry point, so the ratchet cannot see it — and most enforced rows are exactly that shape. For them the matrix is a hand-maintained ledger kept honest by review. Read a row as "this is where the check lives", not as "CI proves nothing was forgotten"; the matrix header carries the measured breakdown.

  • Liveness ledger (packages/spec/liveness/, ADR-0049/0054): every governed spec property is classified live / experimental / dead, with author-time warnings for declared-but-unenforced flags.

The operating rule behind both (ADR-0049): never advertise a capability the runtime doesn't deliver — enforce it, mark it experimental, or remove it.

Known gaps & roadmap

The complete, prioritized gap map lives in issue #2561 (the production "definition of done" for authorization). The headline items:

  • Deny/muting layer (ADR-0066 ⑦⑧) — union-only grants can't take access away; needed for large-org governance and packaged-set adjustment.
  • Capability registry (ADR-0066 D1) — landed: capabilities are seeded as first-class sys_capability records from the canonical list in @objectstack/spec (security/capabilities.tsPLATFORM_CAPABILITIES), seeded by packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts. A package now DEFINES its own capabilities explicitly via defineCapability / stack.capabilities, seeded with managed_by:'package' + package_id provenance by bootstrap-declared-capabilities.ts — replacing the implicit derive-from-systemPermissions back-door (which stays for back-compat). The authoring lint (ADR-0066 ⑨) is also landed: validateCapabilityReferences (@objectstack/lint) warns at author time (os validate / os lint) when a requiredPermissions names a capability registered nowhere — no built-in, no permission set grants it via systemPermissions, no sys_capability seed.
  • Per-operation requiredPermissions (ADR-0066 ⑤) — landed: an object's requiredPermissions may be a string[] (gates all CRUD) or a { read, create, update, delete } map (read-open / write-gated), enforced per operation by plugin-security (security-plugin.ts capability AND-gate).
  • Secure-by-default rollout (ADR-0066 ④) — system-object slice landed: the raw secret/credential stores (sys_secret, sys_jwks, sys_verification, sys_oauth_access_token, sys_oauth_refresh_token, sys_device_code) declare access: { default: 'private' } — no wildcard grant reaches them; platform admins retain access via the posture-gated superuser bypass. sys_sso_provider is capability-gated (manage_platform_settings). Member self-service objects (sys_session, sys_api_key, sys_oauth_application, sys_two_factor) deliberately stay public-posture (the Account app reads them as the member; row scoping is their guard). Still open: Studio posture surfacing (objectui).
  • Deny/muting subtract layer (ADR-0005 overlay; ADR-0066 precedence step 4) — how an environment adjusts a packaged set without forking it; deferred until proven need (ADR-0086 P2).
  • Enterprise authentication (ADR-0069) — password policy, lockout, enforced MFA (P1) and session lifecycle + global IP allowlist + shared multi-node rate-limit store (P2) are landed; the remaining gap is per-org allowed_ip_ranges (#2571). SSO/SCIM is P3.

ADR index

ADROwns
0049No unenforced security properties (enforce / mark / remove)
0054Prove-it-runs — high-risk classes need runtime proofs
0056Permission-model landing: OWD, anonymous deny default, D10 matrix
0057Business units, scope depth, declarative RBAC seeding, platform-owned assignment
0066Unified model: capability registry, posture, precedence, future refinements
0068Built-in identity positions (formerly "identity roles"), EvalUser
0069Enterprise authentication hardening (phased)
0078No inert declarable metadata
0086Metadata↔config boundary, package provenance, cross-package composition
0090Permission Model v2: position rename + vocabulary freeze, profile removal, fail-closed OWD default + external dial, audience anchors, principal taxonomy, publish linter, delegated administration, explain engine + access matrix
0091Grant lifecycle: validity windows + resolution-time filtering (L1, landed), delegation, break-glass, recertification substrate
0096Execution-surface identity admission — no data-engine call without an explicit principal (the MCP HTTP surface admits identity here)
0101MCP stdio principal admission — env-supplied API-key identity, fail-closed, no system bypass

On this page