Status: Proposed Date: 2026-05-29 Supersedes: — Builds on: ADR-0005 (Metadata Customization Overlay), ADR-0008 (Metadata Repository & Change Log), ADR-0009 (Execution-Pinned Metadata)
ObjectStack already ships a complete metadata lifecycle — draft → publish → rollback — backed by a per-environment overlay store (ADR-0005, ADR-0008). What it lacks is a protection model: the rules that decide which items a tenant may modify, which attributes within those items are off-limits, and who is allowed to override the defaults.
Today the only knob is the type-level allowOrgOverride flag on each
MetadataTypeRegistryEntry. That flag is binary and applies to every
item of that type:
- Flip it on → every packaged item of the type is overridable.
- Flip it off → no packaged item of the type can be overridden, even ones a tenant clearly should be allowed to customise.
This coarse-grained design has already caused real friction:
- To protect critical package-shipped objects (e.g. the
setupapp, thesys_userobject), we recently flippedobject/fieldtoallowOrgOverride: false. That correctly locks the dangerous items but as collateral damage also blocks the 99% of packaged objects where label / icon / picklist edits should be perfectly safe. - Even when overlay is allowed for a type, nothing prevents a tenant
from rewriting a field's
nameortype— both physically tied to the underlying database column. The change validates against the Zod schema, the overlay is written, and the runtime crashes on the next insert. - There is no
actorrecorded on overlay rows, so auditors cannot answer "who changed this field's required flag last Friday?" - The only escape hatch for emergency unlock is the
OBJECTSTACK_METADATA_WRITABLEenvironment variable, which is process-wide, undated and bypasses any RBAC.
These gaps are well-trodden ground in the low-code industry. Salesforce, ServiceNow, Microsoft Dataverse and Frappe / ERPNext all converged on a four-layer protection model with very similar shape. This ADR adopts that model for ObjectStack with the minimum surface area needed to close the gaps above.
Every mature low-code platform that allows runtime customisation of package-delivered metadata implements protection at three or four nested layers. The vocabulary differs but the layering is identical.
| Layer | Salesforce | ServiceNow | Dataverse | Frappe |
|---|---|---|---|---|
| L1 — Type level | Standard vs Custom Object | Application scope | Solution Component types | Standard vs Custom DocType |
| L2 — Package level | Managed vs Unmanaged Package | Application sys_policy |
Managed vs Unmanaged Solution | Module flags |
| L3 — Item level | isProtected per component |
sys_policy per record |
IsCustomizable per entity |
DocType istable etc. |
| L4 — Attribute level | per-field per-attribute matrix | Dictionary Override | per-attribute Managed Properties | Property Setter per field |
| Provenance marker | __c naming suffix |
sys_customer_updated |
IsManaged flag |
customer_updates table |
| Audit | Setup Audit Trail (immutable) | sys_audit + sys_customer_updated_xml |
Audit Log | basic log |
| Emergency unlock | ModifyMetadata permission + Change Set |
"Customer Update" override | "Override Behavior" admin | direct DB edit |
| Reset to default | delete the customisation | delete customer update | delete the customisation | delete Custom Field / Property Setter |
Two architectural notes are common to all four:
- Defence is cumulative, not exclusive. A write must pass every layer; rejection at any one is enough. Code paths short-circuit at the first refusal.
- Removal is reversal. Deleting a customisation always restores the package-shipped default with zero loss. This is the operational superpower that lets administrators experiment freely.
ObjectStack today implements L1, partial L2, and the lifecycle (draft / publish / rollback). L3, L4, provenance, audit, RBAC unlock and reset-to-default are absent.
Adopt the four-layer protection model. Each write to the metadata API is checked in order:
PUT /api/v1/meta/:type/:name (and POST .../publish, .../rollback, DELETE)
│
▼
L1 type-level allowOrgOverride ─ fail → 403 not_overridable
│
▼
L2 package-level metadataDefaults ─ fail → 403 package_locked
│
▼
L3 per-item _lock ─ fail → 403 item_locked
│
▼
L4 per-path frozenPaths ─ fail → 422 frozen_path
│
▼
write overlay row (with actor, source, diff)
│
▼
audit log append (immutable)
The check order matters. Coarse layers fail fast and produce the most informative error. By the time control reaches L4 the request is already known to be writeable in principle — only specific paths within the payload are rejected.
The existing allowOrgOverride / allowRuntimeCreate flags on
MetadataTypeRegistryEntry continue to express type-wide policy:
allowOrgOverride: false→ no overlay write to any item of this type is ever accepted from a tenant. (datasource,router,function,servicekeep this stance because they encode deployment topology.)allowRuntimeCreate: false→ tenants cannot author brand-new items of this type at runtime.
The previous binary choice "lock the whole type vs leave it open" is preserved but is now expected to be the least restrictive default: fine-grained protection lives at L2 / L3 / L4.
Package authors declare a default lock in the package manifest. The
loader applies it to every artifact produced by that package, equivalent
to stamping _lock on each file individually.
// objectstack.package.ts
export default definePackage({
id: 'com.objectstack.setup',
metadataDefaults: {
lock: 'full',
lockReason: 'Core admin UI — tenant edits can lock users out',
},
unlocked: [
'view/setup_user_list', // explicit exceptions
'translation/*', // glob allowed
],
});Semantics:
metadataDefaults.lockapplies the named lock level to every artifact this package contributes, unless the artifact itself already declares a lock (artifact wins) or itstype/namematches an entry inunlocked(which clears the package default).- The loader writes the resolved lock into the artifact's
_lockand_lockSource: 'package'fields, so downstream protocol code sees a single normalised shape (L3) and never reads the manifest at request time.
Every metadata artifact may declare a per-item lock:
defineApp({
name: 'setup',
label: 'Setup',
_lock: 'full',
_lockReason: 'Core admin UI — see ADR-0010',
});Lock levels (named after ServiceNow sys_policy with explicit
delete-vs-edit split borrowed from Dataverse):
_lock |
Overlay writes | Delete | New items (same type) |
|---|---|---|---|
none (default) |
allowed if L1/L2 allow | allowed | allowed |
no-overlay |
rejected | allowed | allowed |
no-delete |
allowed | rejected | allowed |
full |
rejected | rejected | allowed |
full blocks all destructive operations on the specific item but
never restricts the user's ability to author new items of the same
type — symmetry with the L1 split between allowOrgOverride (override
existing) and allowRuntimeCreate (author new).
For artifacts whose lock permits overlay writes, frozenPaths declares
which JSON paths within the payload remain off-limits:
defineObject({
name: 'crm_account',
fields: { /* ... */ },
_frozenPaths: [
'name', // table name = physical column
'_packageId',
'fields.*.name',
'fields.*.type',
'fields.id.*', // system field fully frozen
'fields.created_at.*',
],
});A write that touches any frozen path returns 422 frozen_path with the
offending path in the error envelope. Glob semantics follow JSON
Pointer with the * wildcard meaning "any single key" (no recursion).
Type registry entries may declare default frozen paths that the loader appends to every packaged artifact of the type:
// in DEFAULT_METADATA_TYPE_REGISTRY
{
type: 'object',
defaultFrozenPaths: ['name', '_packageId', 'fields.*.name', 'fields.*.type'],
// ...
}Frozen-path enforcement only runs against artifacts whose _provenance
is 'package' (see §3.5). Tenant-authored items have no frozen paths;
the tenant owns them outright.
Every artifact carries a _provenance field:
| Value | Meaning |
|---|---|
package |
Loaded from a code package (any of .object.ts, .view.json, …). Set by the loader, immutable thereafter. |
org |
Authored at runtime by a tenant via the metadata API. Set on first save. |
env-forced |
Written via the emergency escape hatch (env variable or ?force=true with metadata.unlock_protected permission). Triggers stronger audit. |
Rules:
- Overlay rows targeting a
packageartifact respect L3 + L4. - Tenant
orgartifacts ignore L3 / L4 entirely (the tenant owns them in full, can also delete them). _provenanceitself is in every type's defaultfrozenPaths. A client can never relabel its own override as "package".
Provenance also drives the Studio UI: "package" items get a lock icon and a "modify with care" banner; "org" items get a delete button.
A new table sys_metadata_audit records every write:
| Column | Meaning |
|---|---|
id |
uuid |
ts |
wall clock |
environment_id |
tenant scope |
actor_user_id |
who called the API (NULL for system jobs) |
actor_system_id |
service identifier when actor is non-human |
type, name |
target metadata |
op |
save_draft / publish / discard_draft / rollback / delete / reset_to_default |
source |
studio / cli / api / env-forced / package-install |
diff |
JSON Patch of the change |
package_id, package_version |
base package coordinates at the time of write |
lock_overridden |
_lock value that was bypassed (NULL if none) |
request_id |
correlation with the HTTP request log |
Audit rows are append-only. There is no API to update or delete
them; the table is owned by the metadata plugin and surfaces only as
read-only queries on /api/v1/meta/audit?type=&name=&since=.
The audit table is independent of sys_metadata_overlay and
sys_metadata_history (ADR-0008) — the latter store state, the
former stores intent and provenance. Compliance reports read from
the audit table.
A new operation:
POST /api/v1/meta/:type/:name/reset
→ 200 { reset: true, restoredFrom: 'package', version: 'sha256:...' }
Semantics: delete the overlay row(s) for the target item, regardless of draft / publish state. The next GET returns the package-shipped definition unchanged. Equivalent to ServiceNow "Remove Customer Update" or Frappe "Reset Customizations".
Guarded by _lock (rejected for no-delete and full) and by L1
allowOrgOverride. Records an audit row with op: reset_to_default.
The OBJECTSTACK_METADATA_WRITABLE env variable remains for
single-process dev / bootstrap scenarios but is no longer the
production unlock path. Two replacement mechanisms:
- Permission-gated force write. A new permission
metadata.unlock_protectedlets the holder sendX-Override-Lock: true(header) plus?force=true(query). When both are present and the permission check passes, L2/L3/L4 are bypassed. The audit row recordslock_overriddenandsource: env-forced. - Per-environment unlock list. A new settings key
metadata.unlocked_items: string[](list oftype/namestrings, globs allowed) flips L3 tononefor the named items in that environment only. Persisted, not env-variable; editable only with the samemetadata.unlock_protectedpermission. Mirrors Salesforce "Remote Site Settings" precedent — high-risk toggles live in DB + audit log, not env files.
The env variable continues to take precedence in tests and CI to keep existing fixtures working, but production deployments are expected to drop it.
Every artifact's Zod schema extends a common base that adds optional protection fields. The fields are stripped on write and reattached on load by the metadata loader, so user-authored TS files do not need to import them unless they want to opt in.
// packages/spec/src/kernel/metadata-artifact.zod.ts
export const MetadataLockSchema = z.enum(['none', 'no-overlay', 'no-delete', 'full']);
export type MetadataLock = z.infer<typeof MetadataLockSchema>;
export const MetadataProvenanceSchema = z.enum(['package', 'org', 'env-forced']);
export type MetadataProvenance = z.infer<typeof MetadataProvenanceSchema>;
export const MetadataArtifactBaseSchema = z.object({
_packageId: z.string().optional(),
_packageVersion: z.string().optional(),
_provenance: MetadataProvenanceSchema.optional(),
_lock: MetadataLockSchema.optional(),
_lockReason: z.string().optional(),
_lockSource: z.enum(['artifact', 'package', 'env', 'settings']).optional(),
_frozenPaths: z.array(z.string()).optional(),
});Each metadata Zod schema (ObjectSchema, ViewSchema, AppSchema, …)
spreads the base in:
export const AppSchema = MetadataArtifactBaseSchema.extend({
name: z.string().regex(/^[a-z_][a-z0-9_]*$/),
label: z.string(),
navigation: z.array(NavigationItemSchema),
// …
});This keeps the loader, the protocol and Studio's editor reading from a single normalised shape.
const MetadataTypeRegistryEntryBaseSchema = z.object({
// … existing fields …
defaultFrozenPaths: z.array(z.string()).default([])
.describe('JSON paths frozen on every packaged item of this type'),
});The loader unions entry.defaultFrozenPaths with artifact._frozenPaths
when materialising a packaged item, so the type can declare blanket
schema-level locks (e.g. name, _packageId) without each file having
to repeat them.
export const PackageMetadataDefaultsSchema = z.object({
lock: MetadataLockSchema.optional(),
lockReason: z.string().optional(),
});
export const PackageManifestSchema = z.object({
// … existing …
metadataDefaults: PackageMetadataDefaultsSchema.optional(),
unlocked: z.array(z.string()).default([])
.describe('type/name patterns exempted from metadataDefaults.lock'),
});-- audit log (immutable, no UPDATE/DELETE)
CREATE TABLE sys_metadata_audit (
id UUID PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
environment_id TEXT NOT NULL,
actor_user_id TEXT,
actor_system_id TEXT,
type TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
source TEXT NOT NULL,
diff JSONB,
package_id TEXT,
package_version TEXT,
lock_overridden TEXT,
request_id TEXT,
INDEX idx_audit_env_type_name (environment_id, type, name, ts DESC)
);
-- per-environment unlock list (small, write-rarely)
CREATE TABLE sys_metadata_unlocked (
environment_id TEXT NOT NULL,
pattern TEXT NOT NULL, -- 'type/name' or glob
reason TEXT,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
PRIMARY KEY (environment_id, pattern)
);Existing fields are unchanged; new fields are additive. Pre-ADR-0010
clients continue to work. Studio reads editable / deletable /
resettable to drive button state directly, no second round trip
needed.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/meta/:type/:name/reset |
Delete all overlay rows; restore package default |
GET |
/api/v1/meta/audit |
Query audit log (filter by type/name/since/actor) |
GET |
/api/v1/meta/unlocked |
List per-environment unlock entries |
POST |
/api/v1/meta/unlocked |
Add a type/name pattern to the unlock list (requires metadata.unlock_protected) |
DELETE |
/api/v1/meta/unlocked/:pattern |
Remove an unlock entry |
All protection refusals carry a machine-readable code for client
branching and a human-readable hint pointing at this ADR:
// L1
{ "error": "[not_overridable] ...", "code": "not_overridable", "status": 403 }
// L2
{ "error": "[package_locked] App 'setup' belongs to package 'com.objectstack.setup' which sets metadataDefaults.lock=full.",
"code": "package_locked", "status": 403, "package": "com.objectstack.setup" }
// L3
{ "error": "[item_locked] App 'setup' is locked (_lock=full). See ADR-0010 §3.3.",
"code": "item_locked", "status": 403, "lock": "full" }
// L4
{ "error": "[frozen_path] Path 'fields.id.type' is frozen on packaged object 'crm_account'.",
"code": "frozen_path", "status": 422, "path": "fields.id.type" }The migration is staged so each phase is independently shippable.
- Add
MetadataArtifactBasetopackages/spec/src/kernel/. Spread it into every metadata Zod schema. - Add
_lockenforcement inprotocol.saveMetaItem,publishMetaItem,deleteMetaItem,rollbackMetaItem. Returns403 item_locked. - Add
lock/editable/deletablefields to the GET response. - Add
sys_metadata_audittable + write path. Record actor from the request context. - Update
setupapp,sys_userobject and any other framework-shipped critical items with_lock: 'full'. - Contract tests:
- PUT against
_lock: fullitem → 403 - PUT against
_lock: no-overlayitem → 403, DELETE allowed - PUT against
_lock: no-deleteitem allowed, DELETE → 403 - audit row written for every successful op
- audit row also written for refused op (op =
denied)
- PUT against
- Loader stamps
_provenance: 'package'on every artifact at load time. Loader rejects user-authored_provenancefield on write. defaultFrozenPathsonMetadataTypeRegistryEntry. Loader unions them with artifact_frozenPaths.- New JSON Pointer evaluator with
*wildcard inpackages/spec/src/ shared/json-path.ts. Used by L4 enforcement on overlay save. - Revert the Phase-0 sledgehammer: flip
objectandfieldback toallowOrgOverride: true,supportsOverlay: true. AdddefaultFrozenPaths: ['name', '_packageId', 'fields.*.name', 'fields.*.type']forobject. Tests update to cover the "soft-property edit ok, schema edit 422" matrix. - Add reset endpoint.
definePackage()acceptsmetadataDefaults+unlocked. Loader resolves to artifact-level_lockat install time.- New permission
metadata.unlock_protected. New headerX-Override-Lock+ query?force=truecombination triggers the unlock path (still validates against L1; only L2/L3/L4 are bypassed). sys_metadata_unlockedtable + CRUD endpoints. Lock evaluator consults the unlock list before refusing.- Deprecate
OBJECTSTACK_METADATA_WRITABLEin production builds (warn on startup); keep in test / dev.
- Read
editable/deletable/resettableflags on the editor page; disable buttons + show tooltip withlockReason. - Lock badge on the directory list.
- "Reset to package default" button (visible only when
resettable). - Provenance badge ("Package" / "Custom") on the list and detail pages.
- Audit log tab on each item (calls the new audit endpoint).
Public author surface (packages/spec/src/shared/protection.zod.ts)
that translates into the private _lock envelope at load time:
export const SETUP_APP: App = {
name: 'setup',
label: 'Setup',
protection: {
lock: 'full',
reason: 'Core admin UI shipped by @objectstack/platform-objects.',
docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
},
// ...
};Loader (metadata/plugin.ts + objectql/registry.ts) calls
applyProtection(item, { packageId }) to translate the block into the
private _lock/_lockReason/_lockDocsUrl/_lockSource/
_provenance/_packageId envelope and strips the public protection
block so it never leaks into the overlay row. _lockSource defaults to
'package' when a package id is supplied and 'artifact' otherwise.
The lockDocsUrl is surfaced on the GET response and rendered as a
"View docs →" link in the Studio lock banner.
Multi-layer overlay (base + ISV layer + customer layer) is the only
mature-platform feature left unaddressed by this ADR. It is large
enough to deserve its own ADR if and when ISV packaging becomes a
priority. The L1–L4 model in this ADR is forward-compatible: the
overlay table just needs an additional layer column.
This ADR ships as additive for every existing client.
- Schema: the new
_lock,_lockReason,_provenance,_frozenPaths,_packageId,_packageVersion,_lockSourcefields are alloptionalon the artifact base. Existing artifacts validate unchanged; absence means_lock: 'none',_provenance: 'package'for loader-introduced items. - GET response:
lock/editable/deletable/resettable/provenanceare new keys; pre-ADR-0010 clients ignore them. - Write errors: new error codes (
item_locked,frozen_path,package_locked) join the existing set; all carry HTTP 403/422 so generic error handling continues to work. - Object / field rollback: Phase 2 restores the pre-incident
default of
allowOrgOverride: trueforobjectandfield. The net effect for tenants is "you can edit soft properties again, but schema-level paths are now frozen". This is strictly safer than the pre-ADR-0010 state (where schema edits were permitted but unsafe) and strictly more permissive than the Phase-0 sledgehammer. - Audit log: new table; nothing reads it until Phase 4 adds the Studio tab. Disk overhead is bounded by a per-environment rolling retention (default 365 days, configurable).
- Env unlock variable: continues to function; warned-but-not-removed to preserve every existing CI fixture.
A short codemod in packages/cli will offer to add _lock: 'full'
declarations to apps/objects that contain the substring setup,
admin, console, system in their name, as a defaults-of-good-taste
nudge. The codemod is opt-in.
- Closes the four largest gaps versus Salesforce / ServiceNow / Dataverse / Frappe.
- Allows the Phase-0 sledgehammer (
object/fieldtype-level lock) to be replaced with the proper "type open, schema paths frozen" model, restoring the ability for tenants to edit packaged objects' soft properties. - Produces a compliance-grade audit trail; "who changed this metadata field last week" becomes a SQL query.
- Emergency unlock moves from process-wide env variable to permission-gated, audited, per-item action.
resetendpoint turns metadata customisation into a safe-by-default activity: the worst case is "click reset".
- Adds three Zod schemas (
MetadataLockSchema,MetadataProvenanceSchema,MetadataArtifactBaseSchema) and two tables (sys_metadata_audit,sys_metadata_unlocked). - Every metadata write now consults four guards in sequence; a sub- millisecond cost in practice but worth noting for high-throughput bulk imports. The bulk-register endpoint will short-circuit by pre-resolving locks once per type.
- Package authors have one more thing to think about: whether their
package should ship
metadataDefaults.lock. Documentation must make this opt-in clearly, with concrete examples. - The
OBJECTSTACK_METADATA_WRITABLEenv variable is soft-deprecated. Existing operators relying on it in production need a one-time migration (move tometadata.unlock_protected+ the unlock list).
- Tenant ergonomics gain a "this is locked" experience instead of "the save mysteriously 403'd" experience.
- ISV packaging (Phase 5) becomes a smaller delta — the L1–L4 model is already layer-aware.
The minimal patch. Rejected because:
- It still allows schema-level edits on the open types (
view,app, …) — no L4 means nothing preventsview.list.type = 'invalid'. - It permanently blocks tenants from doing safe edits (e.g. changing a
package object's
label), pushing them toward forking the package source, which defeats the platform's value proposition. - It diverges from every reference platform and would surprise users coming from Salesforce / Dataverse.
Encode provenance into the name (crm_account is package,
crm_account__c is custom). Rejected because:
- ObjectStack names already participate in URL paths, foreign keys and the physical table name. Adding a suffix mid-life is a breaking rename.
- A dedicated
_provenancefield is cheaper, explicit, and future- proofs richer states (env-forced, futuremergedfor stacked packages).
Demand that every metadata write be wrapped in a numbered change-set that the operator applies. Rejected because:
- Doubles the user-visible workflow (write and apply).
- The draft / publish / rollback lifecycle already provides the "intent vs effect" separation that change-sets are designed for.
Replace _lock with permissions like app.setup.edit. Rejected
because:
- Permissions explode in count (1 per protected item).
- Loses the self-describing property: an artifact would no longer know it is locked; the lock would live in a separate permission matrix.
- RBAC unlock (Phase 3) is the right place for "who may bypass", complementary to per-item declarations, not a replacement.
- ADR-0005 — Metadata Customization Overlay (the two-layer base).
- ADR-0008 — Metadata Repository & Change Log (draft / publish / rollback storage).
- ADR-0009 — Execution-Pinned Metadata (version pinning for executable types).
- Salesforce: ISVforce Guide §"Managed Package Component Behavior", Metadata API Developer Guide §"Custom Object Modification".
- ServiceNow: Product Documentation §"Application scoping", §"Dictionary Override", §"Customer Updates".
- Microsoft Dataverse: Power Apps Developer Guide §"Use managed properties to restrict customizations".
- Frappe Framework: Documentation §"Customize Form", §"Property Setter", §"Custom Field".