ObjectStackObjectStack

ObjectQL security and access control

Comprehensive security model - ACL, field-level security, row-level permissions, and data isolation

Security Protocol

ObjectStack implements a multi-layered security model that enforces access control at the data layer, before queries execute. Security is declarative—defined as metadata (permission sets, row-level policies, sharing rules), not scattered in application code.

Security Philosophy

Traditional approach:

// Security in application code (easily bypassed)
app.get('/api/accounts', (req, res) => {
  // Developer must remember to check permissions
  if (!req.user.hasPermission('read_account')) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  // Easy to forget row-level filtering
  const accounts = await db.query('SELECT * FROM account');
  res.json(accounts);
});

ObjectStack approach:

// Security as metadata (enforced automatically)
// permission set: sales_rep grants read on account
// row-level policy filters by ownership

const accounts = await dataEngine.find('account');
// owner_id == current_user.id (CEL) → compiled to a row filter by the RLS compiler

The model is composed of three distinct metadata types (see packages/spec/src/security and packages/spec/src/identity), per the Permission Model v2 vocabulary (ADR-0090):

  • Permission Set (permission metadata) — the only capability container: CRUD, FLS, RLS, scope depth, View/Modify-All, system permissions. There is no profile concept — a user's capability is the union of every set they hold.
  • Position (position metadata) — the flat distribution layer: who holds which permission sets. Hierarchy lives in the business-unit tree (sys_business_unit), never on positions.
  • Sharing Rule + Organization-Wide Defaults (OWD) — broaden the baseline visibility set for an object.

Security Layers

Object-Level Security (CRUD)

Who can Create, Read, Edit, Delete which objects (allowCreate/allowRead/allowEdit/allowDelete)

Field-Level Security

Per-field readable/editable flags (e.g., hide salary from non-managers)

Row-Level Security

Filter which records users can see via USING/CHECK policies (e.g., see only own accounts)

Masking & Encryption

Mask or encrypt sensitive field values at rest and on read


1. Object-Level Security (CRUD Permissions)

Object permissions live inside a permission set. Each entry in the objects map keys an object name to a boolean grant. The full ObjectPermissionSchema is defined in packages/spec/src/security/permission.zod.ts.

Basic Permission Set

# sales_rep.permission.yml
name: sales_rep            # lowercase snake_case, required
label: Sales Representative
objects:
  account:
    allowCreate: true
    allowRead: true
    allowEdit: false
    allowDelete: false
  opportunity:
    allowCreate: true
    allowRead: true
    allowEdit: true
    allowDelete: false

Beyond the four CRUD flags, the schema also exposes lifecycle and super-user grants:

FlagMeaning
allowCreate / allowRead / allowEdit / allowDeleteStandard CRUD
allowTransferChange record ownership (assign/reassign/disown owner_id) — enforced now via the insert/update owner_id guard (#3004); the dedicated transfer op is still M2
viewAllRecordsRead every record, bypassing sharing & ownership
modifyAllRecordsWrite every record, bypassing sharing & ownership

The former allowRestore / allowPurge flags were retired (#12497, ADR-0049): the restore / purge operations they claimed to gate have never existed, so authoring them granted nothing — the schema now refuses them with a migration prescription. A dispatched restore / purge is denied unconditionally (fail-closed destructive-operation backstop). The flags return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883).

One exception, by ruling: the boolean literal false — the default the published 17.x toolchain emitted into every built artifact — parses as inert residue and is silently stripped (#12840), so it raises no error and reaches no parsed output. Every other value, "false" and 0 included, is refused exactly like true; this is not a truthy/falsy split. Since neither key can reach validated data, guards belong in pre-parse tooling over raw sources, never in code reading a parsed permission set.

Permission sets are additive-only: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in everyone baseline (ADR-0090 D5). A true anywhere wins; there are no subtraction sets — to withhold, don't grant.

Permission Check Flow

User requests: GET /api/v1/data/account/123
    ↓
1. Object-Level Permission
   ✓ Does the user's effective permission set grant allowRead on 'account'?
    ↓
2. Row-Level Security (RLS)
   ✓ Does a USING policy admit record 123?  (unless viewAllRecords)
    ↓
3. Field-Level Security (FLS)
   ✓ Strip fields whose FieldPermission.readable is false
    ↓
Return filtered result

2. Row-Level Security

Row-level filtering is expressed as RLS policies (RowLevelSecurityPolicySchema in packages/spec/src/security/rls.zod.ts). Policies carry a CEL using clause (the rows a SELECT reads and an UPDATE or DELETE may target) and/or a check clause (judged on every row an INSERT or UPDATE writes, an array insert and a multi: true update included; when no applicable policy declares check, each using stands in) — canonical CEL since ADR-0058; a legacy SQL-style =/IN (...) predicate still compiles via a deprecated bridge (warns). On reads, multiple policies for one object are combined with OR; for the write check, see RowLevelSecurityPolicySchema.check. Available context variables are the unique identifiers and membership sets the runtime pre-resolves: equality predicates may use current_user.id, current_user.email (the unique, seedable owner anchor), or current_user.organization_id; set-membership predicates may use id in current_user.org_user_ids, 'manager' in current_user.positions, or any §7.3.1 set staged in ExecutionContext.rlsMembership. Display name and arbitrary user fields are intentionally not resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.

Policies can be attached to a permission set via its rowLevelSecurity array, or registered as standalone metadata.

Owner-Based Access

rowLevelSecurity:
  - name: opportunity_owner_access
    object: opportunity
    operation: select
    using: 'owner_id == current_user.id'
// At runtime the RLS compiler injects:
// WHERE owner_id = '<current_user_id>'
const opportunities = await dataEngine.find('opportunity');

Tenant Isolation

organization_id maps to ExecutionContext.tenantId. To enforce multi-tenant isolation, define a policy with both using and check:

rowLevelSecurity:
  - name: account_tenant_isolation
    object: account
    operation: all
    using: 'organization_id == current_user.organization_id'
    check: 'organization_id == current_user.organization_id'

The RLS helper factory exports RLS.tenantPolicy(object) / RLS.ownerPolicy(object) / RLS.positionPolicy(...) to generate these.

Position-Scoped Access

positions restricts a policy to users holding one of the named positions (ADR-0090 D3; formerly roles); omit it to apply to everyone:

rowLevelSecurity:
  - name: manager_team_access
    object: task
    operation: select
    using: 'assigned_to_id in current_user.team_member_ids'  # pre-resolved §7.3.1 set — NOT a subquery (ADR-0055)
    positions: [manager, director]

Regional / Territory Access

rowLevelSecurity:
  - name: regional_sales_access
    object: account
    operation: select
    using: 'territory_id in current_user.territory_ids'  # pre-resolved set (current_user.region is not exposed)
    positions: [sales_rep]

Time-Based Access

rowLevelSecurity:
  - name: active_records_only
    object: contract
    operation: select
    using: "status == 'active'"  # date-window/function predicates (NOW(), arithmetic) are NOT pushdown-able (ADR-0055) — enforce time windows in the app layer or a pre-resolved set

RLS conditions are compiled to parameterized queries, and the enforcement path fails closed: an applicable policy that cannot be compiled denies (returns zero rows) rather than being dropped. (The old global RLSConfigSchema was removed in 2026-07 — it was never read by the enforced path; per-policy RowLevelSecurityPolicySchema is the live surface.)


3. Field-Level Security

Field permissions are a map on the permission set keyed by <object>.<field>, each with two boolean flags (FieldPermissionSchema):

# sales_rep.permission.yml (continued)
fields:
  user.salary:
    readable: false
    editable: false
  user.ssn:
    readable: false
    editable: false
# hr_manager.permission.yml
fields:
  user.salary:
    readable: true
    editable: true

Behavior:

// Sales rep reads a user — salary/ssn stripped (readable: false)
const u = await dataEngine.findOne('user', { where: { id: '123' } });
// { id: '123', name: 'John Doe', email: 'john@example.com' }

// HR manager (different permission set) reads the same user — salary visible
const u2 = await dataEngine.findOne('user', { where: { id: '123' } });
// { id: '123', name: 'John Doe', email: '…', salary: 120000 }

Field Permission Reference

fields:
  <object>.<field>:
    readable: true | false   # default true  — can see the field
    editable: true | false   # default false — can modify the field

There is no separate create flag for fields. editable governs both create-time and update-time writes; read stripping is governed by readable.

Static readonly fields on the write path

readonly: true on a field definition is a schema-level lock, independent of permission sets: no permission set can grant a write to it. It is enforced by stripping, not by rejecting — the offending key is removed from the payload and the rest of the write is committed. The write therefore succeeds (REST answers 200), and the read-only column simply keeps its stored value.

Five rules decide whether a given value survives:

#RuleEffect
1Trusted context is exemptA write carrying context.isSystem === true skips the strip entirely and may set read-only columns.
2Only caller-supplied keys are candidatesThe engine snapshots the payload's keys at entry (suppliedKeys), before middleware and beforeUpdate hooks run. Only keys in that snapshot can be stripped.
3Hook / middleware backfill survivesA key a beforeUpdate hook adds to data is absent from the entry snapshot, so it is not a candidate — this is why the built-in updated_by / updated_at stamps land even though those columns are readonly.
4context.preserveAudit admits a whitelist — on UPDATE onlyAn opt-in historical import reinstates the audit/timestamp family and author-declared business readonly fields; platform-managed system columns (tenancy, generated) stay stripped. This exemption exists on the UPDATE path and nowhere else — see below.
5Hooks are shown the persist image, not the submission (#16344, #22306)On UPDATE, a caller-supplied value for a readonly field is hidden from ctx.input.data before beforeUpdate is dispatched, so a hook cannot derive a persisted column from a value the row will never hold. What the caller actually sent is on ctx.submitted — diagnostics only, never the persist image. On INSERT the same value is withheld from beforeInsert, before the defaults run, so the hook sees what the create stores; a create has no ctx.submitted.

Rule 2 selects the candidates; rules 3 and 5 decide what a hook can do about one. A key the caller never sent is not a candidate at all, which is why the built-in updated_by / updated_at stamps land. A key the caller did send stays a candidate — but a hook that assigns it owns the value standing on it and the strip keeps that write (#5591 / #14088: authorship is recorded while the hook writes happen, not inferred from value equality afterwards). So a beforeUpdate hook can both backfill a read-only field and overwrite one the caller supplied; what it can no longer do is rescue the caller's own value, because since #16344 that value is not on ctx.input.data for it to echo back.

`beforeUpdate` no longer sees the caller's read-only values (#16344)

This is a breaking change to what a hook reads, not to what is stored: the accept / refuse set is unchanged, onFieldsDropped reports the same fields under the same readonly reason, the WARN says the same sentence, and strictReadonlyWrites refuses the same writes. What moved is the hook's view.

  • A handler deriving a column from a read-only field reads the stored row on ctx.previous, or treats the key's absence as "unchanged" — which is what absence always meant for a field the caller never sent.
  • A handler reporting on what the caller sent (a guard naming an offending key) reads ctx.submitted.
  • A self-assignment (data.x = data.x) on such a field is now a no-op — the stored value stands, and the write is stripped and reported exactly as an un-hooked one is.
  • ctx.submitted is not marshalled onto the sandboxed body face, and objectstack build refuses to lower a handler that reads it (the handler is bundled instead). A body deriving from a read-only column reads ctx.previous, the stored row, which is the correct source either way.

readonlyWhen locks are deliberately still hook-writable (#9107).

beforeInsert follows the same rule since #22306, while the create-side strip itself stays after the hooks (#14147). A caller-supplied value the strip will take is withheld before the defaults run, so the hook sees exactly what an honest caller's hook sees — the field's defaultValue, or no key — and a hook that stamps an absent read-only column (if (!data.x) data.x = …) stamps it whether or not the caller sent one. Two values stay visible: a runtime-owned autonumber (#6339), and, under preserveAudit, the audit timeline the historical-import channel reinstates through a beforeInsert hook. There is no ctx.submitted on a create.

preserveAudit is an UPDATE-path exemption. It does not apply on INSERT (#6640). Both write paths run the same strip inside the engine (stripReadonlyFields, #14147), but they read it differently: UPDATE consults preserveAudit, while CREATE is passed no such flag and keeps context.isSystem as its only exemption. So one historical import that upserts keeps an author-declared readonly column on the rows it updates and strips it from the rows it creates.

That asymmetry is deliberate, not an oversight: treatAsHistorical arrives on an ordinary (non-system) REST import request, so honouring it on create would let any caller seed the approval/status columns the create-side strip exists to protect — in a single POST. To replay archival read-only facts on create, write from a system context (isSystem), the same trust marker rule 1 already names.

A non-system create that requests preserveAudit and loses fields to the strip is not silently ignored: the server logs a WARN naming the object, the stripped fields, and this UPDATE-only rule. The strip still applies — the warning replaces the silence, not the enforcement.

Server-side plugins are not trusted by default. ctx.getService('data') hands back the same engine the REST layer uses, with an empty execution context — so a cron job or background task writing a system-computed read-only column travels the same strip as untrusted client input, and its value is dropped while the call reports success. Trusted server code must say so:

const data = ctx.getService('data');
await data.update('attendance', { id, status: 'closed', work_duration: 480 },
  { context: { isSystem: true } });   // ← required to write a `readonly` column

isSystem is the documented convention for this, and it is deliberately explicit: it bypasses permission checks too, so it declares "this write is platform code acting as the platform", not "this write came from a plugin".

The read-only strip is one of 80 behaviours the platform keys off that flag, across 18 packages — ownership stamping, referential-integrity checks and sharing-grant materialisation all change with it, and none of them is visible from the write payload. Before setting it, read the full census: System Context (isSystem).

Detecting a drop programmatically. Every strip pass reports itself to the caller through options.onFieldsDropped, so a caller that reports per-field success (a flow's update_record step, an import runner) can surface a warning instead of a clean success:

await data.update('attendance', { id, work_duration: 480 }, {
  onFieldsDropped: ({ object, fields, reason }) => {
    // { object: 'attendance', fields: ['work_duration'], reason: 'readonly' }
    logger.warn(`dropped ${fields.join(', ')} on ${object} (${reason})`);
  },
});

reason is 'readonly' for this static lock and 'readonly_when' for a conditional readonlyWhen predicate. Two more values report legal strips that are not read-only locks: 'primary_key' — an update payload whose id the engine has already ruled is not an identifier is dropped rather than written over the targeted row's primary key — and 'computed' — a value for a formula field, which the engine computes on every read and no driver has a column for, is dropped on every write in every context, isSystem included. The vocabulary is open — it grows as the write path gains legal strips — so branch on reason exhaustively rather than treating "not readonly_when" as "read-only". The listener is an in-process callback: it is delivered by the local engine, and does not cross the RPC / Virtual Data Engine boundary, so a remote caller never receives these events. Without a listener, the only trace is a server-side WARN naming the object, the field, and both remedies.


4. Data Masking

Status: REMOVED (2026-07, ADR-0056 D8 "design + enforce, or remove"). MaskingRuleSchema / MaskingConfigSchema were deleted from the spec: no redaction layer ever applied them, and the per-field maskingRule property had already been pruned in 2026-06. To keep a value out of reader hands, use FLS (readable: false field permissions — enforced by plugin-security's field masker), hidden, or a type: 'secret' field. A subtractive masking/deny layer, if needed, arrives with the ADR-0066 ⑦/⑧ muting work. Disposition tracked in the ADR-0056 D10 conformance matrix (data-masking).


5. Sharing Rules & Organization-Wide Defaults

The baseline visibility of an object is set by its Organization-Wide Default (OWD) — the sharingModel field on the object (packages/spec/src/data/object.zod.ts), one of the four canonical values private, public_read, public_read_write, controlled_by_parent (ADR-0090 D4 — the legacy aliases read/read_write/full were removed from the enum; authoring rejects them). A custom object with no declared sharingModel resolves to private (ADR-0090 D1 — the unset state no longer means public). There is no separate external default: an external portal/partner principal reaches explicitly shared rows only (ADR-0095). Sharing rules then grant additional access on top of that baseline — sharing only ever widens, RLS only ever narrows.

# account.object.yml
name: account
sharingModel: private   # owner-only baseline (also the D1 default)

Criteria-Based Sharing

Share records whose fields match a CEL predicate (CriteriaSharingRuleSchema in packages/spec/src/security/sharing.zod.ts):

# share_enterprise_accounts.sharing.yml
name: share_enterprise_accounts
type: criteria
object: account
accessLevel: read           # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
  type: position            # user | team | position | unit_and_subordinates | business_unit | field
  value: sales_rep

unit_and_subordinates expands a business-unit subtree: the unit named by value plus every descendant unit's members (ADR-0057 D5 / ADR-0090 D3 — the former position-tree walk was re-homed onto the sys_business_unit tree).

field is the record-relative recipient (#14103, maintainer ruling 2026-09-02): value names a user-typed field on the object, and each matched record is shared with the user or users that column holds on it (multiple: true shares with every user it names; an empty column shares with nobody). It expands once per matched record, not once per rule. There is no manager recipient — "the owner's manager" is a user field the application stores on the record, named by a field recipient. The executor (#15072) derives the rule's grants from each matched record's column, on the record's own write and on every whole-rule pass alike.

Owner-Based Sharing — removed in v17

Owner-based rules (type: 'owner', ownedBy) were removed from the authoring surface in v17 (#1878), together with the group / guest recipients. They depended on live membership the static seeder cannot track, so they validated but never materialized a share. Use a criteria rule or a scope-depth grant instead; none of these shapes parses anymore, so a stale definition fails loudly at authoring time instead of silently doing nothing (ADR-0078).

"Skipped" and "rejected" are different instructions to an author. Before v17 these rules were declared but skipped at seed time: you could write one, it parsed, and the seeder ignored it. They are not skipped now — they do not parse. SharingRuleSchema rejects the block above with three issues: type: 'owner' fails the criteria literal, the required condition is reported missing, and ownedBy comes back as an unrecognized key carrying its own guidance message. There is also no OwnerSharingRuleSchema to point at — packages/spec/src/security/sharing.zod.ts exports CriteriaSharingRuleSchema, and SharingRuleSchema is that schema.

The retired share_west_region rule — "West-region accounts reach the regional managers" — is expressed by predicating the field instead of the owner:

# share_west_region.sharing.yml
name: share_west_region
type: criteria
object: account             # sharingModel: private, declared above
accessLevel: edit
condition: 'record.region == "west"'
sharedWith:
  type: position
  value: west_region_managers

Enforcement status. Criteria rules with user / team / position / unit_and_subordinates / business_unit / field recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes sys_record_share grants, ADR-0058 D3); the field recipient (#14103) is expanded once per matched record rather than once per rule (#15072). Owner-type rules and the group / guest recipients are not [experimental — not enforced] and are no longer skipped at seed time — v17 removed them from the schema, so they do not parse at all (see above). What is still skipped-and-logged is a condition the compiler cannot lower (functions, cross-object traversal): it is never seeded as a permissive match-all (ADR-0049).

accessLevel is one of read or edit. Sharing widens which rows a principal reaches, never which verbs they may use — an edit share opens update, not delete: delete comes from ownership, the ADR-0057 DEPTH scopes, or the modifyAllRecords bypass, enforced by the sharing layer's own canDelete gate (distinct from the canEdit update gate) on top of the object-level CRUD gate (ADR-0111 D3). A third level full ("Full Access — transfer/share/delete") was authorable through protocol 16 but never granted any of those verbs: both enforcement sites matched edit/full alike, so it was equivalent to edit while telling admins otherwise, and it was removed (#3865, ADR-0078). Stacks still authoring it are rewritten to edit at load by the sharing-rule-access-level-full-to-edit conversion.

For "anyone with the link" style external sharing, an object opts in via publicSharing (see packages/plugins/plugin-sharing/src/share-link-service.ts):

name: proposal
publicSharing:
  enabled: true
  allowedAudiences: [link_only]
  allowedPermissions: [view]
  redactFields: [internal_notes]
  maxExpiryDays: 30
  eligibility: "record.status == 'published'"

Who may mint and revoke (ADR-0111 D8). Minting a link requires the object's publicSharing opt-in and authority over the record: the caller can see it, or owns it, or holds modifyAllRecords on the object. An opted-in object deliberately delegates re-share power to anyone who can view the row; the owner and the Modify-All holder may mint even where the data door refuses them, as on an object declared access: { default: 'private' }, which no wildcard grant covers — so a member can share a record of their own there. A hierarchy manager whose write depth covers the owner is not admitted without visibility: a link creates access, so they need to see the record to mint one. Under the group and isolated tenancy postures the owner and Modify-All alternatives are withheld and visibility alone admits, so no mint crosses the organization wall. Nor do they apply past a capability the object requires (requiredPermissions): a caller who lacks it is refused even on a record they own. The opt-in is checked first and eligibility (below) last. Revoking a link is allowed for the link's creator, a record share-manager (the record's owner, a modifyAllRecords admin, or a hierarchy manager whose write depth covers the owner — the same authority as canManageShares), or system context: a link someone else minted on your record is your record's exposure to kill, not only its creator's.

When eligibility is enforced (#13608). The optional eligibility CEL predicate is a standing policy about which records may be reached anonymously, not a mint-time check. The platform evaluates it when a link is minted and again on every redemption, against the record it is about to serve. So flipping a record out of the policy — audience to internal, status back to draft — stops every token minted while it was in, immediately, with no revocation step to remember. Fail-closed at both points: a predicate that will not compile, or that faults on the record, refuses rather than assuming consent. What an anonymous holder sees is the ordinary "invalid or expired" answer, identical to the one an unknown token gets; the readable reason a link died is written to the server log, never to the response.

Upgrade note. Before this, eligibility was enforced only at mint, so a record reclassified after a link was issued kept being served in full to anyone holding the URL. Deployments upgrading across that change can feel it: links minted while a record qualified stop resolving as soon as it stops qualifying. That is the intent — the alternative was a declared policy the platform did not hold — but if you depend on links surviving a reclassification, widen the predicate rather than relying on the old behaviour.

When publicSharing.enabled is off (#14033). The block's own switch is the same standing-policy shape as eligibility above, one level up: it too is held at every redemption, not only at mint. Turning it off stops every existing link on the object immediately, with no revocation step to remember; turning it back on restores them, with no link needing to be re-minted. Turning the block off silences everything inside it — eligibility is not evaluated, redactFields is not computed — so the bypass some callers have at mint (a late schema scan, or a system context) buys only the mint: it still succeeds while the block is off, but what it mints does not resolve until the block is enabled. What an anonymous holder sees is the same "invalid or expired" answer eligibility already gives; the readable reason is written to the server log, never to the response.

Upgrade note. Before this, publicSharing.enabled gated only the mint — once a link was issued it kept resolving after the object's switch was turned off. Deployments upgrading across that change can feel it: links that used to keep serving after enabled was flipped off now stop immediately, and resume as soon as it is flipped back on. That is the intent — the alternative was a declared switch the platform did not hold — but there is no way to keep an already-minted link serving through the object being switched off; the object must stay opted in for its links to serve.


6. Field-Level Encryption

Status: schema exists, field wiring removed. EncryptionConfigSchema (packages/spec/src/system/encryption.zod.ts, algorithms aes-256-gcm default / aes-256-cbc / chacha20-poly1305; key providers local, aws-kms, azure-key-vault, gcp-kms, hashicorp-vault) remains in the System namespace, but the per-field encryptionConfig property was pruned from the Field schema in 2026-06 — at-rest field encryption was never implemented by the runtime. The supported channel for secret values is a type: 'secret' field (one-way handling via the settings crypto provider). The example below shows the schema shape for implementers.

fields:
  ssn:
    type: text
    label: SSN
    encryptionConfig:
      enabled: true
      algorithm: aes-256-gcm
      scope: field           # field | record | table | database
      keyManagement:
        provider: aws-kms
        keyId: ssn-master-key
        rotationPolicy:
          enabled: true
          frequencyDays: 90

Set deterministicEncryption: true to allow equality queries on the ciphertext, or searchableEncryption: true for search support.

Secret fields. For reversible secrets (DB passwords, API keys, tokens) the field type: secret round-trips through the registered secret provider — encrypted on write, decrypted on read. See packages/spec/src/data/field.zod.ts.


7. Auditing & Field History

Object- and field-level history is opt-in metadata, not a free-form enable.audit block.

  • Object history tab — set enable.trackHistory: true on the object (the ObjectCapabilities block in object.zod.ts) to surface the record History tab.
  • Per-field history — set trackHistory: true on an individual field to render that field's value changes on the record activity timeline. (The former per-field auditTrail flag was pruned in 2026-06 — it had no runtime consumer.)
  • RLS audit — removed (2026-07, ADR-0056 D8): the RLSAuditConfigSchema/RLSAuditEventSchema shapes were never emitted or read by the runtime RLS path and were deleted from the spec. Enforcement decisions surface today through the security plugin's fail-closed warnings and the standard audit log (plugin-audit), not a dedicated RLS event stream.
# account.object.yml
name: account
enable:
  trackHistory: true

fields:
  annual_revenue:
    type: currency
    trackHistory: true

8. Best Practices

Principle of Least Privilege

# Restrictive permission set: read only what you own
name: account_owner
objects:
  account:
    allowRead: true
rowLevelSecurity:
  - name: account_owner_access
    object: account
    operation: select
    using: 'owner_id == current_user.id'

Defense in Depth

Combine OWD (sharingModel: private), an RLS using policy, FLS (readable: false) on sensitive fields, and type: 'secret' for values that must never round-trip to clients.

Security by Default

Leave registry-level systemFields injection enabled (the object default) so multi-tenant objects auto-stamp organization_id on create, and use field defaultValue to seed ownership fields — so records are never left unscoped. (systemFields accepts two opt-out keys — tenant and audit — both active; owner_id provisioning is governed separately by the object-level ownership property. See packages/spec/src/data/object.zod.ts.)


Next Steps

On this page