From bac572236d0865e820cbf75117ad6283c94d33e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:23:30 +0000 Subject: [PATCH 1/4] wip(spec,plugin-sharing): correct the single-posture event text and the sharing-rule organization help Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Pjh5f11tyUbYMQmSZaJZFS --- .../src/objects/sys-sharing-rule.object.ts | 5 ++- .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 2 +- packages/spec/src/api/events.zod.ts | 31 ++++++++++--------- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 5597d023128..b9584d07a65 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -92,11 +92,14 @@ export const SysSharingRule = ObjectSchema.create({ fields: { id: Field.text({ label: 'Rule ID', required: true, readonly: true, group: 'System' }), + // A stored rule always carries its organization. A rule that reaches every + // organization is a DECLARED rule, marked by the typed `deploymentWide` + // projection (sharing-rule-service.ts) — never by an empty column here. organization_id: Field.lookup('sys_organization', { label: 'Organization', required: false, group: 'System', - description: 'Tenant that owns this rule; null = global', + description: 'The organization this rule belongs to.', }), name: Field.text({ diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index fd64df1b7fd..8c6ecb28d92 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -113,7 +113,7 @@ export const esESObjects: NonNullable = { }, organization_id: { label: "Organización", - help: "Tenant que posee esta regla; null = global." + help: "La organización a la que pertenece esta regla." }, name: { label: "Nombre", diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index a310cc8918f..4fd6262e64e 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -113,7 +113,7 @@ export const jaJPObjects: NonNullable = { }, organization_id: { label: "組織", - help: "このルールを所有するテナント。null = グローバル" + help: "このルールが属する組織。" }, name: { label: "名前", diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index ede8641e577..4fca0674744 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -113,7 +113,7 @@ export const zhCNObjects: NonNullable = { }, organization_id: { label: "组织", - help: "拥有该规则的租户;null = 全局" + help: "该规则所属的组织。" }, name: { label: "名称", diff --git a/packages/spec/src/api/events.zod.ts b/packages/spec/src/api/events.zod.ts index 9a371876bfc..293c41e1289 100644 --- a/packages/spec/src/api/events.zod.ts +++ b/packages/spec/src/api/events.zod.ts @@ -257,17 +257,18 @@ export const DataEventSchema = lazySchema(() => z.object({ * * **Absent = the record belongs to no organization.** Two situations, one * meaning: - * - a `single`-posture deployment — `postureEnforcesWall(posture)` is - * `false` and `postureStampsOrganization(posture)` with it (see - * `@objectstack/spec/security`): there is no organization wall and - * nothing stamps the column, so EVERY event is organization-less; - * - a row that carries no organization under a walled posture (`group` / - * `isolated`): a row of an object that stands outside the wall — + * - a row of an object that stands outside the wall, under any posture — * `tenancy.enabled: false` by declaration, or no `organization_id` * column at all (the identity tables, and the deployment-level state of - * ADR-0131 D7). An organization-less row on a tenant table is not a - * third case: ADR-0131 D1 makes it invalid state, and no tenant-scoped - * read reaches it (D8). + * ADR-0131 D7); + * - every row of a composition that registers no organization object: + * nothing there has an organization to stamp. + * A `single`-posture deployment is not a third case: every write to a + * tenant table is stamped with its organization — on a single-organization + * deployment, the Default Organization the engine derives (ADR-0131 D9) — + * so its events carry it like a walled deployment's. Nor is an + * organization-less row on a tenant table: ADR-0131 D1 makes it invalid + * state, and no tenant-scoped read reaches it (D8). * In both, a consumer may read absence as "not behind any organization * wall" — the reading the read path gives a row of an object with no * tenant column. It may NOT read absence as "unknown, resolve it yourself": @@ -282,8 +283,8 @@ export const DataEventSchema = lazySchema(() => z.object({ * the empty string is refused, so "no organization" has exactly one * spelling — the key is absent. * - * **Optional as a contract fact, not as a transition.** A `single`-posture - * deployment stays organization-less for its whole life, so a required key + * **Optional as a contract fact, not as a transition.** A row outside the + * wall belongs to no organization for its whole life, so a required key * would either force a fabricated tenant there or leave the engine unable to * publish at all (the publish site `parse`s the event and drops it on * failure). Declared = enforced: this optionality is exactly what validation @@ -295,10 +296,10 @@ export const DataEventSchema = lazySchema(() => z.object({ organizationId: z.string().min(1).optional().describe( 'Organization the record belongs to (its organization_id), so a tenant-scoped ' + 'consumer can discriminate the event\'s tenant without reading the record body. ' - + 'Absent when the record belongs to no organization: every event on a single-posture ' - + 'deployment (no organization wall, nothing stamps the column), and a row that ' - + 'carries no organization under a walled posture (environment-wide, or an object ' - + 'outside the wall) — read absence as ' + + 'Absent when the record belongs to no organization: a row of an object outside the ' + + 'wall (environment-wide, or no organization column), and every row of a composition ' + + 'that registers no organization object. A single-posture deployment stamps every ' + + 'tenant row with its Default Organization, so its events carry it — read absence as ' + '"not behind any organization wall", never as "unknown". Present = exactly that ' + 'organization; never fabricated, and the empty string is refused.', ), From 22f50fe85dedd8eff5ea5ff2a374ac25f149bf47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:24:50 +0000 Subject: [PATCH 2/4] fix(scripts): check-tenant-chokepoint accepts where(tenant, 'is not', null), as its header says Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Pjh5f11tyUbYMQmSZaJZFS --- scripts/check-tenant-chokepoint.mjs | 37 ++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index 355e2cf9e2f..7d69ddee0f1 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -133,7 +133,10 @@ * a raw fragment (`whereRaw`, `orWhereRaw`, `andWhereRaw`, `havingRaw`, `raw`) * whose own literal text says `IS NULL` and that names the column — as a * template substitution, as a binding in its bindings array, or literally. - * `IS NOT NULL` is not an arm and is never refused. + * `IS NOT NULL` is not an arm and is never refused, in any spelling: + * `whereNotNull`, a raw `IS NOT NULL`, and `where(field, 'is not', null)`, + * which Knex compiles to `IS NOT NULL` (its operator is matched as Knex + * matches it: lower-cased and trimmed). * * Exemptions: none (`NULL_ARM_EXEMPT` is empty), and the rule needs none on * `main`. One NULL disjunct survives in the platform BY DESIGN and is outside @@ -311,6 +314,15 @@ const TENANT_PREDICATE_CALLS = new Set(['where', 'whereIn']); /** `IS NULL`, never `IS NOT NULL` — the latter is not an arm. */ const IS_NULL_TEXT = /\bis\s+null\b/i; +/** + * Is `node` the literal operator `'is not'`? Knex lower-cases and trims the + * operator, and compiles `where(col, 'is not', null)` to `IS NOT NULL` — not + * an arm. Only a literal is read: an operator the gate cannot see is refused. + */ +function isNotNullOperator(node) { + return !!node && ts.isStringLiteralLike(node) && node.text.toLowerCase().trim() === 'is not'; +} + /** * `'::'` -> reason a reader can check. Empty by design: the rule * needs no exemption on `main`, and an entry is a decision against ADR-0131 @@ -394,7 +406,12 @@ function nullArmOf(call, bindings) { const args = call.arguments; if (NULL_CALLS.has(verb) && isTenantRef(args[0], bindings)) return `${verb}()`; if (WHERE_CALLS.has(verb)) { - if (args.length >= 2 && isTenantRef(args[0], bindings) && isNullLiteral(args[args.length - 1])) { + if ( + args.length >= 2 && + isTenantRef(args[0], bindings) && + isNullLiteral(args[args.length - 1]) && + !(args.length === 3 && isNotNullOperator(args[1])) + ) { return `${verb}(, …null)`; } const obj = args[0]; @@ -629,7 +646,7 @@ const SELF_TEST_VERDICT = 'check-tenant-chokepoint self-test reached its verdict // to find what stopped registering. const SELF_TEST_BATTERIES = Object.freeze({ 'check-tenant-chokepoint self-test': 17, - 'D8 null-arm rule': 22, + 'D8 null-arm rule': 24, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -865,6 +882,13 @@ function selfTest() { 'a raw fragment naming the literal tenant column', `census(table) { return this.knex.raw('select * from ?? where organization_id is null', [table]); }`, ], + [ + 'an operator the gate cannot read stays refused — only the literal is not is exempt', + `scope(builder, object, op) { + const f = this.resolveTenantField(object); + return builder.orWhere(f, op, null); + }`, + ], [ 'a module-level helper outside the class is no hiding place', `x() {} @@ -910,6 +934,13 @@ function selfTest() { 'a null comparand on an ordinary column', `clear(qb) { return qb.where('deleted_at', null); }`, ], + [ + "the three-argument IS NOT NULL form, where(col, 'is not', null), in Knex's own casing tolerance", + `scope(builder, object) { + const field = this.resolveTenantField(object); + return builder.where(field, 'is not', null).andWhere('organization_id', ' IS NOT ', null); + }`, + ], ]; for (const [name, src] of nullArmSilent) { const found = arms(src); From b509011f7c370171c5b40fd514a6f8999286e958 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:46:59 +0000 Subject: [PATCH 3/4] chore(plugin-sharing): regenerate the en bundle; changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Pjh5f11tyUbYMQmSZaJZFS --- .changeset/24902-tenant-text-after-step-7.md | 12 ++++++++++++ .../src/translations/en.objects.generated.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changeset/24902-tenant-text-after-step-7.md diff --git a/.changeset/24902-tenant-text-after-step-7.md b/.changeset/24902-tenant-text-after-step-7.md new file mode 100644 index 00000000000..8f2c3788f3d --- /dev/null +++ b/.changeset/24902-tenant-text-after-step-7.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +'@objectstack/plugin-sharing': patch +--- + +fix(spec,plugin-sharing): the `DataEvent.organizationId` description and the sharing-rule Organization help stop describing organization-less rows that 18.x no longer writes + +Clause-②: no (description and help text only: no response, status, outcome, boot verdict or export moves) +Metadata-protocol: none (a schema description and a field's help text; nothing a metadata author writes changes) + +- **`DataEvent.organizationId` (`@objectstack/spec/api`).** The description said every event on a `single`-posture deployment carries no organization, because nothing stamps the column there. Since 18.x the engine stamps every tenant row on a single-organization deployment with its Default Organization, so those events carry it. The key is now described as absent in two cases: a row of an object outside the wall, and every row of a composition that registers no organization object. The schema itself is unchanged (`z.string().min(1).optional()`). +- **`sys_sharing_rule.organization_id` (`@objectstack/plugin-sharing`).** The Setup help said "null = global". A stored rule always carries its organization; a rule that applies to every organization is a declared rule. The help now reads "The organization this rule belongs to.", with the translated locales updated to match. diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index dfdb2e3710e..1dc5d3be918 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -113,7 +113,7 @@ export const enObjects: NonNullable = { }, organization_id: { label: "Organization", - help: "Tenant that owns this rule; null = global" + help: "The organization this rule belongs to." }, name: { label: "Name", From 02067f8ceb0ebc2c75ec4f576055b4948a8c0a17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:56:07 +0000 Subject: [PATCH 4/4] chore(spec): regenerate the events reference Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Pjh5f11tyUbYMQmSZaJZFS --- content/docs/references/api/events.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/api/events.mdx b/content/docs/references/api/events.mdx index 1b8de25194d..07e83347d6d 100644 --- a/content/docs/references/api/events.mdx +++ b/content/docs/references/api/events.mdx @@ -59,7 +59,7 @@ const result = BulkDataEventSchema.parse(data); | **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` | ✅ | Event type | | **object** | `string` | ✅ | Object name | | **recordId** | `string` | ✅ | Record ID | -| **organizationId** | `string` | optional | Organization the record belongs to (its organization_id), so a tenant-scoped consumer can discriminate the event's tenant without reading the record body. Absent when the record belongs to no organization: every event on a single-posture deployment (no organization wall, nothing stamps the column), and a row that carries no organization under a walled posture (environment-wide, or an object outside the wall) — read absence as "not behind any organization wall", never as "unknown". Present = exactly that organization; never fabricated, and the empty string is refused. | +| **organizationId** | `string` | optional | Organization the record belongs to (its organization_id), so a tenant-scoped consumer can discriminate the event's tenant without reading the record body. Absent when the record belongs to no organization: a row of an object outside the wall (environment-wide, or no organization column), and every row of a composition that registers no organization object. A single-posture deployment stamps every tenant row with its Default Organization, so its events carry it — read absence as "not behind any organization wall", never as "unknown". Present = exactly that organization; never fabricated, and the empty string is refused. | | **changes** | `Record` | optional | Changed fields | | **before** | `Record` | optional | Before state | | **after** | `Record` | optional | After state |