Skip to content

Commit 502f179

Browse files
os-steveclaude
andauthored
fix(spec): retire tenancy.organizationField from the authorable surface (#19054) (#19618)
Fixes #19054 Clause-②: no Executes the maintainer ruling recorded verbatim on the card: 「organizationField 撤出可授权面 同意你的建议」. `object.tenancy.organizationField` leaves the authorable surface at protocol 18 (ADR-0049 enforce-or-remove). The divergence the key existed for is **not** retired — only its authorability. ## What the key was, and why it could never be more than one table's fact It 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."* Re-measured at head before this branch: the entire repository declared it **once**, on `packages/platform-objects/src/identity/sys-api-key.object.ts` — the better-auth credential table — and zero business objects declared it. Its readers were three platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still being authorable on every object. ## The shape of the change `TenancyConfigSchema` is a `strictObject`, so this is the strict-deletion route: - the key is deleted from the shape, and `TENANCY_RETIRED_KEY_GUIDANCE` gains its prescription beside the two v15.0 precedents (`tenancy.strategy`, `tenancy.crossTenantAccess`). Authoring it is now **refused with the prescription**, not stripped - D2 conversion `object-tenancy-organization-field-removed` (`toMajor: 18`, `retiredFromLoadPath: true`) strips it from authored sources and stored `sys_metadata` rows; D3 wires it into the protocol-18 chain step; `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** (not tombstoned): the key leaves the walked shape entirely, so a surviving row would read as an ORPHAN. `liveness/README.md`'s `object` row records why, and `state-counts.md` moves `object` 51 → 50 live Limb 0 of the shared resolver now reads a platform-internal table instead of a declaration: ``` PLATFORM_STAMP_ORGANIZATION_COLUMNS = { sys_api_key: 'active_organization_id' } ``` keyed by the object's registered NAME, read by the STAMP face alone. `resolveRecordOrganizationField` and `createRecordOrganizationResolver` keep their signatures — `check:api-surface` is byte-identical — and the engine-bound face passes the name it was asked about rather than reading `objectDef.name`, because several engine doubles in this monorepo return a bare `{ tenancy, fields }` map with no `name`. ## The two facts the card said must survive 1. `sys_api_key` is `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns` bails before tenancy is consulted and no `organization_id` is injected. Pinned, and the pin is now stated as the better-auth bail rather than as key-blindness (`packages/spec/src/data/injected-system-columns.test.ts`). 2. ⛔ The column is **not** renamed to `organization_id`. 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. `plugin-security`'s Layer-0 suite pins both halves against the real shipped object. The stamp/wall divergence pin is green: `resolveRecordWallOrganizationField` never read the key and is untouched. ## Base merge after #19600 landed, and the tombstone version it exposed (2026-09-23) The collision partner this section used to name, #19610, has landed, and so has #19600 (card #15178, merged at 03:04:25Z as `d0f1845657`). #19600 is one of the three PRs in the serial on `packages/spec/src/migrations/registry.ts` described in notice `5780847968`. After it landed, this PR read `dirty`. ⚠️ **The sentence that stood here before was wrong in part.** `registry.ts` is only partly generated. Its `<os-generated …>` regions are regenerated. But `registry.ts:18-38` says outright that each step's `rationale` and `conversionIds` are **hand-written and merge as text**. No gate turns red when a paragraph is dropped from them. The merge was done on the branch with no rebase and no force-push. It is three commits: 1. **`bde765bf05` merges `origin/main` at `67add1301a`.** It is a merge commit with parents `7cc0ca1b3d` and `67add1301a`, and it resolved two textual conflicts by hand. - `step18.rationale` keeps #19600's paragraph verbatim. Its last line is re-terminated with a trailing space, and this PR's paragraph is appended after it. - `step18.conversionIds` keeps both `'translation-per-app-settings-removed'` and `'object-tenancy-organization-field-removed'`. That gives 33 ids, 33 of them distinct. - `packages/spec/src/conversions/registry.ts` keeps both D2 conversions in `CONVERSIONS_BY_MAJOR[18]`, in landing order. The file's own rule is 「ordering within a major is application order」. - The generated regions were regenerated and never hand-merged. 2. **`9d5fb0ba5f` is regeneration only.** It regenerates the two reference pages that `os-regen-merge.sh` had deferred. 3. **`3fb1a4994c` is a CONTENT change, not merge resolution.** The merge brought in `check:future-spec-major` (#19655), which landed after this PR's old base, and CI went red on two sites. Under ADR-0087 (amended 2026-09-13), a tombstone names the npm release it ships in, never the protocol major. This retirement ships `minor`, so it lands in 17.x. The commit therefore changes the prescription at `packages/spec/src/data/object.zod.ts:540` and its refusal pin at `packages/spec/src/data/object.test.ts:1947` from `@objectstack/spec 18` to `@objectstack/spec 17`. The protocol-major references (`toMajor: 18`, `RETIRED_KEYS_BY_MAJOR[18]`, `os migrate meta --from 17`) are unchanged, because the gate permits them. **Measured by the dispatching seat against the committed trees, not taken from the dev's narration:** - The merge commit against the main parent `67add1301a`: 2 files, +128/−1. Every hunk is this PR's. - The merge commit against the branch parent `7cc0ca1b3d`: 2 files, +656/−28. Every hunk is main's. - On the merged `registry.ts`, each of the following appears exactly once: - the shared closing line `dataset. '` - #19600's paragraph - #19600's last line, continuing with a trailing space - each of the two `conversionIds` - the retired-key row - A dark control phrase appears 0 times. - This PR's paragraph follows #19600's. ⚠️ **The earlier contract review (`5765681233`) names head `7cc0ca1b3d`.** Commit `3fb1a4994c` changes a string that review's AC2 pinned, so this head move is **not** regeneration-only, and the earlier record does not govern the new head. `check-clause2-carriers.mjs --pair 19618` confirms it: exit 4, C6. A fresh contract review of the current head is owed before landing. ## Verification **Re-measured at the current head `3fb1a4994c`, after the base merge.** - **CI**, measured by the seat from the head's check-runs: 35 checks, latest run per name. 33 success, 2 skipped (`Console Pin Gate`, `Packed-tarball smoke (opt-in)`), 0 failed. All five type-check lanes pass. The legacy commit status is `success`. - **Suites and gates**, from the os-dev report `5788876254`, which the seat did not re-run: | package or gate | result | | --- | --- | | `@objectstack/spec` | 516 files, 15065 passed, 1 todo | | `@objectstack/metadata-core` | 16 files, 285 passed (unchanged) | | `@objectstack/plugin-audit` | 25 files, 363 passed (unchanged) | | typecheck, spec and metadata-core | exit 0 | | `check:generated` | 15 of 15 current | | `check:future-spec-major` | exit 0 | | `dispatch-gates --ran` | 115 accounted: 112 run with exit 0, 3 NOT MEASURED | - The spec suite grew from the pre-merge 509 files / 14898 tests. The +7 files are exactly the seven spec test files `main` added in the merged range. - `check:future-spec-major` was checked against a lit control: re-planting `18` makes it exit 1 with exactly one problem. - The 3 NOT MEASURED gates were refused for build prerequisites (exit 3). They run in CI jobs that build first. The tables below are the **pre-merge** readings, kept as history: Every number in the tables below was taken at `7cc0ca1b3d`, the pre-merge head. **Reverse verification (both legs committed first, both restored byte-identically, both via `scripts/ablation-replace.mjs`):** | ablation | anchor → replacement landed | result | | --- | --- | --- | | the platform stamp row renamed (`sys_api_key` → `sys_api_key_ABLATED`) | anchor 1 → 0, blob `0be02fdcc6b7` → `21856a4a20d0` | **4 of 19** metadata-core tests RED; restore `blob == HEAD`, `git diff HEAD` empty | | the prescription's first clause replaced with placeholder text | anchor 1 → 0, blob `2e9e19825ef6` → `eea8b4c7f061` | refusal pin RED with `expected 'Unrecognized key(s) on 'tenancy': 'or…' to contain ''tenancy.organizationField' was remov…'` — the pin measures the PRESCRIPTION, not merely that parse throws; restore verified the same way | **Suites** (`pnpm test` per package, through the shared verify lock): | package | result | | --- | --- | | `@objectstack/spec` | 509 files, 14898 passed, 1 todo | | `@objectstack/metadata-core` | 16 files, 285 passed | | `@objectstack/platform-objects` | 53 files, 848 passed | | `@objectstack/plugin-security` | 117 files, 2249 passed | | `@objectstack/plugin-audit` | 25 files, 363 passed | **Typecheck:** `@objectstack/spec`, `@objectstack/metadata-core`, `@objectstack/platform-objects`, `@objectstack/plugin-audit`, `@objectstack/plugin-security` — all green, test layers included. **Gates:** `node scripts/pm/dispatch-gates.mjs --ran` reconciles **114 derived / 114 run / 0 NOT-MEASURED / 0 UNRUN** against this diff. `pnpm --filter @objectstack/spec check:generated` reports 15 of 15 artifacts current. `pnpm lint` (`eslint . --no-inline-config`, the whole repo, no narrowing) exits 0. **The three sanctioned platform-row writers' pins stayed green UNTOUCHED**, as the card required — `plugin-approvals` (`approval-node`, `backfill-platform-row-organizations`), `service-automation` (`suspended-run-store`), `service-storage` (`backfill-sys-file-organizations`): 33 + 52 + 15 tests, zero edits. The `driver-sql` and `trigger-schedule` read-neutrality suites are green untouched too (36 + 61). ## Acceptance notes **Declared widening of the dispatched file surface — three files, each because this diff makes a statement in it FALSE.** None was edited for tidiness; each is named with the measurement that forced it. 1. `packages/spec/src/shared/alias-integrity.test.ts` — RED. It pins the exact key set of the folded `tenancy` guidance table: `expected [ 'crossTenantAccess', …(2) ] to deeply equal [ 'crossTenantAccess', 'strategy' ]`. The retirement adds the third row, which is the only channel the refusal travels on. 2. `packages/plugins/plugin-security/src/tenant-layer.test.ts` — RED. It asserted the declaration off the shipped object: `expected undefined to be 'active_organization_id'`. Rewritten to pin what this suite actually owns: the stamp column exists as a field, `organization_id` does not, and `tenancy` is exactly `{ enabled: false }`. 3. `packages/plugins/plugin-audit/src/audit-writers.test.ts` — RED, two cases, and one of them is a **finding the card asked for**. See the next section. A fourth file, `packages/spec/src/automation/schedule-organization.zod.ts`, carried a docblock asserting "`tenancy.organizationField` wins there" — a statement this diff falsifies, and one that **publishes**, into `content/docs/references/automation/schedule-organization.mdx`. Corrected in prose; the generated page follows. **⭐ Finding — one sanctioned writer's pin DID have to be edited, and the reason is not cosmetic.** Two `plugin-audit` cases went red: - *"`organizationField` outranks `tenantField`"* pinned the precedence on `crm_lead`, an object declaring BOTH keys, with the comment *"No shipped object declares both; this pins the precedence so the day one does is not a coin flip."* After the retirement no application can declare a stamp column at all, so the question is **closed rather than answered**. The case is rewritten to pin the closed set — an application object carrying a lookalike column stamps from its own wall. - *"control: without the declaration the credential table still stamps the actor's org"* fed a `sys_api_key` schema with **no** `tenancy` block and pinned the actor's org, proving the stamp came from the declaration rather than from a column-name heuristic. Keying limb 0 by object name makes that shape stamp `active_organization_id` instead. This is a **real, deliberate behaviour change on a shape that is not reachable for the shipped table** — `sys_api_key` is `managedBy: 'better-auth'` and `protection: { lock: 'full' }`, so its block cannot be dropped. Recorded in the rewritten case rather than smoothed over, and the `#5315` guard that did not move (column absent ⇒ fall through to the actor's org) is pinned beside it. **⭐ Finding — two issue citations this repo carries in these files do not resolve.** `check-issue-citations --base origin/main` judged 12 citations this change adds and refused all 12: `#8778` and `#8707` are `allocated-but-absent` (minted, ≤ frontier 19616, not on the board; deleted vs transferred NOT MEASURED). Both are pre-existing text — the diff only re-adds them by rewriting the docblocks around them. Following the gate's own prescription, the added lines now name the rulings in prose and cite the cloud record that does resolve. ⛔ No number was guessed. The standing occurrences on unchanged lines elsewhere in the tree are untouched and are not this PR's to repair. **Stale-but-green fixture residue, deliberately NOT touched** (green today, outside the dispatched surface, and not a defect — the fixtures feed drivers and engine doubles, never `TenancyConfigSchema`): `packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts`, `packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts`, `packages/plugins/plugin-approvals/src/{approval-node,backfill-platform-row-organizations}.test.ts`, `packages/services/service-automation/src/suspended-run-store.test.ts`, `packages/services/service-storage/src/backfill-sys-file-organizations.test.ts` still author `tenancy: { …, organizationField: … }` in raw object-definition fixtures. Their assertions remain true; what has gone vacuous is the *claim* that the driver / wall face is neutral about a key nobody can write. `packages/lint/src/validate-object-field-refs.ts` carries the key in a list of scalars it deliberately does not judge. **No tree-scoped absence pin is added**, and that is a decision rather than an omission: the playbook's tree-scoped form would have to declare its radius in `scripts/cross-package-test-inputs.mjs` and `turbo.json`, both far outside this card's surface, and it would go red against exactly the six inert fixtures above. The absence is instead enforced where it is cheap and exact — `authorable-surface/data.json` has no row, and `check:authorable-surface` is the gate over that baseline. ## Clause-② re-judged from the diff `no`, and the diff agrees. No hunk puts a new key on a published payload: the guidance row is a prescription string, the `RETIRED_KEYS_BY_MAJOR` / `CONVERSIONS_BY_MAJOR` entries are registry rows, `json-schema/**` **loses** a key, and `api-surface/` is byte-identical — `resolveRecordOrganizationField`'s signature is unchanged. This is a pure retirement, which narrows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7a8bd4 commit 502f179

22 files changed

Lines changed: 688 additions & 287 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-core': minor
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
**BREAKING** — retire `object.tenancy.organizationField`, the stamp-only column
8+
declaration the whole protocol declared exactly once, on a table this platform ships.
9+
10+
The key answered "which column says who this platform row is ABOUT", where
11+
`tenancy.tenantField` answers "what is this object WALLED by". The spec's own docblock
12+
stated the consequence: *"For ordinary objects the two coincide and `organizationField`
13+
is never needed."* Measured on `main` before this change, the entire repository declared
14+
it **once** — `packages/platform-objects/src/identity/sys-api-key.object.ts`, the
15+
better-auth credential table — and zero business objects declared it anywhere. Its
16+
readers were three platform-row writers, scope-pinned **by name** (audit stamping, the
17+
approval-row writer, the automation-run recorder), so an application declaration was
18+
inert by construction while still being authorable on every object, which made every
19+
future piece of organization logic owe the question "what if somebody set this?".
20+
ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim and untranslated:
21+
「organizationField 撤出可授权面 同意你的建议」.
22+
23+
## FROM → TO
24+
25+
| you wrote (17.4 and earlier) | write instead |
26+
| --- | --- |
27+
| `tenancy: { enabled: false, organizationField: 'active_organization_id' }` | `tenancy: { enabled: false }` — delete the key. Nothing read it on an application object |
28+
| `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 |
29+
| 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 |
30+
31+
The `tenancy` block is `.strict()`, so the key is **refused** with its prescription
32+
rather than stripped, and `os migrate meta --from 17` lists the mechanical edits for
33+
existing sources.
34+
35+
## What does NOT change
36+
37+
The `sys_api_key` divergence is intact, and that is the point of the shape this takes.
38+
The credential table is `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns`
39+
bails before tenancy is consulted and no `organization_id` is ever injected; the column
40+
it really carries is better-auth's `active_organization_id`. Its audit, approval and
41+
automation-run rows still stamp that column. What moved is only where the fact is
42+
written: `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, one row,
43+
keyed by object name and read by the STAMP face alone. The WALL face
44+
(`resolveRecordWallOrganizationField`) never read the key and is untouched, so the
45+
stamp/wall divergence pin stands unchanged.
46+
47+
⛔ The column is **not** renamed to `organization_id` and must never be: in this platform
48+
"has an `organization_id` column" IS the wall, so the rename would wall the credential
49+
table on an equality that excludes NULL and every pre-existing key would vanish from its
50+
own owner's key list.
51+
52+
## For `@objectstack/metadata-core` consumers
53+
54+
`resolveRecordOrganizationField` and `createRecordOrganizationResolver` keep their
55+
signatures and their four-limb precedence. Limb 0 is now keyed by the object's
56+
registered NAME against the platform table instead of by a declaration on the definition:
57+
the engine-bound resolver passes the name it was asked about, and the two-argument
58+
function reads `objectDef.name` when the definition carries one. A caller that fed it a
59+
hand-built definition carrying `tenancy.organizationField` — only reachable by
60+
reimplementing a platform writer — now gets limbs 1 to 4.
61+
62+
The retirement kit, in the shape the playbook prescribes:
63+
64+
- the key is DELETED from `TenancyConfigSchema` (the block is a `strictObject`), and a
65+
`TENANCY_RETIRED_KEY_GUIDANCE` row carries the prescription beside the two v15.0
66+
precedents (`tenancy.strategy`, `tenancy.crossTenantAccess`)
67+
- D2 conversion `object-tenancy-organization-field-removed` (`toMajor: 18`,
68+
`retiredFromLoadPath: true`) strips the key from authored sources and stored
69+
`sys_metadata` rows; D3 wires it into the protocol-18 chain step, and
70+
`RETIRED_KEYS_BY_MAJOR[18]` declares `data/TenancyConfig:organizationField`
71+
- the `authorable-surface/data.json` row is deleted in this same commit — the strict
72+
route's tripwire — with the build computing the guidance-route proof for itself
73+
- the liveness ledger row is deleted, since the key leaves the walked shape entirely
74+
- pin tests: the authored shape is refused with its prescription, and the `sys_api_key`
75+
stamp is pinned end to end beside the closed-set control (the same shape under any
76+
other object name takes the ordinary limbs)
77+
78+
Clause-②: no
79+
80+
<!-- adr-0087: registered object-tenancy-organization-field-removed -->

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,7 @@ Metadata query with filtering, sorting, and pagination
948948
| **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. |
949949
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
950950
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
951-
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
951+
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
952952
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
953953
| **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. |
954954
| **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. |

‎content/docs/references/automation/schedule-organization.mdx‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,15 @@ opened on, read from the other side.
8686
⚠️ With ONE stated exception, so the sentence above is not read as a promise
8787
it cannot keep. The two halves ask different questions and are answered by
8888
different faces of the shared resolver: the history row is STAMPED (`who is
89-
this row about` — `tenancy.organizationField` wins there, by the #8778 /
90-
cloud#1395 ruling), while the run's acting organization is a WALL reading
91-
(`what is this row scoped by`, which never consults that key). They give the
92-
same answer on every object where the two coincide — every ordinary object,
93-
because a declared stamp column is what makes them differ and one shipped
94-
object declares one (`sys_api_key`, deliberately unwalled, #8287). Sweeping
89+
this row about` — the platform stamp column wins there, by the cloud#1395
90+
ruling), while the run's acting organization is a WALL reading
91+
(`what is this row scoped by`, which never consults that column). They give
92+
the same answer on every object where the two coincide — every ordinary
93+
object, because a stamp column is what makes them differ and exactly one
94+
shipped object has one (`sys_api_key`, deliberately unwalled, #8287; carried
95+
by `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`
96+
since the authorable `tenancy.organizationField` key was retired at protocol
97+
18, #19054). Sweeping
9598
THAT object under `group` stamps the history row from its stamp column while
9699
the run itself acts as nothing and its inbox writes are refused. That is the
97100
correct pair of answers rather than a residue of the old disagreement — a row

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ const result = ApiMethod.parse(data);
153153
| **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. |
154154
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
155155
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
156-
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
156+
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
157157
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
158158
| **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. |
159159
| **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. |
@@ -319,7 +319,6 @@ const result = ApiMethod.parse(data);
319319
| :--- | :--- | :--- | :--- |
320320
| **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object |
321321
| **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. |
322-
| **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`. |
323322

324323
### Nested Shape: `Object.access`
325324

@@ -748,7 +747,6 @@ Boolean-or-predicates override for a built-in CRUD affordance.
748747
| :--- | :--- | :--- | :--- |
749748
| **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object |
750749
| **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. |
751-
| **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`. |
752750

753751

754752
---

‎content/docs/references/system/migration.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ Create a new object
329329
| **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. |
330330
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
331331
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
332-
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
332+
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
333333
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
334334
| **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. |
335335
| **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. |
@@ -614,7 +614,7 @@ Create a new object
614614
| **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. |
615615
| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes |
616616
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
617-
| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
617+
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
618618
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
619619
| **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. |
620620
| **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. |

0 commit comments

Comments
 (0)