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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

it('probes the TABLE unscoped — the question is composition, not type', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

it('probes the TABLE unscoped — the question is composition, not type', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

it('probes the TABLE unscoped — the question is composition, not type', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

it('probes the TABLE unscoped — the question is composition, not type', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

it('probes the TABLE unscoped — the question is composition, not type', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

it('probes the TABLE unscoped — the question is composition, not type', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

it('probes the TABLE unscoped — the question is composition, not type', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/activation-ledger-tenant-less.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
---
"@objectstack/platform-objects": patch
"@objectstack/core": patch
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
---

fix(platform-objects,core): `sys_metadata_activation` ships tenant-less — drop the reserved organization column (#15024)

The ADR-0126 activation ledger records that **this environment** switched a
packaged artifact off. That is deployment-level state, owned by no
organization — so the table ships with no tenant column at all.

It briefly declared one: an `organization_id` marked "RESERVED", nullable, and
written by nobody, held for a per-organization dimension ADR-0126 §5
pre-charted. A reserved nullable tenant column is exactly the shape the
total-organization-ownership record proposed in PR #14976 rules out, and this
one had no reader either. **This is a plain removal, not a migration:** the
table landed after the 17.2.0 tag, so no released version ever carried the
column and no deployment has data in it. Should a per-organization dimension
ever be wanted, it returns as a separate org-owned object — never as a column
on this ledger.

What changed:

- **`sys_metadata_activation` declares `systemFields: { tenant: false }`** and
no longer declares the column. Both halves are needed: the tenant anchor is
INJECTED at registration, so deleting the field alone would have left the
column exactly where it was. ⚠️ Deliberately NOT `tenancy: { enabled: false }`
— that key is the ADR-0066 D2 platform-global *posture*, which the sibling
`sys_sso_provider` uses for the opposite shape (a table that KEEPS its tenant
column and needs the wall over it stood down). Here there is no column to
wall. Both spellings reach `plugin-security`'s `tenancyDisabled`, which is
required rather than incidental: a Layer 0 wall composing an equality on a
column the table does not have denies every row.
- **The declared unique index states `unique: 'global'`** over
`(metadata_type, name)` instead of `'organization'`. ⚠️ The materialized DDL
is unchanged: `normalizeDeclaredIndex` prepends the NULL-safe tenant key part
only when the table HAS a tenant column, so `'organization'` already degraded
to exactly these two columns. What changes is that the declaration now states
the boundary it actually gets, rather than claiming a per-organization one
that does not exist. Still explicit rather than bare `unique: true`, which
lint `unique/unscoped-declared-index` warns on and protocol 18 rejects.
- **`ObjectStoreMetadataActivationStore` drops its NULL filter and its
org-row skip.** `list()` is now every activation row of its type, scoped by
the `metadata_type` discriminator alone, and `setActive` takes the single row
its keyed read returns instead of picking the NULL-organization one out of
the result. Both guarded a column that no longer exists; the declared unique
index over the two columns the lookup keys on is what makes that read
single-valued. `ObjectStoreFlowActivationStore` and
`ObjectStoreActionActivationStore` inherit the change.

Unchanged, and pinned: the operator gate on activation writes under walled
postures (ADR-0126 D3), the `execute()`-time flow consult and the dispatch-time
action consult, "absence of a row means ACTIVE", re-enabling UPDATES the row
rather than deleting it, and a driver `0` reading as false. The pins that
asserted the reserved column and the org-row skip are rewritten to pin the
column's ABSENCE rather than deleted — including at the injection authority
(`resolveInjectedSystemColumns`, which decides whether the column exists) and
in a real booted stack, where the row's key set is a reading of the physical
table.
15 changes: 7 additions & 8 deletions .changeset/ledger-convergence-registration-and-one-store.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,13 +79,12 @@ one-argument constructor and its own docs, and fixes the discriminator.
`ObjectStoreActionActivationStore` / `InMemoryActionActivationStore` /
`ActionActivationRow` / `ActionActivationStore` / `ActionActivationStoreEngine`
/ `ACTION_ACTIVATION_TABLE` are exported from the same modules with the same
shapes. Row semantics are byte-equivalent: install-level rows only
(`organization_id` never written), org-carrying rows skipped on read and
ignored when deciding insert-vs-update, a driver `0` read as false,
read-then-write rather than a blind upsert, and no `delete` in the engine slice
because re-enabling rewrites the row.
shapes. Row semantics are byte-equivalent: deployment-level rows scoped only by
the `metadata_type` discriminator, a driver `0` read as false, read-then-write
rather than a blind upsert, and no `delete` in the engine slice because
re-enabling rewrites the row.

Both existing pin suites stay green **unchanged**, which is what makes them the
proof the consolidation lost nothing — verified by ablation: removing the
org-row skip from the one shared implementation turns both of them red on their
own org-skip assertion, so both really reach it.
proof the consolidation lost nothing — verified by ablation: mutating the one
shared implementation turns both of them red on their own assertions, so both
really reach it.
30 changes: 12 additions & 18 deletions .changeset/sys-metadata-activation-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,30 +11,24 @@ it needs **zero `packages/spec` schema or contract surface** — it is an ordina
platform object, not a metadata type. (The one spec file touched is the
mechanical name census described below, not protocol surface.)

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

Row identity is `(metadata_type, name, organization_id NULL-collapsed)`, spelled
as a declared index with **`unique: 'organization'`** (ADR-0120 D1). That
spelling is load-bearing, and the two obvious alternatives are both wrong here:

- bare `unique: true` on a declared index is the positional spelling of
`'global'` — installation-wide over exactly the listed columns — and is
already warned by lint `unique/unscoped-declared-index` in 17.x;
- a hand-written `['metadata_type', 'name', 'organization_id']` composite is
NULL-DISTINCT in SQL, and this line's `organization_id` is NULL on every row
by construction, so that index would enforce **nothing at all** (#5030,
measured) and one artifact could carry two contradictory `active` rows.

`'organization'` is the arm that closes exactly that hole: the driver prepends
`COALESCE(organization_id, '__global__')` at registration (ADR-0120 D3), which
is what §4's "NULL-collapsed" names.
Row identity is `(metadata_type, name)`, spelled as a declared index with
**`unique: 'global'`** (ADR-0120 D1) — installation-wide over exactly those two
columns, which is the whole of the identity now that the table carries no
tenant column. Stated explicitly rather than as bare `unique: true`: the bare
spelling materializes the same index but leaves the scope unstated, which lint
`unique/unscoped-declared-index` warns on in 17.x and protocol 18 rejects.

The name is also registered in `@objectstack/spec`'s platform-object name census
(`PLATFORM_OBJECTS_BY_PACKAGE`, the `platform-objects` group). That census is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 106 sites**.
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
| 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` |
| 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` |
| 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` |
| 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` |
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
| 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` |
Expand Down
71 changes: 45 additions & 26 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,18 +17,18 @@
// that had quietly hard-coded the other one's.
// 2. **Two bindings over one table do not see each other's rows** — in BOTH
// directions, from the same store class. That is the drift the two copies
// made possible (#12350's own argument: the org-row skip and the
// made possible (#12350's own argument: the discriminator scoping and the
// `0`-is-false read are what a copy loses quietly), and it can only be
// measured where both types are constructed side by side.
// 3. **A type nobody has written yet behaves the same.** ADR-0126 §8
// pre-charts `tool` / `skill` / `position`; a third consumer must inherit
// the semantics rather than re-derive them, and the cheapest proof is an
// unknown discriminator asserted through the same battery.
//
// The four load-bearing row properties themselves (`organization_id` never
// written, org-carrying rows skipped on read, absence means ACTIVE, a driver
// `0` reads as false) are pinned here too — this is where they now live, so
// this is where a change to them has to argue.
// The load-bearing row properties themselves (the ledger is deployment-wide
// and carries no tenant column, absence means ACTIVE, a driver `0` reads as
// false) are pinned here too — this is where they now live, so this is where a
// change to them has to argue.

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

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

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

const rows = await new ObjectStoreMetadataActivationStore(engine, 'flow').list();

// Skipped, not merged: reading it install-level would apply one
// organization's choice to the whole installation — #10243 from the
// read side.
expect(rows.map((r) => r.name)).toEqual(['install_level']);
const store = new ObjectStoreMetadataActivationStore(engine, 'flow');
const rows = await store.list();

// ⚠️ This replaces a pin that asserted a SKIP: the store used to drop
// any row carrying an organization, because the table declared a
// reserved-but-never-written tenant column. The column was dropped
// before it ever shipped, so there is no second axis left — every row
// of this type is an answer, and a filter here would now be dead code
// that reads as if it guarded something.
expect(rows.map((r) => r.name)).toEqual(['first', 'second']);

// The read names the discriminator and NOTHING else. Asserted on the
// query rather than on the result, because a store that had kept a
// tenant predicate would still return both of these rows — the fake's
// rows carry no such column — and the skip would be invisible from the
// result side alone.
expect(calls.find((c) => c.op === 'find')?.options?.where)
.toEqual({ metadata_type: 'flow' });
});

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

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

await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({
name: 'nightly_sync', packageId: 'crm', active: false,
name: 'nightly_sync', packageId: 'crm', active: true,
});

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

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