Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
420fb8c
wip(plugin-webhooks): declared webhooks read from the registry; seede…
claude Oct 19, 2026
e44c642
test(plugin-webhooks): pin the registry read, the ledger switch and t…
claude Oct 19, 2026
8189a84
wip: liveness ledger re-anchored to the registry read; dogfood proof …
claude Oct 19, 2026
e412cb8
docs+ledger: the registry read named across spec comments, liveness, …
claude Oct 19, 2026
bdad70b
spec(liveness): state counts regenerated; isActive named at the dispa…
claude Oct 19, 2026
723af97
fix(plugin-webhooks): a declared webhook is deployment-wide when the …
claude Oct 19, 2026
e0a6dc9
fix(gates): tenant-audit census regenerated for the seeder's two reti…
claude Oct 19, 2026
6b3042a
test(plugin-webhooks): the fake engine refuses a WHERE combinator it …
claude Oct 19, 2026
820121c
fix(lint,showcase): the dead label/description verdict is carried to …
claude Oct 19, 2026
b378ff5
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
c3b1452
test(dogfood): the R2 harness pins one delivery per task change from …
claude Oct 19, 2026
9bce0aa
test(dogfood): the harness case switches the declaration on, so it ca…
claude Oct 19, 2026
7e580dc
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
fa6a837
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
b4ae8e6
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
206d2bc
Merge remote-tracking branch 'origin/claude/issue-24529-8b0g-webhooks…
claude Oct 19, 2026
f89c0f2
wip: birth marker, shapeGeneration, boot guard rule
claude Oct 19, 2026
6e14afa
wip: ceremony steps 5a-5d, inventory, flips, differ ownership
claude Oct 19, 2026
50a2d34
wip: open the ceremony command, ports for webhooks and NOT NULL
claude Oct 19, 2026
9ea4312
wip: existing ceremony tests follow the new step set; NOT NULL routed…
claude Oct 19, 2026
719ab13
wip: the guard's pins follow the birth marker and shapeGeneration
claude Oct 19, 2026
036a2a1
wip: the birth half in every CLI boot, so a CLI-created fresh store b…
claude Oct 19, 2026
0128f07
test(cli): pin the generation-1 ceremony steps over an 18.0-shaped da…
claude Oct 19, 2026
8e6a33a
test: the tenant column's NOT NULL is the ceremony's, not drift; the …
claude Oct 19, 2026
32d1e22
test(dogfood): R2 and R3 restated — the 18.0 and v17 databases take t…
claude Oct 19, 2026
13446cb
fix(cli): a rotation shard's NOT NULL is reported unsupported, never …
claude Oct 19, 2026
06e0556
docs,qa: the open ceremony and the birth marker in the CLI docs and t…
claude Oct 19, 2026
e0c1d2b
chore(spec): regenerate the api-surface, export-origin, declaration-m…
claude Oct 19, 2026
a0fa816
test(platform-objects,service-settings): sys_secret is deployment-lev…
claude Oct 19, 2026
e7a788d
chore: regenerate the platform-object tenancy census (sys_secret and …
claude Oct 19, 2026
f1264d7
chore: the gates the sweep named — the guard double's findOne predica…
claude Oct 19, 2026
a3064ef
test(cli): fixtures standing in for an earlier release's database car…
claude Oct 19, 2026
d6195b9
Merge origin/main into claude/issue-24581-ceremony-open
claude Oct 19, 2026
0232108
docs(qa): the ceremony item's revision 3 cites its PR
claude Oct 19, 2026
dceaa56
fix(driver-turso): the remote face counts what its schema doors creat…
claude Oct 19, 2026
33b7943
test(runtime): the remote-face guard pin reads driver-turso's source
claude Oct 19, 2026
d28f4cf
chore(changeset): the remote face's schema-sync count
claude Oct 19, 2026
edb2db1
test(dogfood): the upgrade ceremony over the 18.0 fixture is a shared…
claude Oct 19, 2026
1699ec5
Merge origin/main after #24667 (step 4) into the ceremony branch
claude Oct 20, 2026
b5010d9
test(dogfood): ceremoniedFixture boots the 18.0 bytes once, refused, …
claude Oct 20, 2026
0f83a8d
fix(text): no shipped text says the ownership ceremony is closed; an …
claude Oct 20, 2026
c6f775c
fix(text): the delta review's text items — fresh installs restated, t…
claude Oct 20, 2026
5428f55
Merge remote-tracking branch 'origin/claude/issue-24581-ceremony-open…
claude Oct 20, 2026
31510bb
wip(plugin-sharing): deployment-wide marker replaces the NULL scope o…
claude Oct 20, 2026
3551d5b
wip: approvals unit screen strict; seed-loader NULL holder read delet…
claude Oct 20, 2026
4852aac
wip(plugin-sharing): pins rewritten to the deployment-wide marker and…
claude Oct 20, 2026
b4e478d
wip(plugin-approvals): unit-screen pins re-stamped to the strict equa…
claude Oct 20, 2026
5907bb2
wip(dogfood): declared rules read deployment-wide on the wire
claude Oct 20, 2026
cc5038b
chore(changeset): 18.x step 7 part 7a-3
claude Oct 20, 2026
a6e2953
Merge origin/main (#24681 landed) into the 7a-3 branch
claude Oct 20, 2026
e10bb2c
docs(permissions): the system-context census drops the retired platfo…
claude Oct 20, 2026
62227f5
test(runtime): a declared rule's served shape under single carries th…
claude Oct 20, 2026
357025d
Merge origin/main into the 7a-3 branch
claude Oct 20, 2026
228e74a
feat(spec,plugin-sharing): SharingRuleRow declares the deploymentWide…
claude Oct 20, 2026
7d8257d
chore(changeset): spec declares the deploymentWide marker
claude Oct 20, 2026
32b3d1c
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
236d39d
test(plugin-sharing): reconcile rule-criteria-org-scope with 7a-1 — n…
claude Oct 20, 2026
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
23 changes: 23 additions & 0 deletions .changeset/24583-7a3-sharing-null-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-sharing': minor
'@objectstack/plugin-approvals': minor
'@objectstack/metadata-protocol': minor
---

feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)

Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data migration a database with organization-less rows needs is the operator's ceremony, `os migrate organization-ownership`, stated below; the 18.x boot refusal already names it. The one wire change is a declared sharing rule's response row (`deploymentWide: true` in place of `organization_id: null`), not an authorable key. -->

**BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.

- **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
- **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
- **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
- **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
- **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
- **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.

Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ One of the largest consumers of the flag;
| 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware` |
| 38 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `packages/plugins/plugin-sharing/src/share-link-service.ts#createLink`, `#revokeLink`, `#listLinks` |
| 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp` |
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, the platform-global-rule delete guard, and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule`, `#bindDeclaredRuleNameGuard` |
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#bindDeclaredRuleNameGuard` |

### 4. Approvals, attachments, comments, knowledge

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';

/**
* [#24301] An organization-scoped load refuses a seed row whose natural key a
* row with NO organization already holds, as a per-row error, instead of
* inserting a twin of that row; and it never adopts the row.
* [ADR-0131 D8] An organization-scoped load reads ITS OWN rows, and only them:
* one read per decision, `organization_id = ORG`, with no NULL arm on any of
* the three readers of `loadExistingRecords` (its #8896 note):
*
* These pins hold the three readers of `loadExistingRecords` (its #8896 note)
* apart, which a real-driver file cannot reach:
* 1. the upsert/ignore/update pre-load;
* 2. `writeBatchPartial`'s retry recheck;
* 3. `writeOne`'s per-row recheck.
*
* 1. the upsert/ignore pre-load sees the unowned holder and refuses;
* 2. `writeBatchPartial`'s retry recheck, and
* 3. `writeOne`'s recheck read this organization's rows only: a row an
* earlier attempt committed carries the organization, so an unowned row
* is never taken for one, and the refused row never reaches either.
* Until step 7 of the organization-ownership line the pre-load also read the
* keys held by rows with NO organization and refused to twin or adopt them
* (#24301). Certification leaves no such row (the ownership ceremony
* attributes every one, D10) and a new write without an organization is
* stamped or refused (D9), so that second read retired. These pins hold the
* retirement: no `{ organization_id: null }` read is issued, whatever the
* mode or path, and the scoped load behaves as the organization's own.
*
* The real drivers' NULL predicate and the stored rows are pinned in
* The real driver's side is pinned in
* `packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts`.
*/

Expand Down Expand Up @@ -112,76 +115,47 @@ const widgets = (mode: string) => [{
records: [{ sku: 'W-HELD', label: 'held' }, { sku: 'W-NEW', label: 'new' }],
}] as any[];

/** A row no organization owns, holding the natural key `W-HELD`. */
function plantUnowned(store: Record<string, any[]>) {
store.tw_widget = [{ id: 'w_unowned', sku: 'W-HELD', label: 'old', organization_id: null }];
/** This organization's own row, holding the natural key `W-HELD`. */
function plantOwn(store: Record<string, any[]>) {
store.tw_widget = [{ id: 'w_own', sku: 'W-HELD', label: 'old', organization_id: ORG }];
}

const refusalsOf = <E extends { field: string; sourceObject: string; targetObject: string }>(result: { errors: E[] }) =>
result.errors.filter((e) => e.field === 'organization_id' && e.targetObject === e.sourceObject);
const nullReads = (finds: any[]) => finds.filter((f) => f.where && 'organization_id' in f.where && f.where.organization_id == null);

describe('[#24301] a scoped load over a NULL-organization holder of a natural key', () => {
it.each(['upsert', 'ignore'])('%s: refuses the held key by name, inserts the rest, and leaves the holder alone', async (mode) => {
const { engine, store } = createFaithfulEngine();
plantUnowned(store);
describe('[ADR-0131 D8] a scoped load reads only its organization\'s rows', () => {
it.each(['upsert', 'ignore', 'update'])('%s: one scoped pre-load, no NULL read, its own row matched', async (mode) => {
const { engine, store, finds } = createFaithfulEngine();
plantOwn(store);
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets(mode), config: config(ORG) });

expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-NEW']);
expect(store.tw_widget[0]).toEqual({ id: 'w_unowned', sku: 'W-HELD', label: 'old', organization_id: null });
expect(store.tw_widget.find((r) => r.sku === 'W-NEW')?.organization_id).toBe(ORG);
expect(result.summary.totalInserted).toBe(1);
expect(result.summary.totalErrored).toBe(1);
expect(engine.update).not.toHaveBeenCalled();
const [refusal] = refusalsOf(result);
expect(refusal).toMatchObject({ sourceObject: 'tw_widget', targetField: 'sku', attemptedValue: 'W-HELD', recordIndex: 0 });
expect(refusal.message).toContain("'w_unowned'");
// The ceremony is the remedy, with its reviewed plan; the first sentences
// (what was refused, and why) stay.
expect(refusal.message).toContain('which has no organization (organization_id is NULL)');
expect(refusal.message).toContain('decided by the organization-ownership ceremony, not by a seed load.');
expect(refusal.message).toContain(
'Attribute the unowned rows first (`os migrate organization-ownership --out PLAN`, then the same command with '
+ '--apply --plan-file PLAN --backup-taken), then re-run this seed',
);
expect(refusal.message).not.toMatch(/fresh installs only|on a 17\.x release|carries the upgrade path/);
});

it("the organization's own row wins: a key held both by it and by an unowned row is matched, not refused", async () => {
const { engine, store } = createFaithfulEngine();
plantUnowned(store);
store.tw_widget.push({ id: 'w_own', sku: 'W-HELD', label: 'held', organization_id: ORG });
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('upsert'), config: config(ORG) });

expect(result.errors).toEqual([]);
expect(result.summary.totalSkipped).toBe(1);
expect(store.tw_widget.filter((r) => r.sku === 'W-HELD')).toHaveLength(2);
expect(nullReads(finds)).toEqual([]);
expect(finds.filter((f) => f.objectName === 'tw_widget').map((f) => f.where)).toEqual([{ organization_id: ORG }]);
// The held key is this organization's: matched, never twinned.
expect(store.tw_widget.filter((r) => r.sku === 'W-HELD')).toHaveLength(1);
if (mode === 'update') {
expect(store.tw_widget).toHaveLength(1);
} else {
expect(store.tw_widget.find((r) => r.sku === 'W-NEW')?.organization_id).toBe(ORG);
expect(result.summary.totalInserted).toBe(1);
}
});

it('update mode writes no new row, so it keeps its behaviour: the held key is skipped, nothing refused', async () => {
it('the self-referencing (sequential) path reads the same way', async () => {
const { engine, store, finds } = createFaithfulEngine();
plantUnowned(store);
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('update'), config: config(ORG) });

expect(result.errors).toEqual([]);
expect(result.summary.totalSkipped).toBe(2);
expect(store.tw_widget).toHaveLength(1);
expect(finds.some((f) => f.where?.organization_id === null)).toBe(false);
});

it('the self-referencing (sequential) path refuses the same way', async () => {
const { engine, store } = createFaithfulEngine();
store.tw_unit = [{ id: 'u_unowned', code: 'ROOT', parent: null, organization_id: null }];
store.tw_unit = [{ id: 'u_own', code: 'ROOT', parent: null, organization_id: ORG }];
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
seeds: [{ object: 'tw_unit', externalId: 'code', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ code: 'ROOT' }, { code: 'LEAF' }] }] as any[],
config: config(ORG),
});
expect(result.errors).toEqual([]);
expect(store.tw_unit.map((r) => r.code).sort()).toEqual(['LEAF', 'ROOT']);
expect(refusalsOf(result).map((e) => e.attemptedValue)).toEqual(['ROOT']);
expect(nullReads(finds)).toEqual([]);
});

it('writeBatchPartial retry recheck: reads only the organization\'s rows, and the refused row never reaches an insert', async () => {
it('writeBatchPartial retry recheck: reads only the organization\'s rows, and inserts nothing twice', async () => {
const { engine, store, finds } = createFaithfulEngine();
plantUnowned(store);
plantOwn(store);
// The first array insert commits, then loses its response: the retry's
// recheck must find W-NEW as already written and insert nothing again.
const realInsert = (engine.insert as any).getMockImplementation();
Expand All @@ -195,24 +169,19 @@ describe('[#24301] a scoped load over a NULL-organization holder of a natural ke
});
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('upsert'), config: config(ORG) });

expect(calls).toBe(1);
expect(payloads.flat().map((r) => r.sku)).toEqual(['W-NEW']);
expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-NEW']);
expect(refusalsOf(result)).toHaveLength(1);
expect(result.summary.totalInserted).toBe(1);
// One NULL read, the pre-load's; every recheck after the first insert is scoped.
const nullReads = finds.filter((f) => f.where?.organization_id === null);
expect(nullReads).toHaveLength(1);
const afterPreload = finds.slice(finds.findIndex((f) => f.where?.organization_id === null) + 1)
.filter((f) => f.objectName === 'tw_widget');
// The retry happened, and its recheck is the one scoped read after the pre-load.
expect(calls).toBe(1);
expect(afterPreload.map((f) => f.where)).toEqual([{ organization_id: ORG }]);
expect(nullReads(finds)).toEqual([]);
// The pre-load and the retry's recheck: both scoped.
expect(finds.filter((f) => f.objectName === 'tw_widget').map((f) => f.where))
.toEqual([{ organization_id: ORG }, { organization_id: ORG }]);
});

it('writeOne recheck (an engine with no insertMany, an uncertain batch): the same, per row', async () => {
const { engine, store, finds } = createFaithfulEngine();
plantUnowned(store);
store.tw_widget.push({ id: 'w_unowned_2', sku: 'W-HELD-2', label: 'old', organization_id: null });
plantOwn(store);
const realInsert = (engine.insert as any).getMockImplementation();
const payloads: any[][] = [];
(engine.insert as any).mockImplementation(async (obj: string, data: any, opts: any) => {
Expand All @@ -224,27 +193,26 @@ describe('[#24301] a scoped load over a NULL-organization holder of a natural ke
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({
seeds: [{
object: 'tw_widget', externalId: 'sku', mode: 'upsert', env: ['prod', 'dev', 'test'],
records: [{ sku: 'W-HELD' }, { sku: 'W-NEW' }, { sku: 'W-HELD-2' }, { sku: 'W-NEW-2' }],
records: [{ sku: 'W-HELD' }, { sku: 'W-NEW' }, { sku: 'W-NEW-2' }],
}] as any[],
config: config(ORG),
});

// One whole-array attempt, then the per-row degradation: two single inserts.
expect(payloads.map((p) => p.map((r) => r.sku))).toEqual([['W-NEW', 'W-NEW-2'], ['W-NEW'], ['W-NEW-2']]);
expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-HELD-2', 'W-NEW', 'W-NEW-2']);
expect(refusalsOf(result).map((e) => e.attemptedValue)).toEqual(['W-HELD', 'W-HELD-2']);
expect(store.tw_widget.map((r) => r.sku).sort()).toEqual(['W-HELD', 'W-NEW', 'W-NEW-2']);
expect(result.summary.totalInserted).toBe(2);
expect(finds.filter((f) => f.where?.organization_id === null)).toHaveLength(1);
expect(nullReads(finds)).toEqual([]);
});

it('an unscoped load reads no NULL holders and keeps matching the unowned row', async () => {
it('an unscoped load reads no NULL holders either, and matches by key alone', async () => {
const { engine, store, finds } = createFaithfulEngine();
plantUnowned(store);
plantOwn(store);
const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: widgets('upsert'), config: config() });

expect(refusalsOf(result)).toEqual([]);
expect(result.errors).toEqual([]);
expect(store.tw_widget.filter((r) => r.sku === 'W-HELD')).toHaveLength(1);
expect(store.tw_widget.find((r) => r.sku === 'W-HELD')?.label).toBe('held');
expect(finds.some((f) => f.where?.organization_id === null)).toBe(false);
expect(nullReads(finds)).toEqual([]);
});
});
Loading
Loading