Repository navigation
finding(spec/security): AdminScopeSchema.businessUnit is the shape's one REQUIRED key and is declared z.string() with no minimum — so {businessUnit: ""} and a whitespace-only value both parse green, satisfying the anchor requirement vacuously #19461
Description
Activity
objectstack-fleet commented
on Sep 23, 2026 ContributorMore actionsTriage (seat
session_01Tw7jnJinGHvoGSi8aFkhPJ):domain:spec(packages/spec/src/security/permission.zod.ts) · p2 · Bug ·area:access; rationale: the one required key of the delegated-admin-scope shape is satisfiable by an empty stringPath: 真挡得住的权限 | access-security.write-path-guards | P② | p2
Graded at 2026-09-23T01:21Z.Class (c) with (b) beside it, verified by this seat at the line:
packages/spec/src/security/permission.zod.ts:645declares
businessUnit: z.string().describe('[ADR-0090 D12] Delegation boundary: …')
— ⛔ no.min(1), ⛔ no.trim(). So{ businessUnit: '' }and{ businessUnit: ' ' }parse green, and the file is*.zod.ts, which the spec package publishes ⇒ this is a shipped shape.⇒ the shape's only required key is satisfiable vacuously. It refuses
{ includeSubtree: true }because a subtree needs a root, then accepts an empty root.⭐ The AI-authoring angle is the sharper one and is why this is (c): an agent asked for a required
businessUnitit does not yet know satisfies the requirement by emitting the key empty — the single most likely failure — and the platform answers 「accepted」, storing an anchor that anchors nothing.⚠️ Two fences carried onto the card, both from the filer and both honoured here. (1) ⛔ No live privilege escalation is asserted — nobody has traced what a consumer does with an empty anchor, and that trace is the dispatch's first act, ⛔ not its conclusion. (2) The objectui consumer already gates stricter than the schema (non-empty, trimmed), so the reachable population today is narrower than the schema's — which is a reason to fix the declaration, ⛔ not a reason to defer it.Type Bug: adding
.min(1).trim()pulls the accept set back to what the key is declared to mean. 条款②no.Generated by Claude Code
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guards
on Sep 23, 2026 os-support-ai commented
on Sep 23, 2026 CollaboratorMore actionsClaim: PM loop —
AdminScopeSchema.businessUnitaccepts an empty or whitespace-only anchor, dispatched at 2026-09-23T02:30Z
Session:session_013RDBh5DqXd2xnLwvHLgLFr
Branch:claude/issue-19461-admin-scope-business-unit-nonblank
Worktree:objectstack-issue-19461
Domain:domain:spec
Seat:domain:spec#1
File surface:packages/spec/src/security/permission.zod.ts(thebusinessUnitkey ofAdminScopeSchema) and its tests; one new ADR-0087 semantic entry underpackages/spec/src/migrations/entries/semantic/, if the repo's convention for a newly refused shape asks for one, withpackages/spec/src/migrations/registry.tsregenerated and ⛔ never hand-edited; whatever the repo's own generators rewrite; and.changeset/. ⛔ No consumer code inplugin-security,plugin-authorpackages/lint. If a consumer needs to change, the round stops and reports.
Container & model:M,mode:subagent,model: opus (default judgment tier). This is a delegation boundary, and the first act is a consumer trace, so the judgment tier applies.
Clause-②: no
Thread-read: 5787338975
Serial constraints cleared: census over all 24 open PRs at 2026-09-23T02:30Z (373 file rows; lit control 94packages/spec/rows).packages/spec/src/security/**,content/docs/references/security/**and everyjson-schema/authorable-surface/livenesspath for this shape are CLEAR.⚠️ packages/spec/src/migrations/registry.tsis held by six open PRs (#19752, #19750, #19657, #19637, #19618, #19600). That is the file's normal state: entries are separate files, and a conflicting merge is resolved by running the generator. The round resolves it that way and ⛔ never by hand.#19733(the #17242 citation corpus claim, seat 2) touches one script file today: CLEAR.
On-hold trigger index: 84 openpm:on-holdcards were read; 0 namepermission.zod,AdminScopeorbusinessUnit. ⇒ no rider obligation.The premise, re-measured before the claim
origin/maine99a14ceae:packages/spec/src/security/permission.zod.ts:645readsbusinessUnit: z.string().describe('[ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root'),with ⛔ no minimum and ⛔ no trim. The shape is astrictObject, and every other key carries a default, so this is its one required key. The filer's reading was taken on the17.4.0pin; this one is onmain, and it matches.What the round is told, carrying both of the card's fences
- The consumer trace comes first, and it is a reading, ⛔ not a conclusion. Twenty non-test files on
mainname the shape. The round reads what the delegated-admin gate and the other named consumers do with an empty anchor. ⛔ No privilege escalation is asserted unless it is measured. - Stored data. If any read or load path re-parses a stored scope through this schema, narrowing the accept set would refuse existing rows on read. That is a migration shape, which the maintainer decides, so the round stops and reports instead of landing it.
- The fix refuses an empty and a whitespace-only
businessUnit, with the path named. Triage suggested.min(1).trim()..trim()is a transform that changes the parsed value, so the round chooses between a transform and a non-transforming refinement and says why. That choice is a lead for the round, ⛔ not an answer.
Generated by Claude Code
- The consumer trace comes first, and it is a reading, ⛔ not a conclusion. Twenty non-test files on
objectstack-fleet commented
on Sep 23, 2026 ContributorMore actionsos-dev-report
{
"issue": 19461,
"status": "needs_decision",
"branch": "claude/issue-19461-admin-scope-business-unit-nonblank",
"pr": null,
"session": "session_013RDBh5DqXd2xnLwvHLgLFr",
"premise_still_valid": true,
"summary": "Premise re-measured on origin/main e99a14c: AdminScopeSchema accepts businessUnit '' and ' ' (and a tab), refusing only the missing key. Step 1 (consumer trace, a reading): the delegated-admin gate resolves the anchor by exact sys_business_unit.name lookup, and a blank anchor resolves to an EMPTY subtree, identical to any non-resolving name; measured on the real ObjectQL engine + InMemoryDriver + real DelegatedAdminGate, no escalation measured. Step 2 STOP CONDITION MET: three plugin-security paths re-parse a STORED admin_scope through PermissionSetSchema, which embeds AdminScopeSchema, inside saveMetaItem. They are the boot reconciliation backfill (permission-set-projection.ts:1570-1574, called at boot from security-plugin.ts:3820), the restore leg (:1195) and the data-door edit merge (:1341-1348). Measured: a narrowing flips the backfill's real input body from GREEN to REFUSED at adminScope.businessUnit. Per the dispatch, nothing was shipped (no commit, no PR); the maintainer decides the migration shape.",
"tests": "(1) PREMISE, tsx over packages/spec/src at e99a14c: AdminScopeSchema.safeParse businessUnit '' GREEN, ' ' GREEN, tab GREEN, 'north_america' GREEN, key absent REFUSED [invalid_type @ businessUnit]. (2) CONSUMER TRACE, measured with a tsx probe over the real ObjectQL engine src, InMemoryDriver and DelegatedAdminGate.describeDelegableScope: inserting a sys_business_unit named '' or ' ' is REFUSED VALIDATION_FAILED 'name is required' even under isSystem (record-validator.ts:251 treats whitespace-only as missing). find where name '' returns [], ' ' returns [], ' north_america ' returns []. Controls: 'north_america' returns [bu_na], 'acme' returns root plus 2 children, so the filter is not dropped. Gate: blank anchors give businessUnitIds [] and placeable [], identical to 'no_such_unit'. Readings, file:line: resolveHeldScopes accepts any string anchor (delegated-admin-gate.ts:889) and resolveSubtree answers an empty set for a missing root (:912-915). Assignment (:609), direct-grant (:661) and explain (scopesCoverUser :300) need subtree membership, so they refuse. Minting a blank-anchored scope is refused by checkStrictContainment as 'does not resolve' (:857-859), and invitation placement dry-runs the same gate (invitation-placement.ts:164). Tenant admins return early (:214), so they can STORE a blank anchor today. The binding leg (:707-708) and env-set authoring (:740-744) never read the subtree. They approve the same way for a blank anchor as for a valid one, so they are anchor-independent and gain nothing from the blank. invitation-role-cap.ts:169 and validate-org-axis-red-lines.ts:28 mention adminScope in prose only and read nothing. (3) STORED-DATA TRACE: saveMetaItem validates through resolveOverlaySchema (metadata-protocol protocol.ts:701-703), which answers getMetadataTypeSchema('permission') = PermissionSetSchema (metadata-type-schemas.ts:164), and embeds adminScope: AdminScopeSchema.optional() (permission.zod.ts:901), safeParse at protocol.ts:16075-16077. There is no system-actor bypass before it. Probe over the REAL permissionSetBodyFromRow plus the registered 'permission' schema: a legacy row with admin_scope businessUnit '' / ' ' / 'north_america' gives GREEN for all three today. Next, a narrowing mutated on disk via scripts/ablation-replace.mjs: anchor 1 to 0, blob 0e6d6902b063 to 439462b09aae. Result: '' REFUSED [too_small, custom @ adminScope.businessUnit], ' ' REFUSED [custom @ adminScope.businessUnit], 'north_america' GREEN. Restore verified: blob == HEAD 0e6d6902b063, git diff HEAD empty, porcelain 0 lines. The read side does NOT refuse: the rehydration seams (metadata database-loader.ts:825, metadata-protocol protocol.ts:4716, objectql plugin.ts:2107) replay ADR-0087 conversions only, with no schema parse. The one read-side parse, computeMetadataDiagnostics (metadata-diagnostics.ts:97, called at protocol.ts:6858 and :8740), stamps _diagnostics valid:false and refuses nothing. (4) POPULATION: in-tree blank businessUnit literals = 0 (git grep; control: 17 businessUnit literals in 11 files match the wider pattern). Deployed population: NOT MEASURED, no access. (5) GATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands gives exit 2, 'this branch changes nothing against origin/main (merge base e99a14c), nothing to derive'. No diff, so no gate is owed.",
"mcp_calls": "0",
"api_writes": "1 REST write: POST /repos//issues/19461/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). Plus 1 git push (write-pace wrapped) of the EMPTY branch claude/issue-19461-admin-scope-business-unit-nonblank at e99a14c as the rule-1 claim marker and write-route probe. It carries no commit beyond main. No PR, no labels, no PATCH.",
"open_questions": [
{
"question": "MIGRATION SHAPE (maintainer). Refusing a blank businessUnit changes behaviour for ALREADY-STORED permission sets on three non-read paths. (a) Boot backfill: a legacy sys_permission_set row with no metadata presence and a blank anchor stops backfilling and logs the ADR-0094 D4 durability ERROR on every boot (permission-set-projection.ts:1577-1606). That population is pre-ADR-0094 data-door rows: the admin_scope column exists since v13 and ADR-0094 was accepted 2026-07-14, so the size is NOT MEASURED. (b) Restore leg: a restored blank-anchored set comes back as a record with no metadata definition, error-logged (:1195-1206). (c) Data-door edit: any definition edit of a stored blank-anchored set, a label included, is refused 422 at adminScope.businessUnit until the anchor is named or the scope removed (:1341-1348). Stored rows are never refused on READ. Which shape lands?",
"options": [
"A. Land the refusal as a schema-only narrowing. Record it with an ADR-0087 step-18 semantic entry and a changeset stating that stored rows are not rewritten, the read path never refuses, the next re-save refuses at adminScope.businessUnit, and a legacy backfill reports on the existing durability channel, whose own prescription is 'make the record body spec-valid, the error names the offending key'. Cost: a boot ERROR per affected legacy row until it is fixed or deleted. Precedents: filter-between-blank-endpoint-refused ('the read path does not re-validate stored rows ... RE-SAVING one is refused') and #16870 on this same PermissionSetSchema, which shipped with no backfill accommodation.",
"B. Land A plus a plugin-security change so that the backfill and restore legs handle a now-invalid stored scope, e.g. by writing the definition without the scope and saying so. This is consumer code outside this card's write surface. It is also not lossless: a blank-anchored scope still approves the anchor-independent axes, authorEnvironmentSets (delegated-admin-gate.ts:740-744) and binding onto a zero-holder position (:707-708), and holding it makes the principal a delegate at all (:252-253).",
"C. Add an ADR-0087 D2 conversion that rewrites a stored blank anchor on rehydration. There is no lossless rewrite: no anchor can be derived, and dropping the scope has the loss named in B.",
"D. Leave the schema alone and refuse only at an authoring-lint layer. saveMetaItem keeps storing the blank anchor through both doors, so declared is still not enforced."
],
"recommendation": "A, because the read path never refuses a stored row: the rehydration seams do not parse, and the one read-side parse only flags _diagnostics. The only non-author path that changes, the boot backfill, already has a designed loud degradation report whose prescription is exactly this fix. The in-tree population is 0, and A matches the two nearest precedents. NOTE: this dispatch did not carry the four-axis decision framework (os-dev.md 'stop and ask the PM for it, never invent axes'), so options are given with costs and without axes. Please supply the framework if an axis analysis is wanted."
},
{
"question": "IMPLEMENTATION LEAD for the round that lands it: transform or refinement, and blank-only or blank-plus-padded?",
"options": [
"A. A non-transforming refinement that refuses empty and whitespace-only at the businessUnit path and accepts a real name byte-identical. The parsed type is unchanged (AdminScope vs AdminScopeParsed, ADR-0122).",
"B. .min(1).trim() as triage suggested. This is a transform, and saveMetaItem persists request.item verbatim, not parsed.data (protocol.ts:16056-16064). So ' north_america ' would validate as 'north_america' while the padded original is stored, and the gate's exact lookup resolves a padded anchor to nothing (measured: find ' north_america ' returns []). The result is a silent fail-closed that the parse reported as accepted.",
"C. A, and also refuse leading or trailing whitespace around a real name. That is the same measured consequence (resolves nothing) and the ui-list-view-groupbyfield-padded-refused precedent, but it is wider than this card."
],
"recommendation": "A, keeping to the card. B is measured to accept a value the runtime cannot resolve. C is the seat's call, because it narrows a published face beyond the card."
}
],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted, not filed, not classified: resolveSubtree looks the anchor up by name with limit 1 under an isSystem context and no organization predicate (delegated-admin-gate.ts:912). sys_business_unit.name has no unique index: the only unique index is code+organization_id (sys-business-unit.object.ts:259-264). Two units sharing a name therefore resolve to whichever row is read first. Cross-organization reachability NOT MEASURED. Dedupe words: resolveSubtree name limit 1 · sys_business_unit name not unique · adminScope anchor ambiguous",
"carrier: 承接者:无 · noted, not filed: the HeldScope docstring 'Empty = misconfigured, approves nothing' (delegated-admin-gate.ts:132) overstates. The binding leg (:707-708) and env-set authoring (:740-744) do not read the subtree, by design ('fresh inserts are inert until distributed'), so a non-resolving anchor still approves them, as does any valid anchor. This is not a defect of the blank anchor, and it bears on why options B and C above are lossy."
]
}
Generated by Claude Code
os-support-ai commented
on Sep 23, 2026 CollaboratorMore actionsRelease:
session_013RDBh5DqXd2xnLwvHLgLFr—domain:specexecution seat 1 (seat post #6017), 2026-09-23T02:47Z. The round stopped on the condition its dispatch set, and nothing shipped: no commit, no PR. The remote branchclaude/issue-19461-admin-scope-business-unit-nonblanksits atorigin/mainas a claim marker only. The dev's report is comment 5788045256. Same stroke:pm:dispatched→needs-user-decision, assignee cleared.维护者速读
「委派管理员」的管辖范围必须写明一个业务单元(
businessUnit)作为根。今天这一项写成空字符串或几个空格也能通过校验。实测它不会被滥用:空根找不到任何单元,于是什么都批不下来。但它也不说明任何事——声明了必填,却没有真正强制。要把空值拒掉只改一行校验。麻烦在已经存进库里的权限集:平台在三个地方会把存量数据重新过一遍这个校验——启动时的补录、恢复、以及管理员下一次编辑。收紧之后,这三处碰到一条空根的旧记录就会报错。报错是响亮的:启动日志出 ERROR,编辑返回 422 并指出是哪个字段。读取永远不受影响。仓库和示例里这种旧记录数为 0,线上数量无法测量。
- A 只收紧校验,存量不改写:下次保存时报错,启动补录走现有的「持久性降级」报错通道。同族先例(
$between两端不得为空)就是这样发的。 - B A 再加上改安全插件,让补录和恢复跳过坏的范围,不报错。
- C 读取时自动改写存量:没有无损改法,根无从推断。
- D 不改校验,只在 lint 里拦。
推荐 A。选 A / B / C / D?
The question, and why it is the maintainer's
An admin scope must name the business unit it governs. Today an empty or blank name passes, so the one required key of the scope is satisfied vacuously. Refusing it is a one-line change to a published shape, and it changes behaviour for scopes already stored. That is two floor items at once: a published-contract narrowing and a migration shape, on a security boundary.
⚠️ Triage'sClause-②: nodoes not survive the precedent. The nearest sibling,$betweenrequiring two non-blank endpoints (#18012, PR #19066), shipped asClause-②: yes,minor, with a maintainer approval quoted in its commit (「146 同意」). AGENTS.md's changeset rule makes(narrowing)BREAKING. ⇒ read this card asClause-②: yes (narrowing). ⛔ An execution seat does not rewrite triage's declaration; it is stated here so the ruling covers it.Governing text: ADR-0090 D12 (Accepted 2026-07-09) — an admin scope declares 「where — a BU subtree (the tree is the natural delegation boundary)」. An empty name names no subtree. ADR-0090's context also rules the launch-window posture: 「renames and removals below are one-step, with no aliases」. ADR-0087 governs how a newly refused shape is recorded (semantic entry; a D2 conversion only when lossless). ⛔ Neither ADR answers what happens to rows already stored, which is this question.
What the round measured, re-checked by this seat at source on
origin/maine99a14ceaepremise reading re-check blank passes today businessUnit: z.string().describe(…)with no minimum;'',' 'and a tab parse GREEN, and an absent key is REFUSEDgit grep -n "businessUnit: z.string()" origin/main -- packages/spec/src/security/permission.zod.tsa blank anchor approves nothing on the subtree axes resolveSubtreelooks the root up by exact name and returns an empty set when none is found — 「misconfigured scope → approves nothing (fail closed)」. The round ran the real gate: a blank anchor behaves exactly like a name that does not existgit show origin/main:packages/plugins/plugin-security/src/delegated-admin-gate.ts—resolveSubtreestored scopes are re-validated on write paths PermissionSetSchemaembedsadminScope: AdminScopeSchema.optional()(permission.zod.ts:901) and is the registeredpermissionschema (metadata-type-schemas.ts:164).saveMetaItemrunsschema.safeParse(request.item)before persisting. The boot reconciliation (security-plugin.ts, ADR-0094 D4) backfills legacy rows throughsaveMetaItem({ type: 'permission', …, item: permissionSetBodyFromRow(row), actor: 'system' })git grep -n "adminScope: AdminScopeSchema" origin/main -- packages/spec/src/security/permission.zod.ts·git grep -n "permission: PermissionSetSchema" origin/main -- packages/spec/src/kernel/metadata-type-schemas.tsreads never refuse the rehydration seams replay ADR-0087 conversions only and do not parse; the one read-side parse ( computeMetadataDiagnostics) stamps_diagnosticsand refuses nothingthe round's reading, file and line in 5788045256; ⛔ not re-run by this seat population blank businessUnitliterals in the tree: 0 (lit control: 17businessUnitliterals in 11 files). Deployed rows: NOT MEASUREDgit grep -n -E "businessUnit:\s*['\"]\s*['\"]" origin/mainThe round also measured the narrowing itself by an ablation, restored and verified by blob hash. The backfill's real input body goes from GREEN to REFUSED at
adminScope.businessUnitfor''and' ', and stays GREEN for a real name.Options × real cost
what ships what a customer sees A the refusal in AdminScopeSchema; an ADR-0087 step-18 semantic entry; aminorchangeset stating that stored rows are not rewritten, that reads never refuse and that the next save refusesan admin who edits a set with a blank anchor gets a 422 naming adminScope.businessUnit. A pre-ADR-0094 legacy row with a blank anchor logs the existing durability ERROR at each boot until it is fixed or deleted; that error's own prescription is 「make the record body spec-valid」B A, plus plugin-securitychanges so that backfill and restore write the set without the scopequieter boots, but ⛔ not lossless: a blank-anchored scope still approves the axes that never read the subtree (authoring environment sets; binding onto a position with no holders), and holding any scope makes the principal a delegate. Dropping it silently changes who is a delegate C a D2 conversion that rewrites the anchor on load ⛔ no lossless rewrite exists: no anchor can be derived. Its only form is B's drop, done invisibly D lint only stored and newly saved scopes keep a blank anchor through both doors; declared-but-not-enforced stays Business meaning: A is 「the form now rejects a blank required box; old forms get rejected when someone next touches them, loudly」. B is 「the same, but the system quietly edits old forms」. C is 「guess what the old forms meant」. D is 「leave the box optional in practice」.
四维分析
os-decision-facets
- ① 项目长远合理性 — A 让 D12「范围 = 一个 BU 子树」成为唯一可存的形状,不增加任何特例;B 往安全插件里加一条「坏范围静默丢弃」的特例路径,而且丢弃会改变谁是委派管理员;C 无损形态不存在;D 让声明与强制继续分叉。⇒ A。
- ② 实际业务拉动 — 仓库与示例里空根 0 处,objectui 的编辑器已经在客户端强制非空(objectui PR
enforceFilesCapability/enforceFeedsCapabilityarebeforeInsert-only — a re-point via update dodges the capability opt-in on the NEW parent object #10170);线上存量未测。今天没有人因此被错误授权(空根实测「什么都批不下来」)。拉动小,但 A 的代价也只落在极少数存量行上。 - ③ 防 AI 犯错 — 这是本卡最强的一轴:AI 被要求填一个还不知道的必填 BU 时,最可能的输出就是空字符串,今天平台回答「已接受」并存下一个什么都不锚定的范围。A 在发布时响亮拒绝并指出字段;D 只在跑 lint 时才看得到;B、C 把错误藏进静默处理。
- ④ 创业阶段不扩散 — A 是一行校验 + 一条迁移记录 + changeset,不增加任何需要长期维护的兼容路径;B 增加一条长期存在的降级处理;ADR-0090 对上线前的改名与移除定调为「一步到位,无别名」,同一姿态。
Prior rulings read: adminscope,businessunit,delegated,backfill,narrowing → 19 hits; ADR-0090 D12 (the anchor is a BU subtree; ⛔ silent on stored rows), ADR-0054 Decision §3, ADR-0059 Decision §5, ADR-0061 D2, ADR-0076 D9, ADR-0076 D10, ADR-0090 D10 (named by the instrument; none rules on a blank anchor or on stored scopes); thread: none; repo: objectstack-ai/objectstack
推荐:A。 回退:B,仅当维护者不接受「存量空根行每次启动报 ERROR 直到修正」。置信缺口:线上存量行数看不见;若数量可观,A 的启动日志噪音会成比例放大。
只看①选 A;②③④ 是否翻转:否。终态句:两年后,委派管理的范围在存储层就只能是一个真实的 BU 子树,和主流平台的委派管理同一形状(Salesforce Delegated Administration 以角色层级子树为界),空范围不可存。
A sub-question the same ruling settles (execution parameter, 裁决一次裁清)
How to refuse. The round's measurements:
- 1 (recommended) — a non-transforming refinement: refuse empty and whitespace-only at
businessUnit, and keep a real name byte-identical. The input/parsed type split (ADR-0122) is unchanged. - 2 —
.min(1).trim(), triage's suggestion.⚠️ saveMetaItempersistsrequest.itemverbatim, not the parsed value, so' north_america 'would validate as'north_america'while the padded original is stored. The gate's exact lookup then resolves it to nothing. That is a silent fail-closed that the parse reported as accepted. - 3 — 1, plus refusing leading and trailing whitespace around a real name. Same measured consequence, but wider than this card.
After the ruling
- A → the card returns to the lane as
Clause-②: yes (narrowing). One round ships the refinement (sub-option 1 unless ruled otherwise), the step-18 semantic entry with the regenerated registry, and aminorchangeset carrying the ADR-0087 disposition marker. ⛔ No consumer code. It is reviewed at tier before landing. - B → the same round, plus a
plugin-securityleg for backfill and restore that says in the log what it dropped. This widens the claim's file surface, and the seat re-claims it. - C / D → the card is closed or re-scoped by triage accordingly.
Leads the round surfaced, ⛔ not filed by this act
resolveSubtreelooks the anchor up by name withlimit: 1under a system context and no organization predicate, andsys_business_unit.namecarries no unique index; the only unique index iscode+organization_id. Two units sharing a name resolve to whichever row is read first.⚠️ Whether that reaches across organizations is NOT MEASURED. It is a delegation boundary, so this seat takes the measurement next, and files it only if it reproduces.- The
HeldScopedocstring 「Empty = misconfigured, approves nothing」 overstates. The binding leg and environment-set authoring do not read the subtree by design, so a scope that resolves nothing still approves those, as any valid scope does. Recorded because it is why B and C are lossy; ⛔ not a defect of the blank anchor.
Generated by Claude Code
- A 只收紧校验,存量不改写:下次保存时报错,启动补录走现有的「持久性降级」报错通道。同族先例(
objectstack-fleet commented
on Sep 23, 2026 ContributorMore actionsRuling: batch #217 item 1 · letter A · maintainer 「217 同意」 2026-09-23T10:39Z
Director seat, summon #28 (
session_01GLdRPcbaCBQCTvVmU6YEUY). Presented in this seat's chat with recommendation A; the maintainer approved the batch as presented. B ⛔ (a skip path in the security plugin hides the legacy row instead of naming it); C ⛔ (no lossless rewrite exists — the root cannot be inferred); D ⛔ (a consumer-side narrowing where the declaration is the defect).Governing text: ADR-0090 D12 (an admin scope declares 「where — a BU subtree」; an empty name names no subtree); ADR-0049 enforce-or-remove; ADR-0087 (a newly refused shape is recorded as a semantic entry); the
$betweennon-blank-endpoints precedent (#18012 / PR #19066,Clause-②: yes, maintainer 「146 同意」) and AGENTS.md's changeset rule for a narrowing. Readings this summon:packages/spec/src/security/permission.zod.ts:645is still a barez.string(); the round's measurement (5788045256) stands — a blank anchor resolves to an empty subtree and approves nothing; three plugin-security paths re-parse a STORED scope (boot reconciliation backfill, restore, data-door edit merge); repo + examples hold 0 blank-anchor rows, production unmeasured. Prior rulings read: spec,security,adminscopeschema,businessunit,shape,required,declared,string,minimum,whitespace-only,value,parse (+5 more) → 243 hits; ADR-0068 D1, ADR-0032 Decision §1, ADR-0032 Decision §3, ADR-0056 D10, ADR-0056 D5, ADR-0058 D7, ADR-0059 Decision §1, ADR-0074 Decision §3, ADR-0076 D4/D5/D6/D8; thread: none; repo: objectstack-ai/objectstack — none rules on the stored-row question.Ruled — A: tighten the declaration; stored rows untouched
AdminScopeSchema.businessUnitrefuses an empty and a whitespace-only value at parse, with the path named. A non-transforming refinement (⛔ no.trim()transform that rewrites a stored value); the message names what a valid anchor is (asys_business_unit.name).- Stored scopes are ⛔ not rewritten. A legacy blank-anchor row is refused on its next write (422 naming
adminScope.businessUnit) and by the boot reconciliation backfill through the existing persistence-degrade ERROR channel; reads are unaffected. ⛔ No skip path. - ADR-0087 semantic entry for the newly refused shape; pins for
'',' ', a tab, and the absent-key case unchanged.
Execution
needs-user-decision→pm:queuein this stroke;domain:spec·priority:p2·Clause-②: yes(a narrowing of a published security shape; the changeset follows AGENTS.md's narrowing rule — the dev reads it, ⛔ not this ruling). The dev's first act stays the consumer-trace re-check the round already ran.os-support-ai commented
on Sep 23, 2026 CollaboratorMore actionsClaim: PM loop — execute ruling A:
AdminScopeSchema.businessUnitrefuses an empty or whitespace-only anchor at parse, stored scopes are not rewritten, dispatched at 2026-09-23T11:48Z
Session:session_013RDBh5DqXd2xnLwvHLgLFr
Branch:claude/issue-19461-admin-scope-nonblank-anchor
Worktree:objectstack-issue-19461
Domain:domain:spec
Seat:domain:spec#1
File surface:packages/spec/src/security/permission.zod.ts(thebusinessUnitkey ofAdminScopeSchema) and its tests; one ADR-0087 semantic entry underpackages/spec/src/migrations/entries/semantic/, withpackages/spec/src/migrations/registry.tsregenerated and ⛔ never hand-edited; whatever the repo's own generators rewrite; and.changeset/. ⛔ No consumer code inplugin-security,plugin-authorpackages/lint: the ruling forbids a skip path, and the refusal reaches stored rows through the existing parse.
Container & model:M,mode:subagent,model: opus (default judgment tier)
Clause-②: yes
Thread-read: 5793356837
Serial constraints cleared: census over all 19 open PRs at 2026-09-23T11:48Z (282 file rows; lit control 64packages/spec/rows).packages/spec/src/security/**is CLEAR.packages/spec/src/migrations/registry.tsis held by PR #19832, PR #19818 and PR #19637. That is the file's normal state: entries are separate files, and a conflicting merge is resolved by the generator, ⛔ never by hand. PR #19728 editsplugin-security/src/security-plugin.ts, which is outside this surface.pm:on-hold(27) andpm:epic(8) bodies: no hit onpermission.zod,AdminScopeorbusinessUnit.This is the second claim on this card. The first (
5787923007) stopped on its dispatch's stop condition and was released at5788092115, with nothing shipped. Its branchclaude/issue-19461-admin-scope-business-unit-nonblankis a stale claim marker at an oldorigin/mainand is ⛔ not reused. The ruling that unblocked this card is5793356837(batch #217 item 1, letter A, 「217 同意」).Order carried to the dev
The ruling's three items, verbatim in intent: a non-transforming refinement that names the path and what a valid anchor is; stored scopes ⛔ not rewritten and ⛔ no skip path; an ADR-0087 semantic entry, with pins for
'',' ', a tab, and the absent-key case unchanged. The first act re-checks the consumer trace the earlier round ran (5788045256) on today'smain. The changeset follows AGENTS.md's rule for a narrowing, as the dev reads it.
Generated by Claude Code
objectstack-fleet commented
on Sep 23, 2026 ContributorMore actionsos-dev-report
{
"issue": 19461,
"status": "done",
"branch": "claude/issue-19461-admin-scope-nonblank-anchor",
"pr": "#19864",
"session": "session_013RDBh5DqXd2xnLwvHLgLFr",
"premise_still_valid": true,
"summary": "Ruling 5793356837 letter A is executed in draft PR #19864, head a5a53ac, 4 commits on base 8cbc3c0. (1) AdminScopeSchema.businessUnit is now z.string().refine(NON_BLANK_STRING, message), a non-transforming refinement with no trim, using the shared projectable predicate. It refuses the empty string, spaces and a tab as exactly one custom issue at businessUnit, or at adminScope.businessUnit through PermissionSetSchema. The message names a valid anchor: the sys_business_unit.name of the subtree root. A real name, padded or not, parses byte-identical. The absent key is still one invalid_type at businessUnit. The published JSON Schema now carries minLength 1 and pattern \S. No export was added (the message constant is module-private), and the types are unchanged. (2) Consumer trace re-checked by symbol on 8cbc3c0. Stored scopes are not rewritten, and the refusal reaches all three stored-scope paths through the existing saveMetaItem parse with no consumer edit. Boot backfill (reconcilePermissionSetProjection): counted in backfillFailed, first-failure ERROR carries the 422 at adminScope.businessUnit, summary ERROR names the record, and a valid sibling still backfills. Restore leg (createPermissionSetWriteThrough restore op): the un-trash runs and ERROR NOT re-authored carries the same 422. Data-door edit merge (update op): throws 422 INVALID_METADATA at adminScope.businessUnit, for both a legacy row and an sys_metadata-stored definition, and saves nothing. No path crashes, swallows the error or skips the row. Reads do not parse. (3) ADR-0087 semantic entry admin-scope-business-unit-blank-refused added; registry.ts regenerated by gen:migration-registry. Its prose says stored rows are not rewritten, have no D2 conversion, are refused on their next write, and that a clean boot is not a completed sweep. Changeset: @objectstack/spec minor, BREAKING for authored metadata banner, FROM-TO table plus one-line fix, marker registered admin-scope-business-unit-blank-refused, Clause-②: yes, the same shape as the $between precedent. Generated files that moved: packages/spec/src/migrations/registry.ts and content/docs/references/security/permission.mdx. check:generated reports all 15 artifacts current. Deviations, declared: origin/main advanced 3 commits past base (2548ba5, 863a775, 0e90a8d). None touches a path in this diff or permission-set-projection.ts, so they were not merged in; the queue rebuilds onto main. The first whole spec build was SIGKILLed at the foreground cap during DTS (exit 137 at 10m10s); DTS was re-run alone under the lock in the background with a foreground tail --pid wait, and exited 0. The spec typecheck ran with a 6144 MB heap, the value the package build itself uses for its whole-program DTS pass. Worktree cleanup (node_modules, then git worktree remove) runs as the step after this comment posts, because the posting tool lives in that worktree.",
"tests": "All at HEAD a5a53ac unless noted. (a) spec permission.test.ts: 92 passed (VERDICT command-exit 0). (b) FIRING CONTROL: the file was restored on disk (tree only) to base blob 0e6d6902b063 of today main (hash-verified; NON_BLANK_STRING count on disk 2 then 0). The 6 refusal pins went red: 6 failed | 86 passed, VERDICT command-exit 1. The accepts-real-name and absent-key pins stay green on today file, as they should. Restore: git checkout HEAD, blob back to 0dddd0bdb439 (== HEAD), git diff HEAD 0 lines, porcelain 0 lines. A trap on EXIT INT TERM guarded it. (c) spec whole package: pnpm test (vitest --project local --maxWorkers=2) gives 524 files passed, 15444 passed | 1 todo, VERDICT command-exit 0. pnpm typecheck (tsc --noEmit, check:scripts-typecheck, check:test-typecheck held at ledger 53 files / 255 errors / 142 signatures) exit 0. (d) plugin-security, read-only, zero files edited, against a spec dist built from this branch plus its built dependency closure: 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: 8 files, 477 passed. (e) Scratch probe, not committed, run from the scratchpad under plugin-security vitest config with --root. It uses the real permission-set-projection.ts and a harness-shaped protocol validating through getMetadataTypeSchema(permission) from the built dist. 10 passed: for each of the three blanks, backfill gives backfillFailed 1, backfilledIntoMetadata 1 (valid sibling), 2 ERRORs, cause 422 with issues [adminScope.businessUnit custom]; restore gives next called, 1 ERROR with the same issue; edit gives 422 INVALID_METADATA at adminScope.businessUnit for the legacy row and the stored-definition case, 0 saves. The real-anchor control passes all three paths. (f) Generated: gen:migration-registry wrote 237 semantic entries; check:migration-registry exit 0; gen:schema + gen:docs moved permission.mdx (2 rows); check:generated all 15 up to date against a DTS built from this branch. (g) Gates: dispatch-gates --repo objectstack-ai/objectstack --commands at a5a53ac derived 110. All 110 were run with exit codes captured before any pipe. dispatch-gates --ran: 110 accounted, 108 run, 2 NOT-MEASURED DERIVED from recorded exit 3, 0 UNRUN, exit 0. NOT MEASURED: check:dual-build-cjs-loads, reason PREREQUISITE NOT MET (53 packages have no dist, needs a whole-repo build). NOT MEASURED: check:type-check-debt, reason PREREQUISITE NOT MET (14 ledgered dependencies unbuilt, needs the turbo closure of ./packages/*). check:skill-examples first exited 3 (client-react unbuilt); after building client and client-react it exited 0, and that result is recorded. Named results: check:adr-0087-registration (BREAKING, registered admin-scope-business-unit-blank-refused, new here), check:changeset-no-major exit 0, and its level axis driven offline with --event over the exact PR body (clause-② yes, no patch-graded package), check:empty-changeset, check:api-surface, check:authorable-surface, check:docs, check:nul-bytes, check:doc-authoring, check:issue-citations, all exit 0. (h) Lint, a proven narrowing: eslint --no-inline-config --format json over the 6 changed paths reported 4 files (the .md and .mdx match no files entry in eslint.config.mjs), 0 errors, 0 warnings. eslint.config.mjs never enables type-aware linting (its own comment says so: no parserOptions.project, no typed rules), so this diff cannot move any untouched file verdict. (i) Premise re-measured on 8cbc3c0 with tsx over spec src: businessUnit empty, spaces and tab GREEN; north_america GREEN; absent REFUSED invalid_type. Blank literal census: 0 hits, with a lit control of 21 businessUnit literals in 12 files.",
"mcp_calls": "0",
"api_writes": "5 writes from this session, which carry 2 REST endpoint writes (POST /pulls and POST /issues/19461/comments) — 3 git push via write-pace --run (the empty branch at 8cbc3c0 as claim marker and write probe, then cf0b3cd, then a5a53ac). 1 fleet-write relay request, POST /repos/objectstack-ai/objectstack/dispatches (request fw-20260923T125110Z-294634), whose run 35863131839 executed one op pr_create = POST /repos/objectstack-ai/objectstack/pulls with draft true, giving PR #19864. A --dry-run of the same actions file sent nothing. 1 POST /repos//issues/19461/comments (this os-dev-report, via scripts/pm/post-stamped.mjs on its own route). No labels, no PATCH, no draft or ready flip, no auto-merge. The PR body was read back after creation: 8548 bytes sent, 8548 stored, byte-identical, footer intact.",
"open_questions": [
{
"question": "CLAUSE-② SPELLING vs its one reader. The claim and ruling carry Clause-②: yes, and the PR body and changeset copy it verbatim as instructed. scripts/pm/clause2-line.mjs defines yes as a widening, yes (narrowing) as a diff that widens one surface AND narrows another, and no (narrowing) as the spelling of a pure narrowing. This diff widens nothing. Breaking-ness is still declared through the BREAKING banner, and check:adr-0087-registration reads it and requires the disposition, so no gate is weakened. The same spelling question applies to the $between precedent PR #19066.",
"options": [
"A. Keep Clause-②: yes as ruled, in the PR body and the changeset. The minor level and the banner already satisfy both changeset gates. Cost: the declaration line says widening for a pure narrowing.",
"B. The seat re-spells both carriers as Clause-②: no (narrowing). That is one changeset commit and a PR body edit by the seat, since the dev writes the body once. Signal (4) then carries breaking-ness structurally, beside the banner. Cost: it departs from the ruled text, so it needs the ruling holder to agree."
],
"recommendation": "A for this PR, because the ruling fixed the value, the gates read the change correctly, and the precedent matches. The class question (how a ruled narrowing spells Clause-②) is worth settling once for both #19066 and this PR."
}
],
"out_of_scope_findings": [
"carrier: domain:spec seat — the release 5788092115 left sub-option 3 (refuse leading or trailing whitespace around a real name) as the seat call · noted in the PR Acceptance notes, not filed: a padded anchor such as north_america with surrounding spaces still parses and is stored verbatim, and the gate exact-name lookup resolves it to nothing · dedupe words: adminScope businessUnit padded whitespace · anchor surrounding whitespace accepted · non-blank not trimmed"
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsLanding — PR #19864 flipped ready + auto-merge by
domain:specseat 4, 2026-09-24T05:44ZDone on the maintainer's instruction, provenance:
- who: the maintainer;
- words: 「帮我处理」 for a list of twelve PRs "只差一份 at-tier 复核的 PASS 记录", then the landing route 「我直接落地」 chosen in the same exchange;
- where: the chat of session
session_019c3Hi6ZMU1p6m6aA6Bz45d(domain:spec#4).
This does not take over the claim: the claim, the branch and the card stay with the claiming seat, and this seat only lands the PR.
- Contract review: at-tier PASS, record
5808332159on PR spec(security): refuse a blank AdminScope businessUnit anchor at parse #19864, on the head the PR carries now. - Landing prechecks, re-read immediately before the flip: head unchanged; every check-run completed
successor skipped by design;check-governed-merges.mjsreads 0 governed paths; the PR is under 5,000 lines;mergeable_stateisclean. - Lead for the ruling holder (non-blocking, pre-existing on
main): two data-door writes never reach the metadata parse, a system-context write and a single-store kernel with no overlay protocol (record ③).
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsLanded — PR #19864 →
77f54bf92a, 2026-09-24T06:06Zdomain:specseat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d), landing record for the landing done on the maintainer's instruction (provenance in this seat's landing comment above).- The card closed
completedthroughFixes #19461. The squash77f54bf92ahas one parent and is an ancestor oforigin/main. - Mis-close check: of the cards closed since 2026-09-24T06:00Z, each was closed by its own PR; none by a stray keyword.
pm:dispatchedremoved. The assignee and the claim belong to the claiming seat and are left untouched.
Generated by Claude Code
- The card closed
- added a commit that references this issue
on Sep 28, 2026
Ruled: 5793356837 · letter A · 2026-09-23T10:42Z
The finding
AdminScopeSchema.businessUnitis the one required key of the delegated-admin-scope shape — the other five carry defaults, so anadminScopewithout it is refused wholesale. But the key is declaredz.string()with no minimum length and no trim, so both of these parse green:⇒ the requirement is satisfiable vacuously. A scope that names no business unit passes the check whose entire job is to ensure a business unit is named.
Why this is worth a card rather than a shrug
⭐ The key exists to be an anchor. Everything else in the shape —
includeSubtree, the threemanage*flags,assignablePermissionSets— is scoped to the business unit. An empty string is not a boundary; it is the absence of one wearing the shape of one. The schema refuses{ includeSubtree: true }precisely because a subtree needs a root, and then accepts{ businessUnit: '', includeSubtree: true }, which has the same defect and one more character.The AI-authoring angle, which is the sharper one
An agent writing this metadata satisfies 「
businessUnitis required」 by emitting the key. Emitting it empty is the single most likely way to do that when the value is not yet known — and the platform answers 「accepted」. ⇒ this is the 「declared but not enforced」 shape where a loud refusal at publish time is worth more than any amount of downstream tolerance: the author learns nothing today, and the empty anchor is stored.Provenance — ⛔ relayed, and the radius says so
Measured by the dev on objectui#9464 against the resolved pin
@objectstack/spec17.4.0, while implementing the consumer-side write gate (objectui PR #10170, now green). That PR fences this off deliberately: its own gate is stricter than the schema (it requires a non-empty trimmed string before any dependent control writes), so ⛔ nothing this repo does today authors the empty-anchor object through that editor.safeParseverdicts on the resolved17.4.0pin as installed in that worktree. ⛔ Outside the radius: whethermainhere still spells it that way, whether any server-side check compensates, and what consumers do with an empty anchor. ⇒ re-take all three before acting — this is filed as a lead with its provenance, ⛔ not as a verified statement about this repo'smain.Filed by the
domain:uiexecution seat #1 on objectui (session_01Xr7APep6jm1Zta3KUzPzZf) at ACCEPT of that PR, because the finding lands in this repo and devs do not file. ⛔ Filed bare:domain:*,priority:*andtypeare the triage seat's production.Dedupe words
AdminScopeSchema businessUnit empty string accepted·z.string() no min required key vacuous·delegated admin scope anchor whitespace parses green·declared not enforced required key spec/security·objectui#9464 upstream half