Skip to content

Commit 24360dc

Browse files
fix(plugin-sharing): the declared-rule organization walk ends on the rows read; ADR-0131 small carriers retire the NULL-means-global texts (#25266)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg3X7qDVRkhCrq8R4os9DMy3)_ Part of #25203 Part of #15212 Clause-②: no Metadata-protocol: none Claim 6346494581 (`domain:engine` seat 2, card thread `session_01M7Q2xAsaBG8KpAYgUxtSdU`). Items 1, 2, 3, 6, 7 and 8 of the card. Item 4 landed with #25210. Item 5 is a governed `.claude/**` line and goes to the skills lane. Items 9 and 10 (comment 6345275520) wait on #25246 and are not in this claim. So this PR is `Part of #25203`, not `Fixes`: the card stays open for items 5, 9 and 10 (seat correction after the contract review). ## What changed **Item 1, the one behaviour change: `resolveDeclaredRuleScopes` (`packages/plugins/plugin-sharing/src/sharing-plugin.ts`).** - Before: the keyset walk over `sys_organization` filtered each page to string ids, then tested the FILTERED page for "short page, stop". A full page of 500 rows holding one row with no readable `id` read as short, so the walk ended there and answered a partial list. The boot sweep then revoked the declared-rule grants of every organization past that page. - Now: the walk ends on the number of rows READ. A page holding a row whose `id` the walk cannot read answers unknown (`null`), with a warning that names the cause, as the epic's plan amendment 6254164222 prescribes ("treat a filtered-out id as a failed read"). The caller already treats `null` as UNKNOWN: no declared rule is served, and no declared rule's grant is revoked. - Hardening, not a reachable defect: `sys_organization.id` is a text field, so no shipped install produces such a row. - Pin: `sharing-rule.test.ts`, "a FULL page holding a row with no readable id answers unknown (null) …". 1201 organizations, row 10 of the first page loses its id. **Item 2, wording.** - `18.sharing-rule-declared-mirror-retired.ts` `reason`: the D13 paraphrase. ADR-0131 D13 asks an ADR-0087 entry of every retirement **of an authorable shape**. The text now says this entry is registered by choice, because the seeder is not one. (The reconciliation's suggested text named a card number; the repo's runtime-string rule keeps tracker numbers out of shipped text, so the sentence carries the reason instead.) - `18.driver-read-scope-follows-tenant-wall.ts` `surface`: names update and delete "by id or by predicate". - `packages/cli/src/commands/migrate/organization-ownership.ts`: the printed warning "(but on a table whose column is not the tenancy anchor): assign …" now reads ", except on a table whose column is not the tenancy anchor. Assign …". The same idiom is fixed in the comment above `unattributableHeld` and in the `heldUnattributable` docblock (`organization-ownership-apply.ts`). **Item 3, texts of the retired NULL-means-global reading.** - `sys-user-position.object.ts`: the docblock and the Organization help. An assignment applies only while its organization is the active one, and an organization-less row applies nowhere (`grantAppliesInTenant`, `packages/core/src/security/resolve-authz-context.ts`). - `sys-user-permission-set.object.ts`: the Organization help ("Optional organization scope. NULL = applies in every org context." → "The organization this grant belongs to."). - `plugin-security`'s `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. - "simply unstamped": the text lives in `packages/spec/src/contracts/email-service.ts` (the `IEmailService` `organizationId` TSDoc), not in `plugin-email`. It now says the engine stamps the organization it can derive or refuses the insert (ADR-0131 D9), and leaves the row unstamped only in a composition with no organization object. - `packages/verify/src/harness.ts`, `inviteForAudienceGate`: the dedicated organization id hides these invitations from fixtures under a wall only. On `single` an organization owner's read returns them too. - Forced companion: `packages/spec/scripts/liveness/empty-state-registry.mts` loses its `sys_user_permission_set.organization_id` row. That row classified the "NULL = applies in every org context" statement this card removes, and `check:empty-state` reds `STALE — registered, but the statement is gone` without the removal (measured). **Item 6.** `schedule-organization.zod.ts` cited the deleted `objectql/src/tenancy/platform-object-tenancy.ts` ledger. It now cites `service-automation/src/suspended-run-store.ts`, where the store stamps a run's row. `content/docs/references/automation/schedule-organization.mdx` was regenerated (`check:generated --fix`, then re-checked green). **Item 7.** `content/docs/permissions/tenant-audit-census.mdx`, the gate order's second item: the engine sets `bypassTenantAudit` on an `isSystem` write only where the object's schema puts it out of scope (no tenant field, or federated), as `buildDriverOptions` does today. **Item 8.** `delivery-claim-tenant-audit.integration.test.ts`: the docblock quoted the driver's old warning tail. It now quotes the opening and says the prescription after the dash has been reworded. The assertions are unchanged. ## Tests At head `0a71e50d7`: - `plugin-sharing` `pnpm test`: 53 files, 1201 tests passed. - `plugin-security` `pnpm test`: 208 files, 4274 passed, 45 skipped. - `cli` unit tier, `organization-ownership` files: 8 files, 76 passed. - `spec` `src/migrations` + `scripts/liveness`: 17 files, 579 passed. - `typecheck`: plugin-sharing, plugin-security, cli, verify, service-messaging, spec, all exit 0. - Ablation of item 1 (`scripts/ablation-replace.mjs`, the old filtered-page block put back): the new pin went red (1 failed, 5 passed), every other `resolveDeclaredRuleScopes` test stayed green. Restore verified: blob equal to `HEAD`, `git diff HEAD` empty. The suite imports `./sharing-plugin.js` from `src`, so no `dist` leg applies. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `0a71e50d7` derived 124 families. `--ran` reconciliation: 122 run, all exit 0; 2 NOT MEASURED (exit 3, prerequisite not met on this checkout), left to CI: `check:dual-build-cjs-loads` (needs a whole-workspace build) and `check:i18n-coverage` (one of 14 configs could not lint here). `check-plugin-teardown-shape --self-test` and `check:skill-examples` first exited 3 on missing prerequisites (a pinned fixture commit, the client builds) and exited 0 once those were fetched and built. ## Acceptance notes Published text, per item: - Item 1: `@objectstack/plugin-sharing` behaviour and a new warning string. Published. - Item 2: the two semantic entries ship in `@objectstack/spec`'s upgrade guide and registry. Published. No check reads their wording, and `check:upgrade-guide` / `check:spec-changes` are green. The cli warning is a runtime string in `@objectstack/cli`. Published. The two cli comments are not. - Item 3: the two `description`s and the four locale bundles ship in `@objectstack/plugin-security`. Published. The `IEmailService` TSDoc ships in `@objectstack/spec`'s `.d.ts`. Published. The position docblock and the `verify` comment are not published. - Item 6: the TSDoc is in a `*.zod.ts`, which `@objectstack/spec` ships verbatim (`files` includes `src/**/*.zod.ts`). Published. The reference page is docs. - Items 7 and 8: docs and a test comment. Not published. - One `patch` changeset covers the four published packages (`Metadata-protocol: none`). Not changed here (no drive-by): the `sys-user-permission-set.object.ts` docblock still calls an organization-less grant "platform-wide" when it explains the unique index's NULL-safety; `tenant-audit-census.mdx` keeps its historical narrative about the old mute further down. Neither is named by the card. Cross-lane declarations (not claims): `domain:cli` on #22648 (6346689477), `domain:devx` on #20163 (6346705267, for `content/docs`, which the lane table assigns to devx), `domain:spec` on #18549 (6346927889), `domain:services` on #21118 (6346947858, `plugin-sharing`, `plugin-security`, `service-messaging`). --- _Generated by [Claude Code](https://claude.ai/code/session_01M7Q2xAsaBG8KpAYgUxtSdU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc8d886 commit 24360dc

20 files changed

Lines changed: 96 additions & 50 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/cli': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
fix(plugin-sharing): the declared-rule organization walk ends on the rows read, and a row with no readable id answers unknown; texts that still described the retired NULL-organization reading are corrected
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
- **`@objectstack/plugin-sharing`.** The boot's organization walk for declared sharing-rule scopes (`resolveDeclaredRuleScopes`) judged "short page, stop" on the ids it kept, so a full page of `sys_organization` holding one row with no readable `id` ended the walk early and served a partial list, and the boot sweep then revoked the declared-rule grants of every organization past it. The walk now ends on the number of rows READ, and such a row makes the answer unknown: declared rules are served in no organization until the next boot and no declared rule's grant is revoked, with a warning that names the cause. Under the shipped schema every organization row has a text id, so no install is expected to see the new warning.
14+
- **`@objectstack/plugin-security`.** The Organization help on `sys_user_position` and `sys_user_permission_set` no longer says an empty value means "global" or "applies in every org context". A grant or assignment applies only in its own organization, and one with no organization applies nowhere. The four locale bundles carry the new text.
15+
- **`@objectstack/cli`.** `os migrate organization-ownership`'s warning about rows whose owner cannot be derived reads "…while one stays NULL, except on a table whose column is not the tenancy anchor." where it read "(but on a table …)".
16+
- **`@objectstack/spec`.** Wording only, no schema moves: the upgrade guide's `driver-read-scope-follows-tenant-wall` entry names update and delete "by id or by predicate"; the `sharing-rule-declared-mirror-retired` entry no longer paraphrases ADR-0131 D13 as asking an entry of every retirement; `IEmailService`'s `organizationId` TSDoc no longer says an absent value leaves the row unstamped (the engine stamps the derivable organization or refuses the insert); a TSDoc citation in `schedule-organization.zod.ts` names the run store instead of a deleted file.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ now costs nothing, and the population is **re-derivable** rather than quoted.
4646
`auditMissingTenant(object, op, options)` is gated in this order:
4747

4848
1. `OS_TENANT_AUDIT=0` — off entirely;
49-
2. `options.bypassTenantAudit` — the engine sets this for **every**
50-
`ExecutionContext.isSystem` write (ObjectQL's `buildDriverOptions`);
49+
2. `options.bypassTenantAudit` — on an `ExecutionContext.isSystem` write the
50+
engine sets it only where the object's schema puts the write out of scope:
51+
an object with no tenant field, or a federated one (ObjectQL's
52+
`buildDriverOptions`). A caller may still pass it explicitly;
5153
3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`;
5254
4. the deployment posture — the warning is only meaningful on a walled install;
5355
5. the object having a tenant field.

‎content/docs/references/automation/schedule-organization.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ multi-plant MES example is the ADR's own). It does not.
7676
`sys_automation_run` has ALREADY been ruled to use: `ObjectStoreSuspendedRunStore`
7777
resolves a run's organization as `organizationOf(<subject record>) ?? ctx.tenantId`
7878
— subject first, the acting context as the fallback and *never* the primary
79-
(`objectql/src/tenancy/platform-object-tenancy.ts`, the `sys_automation_run`
80-
evidence line). Before this card the two halves disagreed under `group`: the
79+
(`service-automation/src/suspended-run-store.ts`, where the store stamps a
80+
run's row). Before this card the two halves disagreed under `group`: the
8181
history row was stamped from the record while the inbox and delivery rows
8282
followed an acting context that could not exist there, so they were refused.
8383
Filling the acting context from the record makes one run carry ONE

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ export default class OrganizationOwnershipCeremony extends Command {
632632
printWarning(
633633
`${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id, with why, in the plan ` +
634634
'file. The ceremony neither guesses their owner nor deletes them, and --apply does not certify the database ' +
635-
'while one stays NULL (but on a table whose column is not the tenancy anchor): assign each its ' +
635+
'while one stays NULL, except on a table whose column is not the tenancy anchor. Assign each its ' +
636636
'organization, or delete it by hand, before or after --apply, and re-run it.',
637637
);
638638
}
@@ -1121,7 +1121,7 @@ export default class OrganizationOwnershipCeremony extends Command {
11211121
}
11221122
console.log('');
11231123
// Unattributable rows hold certification (ruling C on ADR-0131 D10 fate
1124-
// 4) but on a table whose column is not the tenancy anchor; `advisory`
1124+
// 4), except on a table whose column is not the tenancy anchor; `advisory`
11251125
// counts those and the advisory departures, each said in its own terms.
11261126
const unattributableHeld = report.tables.reduce((n, t) => n + (t.held?.rows ?? 0), 0);
11271127
const unattributableReported = report.tables.reduce((n, t) => n + t.unattributable.length, 0) - unattributableHeld;

‎packages/cli/src/utils/organization-ownership-apply.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3801,7 +3801,7 @@ export const UNATTRIBUTABLE_REMEDY =
38013801

38023802
/**
38033803
* Unattributable rows the post-check holds against certification: every one,
3804-
* but on a fate-4 table whose column is not the tenancy anchor
3804+
* except on a fate-4 table whose column is not the tenancy anchor
38053805
* (`InventoryEntry.notNullExempt`), where an organization-less row is a
38063806
* legitimate one and is reported only.
38073807
*/

‎packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const SysUserPermissionSet = ObjectSchema.create({
110110
organization_id: Field.lookup('sys_organization', {
111111
label: 'Organization',
112112
required: false,
113-
description: 'Optional organization scope. NULL = applies in every org context.',
113+
description: 'The organization this grant belongs to.',
114114
}),
115115

116116
// Provenance, not a business link: the platform writes it, a caller does

‎packages/plugins/plugin-security/src/objects/sys-user-position.object.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './res
1616
*
1717
* `position` stores the position's machine name (matches
1818
* `sys_position.name`), mirroring how `ctx.positions` is keyed everywhere
19-
* downstream. `organization_id = null` means a cross-tenant (global)
20-
* assignment.
19+
* downstream. `organization_id` is the organization the assignment belongs
20+
* to: it applies only while that organization is the active one, and an
21+
* organization-less row applies nowhere (ADR-0131 D1).
2122
*
2223
* `business_unit_id` is the ASSIGNMENT-LEVEL BU anchor (ADR-0090 Addendum;
2324
* reserved by ADR-0057 D4). Positions never bind to a business unit at the
@@ -107,7 +108,7 @@ export const SysUserPosition = ObjectSchema.create({
107108
organization_id: Field.lookup('sys_organization', {
108109
label: 'Organization',
109110
required: false,
110-
description: 'Tenant that owns this assignment; null = global (cross-tenant).',
111+
description: 'The organization this assignment belongs to.',
111112
}),
112113

113114
// Provenance, not a business link — the same contract as

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
3838
},
3939
organization_id: {
4040
label: "Organization",
41-
help: "Optional organization scope. NULL = applies in every org context."
41+
help: "The organization this grant belongs to."
4242
},
4343
granted_by: {
4444
label: "Granted By",
@@ -104,7 +104,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
104104
},
105105
organization_id: {
106106
label: "Organization",
107-
help: "Tenant that owns this assignment; null = global (cross-tenant)."
107+
help: "The organization this assignment belongs to."
108108
},
109109
granted_by: {
110110
label: "Granted By",

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
3838
},
3939
organization_id: {
4040
label: "Organización",
41-
help: "Ámbito de organización opcional. NULL = se aplica en cualquier contexto de organización."
41+
help: "La organización a la que pertenece esta asignación."
4242
},
4343
granted_by: {
4444
label: "Concedido por",
@@ -104,7 +104,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
104104
},
105105
organization_id: {
106106
label: "Organización",
107-
help: "Inquilino propietario de esta asignación; nulo = global (entre inquilinos)."
107+
help: "La organización a la que pertenece esta asignación."
108108
},
109109
granted_by: {
110110
label: "Otorgado por",

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
3838
},
3939
organization_id: {
4040
label: "組織",
41-
help: "オプションの組織スコープ。NULL = すべての組織コンテキストで適用。"
41+
help: "この付与が属する組織。"
4242
},
4343
granted_by: {
4444
label: "付与者",
@@ -104,7 +104,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
104104
},
105105
organization_id: {
106106
label: "組織",
107-
help: "この割り当てを所有するテナント。Null = グローバル(テナント横断)。"
107+
help: "この割り当てが属する組織。"
108108
},
109109
granted_by: {
110110
label: "付与者",

0 commit comments

Comments
 (0)