Skip to content

Commit 77f54bf

Browse 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>
1 parent b7c792b commit 77f54bf

6 files changed

Lines changed: 287 additions & 4 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**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: '' })
20+
-> { success: false,
21+
issues: [{ code: 'custom', path: ['businessUnit'],
22+
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**.
38+
39+
<!-- adr-0087: registered admin-scope-business-unit-blank-refused -->
40+
41+
## Stored permission sets
42+
43+
- **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.

‎content/docs/references/security/permission.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = AdminScopeSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
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. |
3131
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
3232
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
3333
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
@@ -178,7 +178,7 @@ const result = AdminScopeSchema.parse(data);
178178

179179
| Property | Type | Required | Description |
180180
| :--- | :--- | :--- | :--- |
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. |
182182
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
183183
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
184184
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The delegated-admin scope's one required key, required NON-BLANK. Stored
6+
// scopes are deliberately not rewritten: the root of a subtree cannot be
7+
// inferred from a blank, so the stored row is refused on its next write and
8+
// named by the boot reconciliation's existing durability error instead.
9+
export const entry: SemanticMigration = {
10+
id: 'admin-scope-business-unit-blank-refused',
11+
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
12+
// code span already, and a nested backtick would close it.
13+
surface:
14+
'security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored '
15+
+ 'BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). '
16+
+ 'The key is the scope\'s only required one and names the root business unit of the delegated '
17+
+ 'subtree; a blank value satisfied the requirement while naming no unit',
18+
replacement:
19+
'the sys_business_unit.name (machine name) of the business unit at the root of the subtree the '
20+
+ 'delegate administers, written out: `businessUnit: \'north_america\'`. If the permission set '
21+
+ 'should not delegate administration at all, remove `adminScope` from it. ⛔ There is no '
22+
+ 'replacement that can be DERIVED from what was written: a blank names no unit, so the root the '
23+
+ 'author meant is not recoverable, and the platform must not pick one.',
24+
reason:
25+
'Maintainer ruling A on #19461 (decision batch #217 item 1, 2026-09-23 「217 同意」). '
26+
+ '`AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so '
27+
+ '`{ businessUnit: \'\' }` and `{ businessUnit: \' \' }` parsed green — measured against the '
28+
+ 'published spec 17.4.0 and re-measured on `main` before the change. This narrows a published '
29+
+ 'face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope\'s '
30+
+ 'WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate '
31+
+ 'resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves '
32+
+ 'nothing on the subtree axes — no escalation was measured; the defect is a declaration that '
33+
+ 'does not enforce what it declares, satisfied most readily by an author (an AI author above '
34+
+ 'all) that knew the key was required and did not yet know the unit. The refusal is a '
35+
+ 'NON-TRANSFORMING refinement at the key\'s own path, deliberately not a trim: the metadata save '
36+
+ 'path persists the submitted body verbatim rather than the parsed value, so a trimming schema '
37+
+ 'would validate one string and store another that the gate\'s exact lookup cannot resolve. A '
38+
+ 'real name therefore parses byte-identical. Scope is blankness only: a real name with '
39+
+ 'surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there '
40+
+ 'is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping '
41+
+ 'the scope would silently change who is a delegate). The read path does not re-validate stored '
42+
+ 'rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused '
43+
+ 'on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 '
44+
+ 'INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a '
45+
+ 'legacy record with no metadata definition reports it through its existing durability ERROR '
46+
+ '(ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a '
47+
+ 'trashed blank-anchored set brings the record back and reports the missing definition at ERROR '
48+
+ 'the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling '
49+
+ '(2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090.',
50+
acceptanceCriteria:
51+
'Search every authored and stored permission set for an `adminScope` whose `businessUnit` is '
52+
+ 'empty or whitespace-only — metadata files, `sys_metadata` permission rows, and the '
53+
+ '`admin_scope` column of `sys_permission_set` — and write the root unit\'s machine name, or '
54+
+ 'remove `adminScope` where the set should not delegate. The sweep is mechanical for authored '
55+
+ 'metadata: `AdminScopeSchema.safeParse` and `PermissionSetSchema.safeParse` answer exactly one '
56+
+ '`custom` issue at `businessUnit` (or `adminScope.businessUnit` when the set is parsed whole) '
57+
+ 'naming `sys_business_unit.name` as the valid anchor. For STORED rows the search above is the '
58+
+ 'catch-all, because reads never refuse: a definition already stored in `sys_metadata` with a '
59+
+ 'blank anchor loads and resolves exactly as before and says nothing until it is written again. '
60+
+ 'Two write paths then name it: a re-save of the set (refused 422 at adminScope.businessUnit), '
61+
+ 'and — for a legacy `sys_permission_set` record with no metadata definition only — the boot '
62+
+ 'log, where the ADR-0094 D4 backfill\'s first-failure ERROR names the record and carries the '
63+
+ 'offending key and its summary ERROR counts and names the failed records. ⛔ A clean boot is '
64+
+ 'therefore NOT a completed sweep. An absent `businessUnit` is refused exactly as before, with its own invalid_type issue. Nothing is '
65+
+ 'normalised on the way through: an accepted anchor arrives byte-identical to what was written. '
66+
+ 'The repo and example apps carried zero blank anchors at the time of the change, so no fixture '
67+
+ 'had to be rewritten.',
68+
};

‎packages/spec/src/migrations/registry.ts‎

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

0 commit comments

Comments
 (0)