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
12 changes: 12 additions & 0 deletions .changeset/24902-tenant-text-after-step-7.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/api/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | optional | Changed fields |
| **before** | `Record<string, any>` | optional | Before state |
| **after** | `Record<string, any>` | optional | After state |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organization",
help: "Tenant that owns this rule; null = global"
help: "The organization this rule belongs to."
},
name: {
label: "Name",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "組織",
help: "このルールを所有するテナント。null = グローバル"
help: "このルールが属する組織。"
},
name: {
label: "名前",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "组织",
help: "拥有该规则的租户;null = 全局"
help: "该规则所属的组织。"
},
name: {
label: "名称",
Expand Down
31 changes: 16 additions & 15 deletions packages/spec/src/api/events.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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
Expand All @@ -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.',
),
Expand Down
37 changes: 34 additions & 3 deletions scripts/check-tenant-chokepoint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
}

/**
* `'<file>::<method>'` -> reason a reader can check. Empty by design: the rule
* needs no exemption on `main`, and an entry is a decision against ADR-0131
Expand Down Expand Up @@ -394,7 +406,12 @@ function nullArmOf(call, bindings) {
const args = call.arguments;
if (NULL_CALLS.has(verb) && isTenantRef(args[0], bindings)) return `${verb}(<tenant column>)`;
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}(<tenant column>, …null)`;
}
const obj = args[0];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {}
Expand Down Expand Up @@ -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);
Expand Down
Loading