Repository navigation
spec(security): refuse a blank AdminScope businessUnit anchor at parse - #19864
Conversation
AdminScopeSchema.businessUnit is the scope's one required key, and a bare string accepted '' and whitespace-only values, satisfying the requirement while naming no business unit. It now carries the non-transforming NON_BLANK_STRING refinement with a message naming what a valid anchor is (the sys_business_unit.name of the subtree root). A real name parses byte-identical; the absent key keeps its own invalid_type refusal. Stored scopes are not rewritten: the ADR-0087 semantic entry admin-scope-business-unit-blank-refused records that a stored blank anchor is refused on its next write. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…ank-anchor entry Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…nk businessUnit rule Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 04e69dcdaee612e2be22db7cab3b00f78a6f82d4 && git checkout 04e69dcdaee612e2be22db7cab3b00f78a6f82d4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e90a8d1c5f3cd3312730a0c9d7f206b3e887314 a5a53acaf09d2203735cc86d9ce57e5301c20b54 && git checkout -B drift-repro 0e90a8d1c5f3cd3312730a0c9d7f206b3e887314 && git merge --no-ff a5a53acaf09d2203735cc86d9ce57e5301c20b54
node scripts/docs-audit/affected-docs.mjs --json 0e90a8d1c5f3cd3312730a0c9d7f206b3e887314
|
Contract reviewServed-tier: 91/91 Isolated at-tier reviewer subagent, run by the ① Derived judgmentsInputs read: card #19461 body + all 7 comments (ruling = comment 5793356837, batch #217 item 1, letter A: non-transforming refinement refusing empty/whitespace-only Every accept-set / public-surface / runtime change the diff produces, judged against ruling A:
Required and missing: nothing. Ruling items 1-3 are each implemented and pinned. Beyond the ruling: nothing. The ② Semver level
Against AGENTS.md at origin/main: :1084-1085 ( ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
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:z.string().refine(NON_BLANK_STRING, ...), using the shared predicate fromshared/refinement-projection.ts. There is no.trim()and no transform:saveMetaItempersists the submitted body verbatim, so a transform would validate one string and store another. A real name, padding included, parses byte-identical.NON_BLANK_STRINGis a declared projectable refinement, so the published JSON Schema forsecurity/AdminScopenow carriesminLength: 1and a non-whitespacepattern. The.describe()text states the rule, and the regenerated reference page carries it.invalid_typeissue atbusinessUnit, because the refinement never runs on a non-string.AdminScope/AdminScopeParsedtypes 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. Threeplugin-securitypaths re-parse a STORED scope throughPermissionSetSchemainsidesaveMetaItem. The refusal reaches all three through the existing parse, with no consumer edit:permission-set-projection.ts)reconcilePermissionSetProjectionbackfillFailedand reported through the existing ADR-0094 D4 durabilityERROR(first failure carries the 422 namingadminScope.businessUnit; the summary names the record). A valid sibling still backfills.createPermissionSetWriteThroughrestore opERROR(NOT re-authored into metadata), carrying the same 422.createPermissionSetWriteThroughupdate op422 INVALID_METADATAwith one issue atadminScope.businessUnit, for a legacy record-only row and for a definition already stored insys_metadata. Nothing is saved.Reads are unaffected: the rehydration seams do not parse, and
metadata-protocolsaveMetaItemhas no early return before itsresolveOverlaySchema(...).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, withregistry.tsregenerated bygen: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 insys_metadatasays nothing until it is written again.Changeset, and how I read the rule
.changeset/19461-admin-scope-business-unit-blank-refused.md:@objectstack/specminor, body carrying BREAKING for authored metadata, the FROM → TO migration table and one-line fix, the ADR-0087 disposition markerregistered admin-scope-business-unit-blank-refused, and theClause-②: yesline.AGENTS.md's changeset rule:
yestakes at leastminor; a narrowing is BREAKING, so the changeset must carry its migration and exactly one ADR-0087 disposition marker;majoris refused in the launch window (check-changeset-no-major). That is the same shape as the$betweenprecedent (#18012 / PR #19066):Clause-②: yes,minor, a**BREAKING for authored metadata**banner,registereddisposition. The breaking signal thatcheck:adr-0087-registrationreads here is the banner: it reports[BREAKING] registered admin-scope-business-unit-blank-refused (new here).Clause-②line is copied verbatim from the claim and the ruling (yes).scripts/pm/clause2-line.mjsreads a bareyesas a widening and spells a pure narrowingno (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 onecustomissue atbusinessUnit, with the message namingsys_business_unit.name. The same refusal reaches throughPermissionSetSchema.adminScopeat['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 oneinvalid_typeatbusinessUnit.permission.zod.tsrestored on disk to today'smainblob0e6d6902b063(tree only, hash-verified), the 6 refusal pins go red (6 failed | 86 passed). Restore was verified: blob back to HEAD0dddd0bdb439,git diff HEADempty, porcelain clean. On HEAD the file is92 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.pinpass (477 passed), against a specdist/built from this branch. A scratch probe, not committed, drove the three stored-scope paths above with'',' 'and a tab through the real registeredpermissionschema:10 passed. A real anchor control passes all three.@objectstack/specwhole package ata5a53acaf0:pnpm testgives524 passedfiles,15444 passed | 1 todo.pnpm typecheckpasses (tsc --noEmit, scripts typecheck, and test-typecheck held at its ledger).a5a53acaf0: eslint (--no-inline-config,--format json) reported 4 of the 6 changed paths, with 0 errors and 0 warnings. The.mdchangeset and the.mdxreference page match nofilesentry ineslint.config.mjs. That config never enables type-aware linting (it says so itself), so this diff cannot move the verdict on any untouched file.dispatch-gates --ranaccounts for all 110 derived families. 108 ran green. 2 are NOT MEASURED withPREREQUISITE NOT MET(exit 3), because both need a whole-workspace build:check:dual-build-cjs-loadsandcheck:type-check-debt. CI runs both.origin/mainmoved 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 currentmain.Generated files that moved
packages/spec/src/migrations/registry.ts(gen:migration-registry)content/docs/references/security/permission.mdx(gen:schema+gen:docs: thebusinessUnitdescription row, twice)check:generatedreports all 15 generated artifacts up to date against a freshly builtdist/.spec-changes.json,protocol-upgrade-guide.md,authorable-surface/,api-surface/andexport-origins/did not move.Acceptance notes
businessUnitwith 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.plugin-security,plugin-auth,packages/lint), no data migration, no other key ofAdminScopeSchema.Generated by Claude Code