Summary
Decide, model-wide, which authorization primitives are package metadata (versioned,
shipped with a package, draft/published, portable across environments) and which are
environment / system-admin configuration (edited live, environment-specific, and bound
to real users/orgs) — and specify how permissions from different packages compose when
several packages are installed into one environment.
This is a design/ADR task, not a code task yet. The Studio "Access" pillar
(objectstack-ai/objectui#2196) surfaced the gap: opened inside a package design
surface (/studio/:packageId/access), it lists all of the environment's objects
(e.g. "84 objects") and edits environment-level permission sets live, with no package
scope and no composition story. A package should only govern access to its own
objects; the cross-package / assignment concerns belong to the environment admin. The
same class of bug was just fixed for the Interfaces pillar
(objectstack-ai/objectui#2197).
The deliverable is a design (likely a new ADR) that draws the metadata↔config line for
the whole authz model with mainstream-platform precedent, defines cross-package
composition, and lists the resulting spec / bootstrap / Studio / admin changes as a phased
plan.
Deliverable
- Classification — for every authz primitive: metadata / config / hybrid, with a
one-line rationale and the mainstream-platform precedent it follows.
- Composition semantics — how grants from multiple installed packages assemble into a
user's effective access.
- Change list (phased) — spec (
managedBy classification), bootstrap
(bootstrapDeclaredPermissions), manifest wiring, Studio Access-pillar scoping, and the
environment-admin surface. Each phase independently shippable.
Grounded facts (already verified — do not re-derive)
Framework (objectstack-ai/framework):
- Permission sets are environment-level, not package content.
sys_permission_set is
managedBy: 'config' with no packageId/owning-package field
(packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:20,
fields at :125-214). Schema: PermissionSetSchema
(packages/spec/src/security/permission.zod.ts:108-192), Salesforce-shaped (ADR-0056).
- Grants are stored inline as flat dicts on the permission-set record:
objects: Record<objectApiName, ObjectPermission> (permission.zod.ts:120) and
fields: Record<"object.field", FieldPermission> (:122). ObjectPermission =
allowRead/Create/Edit/Delete + viewAll/modifyAllRecords + read/writeScope
(:24-73). One record holds every package's object grants.
- Object api names are package-namespaced and collision-proof: every object in a
package is ${namespace}_${shortName} (e.g. crm_account, todo_task); the full
prefixed name is the physical table name, so two packages cannot collide
(packages/spec/src/kernel/manifest.zod.ts:142-179, esp. :152 lists permissions
among consumers of the qualified name, :166 on physical storage). ⭐ This is what makes
cross-package composition conflict-free at the key level.
- Runtime resolution is a most-permissive UNION across all of a user's assigned
permission sets (plugin-security/src/permission-evaluator.ts:64-101; per-object lookup
falls back to '*' wildcard, :40-51). Assignment resolution:
packages/core/src/security/resolve-authz-context.ts:84-252 (user→roles→sets→union).
- No composition/seeding exists today. Platform defaults (
admin_full_access,
organization_admin, member_default) are hardcoded
(plugin-security/src/objects/default-permission-sets.ts:81-536). Roles declared in a
package manifest ARE seeded (bootstrap-declared-roles.ts:61-95), but there is no
bootstrapDeclaredPermissions — ObjectStackDefinitionSchema.permissions: PermissionSetSchema[] is declarable but never migrated into sys_permission_set. The
manifest's permissions field (manifest.zod.ts:254-265) is plugin install-consent
scopes (ADR-0025), not RBAC grants. → A package cannot ship default access for its
own objects.
- The platform already has two
managedBy axes — this is the existing vocabulary for
the boundary and the design should build on it, not invent a parallel one:
- metadata provenance/layer:
managedBy: 'package' | 'platform' | 'user'
(packages/spec/src/system/metadata-persistence.zod.ts:72).
- object CRUD affordance bucket:
managedBy: 'platform' | 'config' | 'system' | 'append-only' | 'better-auth' (packages/spec/src/data/object.zod.ts:408, defaults
matrix :1061-1100).
objectui (objectstack-ai/objectui) — the triggering surface:
<AccessPillar /> is rendered with zero props, so it never receives the route's
packageId (packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:589-590;
packageId is available at :333-334).
- Left rail lists permission sets environment-wide via
client.list('permission')
(StudioDesignSurface.tsx:2349). The embedded matrix loads all objects via
client.list('object') with no filter (PermissionMatrixEditor.tsx:171) — the "84
objects" — and all fields via client.list('field') (:208).
- Save writes the whole permission-set record including its full
objects dict
(PermissionMatrixEditor.tsx:289-291). ⚠️ Data-loss trap: naively filtering the
matrix rows to the package's objects and then saving "the rendered rows" would delete
every other package's grants from that shared record. Any scoping fix must render only
this package's object rows and slice-merge on save ({ ...original.objects, ...editedPackageKeys }). The namespaced keys make the package's slice unambiguous.
- The proven package-scoping mechanism used by every other pillar:
client.list('object', { packageId }) + client.listDrafts({ packageId, type })
(Interfaces: StudioDesignSurface.tsx:822-823; Data: :1377-1378).
- Client already consumes a flattened per-user effective grant at runtime:
GET /api/v1/auth/me/permissions → { roles, permissionSets, objects, fields }
(packages/permissions/src/MePermissionsProvider.tsx:56, type :21-41).
Design questions to resolve
Q1 — Classify every primitive as metadata / config / hybrid:
Role (definition), Role hierarchy/parent, PermissionSet/Profile definition,
ObjectPermission grant, FieldPermission (FLS), RLS policy (using/check), Sharing rule,
OWD / object.sharingModel, systemPermissions, tabPermissions,
permission-set ASSIGNMENT (sys_user_permission_set, sys_role_permission_set),
Organization / tenancy, actual users/teams.
Q2 — The DEFINITION↔ASSIGNMENT split. Evaluate the hypothesis that authorization
definitions (what a set/role can do) are metadata, while subject bindings (which real
user/team is assigned) are environment data. This is the near-universal line in mainstream
platforms — confirm it holds here and note exceptions.
Q3 — Composition model. Two shapes; pick one or support both:
- A. Shared env permission set, packages contribute disjoint object slices — one
"Contributor" set; package A writes objects.crm_*, package B writes objects.todo_*;
keys never collide (namespacing) and runtime unions them. Works with the current model
IF editors respect the slice invariant. Natural reading of "拼装" = each package fills its
own rows of one matrix. Interacts with the overlay model (ADR-0005).
- B. Each package ships its own permission sets (managed-package style) — package A
ships crm_sales_rep, B ships todo_member; admin assigns multiple; runtime unions.
Requires the missing bootstrapDeclaredPermissions + manifest wiring.
Q4 — Package-shipped default access. Should installing a package grant working default
access to its objects out of the box? If yes: bootstrapDeclaredPermissions + manifest
permissions wiring, and a decision on whether it seeds a package-owned set (Shape B)
or contributes a slice into shared/overlay sets (Shape A via ADR-0005). Reconcile with
the "no silently inert metadata" rule (ADR-0078) — a declarable permissions[] that is
never seeded is exactly the inert-metadata smell.
Q5 — Governance. Does Access editing in the package surface flow through package
draft/publish (ADR-0016/0033) like Data/Interface, or stay live like #2196's banner
("permissions are platform-level … the matrix saves the ACTIVE item directly")? This
follows directly from whether the edited thing is classified as metadata in Q1.
Q6 — Two-door surfaces. Confirm the split: the package Access pillar edits only the
package's object slice (metadata, scoped, draft/published); the environment admin
(metadata-admin) keeps the cross-package all-objects matrix and the assignment UI
(config). Ref builder-ui §3.5 "two doors, one metadata".
Mainstream platforms to reference
Evaluate the classification against how established metadata-driven / low-code platforms
draw the same line, and extract the invariant:
- Salesforce — permission sets, permission set groups, and (legacy) profiles are
packageable metadata (Metadata API components, travel in managed/unmanaged packages);
assignment to users is org data, never packaged; muting permission sets let a
subscriber org subtract from a managed set without editing it (a composition/overlay
precedent). Field-Level Security and object CRUD ride on the permission set.
- ServiceNow — scoped applications + update sets carry ACLs and roles as metadata
(sys_scope); user↔role assignment (sys_user_has_role) is instance data.
- Microsoft Power Platform / Dataverse — solutions (managed/unmanaged) carry
security roles as metadata; env-specific values are externalized via environment
variables and connection references (a clean "config that must not be baked into the
packaged metadata" precedent); team/user role assignment is data.
- OutSystems / Mendix — module/app roles are metadata that deploy with the app;
user→role mapping is runtime/admin.
Expected invariant to confirm: authorization DEFINITIONS travel with the app; SUBJECT
bindings and environment-specific values stay as environment config/data — with an
overlay/subtract mechanism (Salesforce muting, Dataverse layering, ObjectStack ADR-0005) so
a subscriber environment can adjust packaged definitions without forking them.
Relevant ADRs & code
Framework ADRs: 0006 project-environment-split (v4 — the core split),
0005 metadata-customization-overlay, 0007 settings-manifest-and-kv-store,
0003 package-as-first-class-citizen, 0070 package-first-authoring,
0078 no-silently-inert-metadata, 0028 naming/namespace-isolation,
0048 cross-package-metadata-collision, 0056 permission-model landing (the whole
authz surface, enforce/prove/reconcile), 0084 application-builder IA (Access = matrix).
Design doc: docs/design/builder-ui.md §7 (Access pillar — already defers permission-set
composition as the "concept overload" risk).
Spec/runtime: permission.zod.ts, manifest.zod.ts, object.zod.ts (managedBy),
metadata-persistence.zod.ts (managedBy), plugin-security
bootstrap-declared-roles.ts / default-permission-sets.ts / permission-evaluator.ts,
core resolve-authz-context.ts.
objectui: StudioDesignSurface.tsx (AccessPillar), metadata-admin/PermissionMatrixEditor.tsx,
permissions/src/MePermissionsProvider.tsx.
Acceptance criteria
Related
Summary
Decide, model-wide, which authorization primitives are package metadata (versioned,
shipped with a package, draft/published, portable across environments) and which are
environment / system-admin configuration (edited live, environment-specific, and bound
to real users/orgs) — and specify how permissions from different packages compose when
several packages are installed into one environment.
This is a design/ADR task, not a code task yet. The Studio "Access" pillar
(
objectstack-ai/objectui#2196) surfaced the gap: opened inside a package designsurface (
/studio/:packageId/access), it lists all of the environment's objects(e.g. "84 objects") and edits environment-level permission sets live, with no package
scope and no composition story. A package should only govern access to its own
objects; the cross-package / assignment concerns belong to the environment admin. The
same class of bug was just fixed for the Interfaces pillar
(
objectstack-ai/objectui#2197).The deliverable is a design (likely a new ADR) that draws the metadata↔config line for
the whole authz model with mainstream-platform precedent, defines cross-package
composition, and lists the resulting spec / bootstrap / Studio / admin changes as a phased
plan.
Deliverable
one-line rationale and the mainstream-platform precedent it follows.
user's effective access.
managedByclassification), bootstrap(
bootstrapDeclaredPermissions), manifest wiring, Studio Access-pillar scoping, and theenvironment-admin surface. Each phase independently shippable.
Grounded facts (already verified — do not re-derive)
Framework (
objectstack-ai/framework):sys_permission_setismanagedBy: 'config'with nopackageId/owning-package field(
packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:20,fields at
:125-214). Schema:PermissionSetSchema(
packages/spec/src/security/permission.zod.ts:108-192), Salesforce-shaped (ADR-0056).objects: Record<objectApiName, ObjectPermission>(permission.zod.ts:120) andfields: Record<"object.field", FieldPermission>(:122).ObjectPermission=allowRead/Create/Edit/Delete+viewAll/modifyAllRecords+read/writeScope(
:24-73). One record holds every package's object grants.package is
${namespace}_${shortName}(e.g.crm_account,todo_task); the fullprefixed name is the physical table name, so two packages cannot collide
(
packages/spec/src/kernel/manifest.zod.ts:142-179, esp.:152lists permissionsamong consumers of the qualified name,
:166on physical storage). ⭐ This is what makescross-package composition conflict-free at the key level.
permission sets (
plugin-security/src/permission-evaluator.ts:64-101; per-object lookupfalls back to
'*'wildcard,:40-51). Assignment resolution:packages/core/src/security/resolve-authz-context.ts:84-252(user→roles→sets→union).admin_full_access,organization_admin,member_default) are hardcoded(
plugin-security/src/objects/default-permission-sets.ts:81-536). Roles declared in apackage manifest ARE seeded (
bootstrap-declared-roles.ts:61-95), but there is nobootstrapDeclaredPermissions—ObjectStackDefinitionSchema.permissions: PermissionSetSchema[]is declarable but never migrated intosys_permission_set. Themanifest's
permissionsfield (manifest.zod.ts:254-265) is plugin install-consentscopes (ADR-0025), not RBAC grants. → A package cannot ship default access for its
own objects.
managedByaxes — this is the existing vocabulary forthe boundary and the design should build on it, not invent a parallel one:
managedBy: 'package' | 'platform' | 'user'(
packages/spec/src/system/metadata-persistence.zod.ts:72).managedBy: 'platform' | 'config' | 'system' | 'append-only' | 'better-auth'(packages/spec/src/data/object.zod.ts:408, defaultsmatrix
:1061-1100).objectui (
objectstack-ai/objectui) — the triggering surface:<AccessPillar />is rendered with zero props, so it never receives the route'spackageId(packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:589-590;packageIdis available at:333-334).client.list('permission')(
StudioDesignSurface.tsx:2349). The embedded matrix loads all objects viaclient.list('object')with no filter (PermissionMatrixEditor.tsx:171) — the "84objects" — and all fields via
client.list('field')(:208).objectsdict(
PermissionMatrixEditor.tsx:289-291).matrix rows to the package's objects and then saving "the rendered rows" would delete
every other package's grants from that shared record. Any scoping fix must render only
this package's object rows and slice-merge on save (
{ ...original.objects, ...editedPackageKeys }). The namespaced keys make the package's slice unambiguous.client.list('object', { packageId })+client.listDrafts({ packageId, type })(Interfaces:
StudioDesignSurface.tsx:822-823; Data::1377-1378).GET /api/v1/auth/me/permissions→{ roles, permissionSets, objects, fields }(
packages/permissions/src/MePermissionsProvider.tsx:56, type:21-41).Design questions to resolve
Q1 — Classify every primitive as metadata / config / hybrid:
Role (definition), Role hierarchy/
parent, PermissionSet/Profile definition,ObjectPermission grant, FieldPermission (FLS), RLS policy (
using/check), Sharing rule,OWD /
object.sharingModel,systemPermissions,tabPermissions,permission-set ASSIGNMENT (
sys_user_permission_set,sys_role_permission_set),Organization / tenancy, actual users/teams.
Q2 — The DEFINITION↔ASSIGNMENT split. Evaluate the hypothesis that authorization
definitions (what a set/role can do) are metadata, while subject bindings (which real
user/team is assigned) are environment data. This is the near-universal line in mainstream
platforms — confirm it holds here and note exceptions.
Q3 — Composition model. Two shapes; pick one or support both:
"Contributor" set; package A writes
objects.crm_*, package B writesobjects.todo_*;keys never collide (namespacing) and runtime unions them. Works with the current model
IF editors respect the slice invariant. Natural reading of "拼装" = each package fills its
own rows of one matrix. Interacts with the overlay model (ADR-0005).
ships
crm_sales_rep, B shipstodo_member; admin assigns multiple; runtime unions.Requires the missing
bootstrapDeclaredPermissions+ manifest wiring.Q4 — Package-shipped default access. Should installing a package grant working default
access to its objects out of the box? If yes:
bootstrapDeclaredPermissions+ manifestpermissionswiring, and a decision on whether it seeds a package-owned set (Shape B)or contributes a slice into shared/overlay sets (Shape A via ADR-0005). Reconcile with
the "no silently inert metadata" rule (ADR-0078) — a declarable
permissions[]that isnever seeded is exactly the inert-metadata smell.
Q5 — Governance. Does Access editing in the package surface flow through package
draft/publish (ADR-0016/0033) like Data/Interface, or stay live like #2196's banner
("permissions are platform-level … the matrix saves the ACTIVE item directly")? This
follows directly from whether the edited thing is classified as metadata in Q1.
Q6 — Two-door surfaces. Confirm the split: the package Access pillar edits only the
package's object slice (metadata, scoped, draft/published); the environment admin
(
metadata-admin) keeps the cross-package all-objects matrix and the assignment UI(config). Ref builder-ui §3.5 "two doors, one metadata".
Mainstream platforms to reference
Evaluate the classification against how established metadata-driven / low-code platforms
draw the same line, and extract the invariant:
packageable metadata (Metadata API components, travel in managed/unmanaged packages);
assignment to users is org data, never packaged; muting permission sets let a
subscriber org subtract from a managed set without editing it (a composition/overlay
precedent). Field-Level Security and object CRUD ride on the permission set.
(
sys_scope); user↔role assignment (sys_user_has_role) is instance data.security roles as metadata; env-specific values are externalized via environment
variables and connection references (a clean "config that must not be baked into the
packaged metadata" precedent); team/user role assignment is data.
user→role mapping is runtime/admin.
Expected invariant to confirm: authorization DEFINITIONS travel with the app; SUBJECT
bindings and environment-specific values stay as environment config/data — with an
overlay/subtract mechanism (Salesforce muting, Dataverse layering, ObjectStack ADR-0005) so
a subscriber environment can adjust packaged definitions without forking them.
Relevant ADRs & code
Framework ADRs: 0006 project-environment-split (v4 — the core split),
0005 metadata-customization-overlay, 0007 settings-manifest-and-kv-store,
0003 package-as-first-class-citizen, 0070 package-first-authoring,
0078 no-silently-inert-metadata, 0028 naming/namespace-isolation,
0048 cross-package-metadata-collision, 0056 permission-model landing (the whole
authz surface, enforce/prove/reconcile), 0084 application-builder IA (Access = matrix).
Design doc:
docs/design/builder-ui.md§7 (Access pillar — already defers permission-setcomposition as the "concept overload" risk).
Spec/runtime:
permission.zod.ts,manifest.zod.ts,object.zod.ts(managedBy),metadata-persistence.zod.ts(managedBy), plugin-securitybootstrap-declared-roles.ts/default-permission-sets.ts/permission-evaluator.ts,core
resolve-authz-context.ts.objectui:
StudioDesignSurface.tsx(AccessPillar),metadata-admin/PermissionMatrixEditor.tsx,permissions/src/MePermissionsProvider.tsx.Acceptance criteria
precedent, and the DEFINITION↔ASSIGNMENT principle (Q2) stated explicitly.
(Q4), reconciled with the overlay model (ADR-0005) and no-inert-metadata (ADR-0078).
{ packageId }+slice-merge on save + keep the rail global. Closes the object leak and the
data-loss trap. Mirrors
objectstack-ai/objectui#2197.bootstrapDeclaredPermissions+ manifest wiring so packages shipdefault access for their own objects (per Q3/Q4).
surface owns the package slice, optionally under draft/publish (per Q5/Q6).
Related
objectstack-ai/objectui#2196(Access pillar — the trigger),objectstack-ai/objectui#2197(Interfaces package-scope fix — the template).
[P2] Write-only / disconnected metadata (tool, email, sharing, spec-bridge) #1892 / [P2] Aspirational config — prune or mark experimental #1893 (write-only / aspirational metadata hygiene), [11.0][A2] Remove dead author-facing metadata properties (ADR-0049 enforce-or-remove) #2377 (ADR-0049
enforce-or-remove), [P0][security] Permission lifecycle ops ungated (allowTransfer/allowRestore/allowPurge) #1883 (permission lifecycle ops ungated).