Skip to content

Commit 60b42cd

Browse files
fix(spec,plugin-sharing,scripts): retire the organization-less text step 7 left behind; check-tenant-chokepoint accepts where(tenant, 'is not', null) (#25140)
Fixes #24902 Clause-②: no (description and help text, plus a CI gate's rule: 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) ## What changed **1. Text that contradicted behaviour after 18.x step 7** - `packages/spec/src/api/events.zod.ts`: the `DataEvent.organizationId` TSDoc and `.describe()`. They said every event on a `single` deployment carries no organization because "nothing stamps the column". Under ADR-0131 D9, every tenant row on a single-organization deployment is stamped with the Default Organization the engine derives (`resolveSystemInsertOrganization`, `packages/objectql/src/engine.ts`), so its events carry it. The text now matches `content/docs/protocol/objectql/schema.mdx`. Absence is described as 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. `content/docs/references/api/events.mdx` was regenerated (`check:generated --fix`, then a re-check: 14/14 up to date). - `packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts`: the Organization help said "null = global". It now reads "The organization this rule belongs to.", which follows this object's admin-facing rule (no tables, columns or ADRs in a `description`). A code comment above it points to the typed `deploymentWide` projection (`sharing-rule-service.ts`). The `en` bundle was regenerated with `node scripts/check-i18n-bundles.mjs --write`. The zh-CN, ja-JP and es-ES values are hand-written and were updated to match, because merge mode keeps translated values and they would otherwise still say "null = global". - The three "stays NULL on single-tenant" comments in #25128 (C1) are not in these files, so they stay with #25128. **2. The gate nit:** `scripts/check-tenant-chokepoint.mjs` - The header says `IS NOT NULL` is never refused. The rule refused every three-argument `where(tenant, …, null)`, including `where(tenant, 'is not', null)`. Knex compiles that call to `IS NOT NULL`: `knex/lib/query/querybuilder.js:456-457`, operator lower-cased and trimmed. - The rule now exempts exactly a literal `'is not'` operator, matched the way Knex matches it. An operator the gate cannot read is still refused. The header names all three `IS NOT NULL` spellings. - Self-test: there is one new silent case (`'is not'` and `' IS NOT '`) and one new refused case (a non-literal operator). The `D8 null-arm rule` battery floor goes from 22 to 24. - Reverse check: I replaced the new condition with `true`, and the self-test went red (exit 1) on the new silent case. The file was restored with `git checkout HEAD --`, and `git diff HEAD` was empty afterwards. **3. The dormant paths: both are KEPT, with reasons. Neither is retired.** `organizationOutOfWriteReach` (`packages/services/service-storage/src/metadata-store.ts:800`) is **alive**: - Callers: `storage-routes.ts:780`, inside `requireStartingOrganization`. Three upload doors use it: the presigned commit door, chunked completion, and the progress door when its expiry stamp is due. It is the guard behind the `409` an uploader gets after switching organization. It has a door-level pin in `error-envelope.conformance.test.ts:226-245` (the commit door after an organization switch) and unit pins in `metadata-store.test.ts:368-405`. Probe: `vitest run src/error-envelope.conformance.test.ts src/metadata-store.test.ts -t 'switching active organization|organizationOutOfWriteReach'` gives 2 files, 7 passed. service-storage is not in this diff, so CI does not run it. - Only its organization-less-row branch (`metadata-store.ts:809`) was written against the retired NULL arm. That branch can still run. The v18 boot guard is composed by `os serve` alone (`packages/runtime/src/organization-ownership-boot-guard.ts`, "Where it runs"). An embedding host that does not compose it can boot a database that has not been through the ceremony and still holds a v17 organization-less `sys_file` / `sys_upload_session` row. - So nothing is removed. One finding is left for the seat (see Acceptance notes). The `sys_record_share` backfill apply pass (`packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts`) is **dead in every shipped build, but has a published reach**: - No production caller. The only callers are tests: `backfill-sys-record-share-organizations.test.ts` and `record-share-organization-stamp.test.ts:315`. - It does not ship: the tsup entry is `src/index.ts` alone, `files` is `dist`, and the index does not export it. - Probe (a throwaway vitest that was not committed): on a `sys_record_share` table created by the 18.x driver with no relax, storing an organization-less grant is refused (`NOT NULL`). `planSysRecordShareOrganizationBackfill` answers `{scanned: 0, planned: 0}`, and the apply pass writes 0. The existing real-driver pin can only build its legacy rows because it first relaxes the column (`record-share-organization-stamp.test.ts:180`). - **External reach, so it is kept:** the published 17.3 release notes (`content/docs/releases/v17/17-3.mdx:44-48, 284-289`) tell operators to run `planSysRecordShareOrganizationBackfill` / `runSysRecordShareOrganizationBackfill(engine, { dryRun: false })`. The CLI ceremony inventory cites the file as its derivation (`packages/cli/src/utils/organization-ownership-inventory.ts:538`). Retiring it also moves two ledgers outside this claim's file surface: `scripts/engine-double-contract.pinned.json:3670` and the tenant-audit census counts. Per the dispatch, this is the seat's call. ## Acceptance notes - **For the seat, the open question:** the organization-less branch of `organizationOutOfWriteReach` answers "in reach". Since D8, the driver's scoped write cannot reach such a row, so on a host without the boot guard the door writes, misses, and answers the `500` outage text. Option A: keep it as it is; that state only exists on a database that has not been through the ceremony. Option B: make the door refuse such a row with its own answer. That needs a new refusal text, and it touches `storage-routes.ts`, which is outside this claim. I recommend A, and noting it for #24897's ADR text. - **For the seat, the open question:** retire the `sys_record_share` backfill or keep it. Option A: keep it until the 17.x upgrade window is formally closed. Option B: retire the module, its two tests and the two ledger rows now, and add an erratum PR for the 17.3 notes. I recommend A. - The claim names `packages/platform-objects/src/**/sys-sharing-rule.object.ts`. The file is actually at `packages/plugins/plugin-sharing/src/objects/`. I edited it there. - The storage-routes comment (`storage-routes.ts:761`) "a row stamped with no organization stays in reach" has the same staleness as the branch above. It is outside the file surface, so I left it. ## Verification - `pnpm --filter @objectstack/spec test`: 706 files, 21465 tests passed. - `pnpm --filter @objectstack/plugin-sharing test`: 53 files, 1200 passed. - `pnpm --filter @objectstack/plugin-sharing typecheck`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0. - `node scripts/check-tenant-chokepoint.mjs --self-test`: exit 0, 12 refused and 7 silent. The scan itself exits 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 130 commands. 130 commands on head `02067f8c`, each exit code captured before any pipe: 127 exit 0 on the first pass. Three exited 3 (PREREQUISITE NOT MET, nothing measured). `check-plugin-teardown-shape --self-test` needed a pinned commit the shallow clone lacked; after fetching it, exit 0. `spec check:skill-examples` needed `client-react` built; after building it, exit 0. `check:dual-build-cjs-loads` needs ten packages this diff does not touch (studio, client-react, connector-slack and others) built, so it is **NOT MEASURED locally** and declared to CI. `--ran` reconciliation: 130 derived, 130 run, 0 unrun. - `node scripts/pm/check-governed-merges.mjs --branch origin/claude/issue-24902-tenant-text-dormant-paths`: NOT governed, 95 changed lines. - Size **S**. Not a governed surface, so it lands through the ordinary queue. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Pjh5f11tyUbYMQmSZaJZFS --- _Generated by [Claude Code](https://claude.ai/code/session_01Pjh5f11tyUbYMQmSZaJZFS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 863c580 commit 60b42cd

9 files changed

Lines changed: 71 additions & 24 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-sharing': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no (description and help text only: no response, status, outcome, boot verdict or export moves)
9+
Metadata-protocol: none (a schema description and a field's help text; nothing a metadata author writes changes)
10+
11+
- **`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()`).
12+
- **`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.

‎content/docs/references/api/events.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const result = BulkDataEventSchema.parse(data);
5959
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` | ✅ | Event type |
6060
| **object** | `string` | ✅ | Object name |
6161
| **recordId** | `string` | ✅ | Record ID |
62-
| **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. |
62+
| **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. |
6363
| **changes** | `Record<string, any>` | optional | Changed fields |
6464
| **before** | `Record<string, any>` | optional | Before state |
6565
| **after** | `Record<string, any>` | optional | After state |

‎packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,14 @@ export const SysSharingRule = ObjectSchema.create({
9292
fields: {
9393
id: Field.text({ label: 'Rule ID', required: true, readonly: true, group: 'System' }),
9494

95+
// A stored rule always carries its organization. A rule that reaches every
96+
// organization is a DECLARED rule, marked by the typed `deploymentWide`
97+
// projection (sharing-rule-service.ts) — never by an empty column here.
9598
organization_id: Field.lookup('sys_organization', {
9699
label: 'Organization',
97100
required: false,
98101
group: 'System',
99-
description: 'Tenant that owns this rule; null = global',
102+
description: 'The organization this rule belongs to.',
100103
}),
101104

102105
name: Field.text({

‎packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
113113
},
114114
organization_id: {
115115
label: "Organization",
116-
help: "Tenant that owns this rule; null = global"
116+
help: "The organization this rule belongs to."
117117
},
118118
name: {
119119
label: "Name",

‎packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
113113
},
114114
organization_id: {
115115
label: "Organización",
116-
help: "Tenant que posee esta regla; null = global."
116+
help: "La organización a la que pertenece esta regla."
117117
},
118118
name: {
119119
label: "Nombre",

‎packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
113113
},
114114
organization_id: {
115115
label: "組織",
116-
help: "このルールを所有するテナント。null = グローバル"
116+
help: "このルールが属する組織。"
117117
},
118118
name: {
119119
label: "名前",

‎packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
113113
},
114114
organization_id: {
115115
label: "组织",
116-
help: "拥有该规则的租户;null = 全局"
116+
help: "该规则所属的组织。"
117117
},
118118
name: {
119119
label: "名称",

‎packages/spec/src/api/events.zod.ts‎

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -257,17 +257,18 @@ export const DataEventSchema = lazySchema(() => z.object({
257257
*
258258
* **Absent = the record belongs to no organization.** Two situations, one
259259
* meaning:
260-
* - a `single`-posture deployment — `postureEnforcesWall(posture)` is
261-
* `false` and `postureStampsOrganization(posture)` with it (see
262-
* `@objectstack/spec/security`): there is no organization wall and
263-
* nothing stamps the column, so EVERY event is organization-less;
264-
* - a row that carries no organization under a walled posture (`group` /
265-
* `isolated`): a row of an object that stands outside the wall —
260+
* - a row of an object that stands outside the wall, under any posture —
266261
* `tenancy.enabled: false` by declaration, or no `organization_id`
267262
* column at all (the identity tables, and the deployment-level state of
268-
* ADR-0131 D7). An organization-less row on a tenant table is not a
269-
* third case: ADR-0131 D1 makes it invalid state, and no tenant-scoped
270-
* read reaches it (D8).
263+
* ADR-0131 D7);
264+
* - every row of a composition that registers no organization object:
265+
* nothing there has an organization to stamp.
266+
* A `single`-posture deployment is not a third case: every write to a
267+
* tenant table is stamped with its organization — on a single-organization
268+
* deployment, the Default Organization the engine derives (ADR-0131 D9) —
269+
* so its events carry it like a walled deployment's. Nor is an
270+
* organization-less row on a tenant table: ADR-0131 D1 makes it invalid
271+
* state, and no tenant-scoped read reaches it (D8).
271272
* In both, a consumer may read absence as "not behind any organization
272273
* wall" — the reading the read path gives a row of an object with no
273274
* tenant column. It may NOT read absence as "unknown, resolve it yourself":
@@ -282,8 +283,8 @@ export const DataEventSchema = lazySchema(() => z.object({
282283
* the empty string is refused, so "no organization" has exactly one
283284
* spelling — the key is absent.
284285
*
285-
* **Optional as a contract fact, not as a transition.** A `single`-posture
286-
* deployment stays organization-less for its whole life, so a required key
286+
* **Optional as a contract fact, not as a transition.** A row outside the
287+
* wall belongs to no organization for its whole life, so a required key
287288
* would either force a fabricated tenant there or leave the engine unable to
288289
* publish at all (the publish site `parse`s the event and drops it on
289290
* failure). Declared = enforced: this optionality is exactly what validation
@@ -295,10 +296,10 @@ export const DataEventSchema = lazySchema(() => z.object({
295296
organizationId: z.string().min(1).optional().describe(
296297
'Organization the record belongs to (its organization_id), so a tenant-scoped '
297298
+ 'consumer can discriminate the event\'s tenant without reading the record body. '
298-
+ 'Absent when the record belongs to no organization: every event on a single-posture '
299-
+ 'deployment (no organization wall, nothing stamps the column), and a row that '
300-
+ 'carries no organization under a walled posture (environment-wide, or an object '
301-
+ 'outside the wall) — read absence as '
299+
+ 'Absent when the record belongs to no organization: a row of an object outside the '
300+
+ 'wall (environment-wide, or no organization column), and every row of a composition '
301+
+ 'that registers no organization object. A single-posture deployment stamps every '
302+
+ 'tenant row with its Default Organization, so its events carry it — read absence as '
302303
+ '"not behind any organization wall", never as "unknown". Present = exactly that '
303304
+ 'organization; never fabricated, and the empty string is refused.',
304305
),

‎scripts/check-tenant-chokepoint.mjs‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,10 @@
133133
* a raw fragment (`whereRaw`, `orWhereRaw`, `andWhereRaw`, `havingRaw`, `raw`)
134134
* whose own literal text says `IS NULL` and that names the column — as a
135135
* template substitution, as a binding in its bindings array, or literally.
136-
* `IS NOT NULL` is not an arm and is never refused.
136+
* `IS NOT NULL` is not an arm and is never refused, in any spelling:
137+
* `whereNotNull`, a raw `IS NOT NULL`, and `where(field, 'is not', null)`,
138+
* which Knex compiles to `IS NOT NULL` (its operator is matched as Knex
139+
* matches it: lower-cased and trimmed).
137140
*
138141
* Exemptions: none (`NULL_ARM_EXEMPT` is empty), and the rule needs none on
139142
* `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']);
311314
/** `IS NULL`, never `IS NOT NULL` — the latter is not an arm. */
312315
const IS_NULL_TEXT = /\bis\s+null\b/i;
313316

317+
/**
318+
* Is `node` the literal operator `'is not'`? Knex lower-cases and trims the
319+
* operator, and compiles `where(col, 'is not', null)` to `IS NOT NULL` — not
320+
* an arm. Only a literal is read: an operator the gate cannot see is refused.
321+
*/
322+
function isNotNullOperator(node) {
323+
return !!node && ts.isStringLiteralLike(node) && node.text.toLowerCase().trim() === 'is not';
324+
}
325+
314326
/**
315327
* `'<file>::<method>'` -> reason a reader can check. Empty by design: the rule
316328
* needs no exemption on `main`, and an entry is a decision against ADR-0131
@@ -394,7 +406,12 @@ function nullArmOf(call, bindings) {
394406
const args = call.arguments;
395407
if (NULL_CALLS.has(verb) && isTenantRef(args[0], bindings)) return `${verb}(<tenant column>)`;
396408
if (WHERE_CALLS.has(verb)) {
397-
if (args.length >= 2 && isTenantRef(args[0], bindings) && isNullLiteral(args[args.length - 1])) {
409+
if (
410+
args.length >= 2 &&
411+
isTenantRef(args[0], bindings) &&
412+
isNullLiteral(args[args.length - 1]) &&
413+
!(args.length === 3 && isNotNullOperator(args[1]))
414+
) {
398415
return `${verb}(<tenant column>, …null)`;
399416
}
400417
const obj = args[0];
@@ -629,7 +646,7 @@ const SELF_TEST_VERDICT = 'check-tenant-chokepoint self-test reached its verdict
629646
// to find what stopped registering.
630647
const SELF_TEST_BATTERIES = Object.freeze({
631648
'check-tenant-chokepoint self-test': 17,
632-
'D8 null-arm rule': 22,
649+
'D8 null-arm rule': 24,
633650
});
634651

635652
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -865,6 +882,13 @@ function selfTest() {
865882
'a raw fragment naming the literal tenant column',
866883
`census(table) { return this.knex.raw('select * from ?? where organization_id is null', [table]); }`,
867884
],
885+
[
886+
'an operator the gate cannot read stays refused — only the literal is not is exempt',
887+
`scope(builder, object, op) {
888+
const f = this.resolveTenantField(object);
889+
return builder.orWhere(f, op, null);
890+
}`,
891+
],
868892
[
869893
'a module-level helper outside the class is no hiding place',
870894
`x() {}
@@ -910,6 +934,13 @@ function selfTest() {
910934
'a null comparand on an ordinary column',
911935
`clear(qb) { return qb.where('deleted_at', null); }`,
912936
],
937+
[
938+
"the three-argument IS NOT NULL form, where(col, 'is not', null), in Knex's own casing tolerance",
939+
`scope(builder, object) {
940+
const field = this.resolveTenantField(object);
941+
return builder.where(field, 'is not', null).andWhere('organization_id', ' IS NOT ', null);
942+
}`,
943+
],
913944
];
914945
for (const [name, src] of nullArmSilent) {
915946
const found = arms(src);

0 commit comments

Comments
 (0)