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 compilerThe 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 (
permissionmetadata) — 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 (
positionmetadata) — 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: falseBeyond the four CRUD flags, the schema also exposes lifecycle and super-user grants:
| Flag | Meaning |
|---|---|
allowCreate / allowRead / allowEdit / allowDelete | Standard CRUD |
allowTransfer | Change record ownership (assign/reassign/disown owner_id) — enforced now via the insert/update owner_id guard (#3004); the dedicated transfer op is still M2 |
viewAllRecords | Read every record, bypassing sharing & ownership |
modifyAllRecords | Write every record, bypassing sharing & ownership |
The former
allowRestore/allowPurgeflags were retired (#12497, ADR-0049): therestore/purgeoperations they claimed to gate have never existed, so authoring them granted nothing — the schema now refuses them with a migration prescription. A dispatchedrestore/purgeis 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"and0included, is refused exactly liketrue; 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
everyonebaseline (ADR-0090 D5). Atrueanywhere 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 result2. 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 setRLS 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
RLSConfigSchemawas removed in 2026-07 — it was never read by the enforced path; per-policyRowLevelSecurityPolicySchemais 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: trueBehavior:
// 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 fieldThere is no separate
createflag for fields.editablegoverns both create-time and update-time writes; read stripping is governed byreadable.
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:
| # | Rule | Effect |
|---|---|---|
| 1 | Trusted context is exempt | A write carrying context.isSystem === true skips the strip entirely and may set read-only columns. |
| 2 | Only caller-supplied keys are candidates | The engine snapshots the payload's keys at entry (suppliedKeys), before middleware and beforeUpdate hooks run. Only keys in that snapshot can be stripped. |
| 3 | Hook / middleware backfill survives | A 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. |
| 4 | context.preserveAudit admits a whitelist — on UPDATE only | An 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. |
| 5 | Hooks 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.submittedis not marshalled onto the sandboxedbodyface, andobjectstack buildrefuses to lower a handler that reads it (the handler is bundled instead). Abodyderiving from a read-only column readsctx.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` columnisSystem 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/MaskingConfigSchemawere deleted from the spec: no redaction layer ever applied them, and the per-fieldmaskingRuleproperty had already been pruned in 2026-06. To keep a value out of reader hands, use FLS (readable: falsefield permissions — enforced by plugin-security's field masker),hidden, or atype: '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_repunit_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.
SharingRuleSchemarejects the block above with three issues:type: 'owner'fails thecriterialiteral, the requiredconditionis reported missing, andownedBycomes back as an unrecognized key carrying its own guidance message. There is also noOwnerSharingRuleSchemato point at —packages/spec/src/security/sharing.zod.tsexportsCriteriaSharingRuleSchema, andSharingRuleSchemais 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_managersEnforcement status. Criteria rules with
user/team/position/unit_and_subordinates/business_unit/fieldrecipients compile and enforce (the CEL condition lowers to a runtime filter that materializessys_record_sharegrants, ADR-0058 D3); thefieldrecipient (#14103) is expanded once per matched record rather than once per rule (#15072). Owner-type rules and thegroup/guestrecipients 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 aconditionthe compiler cannot lower (functions, cross-object traversal): it is never seeded as a permissive match-all (ADR-0049).
accessLevelis one ofreadoredit. Sharing widens which rows a principal reaches, never which verbs they may use — aneditshare opens update, not delete: delete comes from ownership, the ADR-0057 DEPTH scopes, or themodifyAllRecordsbypass, enforced by the sharing layer's owncanDeletegate (distinct from thecanEditupdate gate) on top of the object-level CRUD gate (ADR-0111 D3). A third levelfull("Full Access — transfer/share/delete") was authorable through protocol 16 but never granted any of those verbs: both enforcement sites matchededit/fullalike, so it was equivalent toeditwhile telling admins otherwise, and it was removed (#3865, ADR-0078). Stacks still authoring it are rewritten toeditat load by thesharing-rule-access-level-full-to-editconversion.
Public Share Links
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, algorithmsaes-256-gcmdefault /aes-256-cbc/chacha20-poly1305; key providerslocal,aws-kms,azure-key-vault,gcp-kms,hashicorp-vault) remains in the System namespace, but the per-fieldencryptionConfigproperty 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 atype: '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: 90Set 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: secretround-trips through the registered secret provider — encrypted on write, decrypted on read. Seepackages/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: trueon the object (theObjectCapabilitiesblock inobject.zod.ts) to surface the record History tab. - Per-field history — set
trackHistory: trueon an individual field to render that field's value changes on the record activity timeline. (The former per-fieldauditTrailflag was pruned in 2026-06 — it had no runtime consumer.) - RLS audit — removed (2026-07, ADR-0056 D8): the
RLSAuditConfigSchema/RLSAuditEventSchemashapes 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: true8. 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
Query Syntax
Database-agnostic query language with filtering, expand, aggregations, and sorting — aligned with the canonical @objectstack/spec QuerySchema
ObjectUI — the UI protocol specification
ObjectUI, the server-driven UI protocol: define layouts, forms and dashboards as JSON or YAML metadata that renderers turn into working interfaces.