diff --git a/.changeset/19054-retire-tenancy-organization-field.md b/.changeset/19054-retire-tenancy-organization-field.md new file mode 100644 index 00000000000..16c36a1f018 --- /dev/null +++ b/.changeset/19054-retire-tenancy-organization-field.md @@ -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 + + diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 20f2b973eb7..758b8777a93 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -948,7 +948,7 @@ Metadata query with filtering, sorting, and pagination | **fields** | `Record; 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. | diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx index 7d1af3edca0..3d3f12a07e0 100644 --- a/content/docs/references/automation/schedule-organization.mdx +++ b/content/docs/references/automation/schedule-organization.mdx @@ -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 diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 13ae54574bb..985aa60d05e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -153,7 +153,7 @@ const result = ApiMethod.parse(data); | **fields** | `Record; 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. | @@ -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` @@ -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`. | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 6e63be89b54..6485f2ebdbc 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -329,7 +329,7 @@ Create a new object | **fields** | `Record; 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. | @@ -614,7 +614,7 @@ Create a new object | **fields** | `Record; 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. | diff --git a/packages/metadata-core/src/record-organization.test.ts b/packages/metadata-core/src/record-organization.test.ts index 3f24d4591d0..728509e8b25 100644 --- a/packages/metadata-core/src/record-organization.test.ts +++ b/packages/metadata-core/src/record-organization.test.ts @@ -8,12 +8,20 @@ * approval-row writer and the automation-run recorder share ONE precedence. * * The four-limb precedence is pinned per limb, and the `sys_api_key` - * divergence is pinned by name: `tenancy.organizationField` answers "which - * column says who this row is ABOUT", `tenantField`/`organization_id` answers - * "what is this object WALLED by", and the two DELIBERATELY diverge for + * divergence is pinned by name: `PLATFORM_STAMP_ORGANIZATION_COLUMNS` answers + * "which column says who this row is ABOUT", `tenantField`/`organization_id` + * answers "what is this object WALLED by", and the two DELIBERATELY diverge for * credential tables (#8287). Flattening that divergence — resolving the stamp * from the wall, or walling from the stamp — is the two-tables-disagree * pathology this promotion exists to end. + * + * ⭐ [#19054] Limb 0 used to read the authorable `tenancy.organizationField` + * key; protocol 18 retires that key (ADR-0049) and the divergence moves into + * the platform table keyed by OBJECT NAME. Two consequences these pins state + * rather than assume: an object outside that table gets limb 0 skipped no + * matter what columns it carries, and the engine-bound faces resolve limb 0 + * from the name they were ASKED about, not from a `name` the definition may or + * may not echo. */ import { describe, it, expect, vi } from 'vitest'; @@ -37,30 +45,54 @@ const hasFieldOf = (def: any) => (field: string) => def?.fields != null && Object.prototype.hasOwnProperty.call(def.fields, field); describe('resolveRecordOrganizationField — the four-limb precedence', () => { - it('limb 0: a declared `tenancy.organizationField` wins over everything, the ADR-0066 opt-out included (sys_api_key)', () => { + it('limb 0: the platform stamp column wins over everything, the ADR-0066 opt-out included (sys_api_key)', () => { // The shipped divergent case: an UNWALLED credential table // (`enabled: false`) whose rows are still ABOUT one organization, under a - // column that deliberately is NOT the tenant column. + // column that deliberately is NOT the tenant column. Since #19054 the + // divergence is a platform fact keyed by object NAME — the definition + // declares nothing. const def = { name: 'sys_api_key', - tenancy: { enabled: false, organizationField: 'active_organization_id' }, + tenancy: { enabled: false }, fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} }, }; expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('active_organization_id'); }); - it('limb 0 guard (#5315): a declared organizationField naming a MISSING column falls through, never resolves to nothing', () => { + it('limb 0 is keyed by NAME, not by column shape: an ordinary object carrying the same column is not a stamp row (#19054)', () => { + // ⛔ The anti-widening pin. The retired key made "this object stamps from + // somewhere else" authorable; the platform table makes it a closed set. An + // application object that happens to carry a column by that name — or that + // would have declared the key before protocol 18 — takes the ordinary + // limbs, so no application can put a fourth spelling of "who is this row + // about" into the platform's mouth. + const lookalike = { + name: 'crm_lead', + tenancy: { enabled: true, tenantField: 'workspace_id' }, + fields: { id: {}, workspace_id: {}, active_organization_id: {}, organization_id: {} }, + }; + expect(resolveRecordOrganizationField(lookalike, hasFieldOf(lookalike))).toBe('workspace_id'); + + const unwalledLookalike = { + name: 'crm_credential', + tenancy: { enabled: false }, + fields: { id: {}, active_organization_id: {}, organization_id: {} }, + }; + expect(resolveRecordOrganizationField(unwalledLookalike, hasFieldOf(unwalledLookalike))).toBeNull(); + }); + + it('limb 0 guard (#5315): a platform stamp column the object does NOT have falls through, never resolves to nothing', () => { // Missing column + disabled tenancy → limb 1 answers null (not the // phantom name, and not organization_id either). const def = { name: 'sys_api_key', - tenancy: { enabled: false, organizationField: 'active_organization_id' }, + tenancy: { enabled: false }, fields: { id: {}, organization_id: {} }, }; expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBeNull(); }); - it('limb 1: `tenancy.enabled === false` WITHOUT an organizationField resolves null even when an org FK exists (ADR-0066)', () => { + it('limb 1: `tenancy.enabled === false` on a non-stamp object resolves null even when an org FK exists (ADR-0066)', () => { // The sys_sso_provider shape: platform-global, keeps an optional org FK, // explicitly not tenant-scoped. Stamping from the FK would hide a global // object's platform rows from the platform admin who acted. @@ -103,38 +135,39 @@ describe('resolveRecordOrganizationField — the four-limb precedence', () => { * implementation that had quietly become a second copy of the precedence. */ describe('resolveRecordWallOrganizationField — limb 0 is not a limb here', () => { - it('the sys_api_key shape: the stamp face answers the declared column, the wall face answers NULL', () => { - // The ONE shipped object that declares the key, and the reason the two - // faces exist: `enabled: false` says nothing walls this table (#8287), so - // there is no organization for work launched from such a row to ACT AS, - // however clearly the row says who it is ABOUT. + it('the sys_api_key shape: the stamp face answers the platform column, the wall face answers NULL', () => { + // The ONE object in the platform stamp table, and the reason the two faces + // exist: `enabled: false` says nothing walls this table (#8287), so there + // is no organization for work launched from such a row to ACT AS, however + // clearly the row says who it is ABOUT. const def = { name: 'sys_api_key', - tenancy: { enabled: false, organizationField: 'active_organization_id' }, + tenancy: { enabled: false }, fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} }, }; expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('active_organization_id'); expect(resolveRecordWallOrganizationField(def, hasFieldOf(def))).toBeNull(); }); - it('a declared organizationField on a WALLED object is still not read — the wall answers its own column', () => { - // The hypothetical an author could write today: the stamp key on an object - // that IS walled, by a different column. The stamp face honours limb 0; the - // wall face takes limb 2, because that is the column the row is scoped by - // and therefore the only one an acting identity may come from. - const def = { + it('the wall face never consults the platform stamp table, even where the object carries that column', () => { + // A stamp row that IS walled would be the shape where the two faces could + // silently converge. `sys_api_key` is not walled, so the discriminating + // fixture is the credential table itself seen from both sides plus the + // ordinary walled neighbour: the wall face must reach its own column by + // limbs 2/3 alone, never by the stamp table. + const walled = { name: 'ws_doc', - tenancy: { enabled: true, tenantField: 'workspace_id', organizationField: 'about_org_id' }, - fields: { id: {}, workspace_id: {}, about_org_id: {}, organization_id: {} }, + tenancy: { enabled: true, tenantField: 'workspace_id' }, + fields: { id: {}, workspace_id: {}, active_organization_id: {}, organization_id: {} }, }; - expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('about_org_id'); - expect(resolveRecordWallOrganizationField(def, hasFieldOf(def))).toBe('workspace_id'); + expect(resolveRecordWallOrganizationField(walled, hasFieldOf(walled))).toBe('workspace_id'); + expect(resolveRecordOrganizationField(walled, hasFieldOf(walled))).toBe('workspace_id'); }); it('limbs 1 to 4 are SHARED — the two faces agree everywhere limb 0 is absent', () => { - // The anti-drift pin. Every shape the stamp face pins above, minus the two - // that declare the key: the answers must be identical, so a future edit - // that "fixes" one body cannot leave the other behind. + // The anti-drift pin. Every shape the stamp face pins above, minus the ones + // in the platform stamp table: the answers must be identical, so a future + // edit that "fixes" one body cannot leave the other behind. const shapes = [ { name: 'sys_sso_provider', tenancy: { enabled: false }, fields: { id: {}, organization_id: {} } }, { name: 'ws_doc', tenancy: { enabled: true, tenantField: 'workspace_id' }, fields: { id: {}, workspace_id: {}, organization_id: {} } }, @@ -157,8 +190,13 @@ describe('createRecordWallOrganizationResolver — the sweep’s memoized face', it('resolves the wall column end to end, and answers null on the unwalled credential shape', () => { const engine = engineOf({ crm_deal: { fields: { id: {}, organization_id: {} } }, + // ⭐ No `name` on the definition, deliberately: the engine-bound faces + // resolve limb 0 from the name they were ASKED about. Several engine + // doubles in this monorepo return bare `{ tenancy, fields }` maps, and a + // stamp column that depended on whether a schema echoes its own name + // would be a difference no caller can see. sys_api_key: { - tenancy: { enabled: false, organizationField: 'active_organization_id' }, + tenancy: { enabled: false }, fields: { id: {}, active_organization_id: {} }, }, }); @@ -228,8 +266,11 @@ describe('createRecordOrganizationResolver — the writers’ memoized face', () it('pins the sys_api_key divergence end to end: the stamp column is active_organization_id, never the wall', () => { const engine = engineOf({ + // Bare definition, no `name` echoed — this is the shape the three + // sanctioned writers' own engine doubles use, and the face must resolve + // limb 0 from the name it was asked about (#19054). sys_api_key: { - tenancy: { enabled: false, organizationField: 'active_organization_id' }, + tenancy: { enabled: false }, fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} }, }, }); @@ -239,12 +280,31 @@ describe('createRecordOrganizationResolver — the writers’ memoized face', () r.organizationOf('sys_api_key', { id: 'k1', active_organization_id: 'org_key' }), ).toBe('org_key'); // A record carrying an `organization_id` VALUE anyway (defensive noise) - // still stamps from the DECLARED column, not the canonical spelling. + // still stamps from the PLATFORM column, not the canonical spelling. expect( r.organizationOf('sys_api_key', { id: 'k1', organization_id: 'org_wrong', active_organization_id: 'org_key' }), ).toBe('org_key'); }); + it('⛔ the stamp table is a closed set: the same shape under any other object name takes the ordinary limbs (#19054)', () => { + // The control for the pin above. The engine-bound face is where limb 0 is + // reached with an authoritative name, so this is where "closed set" has to + // be stated: an object that is byte-identical to `sys_api_key` except for + // its name gets no stamp column at all, and the caller falls back to the + // acting context exactly as it does for every other unwalled object. + const engine = engineOf({ + tenant_credential: { + tenancy: { enabled: false }, + fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} }, + }, + }); + const r = createRecordOrganizationResolver(engine); + expect(r.organizationFieldFor('tenant_credential')).toBeNull(); + expect( + r.organizationOf('tenant_credential', { id: 'k1', active_organization_id: 'org_key' }), + ).toBeNull(); + }); + it('degrades to null — the acting-context fallback signal — on a getSchema-less double, a throwing getSchema, and an unknown object', () => { expect(createRecordOrganizationResolver({}).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull(); const throwing = { getSchema: () => { throw new Error('not booted'); } }; diff --git a/packages/metadata-core/src/record-organization.ts b/packages/metadata-core/src/record-organization.ts index d614b377fe7..ad1daa4564a 100644 --- a/packages/metadata-core/src/record-organization.ts +++ b/packages/metadata-core/src/record-organization.ts @@ -22,36 +22,81 @@ * > SUBJECT record's organization; actor context is the fallback, never the * > primary. * - * ⛔ The `tenancy.organizationField` key this resolver reads stays scope-pinned - * (#8778, widened by name on cloud#1395 — the annotation beside the key in - * `packages/spec/src/data/object.zod.ts` transcribes the ruling): exactly THREE - * consumers are sanctioned — audit stamping, the approval-row writer, and the + * ⛔ The stamp-only divergence this resolver reads stays scope-pinned by the + * ruling quoted above, widened by name on cloud#1395: exactly THREE consumers + * are sanctioned — audit stamping, the approval-row writer, and the * automation-run recorder — and no others. A fourth consumer needs its own - * maintainer ruling before reading the key, exactly as #8778 required. Sharing - * the implementation here does not open the key: it closes the excuse for a - * fourth copy. + * maintainer ruling, exactly as the original scope-pin required. Sharing the + * implementation here does not open it: it closes the excuse for a fourth copy. * - * ⭐ [#18378] This module now answers TWO questions, and only the first reads - * that key. {@link resolveRecordOrganizationField} is the STAMP answer ("who is - * this row about"), consumers still the three above; + * ⭐ [#19054] The divergence is no longer AUTHORABLE. It used to be declared by + * the `tenancy.organizationField` spec key, which every application could write + * and which the whole repository declared exactly once — on `sys_api_key`, a + * table this platform ships. Protocol 18 retires the key (ADR-0049 + * enforce-or-remove) and moves the fact into + * {@link PLATFORM_STAMP_ORGANIZATION_COLUMNS} below. Nothing about the three + * writers' behaviour changes; what changes is that no application can put a + * fourth spelling of "who is this row about" into the platform's mouth. + * + * ⭐ [#18378] This module answers TWO questions, and only the first consults + * that table. {@link resolveRecordOrganizationField} is the STAMP answer ("who + * is this row about"), consumers still the three above; * {@link resolveRecordWallOrganizationField} is the WALL answer ("what is this * row scoped by", and so which organization work launched from it acts as), * which skips limb 0 entirely. A caller of the second is not a fourth consumer - * of the key — it never reads it — and the split is what keeps the scope-pin - * from being widened by callers who only ever wanted the wall. + * — it never reads the table — and the split is what keeps the scope-pin from + * being widened by callers who only ever wanted the wall. * * A platform row is stamped from the organization the record is ABOUT (#8287's * ruling). To do that the writer has to know which column holds it, and * `organization_id` is not universally the answer: `sys_api_key` carries - * `active_organization_id` by deliberate design (#8287). Adding a second - * literal name beside the first would make a writer correct for exactly two - * objects and silently wrong for the third, so the question is asked of the - * schema instead. + * `active_organization_id` by deliberate design (#8287). Hard-coding a second + * literal name inside each writer would make every one of them correct for + * exactly two objects and silently wrong for the third, so the question is + * asked ONCE here — of the platform table for limb 0, and of the object's own + * registered schema for limbs 1 to 4. */ import { isTenancyDisabled } from '@objectstack/spec/data'; import { SystemFieldName } from '@objectstack/spec/system'; +/** + * [#19054] Limb 0's whole population — the platform tables whose rows are ABOUT + * an organization carried under a column that is deliberately NOT the tenant + * column, keyed by the object's registered name. + * + * It replaces the authorable `tenancy.organizationField` key, retired from + * `packages/spec` in protocol 18 (ADR-0049 enforce-or-remove; maintainer ruling + * 2026-09-18, verbatim and untranslated: 「organizationField 撤出可授权面 + * 同意你的建议」). The key was authorable by every application and declared, in + * the entire repository, exactly once — here. The spec's own docblock said why + * it could never be more than that: on an ordinary object the stamp column and + * the tenant column are the same column, so a declaration either restated the + * default or asked for a divergence outside the three sanctioned writers. A + * fact about one table we ship belongs in a table we ship. + * + * ⛔ Adding a row is a PROTOCOL decision, not a convenience. Each row is an + * object whose platform rows are stamped from somewhere other than its wall, + * which is exactly the divergence the cloud#1395 ruling scope-pinned; + * a new one needs its own ruling, the same bar a fourth consumer of the old key + * needed. ⛔ And it is never a substitute for `tenancy.tenantField`: an object + * whose tenant column genuinely is not `organization_id` declares that key, + * which both walls it and stamps its platform rows (limb 2 below). + * + * `sys_api_key` is the one row and the reason the mechanism exists: it is + * `managedBy: 'better-auth'`, so `resolveInjectedSystemColumns` bails before + * tenancy is consulted and no `organization_id` is ever injected; the column it + * really has is better-auth's `active_organization_id`. ⛔ Renaming that column + * to `organization_id` is NOT the simplification it looks like — 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-#8287 key would vanish from its own owner's key list. That is the defect + * #8287 exists to have removed. + */ +const PLATFORM_STAMP_ORGANIZATION_COLUMNS: Readonly> = Object.freeze({ + sys_api_key: 'active_organization_id', +}); + /** * "Does this object's REGISTERED schema declare this field?", memoized per * object. @@ -144,20 +189,21 @@ export function createFieldPresenceProbe( * for the opt-out) and `SystemFieldName.ORGANIZATION_ID` — so the parts that * could drift are one definition, and only the ordering is restated. * - * 0. **Declared `tenancy.organizationField`, when the object really has that - * field.** The read-neutral, STAMP-ONLY declaration #8778's ruling added - * for exactly this consumer (option A; #8707's remaining half). It - * answers "which column says who this row is ABOUT" — a different - * question from "what is this object walled by", which is why it wins - * over every limb below, the ADR-0066 opt-out included: an author who - * declares it on an unwalled object (`sys_api_key`, `enabled: false` by - * necessity — the credential table must never be org-walled, #8287) is - * stating precisely that the trail should follow the record's own - * organization even though no wall does. Honoured only when the field is - * really present, same #5315 guard as limb 2. ⛔ Stamp-only cuts both - * ways: the key's consumers are pinned to the THREE platform-row writers - * the cloud#1395 ruling names (audit, approvals, automation runs) — a - * fourth consumer, or any read path, needs its own ruling first. + * 0. **A {@link PLATFORM_STAMP_ORGANIZATION_COLUMNS} row for this object, + * when the object really has that column.** The stamp-only divergence + * the stamp-only ruling introduced (option A), carried + * since protocol 18 by the platform-internal table above instead of the + * retired authorable `tenancy.organizationField` key. It answers "which + * column says who this row is ABOUT" — a different question from "what is + * this object walled by", which is why it wins over every limb below, the + * ADR-0066 opt-out included: `sys_api_key` is `enabled: false` by + * necessity (the credential table must never be org-walled, #8287) and its + * trail must still follow the record's own organization even though no + * wall does. Honoured only when the column is really present, same #5315 + * guard as limb 2. ⛔ Stamp-only cuts both ways: this limb is read by the + * THREE platform-row writers the cloud#1395 ruling names (audit, + * approvals, automation runs) — a fourth consumer, or any read path, needs + * its own ruling first. * 1. **`tenancy.enabled === false` → `null`.** ADR-0066 platform-global * objects (`sys_sso_provider` is the shipped example) keep an optional org * FK while explicitly NOT being tenant-scoped. Stamping a platform row from @@ -191,14 +237,22 @@ export function createFieldPresenceProbe( * the same conclusion through a heuristic is the same mistake with no gate on * it. * - * `sys_api_key.active_organization_id` is reachable through limb 0 since - * #8778 (it was the object that motivated the key). Its column is still not — - * and must never become — the object's tenant-scope column: + * `sys_api_key.active_organization_id` is reachable through limb 0 since the + * stamp-only ruling (it was the object that motivated the divergence). Its + * column is still not — and must never become — the object's tenant-scope + * column: * `tenancy.tenantField` feeds `applyTenantScope` / `injectTenantOnInsert`, so * declaring it there would wall the credential table on an equality that * excludes NULL — every pre-#8287 key would vanish from its own owner's * list, which is the defect #8287 exists to have removed. * + * ⚠️ Limb 0 is keyed by the object's NAME since protocol 18, so this two-argument + * face reads it off `objectDef.name` — a definition that carries no `name` + * resolves limbs 1 to 4 only. That is not a degradation to design around: the + * engine-bound face below ({@link createRecordOrganizationResolver}), which is + * what all three sanctioned writers actually hold, passes the registered name it + * was asked about and never depends on the definition carrying one. + * * @param objectDef the registered object definition (`engine.getSchema(name)`) * @param hasField the memoized field-presence probe for the SAME object — the * platform asks "does the schema declare this field?" exactly one way @@ -209,7 +263,10 @@ export function resolveRecordOrganizationField( objectDef: unknown, hasField: (field: string) => boolean, ): string | null { - return resolveOrganizationField(objectDef, hasField, { readStampKey: true }); + return resolveOrganizationField(objectDef, hasField, { + objectName: objectNameOf(objectDef), + readStampColumn: true, + }); } /** @@ -221,21 +278,21 @@ export function resolveRecordOrganizationField( * exactly one place. "Which column says who this row is ABOUT" (stamping) and * "which column is this row WALLED by" (scope, and therefore the organization * work launched from the row acts as) coincide on every ordinary object, and - * come apart only where an author declared `tenancy.organizationField` — which + * come apart only on a {@link PLATFORM_STAMP_ORGANIZATION_COLUMNS} row — which * is ONE shipped object, `sys_api_key`, whose whole point is that it is not * walled (#8287). * - * ⛔ It does not read `tenancy.organizationField`, and that is the contract - * rather than an omission. The key's consumers stay pinned to the THREE - * platform-row writers the cloud#1395 ruling names; a caller asking the WALL - * question is not a fourth consumer of the stamp key, it is a caller of a - * different question. Reading limb 0 here would take a declaration meaning "the - * audit trail should follow this row's own organization even though nothing - * walls it" and turn it into an ACTING IDENTITY — a sweep over `sys_api_key` - * would then launch runs acting as an organization derived from an annotation - * that never meant "act as this". These limbs resolve `null` there instead, and - * the caller takes the existing `walled-posture` refusal at its first - * tenant-scoped write (ADR-0112), loudly and by name. + * ⛔ It does not read that table, and that is the contract rather than an + * omission. The divergence stays pinned to the THREE platform-row writers the + * cloud#1395 ruling names; a caller asking the WALL question is not a fourth + * consumer of the stamp column, it is a caller of a different question. Reading + * limb 0 here would take a row meaning "the audit trail should follow this + * row's own organization even though nothing walls it" and turn it into an + * ACTING IDENTITY — a sweep over `sys_api_key` would then launch runs acting as + * an organization derived from an annotation that never meant "act as this". + * These limbs resolve `null` there instead, and the caller takes the existing + * `walled-posture` refusal at its first tenant-scoped write (ADR-0112), loudly + * and by name. * * ⚠️ The twin of `@objectstack/objectql`'s `resolveTenantFieldName`, which says * the same of `SqlDriver.computeTenantField` — three spellings of one rule is @@ -257,11 +314,21 @@ export function resolveRecordWallOrganizationField( objectDef: unknown, hasField: (field: string) => boolean, ): string | null { - return resolveOrganizationField(objectDef, hasField, { readStampKey: false }); + return resolveOrganizationField(objectDef, hasField, { + objectName: objectNameOf(objectDef), + readStampColumn: false, + }); +} + +/** The registered name a definition carries, when it carries one. */ +function objectNameOf(objectDef: unknown): string | undefined { + if (!objectDef || typeof objectDef !== 'object') return undefined; + const name = (objectDef as { name?: unknown }).name; + return typeof name === 'string' && name.length > 0 ? name : undefined; } /** - * The limbs themselves, in ONE place — `readStampKey` selects limb 0 alone. + * The limbs themselves, in ONE place — `readStampColumn` selects limb 0 alone. * * A parameter rather than two bodies because limbs 1 to 4 are shared BY * CONTRACT: the precedence doc above states at length that a platform row's @@ -272,19 +339,19 @@ export function resolveRecordWallOrganizationField( function resolveOrganizationField( objectDef: unknown, hasField: (field: string) => boolean, - { readStampKey }: { readStampKey: boolean }, + { objectName, readStampColumn }: { objectName: string | undefined; readStampColumn: boolean }, ): string | null { if (!objectDef || typeof objectDef !== 'object') return null; - const tenancy = (objectDef as { tenancy?: { organizationField?: unknown; tenantField?: unknown } }).tenancy; - // Limb 0 — the explicit stamp-only declaration (#8778) wins over everything, + // Limb 0 — the platform's own stamp-only divergence, carried by + // `PLATFORM_STAMP_ORGANIZATION_COLUMNS` since #19054, wins over everything, // the ADR-0066 opt-out below included: see the precedence doc above. Reached // by the three sanctioned platform-row writers and by nobody else. - if (readStampKey) { - const stampField = tenancy?.organizationField; - if (typeof stampField === 'string' && stampField.length > 0 && hasField(stampField)) return stampField; + if (readStampColumn && objectName !== undefined) { + const stampColumn = PLATFORM_STAMP_ORGANIZATION_COLUMNS[objectName]; + if (stampColumn !== undefined && hasField(stampColumn)) return stampColumn; } if (isTenancyDisabled(objectDef)) return null; - const declared = tenancy?.tenantField; + const declared = (objectDef as { tenancy?: { tenantField?: unknown } }).tenancy?.tenantField; if (typeof declared === 'string' && declared.length > 0 && hasField(declared)) return declared; if (hasField(SystemFieldName.ORGANIZATION_ID)) return SystemFieldName.ORGANIZATION_ID; return null; @@ -322,7 +389,7 @@ export interface RecordOrganizationResolver { * context fallback instead of failing the write. */ export function createRecordOrganizationResolver(engine: unknown): RecordOrganizationResolver { - return createResolver(engine, resolveRecordOrganizationField); + return createResolver(engine, true); } /** @@ -338,12 +405,20 @@ export function createRecordOrganizationResolver(engine: unknown): RecordOrganiz * treating empty as absent" is where the next drift starts. */ export function createRecordWallOrganizationResolver(engine: unknown): RecordOrganizationResolver { - return createResolver(engine, resolveRecordWallOrganizationField); + return createResolver(engine, false); } +/** + * ⚠️ It passes the name it was ASKED about into limb 0, never + * `objectDef.name`. The registered name is the thing the caller holds and the + * thing the platform table is keyed by; a definition is free not to repeat it + * (several engine doubles in this monorepo do not), and reading limb 0 off the + * definition would make the stamp column depend on whether a schema echoes its + * own name — a difference no caller can see and no test would state. + */ function createResolver( engine: unknown, - resolveField: (objectDef: unknown, hasField: (field: string) => boolean) => string | null, + readStampColumn: boolean, ): RecordOrganizationResolver { const hasField = createFieldPresenceProbe(engine); const columnCache = new Map(); @@ -357,7 +432,10 @@ function createResolver( } catch { /* ignore — best-effort; absence just means the caller falls back */ } - const resolved = resolveField(objectDef, (field) => hasField(objectName, field)); + const resolved = resolveOrganizationField(objectDef, (field) => hasField(objectName, field), { + objectName, + readStampColumn, + }); columnCache.set(objectName, resolved); return resolved; }; diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index b97783734d0..fa21c07885a 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -44,14 +44,21 @@ export const SysApiKey = ObjectSchema.create({ reason: 'Identity table managed by better-auth — see ADR-0010.', docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, - // [#8778, #8707 remainder] Stamp-only organization declaration — NOT a wall. + // [#19054] The stamp-only organization declaration that used to sit here + // (`organizationField: 'active_organization_id'`, the stamp-only ruling) is + // GONE from the authorable surface in protocol 18 (ADR-0049 enforce-or-remove; + // maintainer ruling 2026-09-18, verbatim and untranslated: + // 「organizationField 撤出可授权面 同意你的建议」). // - // `organizationField` tells the audit writer which column carries the - // organization a key row is ABOUT, so history/revocation rows land behind - // the wall of the key's own organization instead of the revoker's active - // one (#8707's repro). It is read by audit stamping ONLY; no tenant-scoping - // path (`applyTenantScope` / `injectTenantOnInsert` / - // `computeTenantLayer0Filter`) reads it — pinned by tests beside each. + // ⛔ Nothing about this table's behaviour changed, and ⛔ nothing here needs + // to replace it. The fact it carried — "platform rows about a `sys_api_key` + // row are stamped from `active_organization_id`, not from any wall" — now + // lives in `@objectstack/metadata-core`'s + // `PLATFORM_STAMP_ORGANIZATION_COLUMNS`, keyed by this object's name, read by + // the three sanctioned platform-row writers (audit stamping, the approval-row + // writer, the automation-run recorder) and by nothing else. It was authorable + // by every application and declared, repo-wide, only here; a fact about one + // table we ship is not a knob customers configure. // // `enabled: false` states explicitly what this table's shape already // implies, and is measured behavior-identical to having no `tenancy` block @@ -66,7 +73,7 @@ export const SysApiKey = ObjectSchema.create({ // excludes NULL, and every pre-#8287 key vanishes from its own owner's // "My Keys" list — the defect #8287 exists to have removed (see the // `active_organization_id` field comment below). - tenancy: { enabled: false, organizationField: 'active_organization_id' }, + tenancy: { enabled: false }, description: 'API keys for programmatic access', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index b27e692b9be..222423fe250 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -1645,15 +1645,18 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 expect(stampOf(created).audit?.organization_id).not.toBe('org-parent'); }); - // ── `tenancy.organizationField` — the stamp-only declaration (#8778) ──── + // ── the platform stamp column — `sys_api_key` (#8778, #19054) ────────── // // The former ⛔ KNOWN GAP case lived here: it pinned that // `sys_api_key.active_organization_id` was UNREACHABLE and stamped the - // ACTOR's org, and was written to go red the day a read-neutral, stamp-only - // declaration landed in `packages/spec`. That day is #8778 (maintainer-ruled - // option A): the cases below are its rewrite, expecting `org-key`. - - it('stamps from a declared `tenancy.organizationField` — the #8707 repro, closed (#8778)', async () => { + // ACTOR's org, and was written to go red the day the divergence became + // expressible. That day is #8778 (maintainer-ruled option A); the cases + // below are its rewrite, expecting `org-key`. #19054 moved the divergence + // off the authorable `tenancy.organizationField` key and into + // `PLATFORM_STAMP_ORGANIZATION_COLUMNS`, keyed by object name — so these + // fixtures declare nothing and the expectations are unchanged. + + it('stamps the platform stamp column — the #8707 repro, closed (#8778, #19054)', async () => { const { engine, fire, created } = makeEngine( { ...MULTI_TENANT, @@ -1662,14 +1665,13 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 // authenticates into under a deliberately different name. sys_api_key: ['id', 'name', 'user_id', 'active_organization_id', 'revoked'], }, - // The shipped declaration shape (sys-api-key.object.ts): the credential - // table stays unwalled (`enabled: false` — `active_organization_id` is - // NOT a tenant-scope column and must never become one), while the - // stamp-only key routes the audit trail to the key's own organization. - // The declaration WINS over the ADR-0066 opt-out limb: an author who - // declares it on an unwalled object is stating exactly that the trail - // follows the record even though no wall does. - { sys_api_key: { tenancy: { enabled: false, organizationField: 'active_organization_id' } } }, + // The shipped shape (sys-api-key.object.ts): the credential table stays + // unwalled (`enabled: false` — `active_organization_id` is NOT a + // tenant-scope column and must never become one), while the platform + // stamp column routes the audit trail to the key's own organization. The + // stamp column WINS over the ADR-0066 opt-out limb: the trail follows the + // record even though no wall does. + { sys_api_key: { tenancy: { enabled: false } } }, ); installAuditWriters(engine as any, 'test.audit'); @@ -1688,17 +1690,17 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 expect(stampOf(created).audit?.organization_id).toBe('org-key'); }); - it('honours `organizationField` only when the field exists (#5315 guard), falling through intact', async () => { - // A declared stamp column the object does not have must fall through to - // the rest of the precedence — the same guard `tenantField` carries — and - // for an `enabled: false` object the fall-through is the ADR-0066 limb: - // actor's org, exactly the pre-declaration behaviour. + it('honours the platform stamp column only when the field exists (#5315 guard), falling through intact', async () => { + // A stamp column the object does not have must fall through to the rest of + // the precedence — the same guard `tenantField` carries — and for an + // `enabled: false` object the fall-through is the ADR-0066 limb: actor's + // org, exactly the pre-divergence behaviour. const { engine, fire, created } = makeEngine( { ...MULTI_TENANT, sys_api_key: ['id', 'name', 'user_id', 'revoked'], }, - { sys_api_key: { tenancy: { enabled: false, organizationField: 'active_organization_id' } } }, + { sys_api_key: { tenancy: { enabled: false } } }, ); installAuditWriters(engine as any, 'test.audit'); @@ -1713,34 +1715,47 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 expect(stampOf(created).audit?.organization_id).toBe('org-actor'); }); - it('`organizationField` outranks `tenantField` — "who is this row about" beats "what walls it"', async () => { - // On an object declaring both, the stamp-only key is the more specific - // answer to the stamping question. (No shipped object declares both; this - // pins the precedence so the day one does is not a coin flip.) + it('⛔ the stamp table is a CLOSED SET: an application object stamps from its own wall (#19054)', async () => { + // This case used to pin `organizationField` outranking `tenantField` on an + // object declaring both — the precedence between the stamp-only key and the + // wall key. Protocol 18 retires that key (ADR-0049), so no application can + // declare a stamp column at all and the precedence question is closed + // rather than answered: limb 0 is keyed by OBJECT NAME against + // `PLATFORM_STAMP_ORGANIZATION_COLUMNS`, whose only row is the platform's + // own credential table. + // + // The fixture keeps the discriminating shape — an object carrying BOTH a + // declared tenant column and a second organization-ish column — so the + // assertion still fails the day something starts inferring a stamp column + // from the record's shape instead of from the closed table. const { engine, fire, created } = makeEngine( - { ...MULTI_TENANT, crm_lead: ['id', 'name', 'workspace_id', 'about_org_id'] }, - { - crm_lead: { - tenancy: { enabled: true, tenantField: 'workspace_id', organizationField: 'about_org_id' }, - }, - }, + { ...MULTI_TENANT, crm_lead: ['id', 'name', 'workspace_id', 'active_organization_id'] }, + { crm_lead: { tenancy: { enabled: true, tenantField: 'workspace_id' } } }, ); installAuditWriters(engine as any, 'test.audit'); await fire('afterInsert', { object: 'crm_lead', input: { id: 'lead-1' }, - result: { id: 'lead-1', name: 'Acme', workspace_id: 'ws-1', about_org_id: 'org-about' }, + result: { id: 'lead-1', name: 'Acme', workspace_id: 'ws-1', active_organization_id: 'org-about' }, session: { organizationId: 'org-actor', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-about'); + expect(stampOf(created).audit?.organization_id).toBe('ws-1'); }); - it('control: without the declaration the credential table still stamps the actor\'s org', async () => { - // The pre-#8778 shape (no `tenancy` block at all). This is what the old - // KNOWN GAP case pinned; kept as the control proving the new stamp comes - // from the DECLARATION, not from a hidden heuristic over the column name. + it('control: the stamp follows the OBJECT, not a declaration — a tenancy block is no longer part of it (#19054)', async () => { + // This case used to feed `sys_api_key` with NO `tenancy` block at all and + // pin the actor's org, proving the stamp came from the DECLARATION rather + // than from a heuristic over the column name. That control is retired with + // the key it controlled: since #19054 the answer is keyed by object NAME, + // so the declaration is not an input and the shape it removed is no longer + // reachable for the shipped table (`sys_api_key` is `managedBy: + // 'better-auth'` and protection-locked, so its block cannot be dropped). + // + // Recorded as a real, deliberate change of behaviour on that unreachable + // shape: an engine returning a bare `sys_api_key` schema now stamps + // `active_organization_id` where it used to stamp the actor's org. const { engine, fire, created } = makeEngine({ ...MULTI_TENANT, sys_api_key: ['id', 'name', 'user_id', 'active_organization_id', 'revoked'], @@ -1755,7 +1770,21 @@ describe('audit writers — the record\'s own organization stamps the row (#8707 session: { organizationId: 'org-actor', userId: 'user-1' }, }); - expect(stampOf(created).audit?.organization_id).toBe('org-actor'); + expect(stampOf(created).audit?.organization_id).toBe('org-key'); + + // The column still has to EXIST — the #5315 guard is the half that did not + // move. Without it the credential table falls through to the actor's org, + // exactly as before. + const bare = makeEngine({ ...MULTI_TENANT, sys_api_key: ['id', 'name', 'user_id', 'revoked'] }); + installAuditWriters(bare.engine as any, 'test.audit'); + await bare.fire('afterUpdate', { + object: 'sys_api_key', + input: { id: 'key-2' }, + previous: { id: 'key-2', name: 'ci', revoked: false }, + result: { id: 'key-2', name: 'ci', revoked: true }, + session: { organizationId: 'org-actor', userId: 'user-1' }, + }); + expect(stampOf(bare.created).audit?.organization_id).toBe('org-actor'); }); }); diff --git a/packages/plugins/plugin-security/src/tenant-layer.test.ts b/packages/plugins/plugin-security/src/tenant-layer.test.ts index 60c6b6f82d5..ea0f543d174 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.test.ts @@ -221,11 +221,11 @@ describe('sys_api_key is not org-walled (#8287)', () => { }); /** - * [#8778] The stamp-only declaration must not move this object's Layer 0 + * [#8778] The stamp-only divergence must not move this object's Layer 0 * inputs. `security-plugin.ts` derives them from exactly two reads — the * registered field set (`objectHasOrgIdField`) and * `tenancy.enabled === false || systemFields.tenant === false` - * (`tenancyDisabled`) — and `tenancy.organizationField` feeds neither. + * (`tenancyDisabled`) — and the stamp column feeds neither. * Derived here against the REAL shipped object, same doctrine as the rest * of this suite: a hand-written boolean and the object can drift, and this * is the pair that must not. @@ -234,13 +234,21 @@ describe('sys_api_key is not org-walled (#8287)', () => { (SysApiKey as { tenancy?: { enabled?: boolean } }).tenancy?.enabled === false || (SysApiKey as { systemFields?: { tenant?: boolean } }).systemFields?.tenant === false; - it('declares the stamp-only organizationField without acquiring the walling column (#8778)', () => { - // The declaration exists (the audit writer's input)… - expect((SysApiKey as any).tenancy?.organizationField).toBe('active_organization_id'); - // …and it did not smuggle a wall in: the field set still has no - // `organization_id`, and the block states `enabled: false` explicitly. + it('carries the stamp column as a FIELD and declares no wall around it (#8778, #19054)', () => { + // [#19054] This case used to read the authorable `tenancy.organizationField` + // declaration off the shipped object. Protocol 18 retires that key + // (ADR-0049) and the divergence moves into + // `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, + // which is not an object declaration and is pinned in that package. What + // this suite owns is the half that was always about THIS object: the stamp + // column exists as a real field, and nothing about it smuggled a wall in. + expect(apiKeyFields.has('active_organization_id')).toBe(true); + // ⛔ The retirement must not have been "simplified" by renaming the column: + // in this platform an `organization_id` column IS the wall (#8287). expect(apiKeyFields.has('organization_id')).toBe(false); - expect((SysApiKey as any).tenancy?.enabled).toBe(false); + // And the block still states the opt-out explicitly, with no residue of the + // removed key. + expect((SysApiKey as any).tenancy).toEqual({ enabled: false }); }); for (const tenancyPosture of ['single', 'group', 'isolated'] as const) { @@ -249,10 +257,11 @@ describe('sys_api_key is not org-walled (#8287)', () => { ...base, tenancyPosture, // Exactly what security-plugin.ts computes from the registered fields - // and the tenancy block — the REAL declaration, post-#8778, so this - // case is also the read-neutrality pin for `organizationField`: if the - // stamp-only key (or the `enabled: false` that must accompany it) ever - // started feeding the wall, this filter would stop being null. + // and the tenancy block — the REAL shipped declaration, so this case is + // also the wall-neutrality pin for the stamp column: if the + // `active_organization_id` column (or the `enabled: false` that must + // accompany it) ever started feeding the wall, this filter would stop + // being null. objectHasOrgIdField: apiKeyFields.has('organization_id'), tenancyDisabled: apiKeyTenancyDisabled, }); diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 42072b9cc48..666025fc110 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -983,7 +983,6 @@ "data/StringOperator:$notContains", "data/StringOperator:$startsWith", "data/TenancyConfig:enabled", - "data/TenancyConfig:organizationField", "data/TenancyConfig:tenantField", "data/TursoConfig:authToken [RETIRED]", "data/TursoConfig:concurrency", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index ebacea08898..815f2f185bb 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -901,7 +901,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | Type | Notes | |---|---| -| object | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) | +| object | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763). **#19054** REMOVES `tenancy.organizationField` at protocol 18 (ADR-0049 enforce-or-remove) — the STRICT-deletion route, so the row leaves this ledger with the key rather than staying as a tombstone: the `tenancy` block is a `strictObject`, the key is gone from the walked shape, and a surviving row would read as an ORPHAN. It was classified `live` on one real consumer (`resolveRecordOrganizationField`'s limb 0) and one real declaration, both of them ours — the key was authorable by every application and declared, repo-wide, only on `sys_api_key`. ⛔ Not a correction of that `live` verdict: the consumer still reads the same column for the same table, now from `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, which is not an authorable surface and therefore has no row here | | field | full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers. **#13043** ends the empty dead column this type had carried since that sweep — the reason the cell said "healthy" until 2026-08-29: `conditionalRequired` is re-classified `live` → `dead` with no key added or removed. It has been a `retiredKey` tombstone since 2026-07-28 (protocol 17, #3855), so the row stays (the `rls.priority` precedent) while the verdict does not. BOTH halves of its evidence were falsified, not just the citation: the `.transform` lowering `conditionalRequired` → `requiredWhen` that the row credited does not exist (field.zod.ts has zero `.transform` calls), and the objectql rule-validator `requiredWhen ?? conditionalRequired` fallback its note leaned on was retired by #3903, which replays the ADR-0087 conversion chain at rehydration instead — so a stored pre-17 row reaches the validator already lowered. The rot was invisible to every citation check (pointer in range, right file, file names the key) and the entry carried no `verifiedAt`, so nothing ever re-asked — the #12516 class, the same shape `action.execute` turned out to have. It was also the ledger's LAST `path:NNN` citation, so retiring it took #13003's line-citation counter to zero **#19187** flips `relatedListFilter` `planned` → `live` 2026-09-20, the fourth member of the related-list family joining its three siblings. ⛔ NOT this type's first flip of that direction, which is what an earlier draft of this cell claimed: `valueDomain` went `planned` → `live` in `fa125f3bfe` (#15316) once the record validator's call into `isValueDomainMember` landed, and it stands `live` with `verifiedAt` 2026-09-04. The correction is kept rather than quietly deleted because the false clause was the same species as the row it was describing — a confident sentence in the file whose job is to say true things about the ledger, falsified by one `git log -p` over this file. The row is the clean case the `app.navigation.runAction` (#10068) and `list.map` (#11442) flips established: #8704 seeded it contract-first with `authorWarn` and wrote the flip condition into its own note, objectui#4664 satisfied that condition, and the flip was taken by re-measuring at the `.objectui-sha` pin rather than on objectui main — `deriveRelatedLists` puts the authored value on the derived descriptor and `RecordDetailView` writes it onto the synthesized `record:related_list` node, so the rows and the tab badge answer one composed question. What makes it a DEFECT rather than bookkeeping is the direction a stale `planned` row fails in: its `authorHint` was a sentence `packages/lint` repeated at every compile — 「the auto-derived related list does not apply this filter yet」 — about a key the pinned console applies, so the ledger was steering authors off a working key rather than merely lagging it. It is also the direction no citation check can see: a `planned` row cites nothing, so nothing rots, and only the consumer landing falsifies it. With it, `field` carries NO `authorWarn` row at any depth, which gates the lint's field walk off entirely (`if (fieldWarn.size > 0)`) — recorded because the next warned field row re-opens that walk, and the #11385 field-walk pin in `packages/lint` is narrowed until one does | | flow | dead count = **5 tombstone entries** + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges), plus errorHandling.`retryDelayMs` RENAMED to `backoffMs` 2026-08-04 (#4964). The rename is why the dead column moved while live did not: a rename is a removal on this ledger, so the old spelling is tombstoned (`retiredKey` keeps it in the walked shape) and the new spelling enters as its own `live` row. Read it beside the four above as the one entry here that cost an author nothing — the block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name, and #4964 spelled its base delay `backoffMs` to match `job.retryPolicy` and a `try_catch` node's `retry`. Remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | | action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions). **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep. **#13036** makes the dead set three: `execute` joins it, re-classified `live` → `dead` 2026-08-29 with no key added or removed. Its `live` verdict rested on a `.transform` lowering `execute` → `target` that protocol 17 (#3855) removed along with the alias; the key has been a `retiredKey` tombstone since 2026-07-28, so the row stays (the `rls.priority` precedent) while the verdict does not. The rot was invisible to every citation check — the pointer was in range, in the right file, and the file names the key — and the entry carried no `verifiedAt`, so nothing ever re-asked | diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index fcb7feceb39..5fba85e41a6 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -161,12 +161,6 @@ "status": "live", "evidence": "packages/drivers/driver-sql/src/sql-driver.ts", "note": "row-level tenant scoping (org-scoping plugin path) reads tenancy.tenantField — audit called it inert; corrected. strategy/crossTenantAccess were REMOVED after spec 15.0 (#2763): zero consumers; tenancy block is now .strict() with tombstone guidance." - }, - "organizationField": { - "status": "live", - "evidence": "packages/metadata-core/src/record-organization.ts#resolveRecordOrganizationField (reads `tenancy.organizationField` as limb 0 of the precedence, and falls through when the object has no such column)", - "note": "STAMP-ONLY by the #8778 maintainer ruling (option A): consulted exclusively by resolveRecordOrganizationField when audit rows are stamped, so a credential table can stay unwalled while its trail follows the record's own organization (sys_api_key.active_organization_id, #8707/#8287). Deliberately read by NO tenant-scoping path; read-neutrality is pinned by tests beside applyTenantScope/injectTenantOnInsert (driver-sql), computeTenantLayer0Filter (plugin-security) and resolveInjectedSystemColumns (spec). 2026-08-25: REPOINTED — the evidence cited plugin-audit/src/audit-writers.ts, which has read the key through `createRecordOrganizationResolver` ever since #10101 promoted the resolver into @objectstack/metadata-core; audit-writers.ts:220 says so in its own re-export comment. The cited file still existed and the citation carried no line, so neither the existence check nor the #11210 line bound could see it. The stamping CALL SITE is audit-writers.ts:863 — kept here in prose rather than as a citation, because a call site that never names the key belongs in `producer`, not in `evidence`. 2026-08-28: RE-ANCHORED (#13003) — the ONLY entry in this batch whose citation was still accurate: `:177-180` still bracket the limb-0 read, so the range was correct and the migration is pure grammar. Worth recording as the control case — it is a 248-line file, and the batch's fourteen rotted siblings were all in files of 700 to 20255 lines.", - "verifiedAt": "2026-08-28" } } }, diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 7c8c1cb13ab..013eabeed4a 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -27,7 +27,7 @@ for both corollaries. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 51 | 0 | 0 | 0 | 1 | 52 | +| `object` | 50 | 0 | 0 | 0 | 1 | 51 | | `field` | 91 | 0 | 0 | 1 | 1 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | | `action` | 44 | 0 | 0 | 3 | 2 | 49 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 44 | 1 | 74 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **933** | **5** | **1** | **168** | **11** | **1118** | +| **total** | **932** | **5** | **1** | **168** | **11** | **1117** | diff --git a/packages/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index 2c16f4f983a..191a0194571 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -84,12 +84,15 @@ import { z } from 'zod'; * ⚠️ 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 diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 562b23f7b16..dc69f35b3fe 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10056,6 +10056,103 @@ const dashboardWidgetChartConfigStructureRemoved: MetadataConversion = { }, }; +/** + * `object.tenancy.organizationField` leaves the authorable surface (protocol + * 18, #19054 — ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, + * verbatim and untranslated: 「organizationField 撤出可授权面 同意你的建议」). + * + * The key named the column a platform row is STAMPED from, as opposed to the + * column the object is WALLED by (`tenantField`). On an ordinary object those + * are the same column — the spec's own docblock said "for ordinary objects the + * two coincide and `organizationField` is never needed" — and the whole + * repository declared it exactly once, on `sys_api_key`, a table this platform + * ships. An authorable key whose only real declaration is ours makes every + * future piece of organization logic ask "what if somebody set this?" for a + * divergence no sanctioned consumer would honour: the cloud#1395 scope-pin + * allows exactly three readers, all of them platform-row writers. + * + * The divergence itself is NOT retired — only its authorability. It moves to + * `@objectstack/metadata-core`'s `PLATFORM_STAMP_ORGANIZATION_COLUMNS` + * (`sys_api_key` → `active_organization_id`, read by the stamp face alone), so + * the three writers keep their behaviour unchanged with no authorable input. + * + * **Retired from the load path** — the `tenancy` block is `.strict()` and + * rejects the key with its prescription (`TENANCY_RETIRED_KEY_GUIDANCE`), so a + * live author is taught at parse. This entry exists so stored 17.x rows replay + * clean (`applyConversionsToStoredItem` — without it a pre-removal row flags + * `metadata_spec_invalid` forever, mislabelling chain-owned history as a + * current-contract violation) and so `os migrate meta --from 17` lists the + * mechanical edits for existing sources. + * + * Deletion is the whole conversion, and it is behaviour-preserving in both + * directions for everything outside this repository: an application that + * declared the key was never read by anything (the three sanctioned consumers + * are platform writers over platform tables), so dropping it changes no + * stamp. A row on a platform object is unreachable from an authored stack — + * `sys_api_key` is `managedBy: 'better-auth'` and protection-locked. + */ +const objectTenancyOrganizationFieldRemoved: MetadataConversion = { + id: 'object-tenancy-organization-field-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'object.tenancy.organizationField', + summary: + 'object `tenancy.organizationField` removed (#19054, ADR-0049 — the stamp-only column ' + + 'declaration was authorable by every application and declared exactly once in the whole ' + + 'protocol, on the platform\'s own credential table; the divergence moves to a ' + + 'platform-internal table in @objectstack/metadata-core and stops being a knob)', + apply(stack, emit) { + return mapCollection(stack, 'objects', (obj, path) => { + // `tenancy.*` sits one level down, so the top-level-only `stripKeys` + // cannot reach it — drill in and copy-on-write, so an untouched object + // keeps its identity (pattern of `object-enable-trash-mru-removed`). + const tenancy = obj.tenancy; + if (!tenancy || typeof tenancy !== 'object' || Array.isArray(tenancy)) return obj; + const stripped = stripKeys( + tenancy as Record, + ['organizationField'], + emit, + `${path}.tenancy`, + ); + if (stripped === tenancy) return obj; + return { ...obj, tenancy: stripped }; + }); + }, + fixture: { + before: { + objects: [ + { + name: 'billing_api_credential', + label: 'Billing API Credential', + tenancy: { enabled: false, organizationField: 'active_organization_id' }, + }, + // The walled neighbour passes through untouched: `tenantField` is the + // key that survives, and it answers the other question. + { + name: 'billing_invoice', + label: 'Invoice', + tenancy: { enabled: true, tenantField: 'workspace_id' }, + }, + ], + }, + after: { + objects: [ + { + name: 'billing_api_credential', + label: 'Billing API Credential', + tenancy: { enabled: false }, + }, + { + name: 'billing_invoice', + label: 'Invoice', + tenancy: { enabled: true, tenantField: 'workspace_id' }, + }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -10160,6 +10257,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { } }); - it('is blind to the stamp-only `tenancy.organizationField` (#8778 read-neutrality)', () => { - // The #8778 scope pin, as widened by the cloud#1395 ruling (2026-08-17): - // `organizationField` is consulted by the sanctioned platform-row WRITERS - // only — audit stamping today, `plugin-approvals` and the automation-run - // recorder once #10101 lands. No READ path reads it, and that is what this - // test pins; the widening does not touch it, because all three sanctioned - // consumers stamp rows rather than read them. - // - // The injection plan must reach the same verdicts with and without it — - // on a plain tenant object, and on the shipped sys_api_key - // shape (better-auth managed + `enabled: false`), where the plan's - // better-auth bail must keep running BEFORE tenancy is read at all. - const withKey = resolveInjectedSystemColumns({ - ...business, - tenancy: { enabled: true, organizationField: 'about_org_id' }, - }); - expect(withKey).toMatchObject( - // Same verdicts as the bare business object — the key changed nothing. - { tenant: true, audit: true, owner: true, owningBusinessUnit: true }, - ); - expect(withKey.names.has('about_org_id')).toBe(false); - + it('injects nothing into the shipped sys_api_key shape — the better-auth bail runs before tenancy', () => { + // [#19054] This case used to pin read-neutrality against the stamp-only + // `tenancy.organizationField` key, feeding the plan a declaration and + // asserting it moved no verdict. The key is retired from the authorable + // surface in protocol 18 (ADR-0049), so there is no declaration left to be + // neutral about and a fixture still carrying one would pin a branch that + // can no longer be reached — a check that passes because nothing is + // produced. What survives is the half that was never about the key: the + // shipped credential table gets NO injected system columns, because the + // `managedBy: 'better-auth'` bail is reached before tenancy is consulted + // at all. That is the fact the whole stamp-vs-wall divergence rests on — + // the table has no `organization_id` and therefore no wall (#8287). const apiKeyShape = resolveInjectedSystemColumns({ name: 'sys_api_key', managedBy: 'better-auth', - tenancy: { enabled: false, organizationField: 'active_organization_id' }, + tenancy: { enabled: false }, fields: {}, }); expect(apiKeyShape).toMatchObject({ tenant: false, audit: false, owner: false, owningBusinessUnit: false }); expect([...apiKeyShape.names]).toEqual(['id']); + + // The bail is the reason, not the `enabled: false`: the same object WITHOUT + // an explicit tenancy block reaches the same verdicts. + const withoutTenancyBlock = resolveInjectedSystemColumns({ + name: 'sys_api_key', + managedBy: 'better-auth', + fields: {}, + }); + expect([...withoutTenancyBlock.names]).toEqual(['id']); }); it('withholds the audit family for systemFields.audit: false', () => { diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 5c7e8aec863..3ffdd05a05e 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1932,21 +1932,37 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () .toEqual({ enabled: false, tenantField: 'workspace_id' }); }); - it('accepts the stamp-only `organizationField`, with no default materialized (#8778)', () => { - // The shipped shape: sys_api_key stays unwalled (`enabled: false`) while - // audit rows stamp the organization of the key they describe. The key is - // consulted by the sanctioned platform-row writers only — audit stamping - // today, plus `plugin-approvals` and the automation-run recorder once - // #10101 lands under the cloud#1395 widening of the #8778 scope pin. No - // read path reads it either way: read-neutrality is pinned beside each - // read path (driver tenant scope, Layer 0, injection plan), not here. - expect( - TenancyConfigSchema.parse({ enabled: false, organizationField: 'active_organization_id' }), - ).toEqual({ enabled: false, organizationField: 'active_organization_id' }); - - // Undeclared stays undeclared — same #5315 doctrine as `tenantField`. - const result = TenancyConfigSchema.parse({ enabled: true }); + it('rejects the retired stamp-only `organizationField` with its prescription (#19054)', () => { + // The shape this used to accept, verbatim — the one declaration the whole + // protocol ever carried (`sys_api_key`, #8778). The block is `.strict()`, + // so the key is REFUSED with the guidance row rather than stripped: a + // silent strip would swap one no-op for another, which is the class + // ADR-0049 exists to end. + const result = TenancyConfigSchema.safeParse({ + enabled: false, + organizationField: 'active_organization_id', + }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('`tenancy.organizationField` was removed in @objectstack/spec 17'); + expect(message).toContain('ADR-0049'); + // The prescription must say what to do INSTEAD, not only that the key is + // gone: delete it, and reach for `tenancy.tenantField` when the object's + // tenant column genuinely is not `organization_id`. + expect(message).toContain('Delete the key.'); + expect(message).toContain('`tenancy.tenantField`'); + expect(message).toContain('os migrate meta --from 17'); + }); + + it('the surviving shape is exactly `enabled` + `tenantField` (#19054)', () => { + // The positive half of the retirement: what the credential table declares + // now parses, and carries no residue of the removed key. + const result = TenancyConfigSchema.parse({ enabled: false }); + expect(result).toEqual({ enabled: false }); expect('organizationField' in result).toBe(false); + + expect(TenancyConfigSchema.parse({ enabled: true, tenantField: 'workspace_id' })) + .toEqual({ enabled: true, tenantField: 'workspace_id' }); }); it('rejects the retired `strategy` with a tombstone pointing at the two real modes', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index b28ce35d820..94991396c20 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -536,6 +536,20 @@ const TENANCY_RETIRED_KEY_GUIDANCE: Record = { 'never had a consumer; setting it granted nothing. Cross-tenant visibility is ' + 'governed by sharing rules / OWD (ADR-0056), `externalSharingModel` (ADR-0090 ' + 'D11), and the object access posture. Delete the key.', + organizationField: + '`tenancy.organizationField` was removed in @objectstack/spec 17 (ADR-0049) — it ' + + 'named the column a platform row is stamped from, and exactly one table in the ' + + 'whole protocol ever needed one: the better-auth credential table, whose rows are ' + + 'about the organization a key authenticates into while the table itself must stay ' + + 'unwalled. That is a fact about a platform table, not a knob an application ' + + 'declares, and on an ordinary object the stamp column and the tenant column are ' + + 'the same column — so every declaration outside the platform either restated the ' + + 'default or asked for a divergence no sanctioned consumer would honour. Delete ' + + 'the key. Stamping now reads a platform-internal table in ' + + '`@objectstack/metadata-core`; an object whose tenant column genuinely is not ' + + '`organization_id` declares `tenancy.tenantField`, which both walls it and stamps ' + + 'its platform rows. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', }; /** @@ -587,56 +601,35 @@ const TENANCY_MODES_EXPLAINER = * (`organization` is the product's noun). Undeclared now stays `undefined` and * the driver's fallback is the single source of truth. * - * `organizationField` (#8707 / #8778, maintainer-ruled option A) is the - * STAMP-ONLY sibling: it answers "which column says who this row is ABOUT", - * where `tenantField` answers "what is this object WALLED by". For ordinary - * objects the two coincide and `organizationField` is never needed; for - * credential tables they deliberately do not — `sys_api_key` records the - * organization a key authenticates into under `active_organization_id` - * precisely so the credential table does NOT become org-walled (#8287). The - * key is consulted only by platform-row stamping — the three sanctioned - * writers below, via `@objectstack/metadata-core`'s - * `resolveRecordOrganizationField` (plugin-audit re-exports it from its - * original path; public surface unchanged) — never by a read path, and that - * read-neutrality is pinned by tests beside each read path. ⛔ Scope-pinned by - * the #8778 ruling: this is ONE stamp-only declaration key, not the opening - * move of a general field-roles mechanism — a consumer other than the three - * sanctioned writers needs its own ruling before reading it. - * - * That pin is WIDENED **by name** by the maintainer ruling recorded on - * cloud#1395, 2026-08-17T03:18Z, accepting the decision-inbox recommendations - * in full — verbatim: 「新进卡六张 同意你的建议」. It is transcribed here so the - * widening is declared, not discovered (#10110): - * - * > Ruled: Option A — extend the #8778 ruling: `resolveRecordOrganizationField` - * > is promoted to a shared resolver used by all three platform-row writers - * > (approvals, automation runs, audit). A platform row's organization is the - * > SUBJECT record's organization; actor context is the fallback, never the - * > primary. - * - * The ruling sanctions exactly THREE consumers of this key, and no others: - * - * 1. **audit stamping** — `@objectstack/metadata-core`'s - * `resolveRecordOrganizationField`; the original #8778 consumer - * (plugin-audit re-exports it from its original path; public surface - * unchanged); - * 2. **`plugin-approvals`** — the approval-row writer (`openNodeRequest`, - * the only `sys_approval_request` insert site); - * 3. **the automation-run recorder** — both write paths of - * `ObjectStoreSuspendedRunStore` in `service-automation` (`serialize()` - * for paused rows, `recordTerminal()` for terminal rows). - * - * All three are live on `main`: #10101's PR #11311 (merged 2026-08-23) - * promoted `resolveRecordOrganizationField` to the shared resolver in - * `@objectstack/metadata-core` and wired all three platform-row writers to - * it — each resolves the SUBJECT record's organization first, falling back - * to actor context, exactly as the ruling above states. The `.describe()` - * below names all three consumers accordingly. - * - * ⛔ The refusal posture is UNCHANGED for a FOURTH consumer. Three named - * platform-row writers are still not a general field-roles mechanism: anything - * outside the list above needs its own maintainer ruling before reading this - * key, exactly as #8778 required. + * `organizationField` is RETIRED from this shape (protocol 18, ADR-0049; + * maintainer ruling 2026-09-18, verbatim and untranslated: + * 「organizationField 撤出可授权面 同意你的建议」). It answered "which column says + * who this row is ABOUT" while `tenantField` answers "what is this object + * WALLED by", and on an ordinary object the two are the same column — the + * former prose in this docblock said so in as many words ("for ordinary + * objects the two coincide and `organizationField` is never needed"). Exactly + * ONE table in the protocol ever diverged, and it is a table the platform + * itself ships: `sys_api_key` records the organization a key authenticates + * into under `active_organization_id` precisely so the credential table does + * NOT become org-walled (#8287). A fact about one platform table is not a knob + * an application declares — an authorable key here made every future piece of + * organization logic ask "what if somebody set this?" for a divergence no + * sanctioned consumer would have honoured anyway. + * + * The divergence itself is unchanged and still shipped: stamping resolves it + * from a platform-internal table in `@objectstack/metadata-core` + * (`PLATFORM_STAMP_ORGANIZATION_COLUMNS`, read by the STAMP face alone), so + * the three sanctioned platform-row writers — audit stamping, the + * approval-row writer in `plugin-approvals`, and the automation-run recorder + * in `service-automation` — keep the behaviour they had, byte for byte, with + * no authorable input. The WALL face + * (`resolveRecordWallOrganizationField`, #18378) never read the key and is + * untouched. + * + * ⛔ What does NOT come back with a new spelling: an application-declared + * "stamp column" of any name. The retirement is the ADR-0049 answer to a key + * whose only real declaration was ours; re-opening it needs its own maintainer + * ruling, exactly as the original scope-pin required of a fourth consumer. * * @example Shared database, platform-default tenant column (organization_id) * { @@ -649,11 +642,9 @@ const TENANCY_MODES_EXPLAINER = * tenantField: 'workspace_id' * } * - * @example An unwalled credential table whose audit rows still stamp the - * organization of the record they describe (sys_api_key, #8778) + * @example An object that opts out of org row-scoping entirely (ADR-0066) * { - * enabled: false, - * organizationField: 'active_organization_id' + * enabled: false * } */ export const TenancyConfigSchema = lazySchema(() => strictObject({ @@ -670,25 +661,13 @@ export const TenancyConfigSchema = lazySchema(() => strictObject({ 'object really has that field — otherwise the same `organization_id` ' + 'fallback applies. No default is materialized here on purpose.', ), - organizationField: z.string().optional().describe( - '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`.', - ), + // `organizationField` was REMOVED here in protocol 18 (ADR-0049) — see the + // docblock above. This shape is `.strict()`, so the key is rejected with its + // prescription from `TENANCY_RETIRED_KEY_GUIDANCE` rather than stripped, and + // the D2 conversion `object-tenancy-organization-field-removed` deletes it + // from older sources and stored rows. The `sys_api_key` divergence it used to + // carry now lives in `@objectstack/metadata-core`'s + // `PLATFORM_STAMP_ORGANIZATION_COLUMNS`, which no author writes. })); /** diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__TenancyConfig__organizationField.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__TenancyConfig__organizationField.ts new file mode 100644 index 00000000000..0c6046b38a1 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__TenancyConfig__organizationField.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim +// and untranslated: 「organizationField 撤出可授权面 同意你的建议」). +// `TenancyConfig.organizationField` named the column a platform row is STAMPED +// from, as opposed to the column the object is WALLED by (`tenantField`). On an +// ordinary object those are the same column, and the whole protocol declared it +// exactly once — on `sys_api_key`, a table this platform ships and no +// application authors. The `tenancy` block is `.strict()`, so the key is +// removed from the shape and its prescription is served from +// `TENANCY_RETIRED_KEY_GUIDANCE`. The divergence itself is unchanged: it moves +// to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, read +// by the three sanctioned platform-row writers alone. D2: +// `object-tenancy-organization-field-removed`. +export const entry = 'data/TenancyConfig:organizationField'; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6e17292ff0e..314b4832ab8 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5231,7 +5231,22 @@ const step18: MigrationStep = { + 'D2 conversion strips the group from per-app bundle entries only (never from a `translation` ' + 'ITEM, which still declares it), and the paired semantic entry says what the strip means, ' + 'because a notice reading "(removed)" does not say that those gaps fall back to the ' - + "manifest's own English literal.", + + "manifest's own English literal. " + + 'Finally it retires object `tenancy.organizationField` (#19054, ADR-0049 ' + + 'enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as ' + + 'opposed to the column the object is WALLED by (`tenantField`); on an ordinary object ' + + 'those are the same column, and the entire protocol declared it exactly once — on ' + + '`sys_api_key`, a better-auth-managed credential table this platform ships and no ' + + 'application authors. Its three readers were all platform-row writers, scope-pinned by ' + + 'name, so an application declaration was inert by construction while still forcing ' + + 'every future piece of organization logic to ask "what if somebody set this?". The ' + + 'divergence is NOT retired, only its authorability: it moves to ' + + '`PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object ' + + 'name and read by the stamp face alone, so audit stamping, the approval-row writer and ' + + 'the automation-run recorder keep their behaviour with no authorable input. The ' + + 'conversion is a lossless delete and there is no semantic residue — an application ' + + 'whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, ' + + 'which both walls the object and stamps its platform rows.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5265,6 +5280,7 @@ const step18: MigrationStep = { 'chart-config-aria-removed', 'dashboard-widget-chart-config-structure-removed', 'translation-per-app-settings-removed', + 'object-tenancy-organization-field-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -14289,6 +14305,19 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `AggregationPipeline.options`, which no `stack.zod.ts` collection declares // and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. 'data/NoSQLQueryOptions:timeout', + // #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim + // and untranslated: 「organizationField 撤出可授权面 同意你的建议」). + // `TenancyConfig.organizationField` named the column a platform row is STAMPED + // from, as opposed to the column the object is WALLED by (`tenantField`). On an + // ordinary object those are the same column, and the whole protocol declared it + // exactly once — on `sys_api_key`, a table this platform ships and no + // application authors. The `tenancy` block is `.strict()`, so the key is + // removed from the shape and its prescription is served from + // `TENANCY_RETIRED_KEY_GUIDANCE`. The divergence itself is unchanged: it moves + // to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, read + // by the three sanctioned platform-row writers alone. D2: + // `object-tenancy-organization-field-removed`. + 'data/TenancyConfig:organizationField', // #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said // "Operation timeout in milliseconds" in prose and carried a `.meta({ title: // 'Timeout (ms)' })` no parse reads — and sat two keys below diff --git a/packages/spec/src/shared/alias-integrity.test.ts b/packages/spec/src/shared/alias-integrity.test.ts index 5214fab3440..e4d765c1663 100644 --- a/packages/spec/src/shared/alias-integrity.test.ts +++ b/packages/spec/src/shared/alias-integrity.test.ts @@ -998,7 +998,12 @@ describe('alias integrity — every table is a true claim about its schema', () const tenancy = bySurface.get('`tenancy`'); expect(tenancy, 'TenancyConfigSchema no longer declares through strictObject').toBeDefined(); - expect(Object.keys(tenancy!.options.guidance ?? {}).sort()).toEqual(['crossTenantAccess', 'strategy']); + // `organizationField` joined the table at protocol 18 (#19054, ADR-0049): + // the strict-deletion route removes the key from the shape and serves its + // prescription from this very map, so the retirement is only audible + // through the folded channel this assertion holds open. + expect(Object.keys(tenancy!.options.guidance ?? {}).sort()) + .toEqual(['crossTenantAccess', 'organizationField', 'strategy']); }); it('no live surface still reports the shared view/page FAMILY name (#8202)', () => {