You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 77f54bf
Browse filesBrowse the repository at this point in the historyBrowse files
spec(security): refuse a blank AdminScope businessUnit anchor at parse (#19864)
Fixes#19461
Clause-②: yes
Executes maintainer ruling **5793356837** (decision batch #217 item 1,
letter **A**, 「217 同意」): tighten the declaration and leave stored rows
untouched. The reading of the changeset rule is in its own section
below.
## What changed
`AdminScopeSchema.businessUnit`
(`packages/spec/src/security/permission.zod.ts`) is the delegated-admin
scope's one required key. It now refuses an empty value and a
whitespace-only value at parse, at the key's own path:
```
FROM AdminScopeSchema.safeParse({ businessUnit: '' }) -> { success: true }
TO AdminScopeSchema.safeParse({ businessUnit: '' }) -> { success: false, issues: [ ONE issue:
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, ...' ] }
```
- **Non-transforming.** The key is `z.string().refine(NON_BLANK_STRING,
...)`, using the shared predicate from
`shared/refinement-projection.ts`. There is no `.trim()` and no
transform: `saveMetaItem` persists the submitted body verbatim, so a
transform would validate one string and store another. A real name,
padding included, parses byte-identical.
- **Declared equals enforced.** `NON_BLANK_STRING` is a declared
projectable refinement, so the published JSON Schema for
`security/AdminScope` now carries `minLength: 1` and a non-whitespace
`pattern`. The `.describe()` text states the rule, and the regenerated
reference page carries it.
- **The absent key is unchanged.** It is still exactly one
`invalid_type` issue at `businessUnit`, because the refinement never
runs on a non-string.
- **No export added.** The message constant is module-private, like the
file's existing refinement helper. `AdminScope` / `AdminScopeParsed`
types are unchanged.
## Stored rows (ruling item 2): not rewritten, refused on the next
write, no skip path
I re-checked the consumer trace on today's `main` (`8cbc3c0084`), by
symbol. Three `plugin-security` paths re-parse a STORED scope through
`PermissionSetSchema` inside `saveMetaItem`. The refusal reaches all
three through the existing parse, with **no consumer edit**:
| path (in `permission-set-projection.ts`) | what a stored blank anchor
now does |
| --- | --- |
| boot reconciliation backfill, `reconcilePermissionSetProjection` | the
row is counted in `backfillFailed` and reported through the existing
ADR-0094 D4 durability `ERROR` (first failure carries the 422 naming
`adminScope.businessUnit`; the summary names the record). A valid
sibling still backfills. |
| restore leg, `createPermissionSetWriteThrough` restore op | the engine
un-trash runs; the missing definition is reported at `ERROR` (`NOT
re-authored into metadata`), carrying the same 422. |
| data-door edit merge, `createPermissionSetWriteThrough` update op | a
label-only edit throws `422 INVALID_METADATA` with one issue at
`adminScope.businessUnit`, for a legacy record-only row and for a
definition already stored in `sys_metadata`. Nothing is saved. |
Reads are unaffected: the rehydration seams do not parse, and
`metadata-protocol` `saveMetaItem` has no early return before its
`resolveOverlaySchema(...).safeParse`. No path crashes, swallows the
error silently or skips the row.
## ADR-0087 (ruling item 3)
New semantic entry
`packages/spec/src/migrations/entries/semantic/18.admin-scope-business-unit-blank-refused.ts`,
with `registry.ts` regenerated by `gen:migration-registry` (not
hand-edited). Its prose says plainly that stored rows are not rewritten,
have no D2 conversion (the root cannot be inferred), and are refused on
their next write. It also says that a clean boot is not a completed
sweep, because a definition already stored in `sys_metadata` says
nothing until it is written again.
## Changeset, and how I read the rule
`.changeset/19461-admin-scope-business-unit-blank-refused.md`:
`@objectstack/spec` **minor**, body carrying **BREAKING for authored
metadata**, the FROM → TO migration table and one-line fix, the ADR-0087
disposition marker `registered admin-scope-business-unit-blank-refused`,
and the `Clause-②: yes` line.
AGENTS.md's changeset rule: `yes` takes at least `minor`; a narrowing is
BREAKING, so the changeset must carry its migration and exactly one
ADR-0087 disposition marker; `major` is refused in the launch window
(`check-changeset-no-major`). That is the same shape as the `$between`
precedent (#18012 / PR #19066): `Clause-②: yes`, `minor`, a `**BREAKING
for authored metadata**` banner, `registered` disposition. The breaking
signal that `check:adr-0087-registration` reads here is the banner: it
reports `[BREAKING] registered admin-scope-business-unit-blank-refused
(new here)`.
⚠️ One reading to flag, not resolved here. The `Clause-②` line is copied
verbatim from the claim and the ruling (`yes`).
`scripts/pm/clause2-line.mjs` reads a bare `yes` as a widening and
spells a pure narrowing `no (narrowing)`. This diff widens nothing. I
did not rewrite the ruling's declaration; the report carries it as an
open question.
## Tests
- `packages/spec/src/security/permission.test.ts`: `''`, `' '` and a tab
are refused as one `custom` issue at `businessUnit`, with the message
naming `sys_business_unit.name`. The same refusal reaches through
`PermissionSetSchema.adminScope` at `['adminScope', 'businessUnit']`. A
real name parses, and a padded one is kept byte-identical (this pins
that there is no transform). The absent key stays one `invalid_type` at
`businessUnit`.
- **Firing control.** With `permission.zod.ts` restored on disk to
today's `main` blob `0e6d6902b063` (tree only, hash-verified), the 6
refusal pins go red (`6 failed | 86 passed`). Restore was verified: blob
back to HEAD `0dddd0bdb439`, `git diff HEAD` empty, porcelain clean. On
HEAD the file is `92 passed`.
- `plugin-security`, read-only (no file edited):
`permission-set-projection`, `packaged-permission-set-lock`,
`delegated-admin-gate`, `delegated-admin-gate-cross-organization`,
`security-plugin`, `bootstrap-seed-round-trips`, `invitation-placement`,
`resolve-permission-sets-for-context.pin` pass (`477 passed`), against a
spec `dist/` built from this branch. A scratch probe, not committed,
drove the three stored-scope paths above with `''`, `' '` and a tab
through the real registered `permission` schema: `10 passed`. A real
anchor control passes all three.
- `@objectstack/spec` whole package at `a5a53acaf0`: `pnpm test` gives
`524 passed` files, `15444 passed | 1 todo`. `pnpm typecheck` passes
(`tsc --noEmit`, scripts typecheck, and test-typecheck held at its
ledger).
- Lint, narrowed and measured at `a5a53acaf0`: eslint
(`--no-inline-config`, `--format json`) reported 4 of the 6 changed
paths, with 0 errors and 0 warnings. The `.md` changeset and the `.mdx`
reference page match no `files` entry in `eslint.config.mjs`. That
config never enables type-aware linting (it says so itself), so this
diff cannot move the verdict on any untouched file.
- Gates: `dispatch-gates --ran` accounts for all 110 derived families.
108 ran green. 2 are NOT MEASURED with `PREREQUISITE NOT MET` (exit 3),
because both need a whole-workspace build: `check:dual-build-cjs-loads`
and `check:type-check-debt`. CI runs both.
- `origin/main` moved 3 commits past the base (`2548ba57de`,
`863a775872`, `0e90a8d1c5`). None touches a path in this diff or the
three stored-scope paths, so I did not merge them in; the queue rebuilds
onto current `main`.
## Generated files that moved
- `packages/spec/src/migrations/registry.ts` (`gen:migration-registry`)
- `content/docs/references/security/permission.mdx` (`gen:schema` +
`gen:docs`: the `businessUnit` description row, twice)
`check:generated` reports all 15 generated artifacts up to date against
a freshly built `dist/`. `spec-changes.json`,
`protocol-upgrade-guide.md`, `authorable-surface/`, `api-surface/` and
`export-origins/` did not move.
## Acceptance notes
- `businessUnit` with surrounding whitespace around a real name (`'
north_america '`) is still accepted and stored as written; the gate's
exact lookup resolves it to nothing. The ruling scoped this card to
blankness. Noted, not filed.
- Out of scope, per the dispatch: no consumer edit (`plugin-security`,
`plugin-auth`, `packages/lint`), no data migration, no other key of
`AdminScopeSchema`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
**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).
6
+
7
+
Clause-②: yes
8
+
9
+
Maintainer ruling A on decision batch #217 item 1, 2026-09-23 「217 同意」.
10
+
11
+
## What changed, and why
12
+
13
+
`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".
14
+
15
+
```
16
+
FROM AdminScopeSchema.safeParse({ businessUnit: '' })
17
+
-> { success: true }
18
+
19
+
TO AdminScopeSchema.safeParse({ businessUnit: '' })
message: 'A blank businessUnit is not a delegation boundary: businessUnit is
23
+
the sys_business_unit.name (machine name) of the business unit at
24
+
the root of the subtree this scope delegates, …' }] }
25
+
```
26
+
27
+
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`).
28
+
29
+
## Migration — FROM → TO
30
+
31
+
| You wrote | Write instead |
32
+
| --- | --- |
33
+
|`adminScope: { businessUnit: '' }`|`adminScope: { businessUnit: 'north_america' }`, using the machine name of the unit at the root of the subtree |
34
+
|`adminScope: { businessUnit: ' ' }` (or a tab or newline) | the same: the root unit's `sys_business_unit.name`|
35
+
| a blank-anchored scope on a set that should not delegate at all | remove `adminScope` from the permission set |
36
+
37
+
**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**.
-**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.
44
+
-**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.
45
+
-**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.
46
+
-**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`.
47
+
48
+
## What does NOT change
49
+
50
+
-**An absent `businessUnit`** is refused exactly as before, with its own `invalid_type` issue.
51
+
-**A real name with surrounding whitespace** is not judged by this change. It parses and is stored byte-identical.
52
+
-**The other keys of the scope** (`includeSubtree`, `manageAssignments`, `manageBindings`, `authorEnvironmentSets`, `assignablePermissionSets`) are untouched.
53
+
-**The published export surface.** No export is added, removed or renamed, and the `AdminScope` / `AdminScopeParsed` types are unchanged.
Copy file name to clipboardExpand all lines: content/docs/references/security/permission.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -27,7 +27,7 @@ const result = AdminScopeSchema.parse(data);
27
27
28
28
| Property | Type | Required | Description |
29
29
| :--- | :--- | :--- | :--- |
30
-
|**businessUnit**|`string`| ✅ |[ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root |
30
+
|**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.|
31
31
|**includeSubtree**|`boolean`| optional (default: `true`) | Cover descendant business units too (default true) |
32
32
|**manageAssignments**|`boolean`| optional (default: `false`) | Manage user↔position assignments within the subtree |
33
33
|**manageBindings**|`boolean`| optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
@@ -178,7 +178,7 @@ const result = AdminScopeSchema.parse(data);
178
178
179
179
| Property | Type | Required | Description |
180
180
| :--- | :--- | :--- | :--- |
181
-
|**businessUnit**|`string`| ✅ |[ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root |
181
+
|**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.|
182
182
|**includeSubtree**|`boolean`| optional (default: `true`) | Cover descendant business units too (default true) |
183
183
|**manageAssignments**|`boolean`| optional (default: `false`) | Manage user↔position assignments within the subtree |
184
184
|**manageBindings**|`boolean`| optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
0 commit comments