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
16 changes: 16 additions & 0 deletions .changeset/25203-adr-0131-small-carriers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-sharing': patch
'@objectstack/plugin-security': patch
'@objectstack/cli': patch
'@objectstack/spec': patch
---

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

Clause-②: no
Metadata-protocol: none

- **`@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.
- **`@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.
- **`@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 …)".
- **`@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.
6 changes: 4 additions & 2 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ now costs nothing, and the population is **re-derivable** rather than quoted.
`auditMissingTenant(object, op, options)` is gated in this order:

1. `OS_TENANT_AUDIT=0` — off entirely;
2. `options.bypassTenantAudit` — the engine sets this for **every**
`ExecutionContext.isSystem` write (ObjectQL's `buildDriverOptions`);
2. `options.bypassTenantAudit` — on an `ExecutionContext.isSystem` write the
engine sets it only where the object's schema puts the write out of scope:
an object with no tenant field, or a federated one (ObjectQL's
`buildDriverOptions`). A caller may still pass it explicitly;
3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`;
4. the deployment posture — the warning is only meaningful on a walled install;
5. the object having a tenant field.
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/schedule-organization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ multi-plant MES example is the ADR's own). It does not.
`sys_automation_run` has ALREADY been ruled to use: `ObjectStoreSuspendedRunStore`
resolves a run's organization as `organizationOf(<subject record>) ?? ctx.tenantId`
— subject first, the acting context as the fallback and *never* the primary
(`objectql/src/tenancy/platform-object-tenancy.ts`, the `sys_automation_run`
evidence line). Before this card the two halves disagreed under `group`: the
(`service-automation/src/suspended-run-store.ts`, where the store stamps a
run's row). Before this card the two halves disagreed under `group`: the
history row was stamped from the record while the inbox and delivery rows
followed an acting context that could not exist there, so they were refused.
Filling the acting context from the record makes one run carry ONE
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ export default class OrganizationOwnershipCeremony extends Command {
printWarning(
`${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id, with why, in the plan ` +
'file. The ceremony neither guesses their owner nor deletes them, and --apply does not certify the database ' +
'while one stays NULL (but on a table whose column is not the tenancy anchor): assign each its ' +
'while one stays NULL, except on a table whose column is not the tenancy anchor. Assign each its ' +
'organization, or delete it by hand, before or after --apply, and re-run it.',
);
}
Expand Down Expand Up @@ -1121,7 +1121,7 @@ export default class OrganizationOwnershipCeremony extends Command {
}
console.log('');
// Unattributable rows hold certification (ruling C on ADR-0131 D10 fate
// 4) but on a table whose column is not the tenancy anchor; `advisory`
// 4), except on a table whose column is not the tenancy anchor; `advisory`
// counts those and the advisory departures, each said in its own terms.
const unattributableHeld = report.tables.reduce((n, t) => n + (t.held?.rows ?? 0), 0);
const unattributableReported = report.tables.reduce((n, t) => n + t.unattributable.length, 0) - unattributableHeld;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/organization-ownership-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3801,7 +3801,7 @@ export const UNATTRIBUTABLE_REMEDY =

/**
* Unattributable rows the post-check holds against certification: every one,
* but on a fate-4 table whose column is not the tenancy anchor
* except on a fate-4 table whose column is not the tenancy anchor
* (`InventoryEntry.notNullExempt`), where an organization-less row is a
* legitimate one and is reported only.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export const SysUserPermissionSet = ObjectSchema.create({
organization_id: Field.lookup('sys_organization', {
label: 'Organization',
required: false,
description: 'Optional organization scope. NULL = applies in every org context.',
description: 'The organization this grant belongs to.',
}),

// Provenance, not a business link: the platform writes it, a caller does
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './res
*
* `position` stores the position's machine name (matches
* `sys_position.name`), mirroring how `ctx.positions` is keyed everywhere
* downstream. `organization_id = null` means a cross-tenant (global)
* assignment.
* downstream. `organization_id` is the organization the assignment belongs
* to: it applies only while that organization is the active one, and an
* organization-less row applies nowhere (ADR-0131 D1).
*
* `business_unit_id` is the ASSIGNMENT-LEVEL BU anchor (ADR-0090 Addendum;
* reserved by ADR-0057 D4). Positions never bind to a business unit at the
Expand Down Expand Up @@ -107,7 +108,7 @@ export const SysUserPosition = ObjectSchema.create({
organization_id: Field.lookup('sys_organization', {
label: 'Organization',
required: false,
description: 'Tenant that owns this assignment; null = global (cross-tenant).',
description: 'The organization this assignment belongs to.',
}),

// Provenance, not a business link — the same contract as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organization",
help: "Optional organization scope. NULL = applies in every org context."
help: "The organization this grant belongs to."
},
granted_by: {
label: "Granted By",
Expand Down Expand Up @@ -104,7 +104,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organization",
help: "Tenant that owns this assignment; null = global (cross-tenant)."
help: "The organization this assignment belongs to."
},
granted_by: {
label: "Granted By",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organización",
help: "Ámbito de organización opcional. NULL = se aplica en cualquier contexto de organización."
help: "La organización a la que pertenece esta asignación."
},
granted_by: {
label: "Concedido por",
Expand Down Expand Up @@ -104,7 +104,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "Organización",
help: "Inquilino propietario de esta asignación; nulo = global (entre inquilinos)."
help: "La organización a la que pertenece esta asignación."
},
granted_by: {
label: "Otorgado por",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "組織",
help: "オプションの組織スコープ。NULL = すべての組織コンテキストで適用。"
help: "この付与が属する組織。"
},
granted_by: {
label: "付与者",
Expand Down Expand Up @@ -104,7 +104,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "組織",
help: "この割り当てを所有するテナント。Null = グローバル(テナント横断)。"
help: "この割り当てが属する組織。"
},
granted_by: {
label: "付与者",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "组织",
help: "可选的组织范围。NULL = 在所有组织上下文中都生效。"
help: "此授权所属的组织。"
},
granted_by: {
label: "授权人",
Expand Down Expand Up @@ -104,7 +104,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
organization_id: {
label: "组织",
help: "拥有此分配的租户;为空 = 全局(跨租户)。"
help: "此分配所属的组织。"
},
granted_by: {
label: "授予人",
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,15 @@ const DECLARED_RULE_ORGANIZATION_PAGE = 500;
* [ADR-0131 D2] The scopes declared sharing rules are served in
* (`SharingRuleService.setDeclaredRuleScopes`): exactly one organization-less
* scope under `single`, and under a wall EVERY organization, read by a keyset
* walk on `id` until a short page. These are the passes the retired seeder
* made, as reads (#10103).
* walk on `id` until a short page — short as READ, never as kept: a page that
* came back full is followed, whatever it held. These are the passes the
* retired seeder made, as reads (#10103).
*
* `null` when the enumeration failed at any page: an unreadable
* `sys_organization` means "we do not know", never "there are no tenants" —
* and never a partial list, because the scopes decide which name-keyed grants
* the boot sweep keeps. The caller marks the scopes UNKNOWN
* `null` when the enumeration failed at any page, or a page answered a row
* whose `id` the walk cannot read: an unreadable `sys_organization` means "we
* do not know", never "there are no tenants" — and never a partial list,
* because the scopes decide which name-keyed grants the boot sweep keeps. The
* caller marks the scopes UNKNOWN
* (`SharingRuleService.setDeclaredRuleScopesUnknown`): no declared rule is
* served, and no declared rule's grant is revoked.
*/
Expand Down Expand Up @@ -116,11 +118,22 @@ export async function resolveDeclaredRuleScopes(
'and restart.', { error: e?.message, read: ids.length });
return null;
}
const page = (Array.isArray(rows) ? rows : [])
const read: any[] = Array.isArray(rows) ? rows : [];
const page = read
.map((r: any) => r?.id)
.filter((id: unknown): id is string => typeof id === 'string' && id !== '');
// A row without a readable id is a failed read, not a shorter page: judged
// on the kept ids, a full page holding one would end the walk early and
// answer the partial list this function never answers.
if (page.length !== read.length) {
logger?.warn?.('SharingServicePlugin: sys_organization answered a row with no readable id — declared ' +
'sharing rules are served in NO organization until the next boot, and no declared rule\'s grant is ' +
'revoked meanwhile (the standing grants keep answering). Remedy: give every sys_organization row ' +
'its id and restart.', { read: ids.length });
return null;
}
ids.push(...page);
if (page.length < DECLARED_RULE_ORGANIZATION_PAGE) return ids;
if (read.length < DECLARED_RULE_ORGANIZATION_PAGE) return ids;
after = page[page.length - 1];
}
}
Expand Down
14 changes: 14 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2370,6 +2370,20 @@ describe('[ADR-0131 D2] resolveDeclaredRuleScopes', () => {
expect(engine.calls()).toBe(3);
});

it('a FULL page holding a row with no readable id answers unknown (null), never the partial list its kept ids would end on', async () => {
// The end of the walk is judged on the rows READ: 500 rows with one id
// filtered out is a full page, not a short one.
const engine = organizations(1201);
const find = engine.find;
engine.find = async (o: string, opts?: any) => {
const rows = await find(o, opts);
return opts?.where ? rows : rows.map((r: Row, i: number) => (i === 9 ? { ...r, id: undefined } : r));
};
const warns: string[] = [];
expect(await resolveDeclaredRuleScopes(engine, 'isolated', { warn: (m) => warns.push(m) })).toBeNull();
expect(warns).toEqual([expect.stringMatching(/no readable id — .*no declared rule's grant is revoked/)]);
});

it('a read that fails on a LATER page answers unknown (null), never the partial list, and says so', async () => {
const warns: string[] = [];
const scopes = await resolveDeclaredRuleScopes(organizations(1201, 2), 'isolated', { warn: (m) => warns.push(m) });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
*
* ## What was measured
* On a walled deployment (`OS_TENANCY_POSTURE=isolated`) the SQL driver's
* `auditMissingTenant` gate printed, for both delivery objects:
* `auditMissingTenant` gate printed, for both delivery objects, a warning
* that opens:
*
* [tenant-audit] updateMany on tenant-scoped object "sys_http_delivery"
* without options.tenantId — writes will not be tenant-isolated.
* without options.tenantId — …
* [tenant-audit] updateMany on tenant-scoped object "sys_notification_delivery"
* without options.tenantId — writes will not be tenant-isolated.
* without options.tenantId — …
*
* What follows the dash is the driver's prescription, and it has been
* reworded since; this file asserts only the opening up to the object name.
*
* The audit is right that the writes are environment-wide. The fix is the
* classification it demands, not the quiet: each `multi: true` write on the
Expand Down
10 changes: 0 additions & 10 deletions packages/spec/scripts/liveness/empty-state-registry.mts
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,6 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [
evidence: 'packages/spec/src/security/sharing.zod.ts; packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts (criteria_json required: true, ADR-0113); packages/plugins/plugin-sharing/src/rule-hooks.ts (shape guard)',
},

{
file: 'packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts',
property: 'organization_id',
semantics: 'open',
rationale:
"A NULL organization scope on a user↔permission-set grant means the grant is UNSCOPED — it applies in every org context. Deliberate and load-bearing rather than an oversight: ADR-0095 D3 / ADR-0068 D2 DERIVE the platform_admin posture from an unscoped `admin_full_access` user grant specifically, and an org-SCOPED grant of the same set must not confer it. So the empty state is not merely wider, it is the distinguishing input to the highest privilege in the system — which is exactly why it belongs on this list rather than being left to memory. `explain-engine.ts` recomputes the identical predicate on purpose so the explain panel's posture cannot sit higher than enforcement's.",
evidence:
'packages/core/src/security/resolve-authz-context.ts (resolveAuthzContext.hasPlatformAdminGrant — the single source of truth) and packages/plugins/plugin-security/src/explain-engine.ts (the identical predicate, replicated so the panel cannot overstate)',
},

{
file: 'packages/spec/src/automation/approval.zod.ts',
property: 'minApprovals',
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/automation/schedule-organization.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ import { z } from 'zod';
* `sys_automation_run` has ALREADY been ruled to use: `ObjectStoreSuspendedRunStore`
* resolves a run's organization as `organizationOf(<subject record>) ?? ctx.tenantId`
* — subject first, the acting context as the fallback and *never* the primary
* (`objectql/src/tenancy/platform-object-tenancy.ts`, the `sys_automation_run`
* evidence line). Before this card the two halves disagreed under `group`: the
* (`service-automation/src/suspended-run-store.ts`, where the store stamps a
* run's row). Before this card the two halves disagreed under `group`: the
* history row was stamped from the record while the inbox and delivery rows
* followed an acting context that could not exist there, so they were refused.
* Filling the acting context from the record makes one run carry ONE
Expand Down
7 changes: 5 additions & 2 deletions packages/spec/src/contracts/email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ export interface SendEmailInput {
* export and cleanup script that filters by organization, which is worse
* than a null. Producers that already hold one (e.g. the messaging email
* channel's `delivery.notification.organizationId`) thread it here; omit
* it when the caller genuinely has none — absent stays legal and the row
* is simply unstamped. Mail about a person rather than an organization
* it when the caller genuinely has none — absent stays legal, and the
* engine then stamps the one organization it can derive for the row or
* refuses the insert where it can derive none (ADR-0131 D9); the row stays
* unstamped only in a composition that registers no organization object.
* Mail about a person rather than an organization
* (auth verification, password reset) is {@link identityMail} instead. Forward-stamping only; pre-existing org-less rows are not
* backfilled.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export const entry: SemanticMigration = {
// No backticks in `surface`: build-upgrade-guide.ts renders it inside a
// code span AND a table cell.
surface:
'ObjectQL find, findOne, count, aggregate and the update and delete predicates on an object '
+ 'with an organization column, called with an execution context that names an organization: '
'ObjectQL find, findOne, count, aggregate, and update and delete by id or by predicate, on an '
+ 'object with an organization column, called with an execution context that names an organization: '
+ 'the DriverOptions tenantId and tenantIds the engine hands every driver',
replacement:
'the driver read scope is the organization wall\'s verdict (computeTenantLayer0Verdict, '
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ export const entry: SemanticMigration = {
+ 'standing access is orphaned — and only then can the ceremony\'s verified marker mean zero NULL '
+ 'rows. The seeder is machinery, not an authorable shape: no key of any spec schema moves, and a '
+ 'stack authors `sharingRules` exactly as before, so no stored-metadata rewrite exists and this '
+ 'entry is the retirement note and the notification channel (ADR-0131 D13: each retirement is an '
+ 'ADR-0087 entry). ADR-0131 D2, D10, D13; ADR-0057 D6 (the runtime shape is canonical).',
+ 'entry is the retirement note and the notification channel (registered by choice: ADR-0131 D13 '
+ 'asks an ADR-0087 entry of every retirement of an authorable shape, and the seeder is not one). '
+ 'ADR-0131 D2, D10, D13; ADR-0057 D6 (the runtime shape is canonical).',
acceptanceCriteria:
'A fresh boot in every posture writes no `sys_sharing_rule` row for a declared rule and the rule '
+ 'still grants; removing the declaration stops its grant; no consumer deletes a declared rule '
Expand Down
Loading
Loading