- Status: Accepted
- Date: 2026-07-19
- Issue: #3220 (root cause surfaced by the #1591 / #3213 better-auth guard work; safe slice shipped in #3222)
- Relates to: ADR-0049 (no unenforced security properties), ADR-0092 (identity write guard mechanism), ADR-0066 (system-row provenance gate)
managedBy is the object-lifecycle bucket UI clients read to render CRUD
affordances, and the security layer reads to enforce matching write defaults.
Its system value had come to mean two incompatible things:
- Engine-owned — runtime rows a platform service owns end to end and no user
ever writes directly:
sys_automation_run,sys_job/sys_job_queue,sys_notification, the approval engine's request/approver/token rows, the sharing engine'ssys_record_share,sys_setting/sys_secret/sys_setting_audit, metadata history/commit, the messaging delivery pipeline (sys_notification_delivery,…_receipt,http_delivery). Every write goes throughisSystem: true/ aSYSTEM_CTXservice engine, or a context-less engine call (the messaging service's raw-engine writes, the metadata-protocol repository's transaction context — neither carries auserId). - Platform-schema, admin/user-writable data — objects whose schema is
platform-defined but whose rows are legitimately written from a user
context by design:
- the RBAC link tables
sys_user_position,sys_user_permission_set,sys_position_permission_set— governed at write time by theDelegatedAdminGate(ADR-0090 D12), which depends on the write carrying the real caller (e.g.suggested-audience-bindings.tsinserts a direct grant withcontext: callerCtx, deliberately notisSystem); sys_user_preference— a user authors their own preferences (RLS self-grant);sys_approval_delegation— user-facing "who covers for me" config;- the messaging config grids surfaced in Setup —
sys_notification_preference(a user mutes their own topics),sys_notification_subscription,sys_notification_template(admin-authored).
- the RBAC link tables
All three restricted buckets (system, append-only, better-auth) collapsed
to one identical all-false affordance row, but only better-auth had real
enforcement: an engine write guard (ADR-0092) plus registration-time
apiMethods reconciliation. system had neither. A blast-radius audit
(#3220) disproved the original "just guard system wholesale" proposal — a
blanket guard would break delegated administration and self-service — and
narrowed the safe, mechanical slice to two latent holes (sys_presence,
sys_metadata), which shipped as #3222. This ADR is the root-cause fix: make the
write policy expressible and enforced without breaking the writable set.
Not to be confused with the row-level
managed_byprovenance column ('platform' | 'package' | 'admin') onsys_sharing_rule/sys_position/sys_capability, guarded byassertSystemRowWriteGate(ADR-0066). That is a per-row provenance axis; this ADR is about the per-object schema bucket. The shared word is a coincidence.
managedBy supplies only the default CRUD affordance row. The authoritative,
enforced write policy for an object is resolveCrudAffordances(schema) — the
bucket default with userActions overrides applied. We do not add a new
enum value. Instead:
- Engine-owned ≔ an object in the
systemorappend-onlybucket whose resolved affordances grant no write (create/edit/deleteall false) — i.e. the bucket default, un-opened. - Admin/user-writable ≔ a
system/append-onlyobject that declaresuserActionsopening the writes it legitimately takes.userActionsis an affordance declaration, not an authorization: the real authz for these rows remains theDelegatedAdminGate/ RLS self-grant / permission sets. The declaration exists so the affordance matrix, theapiMethodsreconciliation, and the engine guard below all agree on what the object permits.
Why not a new enum value: a new managedBy string falls through to the
fully-editable platform default on every already-deployed Console client
(the UI keeps a hand-maintained closed union and an open-ended type), silently
re-granting New/Edit/Delete on an engine table until each client is updated. The
userActions approach changes no wire vocabulary, and every UI surface already
honours userActions. Splitting the enum later remains possible as a pure
rename on top of the now-correct affordances.
A new system-write-guard (plugin-security), modelled on the ADR-0092 identity
guard, fails-closed on any user-context write to a system/append-only
object whose resolved affordances do not grant the corresponding verb. Verb →
affordance: insert → create, update/upsert → edit,
delete/purge/restore → delete, transfer → edit (aligned with the
DelegatedAdminGate governed-operation set). A write is user-context when
context.userId is set and context.isSystem !== true; isSystem and
context-less engine/service writes bypass by construction — exactly the calls the
legitimate engine writers make. It is wired into the security middleware
alongside the other unconditional data-layer gates (package-managed, system-row,
audience-anchor, delegated-admin), after the isSystem short-circuit. Denials
raise PermissionDeniedError (HTTP 403).
The writable set passes the guard because its userActions grant the verb; the
DelegatedAdminGate / RLS then adjudicate the principal, unchanged.
reconcileManagedApiMethods (objectql registry) previously ran only for
better-auth. It now runs for every managed bucket, still purely
affordance-driven: any generic write verb an object advertises in
enable.apiMethods that its resolved affordances do not grant is stripped at
registration with a warning, so the HTTP exposure gate answers a clean 405
instead of admitting a write that the engine guard (or permission layer) would
later 403. This makes the contradiction impossible to ship and is the drift
backstop for future system objects.
The hono-server clampManagedObjectWrites previously clamped only better-auth
objects' advertised affordances. It now clamps system/append-only too, so the
permissions payload the Console consumes reflects the true (guarded) write
policy.
The REST import route created/updated sys_import_job under the caller's
context. Those rows are engine-owned (the import worker owns the lifecycle), so
the route now writes them isSystem-elevated while preserving created_by
attribution, and the object is locked to apiMethods: ['get', 'list']. This
keeps the object engine-owned rather than carving a userActions exception for
a row users never hand-edit.
- New
managedByenum value (e.g.engine) — rejected: silent fully-editable fallthrough on deployed clients, plus enum/matrix churn and ~30 re-bucketed declarations across three UI mirrors and ten locale bundles, for a distinctionuserActionsalready expresses. Revisitable later as a rename. writeViacapability for third-party-managed objects — deferred (#1878): no such object exists today; speculative until a concrete need arises (explicitly out of scope per #3220).
system/append-onlyobjects reject user-context generic writes unlessuserActionsopens them — defence-in-depth for non-REST callers, matching the HTTP-layerapiMethodslock several already carry.- Third-party or downstream
systemobjects that advertised write verbs relying on today's fail-open behaviour will have those verbs stripped with a warning — the intended ADR-0049 correction, called out in the release note. - The taxonomy is now honest: a reader can tell engine-owned from writable by the resolved affordances, and the guard enforces it.
D1 deferred the enum split ("Splitting the enum later remains possible as a pure
rename on top of the now-correct affordances"). Both reasons D1 cited against an
enum value are now retired, so v16 adopts it — additively — as the self-documenting
successor to the engine-owned-DEFAULT overload of system.
Why it is now safe (the D1 objections, resolved):
- Silent fully-editable fallthrough on deployed clients — neutralised by the
server-side enforcement this same ADR added: an unknown bucket resolving to the
platformdefault on an old client is now cosmetic, because the engine write guard (D2),apiMethodsreconciliation (D3) and the/me/permissionsclamp (D4) reject the write regardless of what the client renders. (Re-verified end to end in the showcase: a generic/datacreate on an engine-owned object returns 405.) - Open-ended UI type across three mirrors — closed by objectui#2712: the
ManagedByBucketunion is now a single closed type, so a new value is a compile error to miss, not a silent fallthrough.
The split (additive — system is retained, nothing is removed):
- New enum value
engine-ownedwith the same all-locked default affordance row assystem(create/import/edit/delete: false,exportCsv: true). It joinsENGINE_OWNED_BUCKETS(guard) andGUARDED_WRITE_BUCKETS(clamp); the guard, reconciliation and clamp mechanisms are unchanged — engine-owned is simply an explicit member of the set they already covered by resolved affordance. - The 20 objects that were
systemwith no write-openinguserActions(the metadata store, jobs, approvals runtime rows, sharing rows, automation runs, the messaging delivery/receipt pipeline, secrets, settings) are relabelledsystem → engine-owned— a one-line, behaviour-identical change per object. - The 8 objects that are platform-schema admin/user-writable DATA (the RBAC
link tables
sys_user_position/sys_user_permission_set/sys_position_permission_set,sys_user_preference,sys_approval_delegation, and the messaging config grids) keepmanagedBy: 'system', which now reads as "engine-managed schema, writable viauserActions" — the residual meaning of the bucket after the engine-owned rows move out.
Not a behaviour or enforcement change. Resolved affordances, the guard verdict,
the 405 reconciliation and the permissions clamp are byte-identical before and
after; this is a self-documenting relabel. No data migration (managedBy is schema
metadata, not row data), and no code branches on the 'system' literal (all
enforcement keys off resolveCrudAffordances / the bucket-set membership).
Sequencing (v16 RC). The framework enum and the objectui union land together and
the vendored console is re-pinned before GA; during any sync window an old console
renders an unknown engine-owned object editable but the server still 405s the
write (point 1). Removing the overloaded system entirely — moving the 8 writable
objects to a dedicated writable-platform-data bucket (or config) and retiring
system — is a genuinely breaking rename deferred to v17.