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
53 changes: 53 additions & 0 deletions .changeset/19461-admin-scope-business-unit-blank-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@objectstack/spec': minor
---

**BREAKING for authored metadata** — a delegated-admin scope's `businessUnit` (`AdminScopeSchema`, reached as `adminScope.businessUnit` on a permission set) must now name a business unit. An empty or whitespace-only value is refused at parse, at the key's own path, with a message naming what a valid anchor is: the `sys_business_unit.name` of the root of the delegated subtree (#19461).

Clause-②: yes

Maintainer ruling A on decision batch #217 item 1, 2026-09-23 「217 同意」.

## What changed, and why

`businessUnit` is the scope's one required key, and every other key of the scope is scoped to it. It was declared as a bare string with no minimum, so `{ businessUnit: '' }` and `{ businessUnit: ' ' }` parsed green: the requirement was satisfied by a value that names no business unit. The delegated-admin gate looks the anchor up by exact name, so a blank anchor resolved to an empty subtree. No escalation was measured; the defect is a declaration that did not enforce what it declared. The likeliest author of a blank anchor is an AI that knew the key was required and did not yet know the unit, and until now the platform answered "accepted".

```
FROM AdminScopeSchema.safeParse({ businessUnit: '' })
-> { success: true }

TO AdminScopeSchema.safeParse({ businessUnit: '' })
-> { success: false,
issues: [{ code: 'custom', path: ['businessUnit'],
message: 'A blank businessUnit is not a delegation boundary: businessUnit is
the sys_business_unit.name (machine name) of the business unit at
the root of the subtree this scope delegates, …' }] }
```

The refusal is a non-transforming refinement: nothing is trimmed. The metadata save path stores the submitted body as written, so a trimming schema would validate one string and store another. A real name parses byte-identical. The published JSON Schema states the same rule (`minLength: 1` and a non-whitespace `pattern`).

## Migration — FROM → TO

| You wrote | Write instead |
| --- | --- |
| `adminScope: { businessUnit: '' }` | `adminScope: { businessUnit: 'north_america' }`, using the machine name of the unit at the root of the subtree |
| `adminScope: { businessUnit: ' ' }` (or a tab or newline) | the same: the root unit's `sys_business_unit.name` |
| a blank-anchored scope on a set that should not delegate at all | remove `adminScope` from the permission set |

**The one-line fix: set `businessUnit` to the `sys_business_unit.name` of the business unit at the root of the delegated subtree, or remove `adminScope` if the set should not delegate administration.** This cannot be converted automatically, because a blank names no unit and the intended root cannot be inferred. So this ships as an ADR-0087 D3 structured TODO with **no D2 conversion**.

<!-- adr-0087: registered admin-scope-business-unit-blank-refused -->

## Stored permission sets

- **Stored scopes are not rewritten.** Reads do not re-validate stored rows, so no stored permission set becomes unreadable. A stored blank anchor loads and resolves exactly as before.
- **The next write refuses it.** A Setup or data-door edit of a permission set whose stored scope has a blank anchor answers `422 INVALID_METADATA` naming `adminScope.businessUnit`, until the anchor is named or the scope removed.
- **The boot reconciliation backfill reports it.** A legacy `sys_permission_set` record with no metadata definition and a blank anchor is not backfilled. It is reported on every boot through the existing ADR-0094 D4 durability `ERROR`, which names the record and the offending key, until the record is fixed or deleted. Restoring a trashed blank-anchored set brings the record back and reports the missing definition at `ERROR` the same way. No path skips the row.
- **A clean boot is not a completed sweep.** A definition already stored in `sys_metadata` says nothing until it is written again, so search stored permission sets and the `admin_scope` column for a blank `businessUnit`.

## What does NOT change

- **An absent `businessUnit`** is refused exactly as before, with its own `invalid_type` issue.
- **A real name with surrounding whitespace** is not judged by this change. It parses and is stored byte-identical.
- **The other keys of the scope** (`includeSubtree`, `manageAssignments`, `manageBindings`, `authorEnvironmentSets`, `assignablePermissionSets`) are untouched.
- **The published export surface.** No export is added, removed or renamed, and the `AdminScope` / `AdminScopeParsed` types are unchanged.
4 changes: 2 additions & 2 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const result = AdminScopeSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root |
| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root. Required and non-blank: an empty or whitespace-only value names no business unit and is refused. |
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
Expand Down Expand Up @@ -178,7 +178,7 @@ const result = AdminScopeSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root |
| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root. Required and non-blank: an empty or whitespace-only value names no business unit and is refused. |
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// The delegated-admin scope's one required key, required NON-BLANK. Stored
// scopes are deliberately not rewritten: the root of a subtree cannot be
// inferred from a blank, so the stored row is refused on its next write and
// named by the boot reconciliation's existing durability error instead.
export const entry: SemanticMigration = {
id: 'admin-scope-business-unit-blank-refused',
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
// code span already, and a nested backtick would close it.
surface:
'security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored '
+ 'BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). '
+ 'The key is the scope\'s only required one and names the root business unit of the delegated '
+ 'subtree; a blank value satisfied the requirement while naming no unit',
replacement:
'the sys_business_unit.name (machine name) of the business unit at the root of the subtree the '
+ 'delegate administers, written out: `businessUnit: \'north_america\'`. If the permission set '
+ 'should not delegate administration at all, remove `adminScope` from it. ⛔ There is no '
+ 'replacement that can be DERIVED from what was written: a blank names no unit, so the root the '
+ 'author meant is not recoverable, and the platform must not pick one.',
reason:
'Maintainer ruling A on #19461 (decision batch #217 item 1, 2026-09-23 「217 同意」). '
+ '`AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so '
+ '`{ businessUnit: \'\' }` and `{ businessUnit: \' \' }` parsed green — measured against the '
+ 'published spec 17.4.0 and re-measured on `main` before the change. This narrows a published '
+ 'face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope\'s '
+ 'WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate '
+ 'resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves '
+ 'nothing on the subtree axes — no escalation was measured; the defect is a declaration that '
+ 'does not enforce what it declares, satisfied most readily by an author (an AI author above '
+ 'all) that knew the key was required and did not yet know the unit. The refusal is a '
+ 'NON-TRANSFORMING refinement at the key\'s own path, deliberately not a trim: the metadata save '
+ 'path persists the submitted body verbatim rather than the parsed value, so a trimming schema '
+ 'would validate one string and store another that the gate\'s exact lookup cannot resolve. A '
+ 'real name therefore parses byte-identical. Scope is blankness only: a real name with '
+ 'surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there '
+ 'is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping '
+ 'the scope would silently change who is a delegate). The read path does not re-validate stored '
+ 'rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused '
+ 'on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 '
+ 'INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a '
+ 'legacy record with no metadata definition reports it through its existing durability ERROR '
+ '(ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a '
+ 'trashed blank-anchored set brings the record back and reports the missing definition at ERROR '
+ 'the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling '
+ '(2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090.',
acceptanceCriteria:
'Search every authored and stored permission set for an `adminScope` whose `businessUnit` is '
+ 'empty or whitespace-only — metadata files, `sys_metadata` permission rows, and the '
+ '`admin_scope` column of `sys_permission_set` — and write the root unit\'s machine name, or '
+ 'remove `adminScope` where the set should not delegate. The sweep is mechanical for authored '
+ 'metadata: `AdminScopeSchema.safeParse` and `PermissionSetSchema.safeParse` answer exactly one '
+ '`custom` issue at `businessUnit` (or `adminScope.businessUnit` when the set is parsed whole) '
+ 'naming `sys_business_unit.name` as the valid anchor. For STORED rows the search above is the '
+ 'catch-all, because reads never refuse: a definition already stored in `sys_metadata` with a '
+ 'blank anchor loads and resolves exactly as before and says nothing until it is written again. '
+ 'Two write paths then name it: a re-save of the set (refused 422 at adminScope.businessUnit), '
+ 'and — for a legacy `sys_permission_set` record with no metadata definition only — the boot '
+ 'log, where the ADR-0094 D4 backfill\'s first-failure ERROR names the record and carries the '
+ 'offending key and its summary ERROR counts and names the failed records. ⛔ A clean boot is '
+ 'therefore NOT a completed sweep. An absent `businessUnit` is refused exactly as before, with its own invalid_type issue. Nothing is '
+ 'normalised on the way through: an accepted anchor arrives byte-identical to what was written. '
+ 'The repo and example apps carried zero blank anchors at the time of the change, so no fixture '
+ 'had to be rewritten.',
};
64 changes: 64 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5489,6 +5489,70 @@ const step18: MigrationStep = {
+ 'exportable by nobody, which is the point of the change; confirm that is what you want '
+ 'before granting it back.',
},
// The delegated-admin scope's one required key, required NON-BLANK. Stored
// scopes are deliberately not rewritten: the root of a subtree cannot be
// inferred from a blank, so the stored row is refused on its next write and
// named by the boot reconciliation's existing durability error instead.
{
id: 'admin-scope-business-unit-blank-refused',
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
// code span already, and a nested backtick would close it.
surface:
'security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored '
+ 'BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). '
+ 'The key is the scope\'s only required one and names the root business unit of the delegated '
+ 'subtree; a blank value satisfied the requirement while naming no unit',
replacement:
'the sys_business_unit.name (machine name) of the business unit at the root of the subtree the '
+ 'delegate administers, written out: `businessUnit: \'north_america\'`. If the permission set '
+ 'should not delegate administration at all, remove `adminScope` from it. ⛔ There is no '
+ 'replacement that can be DERIVED from what was written: a blank names no unit, so the root the '
+ 'author meant is not recoverable, and the platform must not pick one.',
reason:
'Maintainer ruling A on #19461 (decision batch #217 item 1, 2026-09-23 「217 同意」). '
+ '`AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so '
+ '`{ businessUnit: \'\' }` and `{ businessUnit: \' \' }` parsed green — measured against the '
+ 'published spec 17.4.0 and re-measured on `main` before the change. This narrows a published '
+ 'face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope\'s '
+ 'WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate '
+ 'resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves '
+ 'nothing on the subtree axes — no escalation was measured; the defect is a declaration that '
+ 'does not enforce what it declares, satisfied most readily by an author (an AI author above '
+ 'all) that knew the key was required and did not yet know the unit. The refusal is a '
+ 'NON-TRANSFORMING refinement at the key\'s own path, deliberately not a trim: the metadata save '
+ 'path persists the submitted body verbatim rather than the parsed value, so a trimming schema '
+ 'would validate one string and store another that the gate\'s exact lookup cannot resolve. A '
+ 'real name therefore parses byte-identical. Scope is blankness only: a real name with '
+ 'surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there '
+ 'is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping '
+ 'the scope would silently change who is a delegate). The read path does not re-validate stored '
+ 'rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused '
+ 'on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 '
+ 'INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a '
+ 'legacy record with no metadata definition reports it through its existing durability ERROR '
+ '(ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a '
+ 'trashed blank-anchored set brings the record back and reports the missing definition at ERROR '
+ 'the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling '
+ '(2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090.',
acceptanceCriteria:
'Search every authored and stored permission set for an `adminScope` whose `businessUnit` is '
+ 'empty or whitespace-only — metadata files, `sys_metadata` permission rows, and the '
+ '`admin_scope` column of `sys_permission_set` — and write the root unit\'s machine name, or '
+ 'remove `adminScope` where the set should not delegate. The sweep is mechanical for authored '
+ 'metadata: `AdminScopeSchema.safeParse` and `PermissionSetSchema.safeParse` answer exactly one '
+ '`custom` issue at `businessUnit` (or `adminScope.businessUnit` when the set is parsed whole) '
+ 'naming `sys_business_unit.name` as the valid anchor. For STORED rows the search above is the '
+ 'catch-all, because reads never refuse: a definition already stored in `sys_metadata` with a '
+ 'blank anchor loads and resolves exactly as before and says nothing until it is written again. '
+ 'Two write paths then name it: a re-save of the set (refused 422 at adminScope.businessUnit), '
+ 'and — for a legacy `sys_permission_set` record with no metadata definition only — the boot '
+ 'log, where the ADR-0094 D4 backfill\'s first-failure ERROR names the record and carries the '
+ 'offending key and its summary ERROR counts and names the failed records. ⛔ A clean boot is '
+ 'therefore NOT a completed sweep. An absent `businessUnit` is refused exactly as before, with its own invalid_type issue. Nothing is '
+ 'normalised on the way through: an accepted anchor arrives byte-identical to what was written. '
+ 'The repo and example apps carried zero blank anchors at the time of the change, so no fixture '
+ 'had to be rewritten.',
},
{
id: 'advanced-plugin-lifecycle-config-retired',
surface:
Expand Down
Loading
Loading