Skip to content

Commit efb3513

Browse files
hotlongclaude
andauthored
fix(platform-objects,core): sys_metadata_activation ships tenant-less — drop the reserved organization_id (#15024) (#15155)
* fix(platform-objects,core): sys_metadata_activation ships tenant-less Drop the reserved organization column from the activation ledger: the object opts out of tenant-column injection, the declared unique index states the 'global' scope it actually materializes, and the store's NULL-filter and org-row skip go with the column they guarded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(platform-objects,core): rewrite ledger pins onto the column's absence Rewrites the pins that asserted the reserved column and the org-row skip so they pin the column's ABSENCE instead of being deleted, updates the row-shape docblocks in both consumers and the runtime activation doors, corrects the two pending changesets that would otherwise describe the column in 17.3's release notes, and adds this change's changeset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(docs): re-anchor system-context census after activation-gate docblock shift The activation-gate docblock correction in this branch is one line shorter than the text it replaced, so both `ec.isSystem` elevation reads moved up by one line. Row 56's anchors on the system-context census page still pointed at the old lines, which the census gate reports from both directions at once: [site-without-a-row] for :138 and :189, and [anchor-is-not-a-read-site] for the stale :139 and :190. Pure line rot, repaired by the gate's own `--fix`. Only the two anchor numbers move; no elevation behaviour text changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b1b723e commit efb3513

16 files changed

Lines changed: 347 additions & 212 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/core": patch
4+
"@objectstack/objectql": patch
5+
"@objectstack/service-automation": patch
6+
---
7+
8+
fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)
9+
10+
The ADR-0126 activation ledger records that **this environment** switched a
11+
packaged artifact off. That is deployment-level state, owned by no
12+
organization — so the table ships with no tenant column at all.
13+
14+
It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
15+
written by nobody, held for a per-organization dimension ADR-0126 §5
16+
pre-charted. A reserved nullable tenant column is exactly the shape the
17+
total-organization-ownership record proposed in PR #14976 rules out, and this
18+
one had no reader either. **This is a plain removal, not a migration:** the
19+
table landed after the 17.2.0 tag, so no released version ever carried the
20+
column and no deployment has data in it. Should a per-organization dimension
21+
ever be wanted, it returns as a separate org-owned object — never as a column
22+
on this ledger.
23+
24+
What changed:
25+
26+
- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
27+
no longer declares the column. Both halves are needed: the tenant anchor is
28+
INJECTED at registration, so deleting the field alone would have left the
29+
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
30+
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
31+
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
32+
column and needs the wall over it stood down). Here there is no column to
33+
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
34+
required rather than incidental: a Layer 0 wall composing an equality on a
35+
column the table does not have denies every row.
36+
- **The declared unique index states `unique: 'global'`** over
37+
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
38+
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
39+
only when the table HAS a tenant column, so `'organization'` already degraded
40+
to exactly these two columns. What changes is that the declaration now states
41+
the boundary it actually gets, rather than claiming a per-organization one
42+
that does not exist. Still explicit rather than bare `unique: true`, which
43+
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
44+
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
45+
org-row skip.** `list()` is now every activation row of its type, scoped by
46+
the `metadata_type` discriminator alone, and `setActive` takes the single row
47+
its keyed read returns instead of picking the NULL-organization one out of
48+
the result. Both guarded a column that no longer exists; the declared unique
49+
index over the two columns the lookup keys on is what makes that read
50+
single-valued. `ObjectStoreFlowActivationStore` and
51+
`ObjectStoreActionActivationStore` inherit the change.
52+
53+
Unchanged, and pinned: the operator gate on activation writes under walled
54+
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
55+
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
56+
rather than deleting it, and a driver `0` reading as false. The pins that
57+
asserted the reserved column and the org-row skip are rewritten to pin the
58+
column's ABSENCE rather than deleted — including at the injection authority
59+
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
60+
in a real booted stack, where the row's key set is a reading of the physical
61+
table.

.changeset/ledger-convergence-registration-and-one-store.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
7979
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
8080
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
8181
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
82-
shapes. Row semantics are byte-equivalent: install-level rows only
83-
(`organization_id` never written), org-carrying rows skipped on read and
84-
ignored when deciding insert-vs-update, a driver `0` read as false,
85-
read-then-write rather than a blind upsert, and no `delete` in the engine slice
86-
because re-enabling rewrites the row.
82+
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
83+
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
84+
rather than a blind upsert, and no `delete` in the engine slice because
85+
re-enabling rewrites the row.
8786

8887
Both existing pin suites stay green **unchanged**, which is what makes them the
89-
proof the consolidation lost nothing — verified by ablation: removing the
90-
org-row skip from the one shared implementation turns both of them red on their
91-
own org-skip assertion, so both really reach it.
88+
proof the consolidation lost nothing — verified by ablation: mutating the one
89+
shared implementation turns both of them red on their own assertions, so both
90+
really reach it.

.changeset/sys-metadata-activation-ledger.md

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
1111
platform object, not a metadata type. (The one spec file touched is the
1212
mechanical name census described below, not protocol surface.)
1313

14-
The whole schema, per §4: `metadata_type` · `name` · `package_id` ·
15-
`organization_id` (nullable, **reserved** — NULL on this entire line; the
16-
per-org dimension is an additive column later, never a redesign) · `active`.
14+
The whole schema, per §4: `metadata_type` · `name` · `package_id` · `active`.
15+
There is **no tenant column**: a row records that THIS ENVIRONMENT switched a
16+
packaged artifact off, which is deployment-level state owned by no
17+
organization. (An earlier revision of this line declared a nullable
18+
`organization_id` marked "reserved"; it was removed before release — see the
19+
sibling changeset for that removal.)
1720
An earlier ADR draft carried designation columns (`replaced_by`, `cloned_from`);
1821
amendment ruling 2 removed them — there is **no recorded linkage** between a
1922
clone and its base, matching the landed #11513 posture ("an ordinary org-owned
2023
set with no upgrade linkage"). The pin test asserts the column set by EQUALITY
2124
and names both removed columns separately, so re-growing the linkage is loud.
2225

23-
Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
24-
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
25-
spelling is load-bearing, and the two obvious alternatives are both wrong here:
26-
27-
- bare `unique: true` on a declared index is the positional spelling of
28-
`'global'` — installation-wide over exactly the listed columns — and is
29-
already warned by lint `unique/unscoped-declared-index` in 17.x;
30-
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
31-
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
32-
by construction, so that index would enforce **nothing at all** (#5030,
33-
measured) and one artifact could carry two contradictory `active` rows.
34-
35-
`'organization'` is the arm that closes exactly that hole: the driver prepends
36-
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
37-
is what §4's "NULL-collapsed" names.
26+
Row identity is `(metadata_type, name)`, spelled as a declared index with
27+
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
28+
columns, which is the whole of the identity now that the table carries no
29+
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
30+
spelling materializes the same index but leaves the scope unstated, which lint
31+
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.
3832

3933
The name is also registered in `@objectstack/spec`'s platform-object name census
4034
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
165165
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
166166
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
167-
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:139`, `:190` |
167+
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:138`, `:189` |
168168
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` |
169169
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
170170
| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:59`, `webhook-provenance.ts:50` |

packages/core/src/utils/metadata-activation-store.test.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,18 @@
1717
// that had quietly hard-coded the other one's.
1818
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
1919
// directions, from the same store class. That is the drift the two copies
20-
// made possible (#12350's own argument: the org-row skip and the
20+
// made possible (#12350's own argument: the discriminator scoping and the
2121
// `0`-is-false read are what a copy loses quietly), and it can only be
2222
// measured where both types are constructed side by side.
2323
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
2424
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
2525
// the semantics rather than re-derive them, and the cheapest proof is an
2626
// unknown discriminator asserted through the same battery.
2727
//
28-
// The four load-bearing row properties themselves (`organization_id` never
29-
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
30-
// `0` reads as false) are pinned here too — this is where they now live, so
31-
// this is where a change to them has to argue.
28+
// The load-bearing row properties themselves (the ledger is deployment-wide
29+
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
30+
// false) are pinned here too — this is where they now live, so this is where a
31+
// change to them has to argue.
3232

3333
import { describe, it, expect, vi } from 'vitest';
3434
// The real engine's OWN update-dispatch predicate, so the double below cannot
@@ -151,9 +151,11 @@ describe('ObjectStoreMetadataActivationStore — the discriminator is a paramete
151151
expect(insert?.data).toEqual({
152152
metadata_type: 'action', name: 'mark_done', package_id: 'crm', active: false,
153153
});
154-
// ⛔ §5: `organization_id` is not written — asserted as an ABSENT key,
155-
// because writing it explicitly (even as null) would be a different row
156-
// shape, and the one the reserved per-org dimension is not.
154+
// No tenant column is written, because the table has none. Kept from
155+
// the era when the column existed-but-was-reserved (where it guarded
156+
// against writing it even as an explicit null): it is now what makes a
157+
// re-introduced tenant write loud at the payload, which is the one
158+
// place the column could come back without touching the declaration.
157159
expect(Object.keys(insert?.data ?? {})).not.toContain('organization_id');
158160
});
159161

@@ -190,18 +192,30 @@ describe('ObjectStoreMetadataActivationStore — the discriminator is a paramete
190192
});
191193

192194
describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics, one home', () => {
193-
it('SKIPS a row carrying an organization rather than reading it install-level', async () => {
194-
const { engine } = makeStoreEngine([
195-
{ id: 'r1', metadata_type: 'flow', name: 'install_level', package_id: 'crm', active: false },
196-
{ id: 'r2', metadata_type: 'flow', name: 'org_scoped', package_id: 'crm', active: false, organization_id: 'org_1' },
195+
it('reads EVERY row of its type — the discriminator is the only scope', async () => {
196+
const { engine, calls } = makeStoreEngine([
197+
{ id: 'r1', metadata_type: 'flow', name: 'first', package_id: 'crm', active: false },
198+
{ id: 'r2', metadata_type: 'flow', name: 'second', package_id: 'crm', active: false },
197199
]);
198200

199-
const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();
200-
201-
// Skipped, not merged: reading it install-level would apply one
202-
// organization's choice to the whole installation — #10243 from the
203-
// read side.
204-
expect(rows.map((r) => r.name)).toEqual(['install_level']);
201+
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
202+
const rows = await store.list();
203+
204+
// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
205+
// any row carrying an organization, because the table declared a
206+
// reserved-but-never-written tenant column. The column was dropped
207+
// before it ever shipped, so there is no second axis left — every row
208+
// of this type is an answer, and a filter here would now be dead code
209+
// that reads as if it guarded something.
210+
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);
211+
212+
// The read names the discriminator and NOTHING else. Asserted on the
213+
// query rather than on the result, because a store that had kept a
214+
// tenant predicate would still return both of these rows — the fake's
215+
// rows carry no such column — and the skip would be invisible from the
216+
// result side alone.
217+
expect(calls.find((c) => c.op === 'find')?.options?.where)
218+
.toEqual({ metadata_type: 'flow' });
205219
});
206220

207221
it('reads a driver `0` as FALSE, and a missing column as the packaged default (true)', async () => {
@@ -237,20 +251,25 @@ describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics,
237251
.toEqual({ id: 'r1', active: true, package_id: 'crm' });
238252
});
239253

240-
it('ignores an org-carrying row when deciding insert-vs-update', async () => {
254+
it('UPDATES the one row the keyed lookup returns — no tenant tie-break left to make', async () => {
241255
const { engine, calls } = makeStoreEngine([
242-
{ id: 'r1', metadata_type: 'flow', name: 'nightly_sync', package_id: 'crm', active: false, organization_id: 'org_1' },
256+
{ id: 'r1', metadata_type: 'flow', name: 'nightly_sync', package_id: 'crm', active: false },
243257
]);
244258

245259
await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
246-
name: 'nightly_sync', packageId: 'crm', active: false,
260+
name: 'nightly_sync', packageId: 'crm', active: true,
247261
});
248262

249-
// The write side of the same wall: overwriting one organization's row
250-
// as if it were the install-level one is the #10243 leak with the
251-
// arrow reversed.
252-
expect(calls.some((c) => c.op === 'update')).toBe(false);
253-
expect(calls.find((c) => c.op === 'insert')?.data?.name).toBe('nightly_sync');
263+
// ⚠️ This replaces a pin that asserted the store IGNORED an
264+
// org-carrying row and inserted a second one instead. That choice
265+
// existed only because a reserved tenant column could put more than one
266+
// row behind the same `(metadata_type, name)` key; with the column gone
267+
// the declared `unique: 'global'` index over exactly those two columns
268+
// makes the keyed read single-valued, so taking the first match is
269+
// taking the only one — and inserting a duplicate would now be the bug.
270+
expect(calls.filter((c) => c.op === 'insert')).toHaveLength(0);
271+
expect(calls.find((c) => c.op === 'update')?.data)
272+
.toEqual({ id: 'r1', active: true, package_id: 'crm' });
254273
});
255274

256275
it('probes the TABLE unscoped — the question is composition, not type', async () => {

0 commit comments

Comments
 (0)