Skip to content
Draft
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
80 changes: 80 additions & 0 deletions .changeset/19054-retire-tenancy-organization-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-core': minor
'@objectstack/platform-objects': patch
---

**BREAKING** — retire `object.tenancy.organizationField`, the stamp-only column
declaration the whole protocol declared exactly once, on a table this platform ships.

The key answered "which column says who this platform row is ABOUT", where
`tenancy.tenantField` answers "what is this object WALLED by". The spec's own docblock
stated the consequence: *"For ordinary objects the two coincide and `organizationField`
is never needed."* Measured on `main` before this change, the entire repository declared
it **once** — `packages/platform-objects/src/identity/sys-api-key.object.ts`, the
better-auth credential table — and zero business objects declared it anywhere. Its
readers were three platform-row writers, scope-pinned **by name** (audit stamping, the
approval-row writer, the automation-run recorder), so an application declaration was
inert by construction while still being authorable on every object, which made every
future piece of organization logic owe the question "what if somebody set this?".
ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim and untranslated:
「organizationField 撤出可授权面 同意你的建议」.

## FROM → TO

| you wrote (17.4 and earlier) | write instead |
| --- | --- |
| `tenancy: { enabled: false, organizationField: 'active_organization_id' }` | `tenancy: { enabled: false }` — delete the key. Nothing read it on an application object |
| `tenancy: { enabled: true, organizationField: 'about_org_id' }` on an object whose tenant column really is `about_org_id` | `tenancy: { enabled: true, tenantField: 'about_org_id' }` — the surviving key both walls the object and stamps its platform rows |
| you declared it to make one platform table's rows stamp differently | nothing to write. That divergence is a platform fact now, not a knob |

The `tenancy` block is `.strict()`, so the key is **refused** with its prescription
rather than stripped, and `os migrate meta --from 17` lists the mechanical edits for
existing sources.

## What does NOT change

The `sys_api_key` divergence is intact, and that is the point of the shape this takes.
The credential table is `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns`
bails before tenancy is consulted and no `organization_id` is ever injected; the column
it really carries is better-auth's `active_organization_id`. Its audit, approval and
automation-run rows still stamp that column. What moved is only where the fact is
written: `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, one row,
keyed by object name and read by the STAMP face alone. The WALL face
(`resolveRecordWallOrganizationField`) never read the key and is untouched, so the
stamp/wall divergence pin stands unchanged.

⛔ The column is **not** renamed to `organization_id` and must never be: in this platform
"has an `organization_id` column" IS the wall, so the rename would wall the credential
table on an equality that excludes NULL and every pre-existing key would vanish from its
own owner's key list.

## For `@objectstack/metadata-core` consumers

`resolveRecordOrganizationField` and `createRecordOrganizationResolver` keep their
signatures and their four-limb precedence. Limb 0 is now keyed by the object's
registered NAME against the platform table instead of by a declaration on the definition:
the engine-bound resolver passes the name it was asked about, and the two-argument
function reads `objectDef.name` when the definition carries one. A caller that fed it a
hand-built definition carrying `tenancy.organizationField` — only reachable by
reimplementing a platform writer — now gets limbs 1 to 4.

The retirement kit, in the shape the playbook prescribes:

- the key is DELETED from `TenancyConfigSchema` (the block is a `strictObject`), and a
`TENANCY_RETIRED_KEY_GUIDANCE` row carries the prescription beside the two v15.0
precedents (`tenancy.strategy`, `tenancy.crossTenantAccess`)
- D2 conversion `object-tenancy-organization-field-removed` (`toMajor: 18`,
`retiredFromLoadPath: true`) strips the key from authored sources and stored
`sys_metadata` rows; D3 wires it into the protocol-18 chain step, and
`RETIRED_KEYS_BY_MAJOR[18]` declares `data/TenancyConfig:organizationField`
- the `authorable-surface/data.json` row is deleted in this same commit — the strict
route's tripwire — with the build computing the guidance-route proof for itself
- the liveness ledger row is deleted, since the key leaves the walked shape entirely
- pin tests: the authored shape is refused with its prescription, and the `sys_api_key`
stamp is pinned end to end beside the closed-set control (the same shape under any
other object name takes the ordinary limbs)

Clause-②: no

<!-- adr-0087: registered object-tenancy-organization-field-removed -->
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ Metadata query with filtering, sorting, and pagination
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
Expand Down
15 changes: 9 additions & 6 deletions content/docs/references/automation/schedule-organization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,15 @@ opened on, read from the other side.
⚠️ With ONE stated exception, so the sentence above is not read as a promise
it cannot keep. The two halves ask different questions and are answered by
different faces of the shared resolver: the history row is STAMPED (`who is
this row about` — `tenancy.organizationField` wins there, by the #8778 /
cloud#1395 ruling), while the run's acting organization is a WALL reading
(`what is this row scoped by`, which never consults that key). They give the
same answer on every object where the two coincide — every ordinary object,
because a declared stamp column is what makes them differ and one shipped
object declares one (`sys_api_key`, deliberately unwalled, #8287). Sweeping
this row about` — the platform stamp column wins there, by the cloud#1395
ruling), while the run's acting organization is a WALL reading
(`what is this row scoped by`, which never consults that column). They give
the same answer on every object where the two coincide — every ordinary
object, because a stamp column is what makes them differ and exactly one
shipped object has one (`sys_api_key`, deliberately unwalled, #8287; carried
by `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`
since the authorable `tenancy.organizationField` key was retired at protocol
18, #19054). Sweeping
THAT object under `group` stamps the history row from its stamp column while
the run itself acts as nothing and its inbox writes are refused. That is the
correct pair of answers rather than a residue of the old disagreement — a row
Expand Down
4 changes: 1 addition & 3 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ const result = ApiMethod.parse(data);
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
Expand Down Expand Up @@ -319,7 +319,6 @@ const result = ApiMethod.parse(data);
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object |
| **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. |
| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. |

### Nested Shape: `Object.access`

Expand Down Expand Up @@ -748,7 +747,6 @@ Boolean-or-predicates override for a built-in CRUD affordance.
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object |
| **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. |
| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. |


---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ Create a new object
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
Expand Down Expand Up @@ -614,7 +614,7 @@ Create a new object
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. |
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. |
| **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
Expand Down
Loading
Loading