Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/15206-meta-doors-environment-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/metadata-core': minor
'@objectstack/rest': minor
'@objectstack/runtime': minor
'@objectstack/plugin-email': minor
'@objectstack/spec': minor
---

feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6)

Clause-②: no (narrowing)

<!-- adr-0087: registered manage-org-presentation-retired, meta-doors-organization-scope-retired -->

**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`.

ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together.

**What changes.**

- **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization.
- **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization.
- **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization.
- **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup.
- **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization.

**What moves for consumers.**

- FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed.
- FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import.
- FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members.
- FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays.
- FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone.
- A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant.

**What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it.

**What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change.
4 changes: 3 additions & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m

In shared-database multi-tenancy, **most metadata types must not be per-org customizable** — overriding them would break the physical schema. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowOrgOverride` in `packages/spec/src/kernel/metadata-plugin.zod.ts`.

> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (`organization_id` NULL) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is not served by any `/meta` read until the promotion ceremony (ADR-0131 C7) carries it to the environment layer; re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. The table still records which types take an overlay of a shipped item.

| Type | `allowOrgOverride` | Rationale |
| :--- | :---: | :--- |
| `view`, `dashboard`, `report`, `email_template`, `translation` | ✅ | Pure rendering / render-time content. Per-org customization is safe. |
Expand Down Expand Up @@ -228,7 +230,7 @@ The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, wi
|:---|:---|:---|
| `defineView(...)` / `defineFlow(...)` / any source file → compiled into `dist/objectstack.json` | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history *is* Git. The metadata layer does not duplicate it. |
| Editing a `.json` under `<root>/<type>/<name>.json` (FS overlay, e.g. `<root>/view/case_grid.json`) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at `<root>/.objectstack/.log/main.jsonl` by `FileSystemRepository`. |
| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ✅ Appended to `sys_metadata_history` (per-org `event_seq`) in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. |
| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row**, environment-wide (`organization_id` NULL) since ADR-0131 D6. | ✅ Appended to `sys_metadata_history` in the **same transaction** as the `sys_metadata` write. No-op puts (identical hash) skip the history row entirely. |
| Deploying a new build (new `dist/objectstack.json`) | ❌ The artifact is loaded into memory, not synced into `sys_metadata`. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. |

### Why artifact never enters the database
Expand Down
28 changes: 20 additions & 8 deletions content/docs/kernel/contracts/metadata-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -401,14 +401,26 @@ const views = await metadataService.listViews('account');
const dashboard = await metadataService.get('dashboard', 'sales_overview');
```

### Org-Level Customization

Per-org view customization rides ADR-0005's metadata overlay (opt-in per type
via `allowOrgOverride`, `view` among the overlay types): an org-scoped write
through the REST meta doors stores a `sys_metadata` row, and the layered read
returns `code` / `overlay` / `effective` for it. The per-user, per-field patch
overlay a previous revision of this page taught here was removed in #13135 —
it was never served by any route.
### Environment Customization

View customization rides ADR-0005's metadata overlay (opt-in per type via
`allowOrgOverride`, `view` among the overlay types): a write through the
`/meta` doors stores a `sys_metadata` row, and the layered read returns
`code` / `overlay` / `effective` for it. Since ADR-0131 D6 the per-organization
overlay axis is retired: the doors carry no organization, so the overlay is
**environment-wide** (`organization_id` NULL) and served to every organization
of the deployment, whatever the author's active organization. An overlay row an
earlier release stored under an organization is not served by any `/meta` read
until the promotion ceremony (ADR-0131 C7) carries it to the environment layer.
Public forms are the exception: until that ceremony the anonymous form doors
read a form `view` in the Default Organization and prefer its overlay for the
form's body, while a withdrawal in either layer closes the form, fail-closed.
So a legacy organization overlay of a public form keeps serving its body there:
a Studio re-save of that body (an environment row) does not change the body the
public form serves, and a Studio withdrawal (an environment row) still closes
it. The per-user,
per-field patch overlay a previous revision of this page taught here was
removed in #13135 — it was never served by any route.

### Permission-Based UI Filtering

Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/objectui/concept.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ ObjectUI merges **3 layers** of configuration to produce the final layout:
↓ Merge
┌─────────────────────────────────────────────────────┐
│ Layer 2: Admin Configuration │
│ - Org overlay: a full FormView write, not a diff │
│ - Env overlay: a full FormView write, not a diff │
│ - Branding: Logo, colors, theme │
└────────────────┬────────────────────────────────────┘
↓ Merge
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/create-vs-edit-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Three layers, each *derive + only store differences* — never "re-list all 40 f
```
1. Derived default derive(object, 'create' | 'edit') ← free, no authoring
2. Author override formViews.create (sparse patch) ← this recipe; only on real divergence
3. Tenant override org overlay delta (ADR-0005) ← a single org wants its own form
3. Runtime override environment overlay (ADR-0005) ← an admin edits it in Studio; per-org overlays retired (ADR-0131 D6)
```

Welding two independent full forms is the **Salesforce page-layout tax**: add a required field, forget the create form → runtime "missing required field" on create; rename a field → silent drift. Keeping data semantics on the object (never on the form) means a form can only ever drift on *which fields appear* — a flat name list that **reference-integrity diagnostics catch as a hard failure** in the AI loop (ADR-0047 §3.5, ADR-0033). That guardrail is what makes the escape hatch safe to hand to an AI author.
Expand Down
28 changes: 10 additions & 18 deletions packages/metadata-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,26 +95,18 @@ export * from './object-schema-fls.js';
// `ITEM_KEY_DISCRIMINATORS` from `registry.ts`, so its surface is unchanged.
export * from './item-key-discriminators.js';

// [#6190 / #7018 / #8805] Which metadata WRITES carry the caller's active
// organization — sunk here from `@objectstack/runtime` by the same criterion as
// the FLS projection above, and for a defect of the same shape. The #6190
// ruling is a decision the CALLER must make (the protocol deliberately REFUSES
// an org-scoped write of a non-overridable type rather than coercing it, so the
// tenancy statement the author made is never silently rewritten) — which means
// every door that writes metadata needs the same predicate. The dispatcher owned
// the only implementation, and `@objectstack/rest` cannot import it: `runtime`
// depends on `rest`, so the reverse edge is a cycle turbo refuses — the exact
// situation this package exists to resolve. `runtime` imports it from here now,
// so its behaviour is unchanged and there is no second copy to drift.
// [#9454 · ADR-0131 D6] The registry-derived per-organization overlay
// predicate (`declaresOrgOverride`) and the organization a protocol READ
// carries (`organizationIdForMetaRead`). The write-side twin retired with the
// per-organization overlay axis: the `/meta` doors carry no organization into
// a metadata write. See the module header for what still reads through it.
export * from './meta-write-org-scope.js';

// [#12702] The capability half of the same decision: which CALLERS a `/meta`
// item write door admits — `manage_metadata` as before, plus the org-scoped
// `manage_org_presentation` for org-overridable types written to the caller's
// own active organization. Sunk here by the same criterion as the scope half
// above: the doors live in `@objectstack/runtime` and `@objectstack/rest`,
// which share no other common home, and the predicate is registry-coupled
// (through `declaresOrgOverride`) so a second copy is forbidden drift.
// [#12702 · ADR-0131 D6] Which CALLERS a `/meta` item write door admits:
// `manage_metadata` (or `isSystem`), on both transports. The org-scoped
// `manage_org_presentation` arm retired with the per-organization overlay
// axis. Sunk here because the doors live in `@objectstack/runtime` and
// `@objectstack/rest`, which share no other common home.
export * from './meta-write-capability.js';

// [commit 1408fe385 / #10101] The shared platform-row organization resolver — sunk here
Expand Down
Loading
Loading