fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298) - #15311
Conversation
…e first level that NAMES one (#15298) `declaredPermissionSets`' docblock stated a short-circuit the code did not have: it pushed the flattened top level and then every package body unconditionally, collecting each permission set twice on today's additive artifact. The duplication was unobservable at the sole (private) call site, which takes the first `isDefault` set, so this corrects a false written contract on a security-path reader rather than a live defect. The reader now walks the discipline its docblock claims — start from the expression the card replaced (`appDefaultPermissionSetName(config.permissions)`) and consult `packages[]` only where it came back `undefined`. The condition is the resolved NAME, never the `permissions` container: branching on the container re-creates the silent loss the card removed one shape further along, since a flattened level that carries sets but marks none of them `isDefault` is legal and hand-authorable. `resolveArtifactPackageOrder` is called before the top level is consulted, so its ADR-0112 refusal of a malformed `packages` stays unconditional instead of depending on which level happened to answer first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 14 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 34409788874a4f85b01d4cca5a57d07e4fc2b742 && git checkout 34409788874a4f85b01d4cca5a57d07e4fc2b742
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6e67b86c03c9e68e95ed93783de02d82b2a6f6d5 274ab06bcf335c82325b65bca0b9ea1c448b753a && git checkout -B drift-repro 6e67b86c03c9e68e95ed93783de02d82b2a6f6d5 && git merge --no-ff 274ab06bcf335c82325b65bca0b9ea1c448b753a
node scripts/docs-audit/affected-docs.mjs --json 6e67b86c03c9e68e95ed93783de02d82b2a6f6d5 |
…eader correction (#15298) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…clared-permission-sets-docblock
…re the sibling branches on the container (#15298) `resolveStackCollection` (packages/cli/src/utils/stack-collections.ts, #15006) landed on main branching on the container, and is right to: it returns a whole collection, so a present top-level key has already answered. This reader extracts a distinguished element out of the collection, so "present" and "answers" are different facts here. Recording the reason in the docblock so the convergence pass reads two readers that differ AND say why, rather than two that differ while appearing to agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…he same unreleased batch (#15298) Both entries are patch bumps on @objectstack/plugin-security and will compile into one release. Leaving the earlier one as written keeps the record honest; naming the superseded sentence keeps the compiled notes from carrying a contradiction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
复核:PASSdiff 层面核过的四件事
我给的方案是错的,而且这次是被测量证伪的派发单里我写的是按容器分支: if (Array.isArray(flattened) && flattened.length > 0) return flattened;席位没照做,改成按答案分支,并且不是凭论证 —— 它建了一个 26 个形状的差分语料(4 个真实产出含 #15004 的 collection-zoo 夹具、14 个单测配置、3 个畸形
原因是我漏掉的那个 case:顶层带着若干 permission set、却一个都没标 消融把这个差距钉死了:
这是本卡第二次由席位纠正我(第一次是它最初提出按答案分支)。两次我都把更正公开留在记录里,而不是私下认下 —— 派发席的错误判断要能被后来的人看见。 顺带,它还回答了我另一张卡(#15293, 工程纪律里值得点名的三处
裁决:那个 open question 走 A(即已落地的做法)问题是:#15226 的 changeset 已在 main 上、未发布,而本 PR 推翻了它的一句话 ——「The resolution now reads the flattened top level FIRST and then each package body」。两条都是同一个包的 A(保留 #15226 的条目,由本条 changeset 点名那句被取代的话)成立,理由与席位给的一致,我补一层: #15226 那句话当时是真的 —— 它准确描述了 #15226 落地时代码在做什么(拼接)。为了让编译出来的发布说明只出现一种说法而去回改它,等于让记录去描述一棵从未存在过的树。而这正是本卡要关掉的那类缺陷(成文契约与实际不符),只是挪到了发布说明这一层。 已核 changeset 第 24-26 行确实点了名,并写明「That described #15226 accurately when it landed」。发布说明的合并是发版时人工编译的动作 —— 这条点名正是让编译者能有意识地把两条收成一条,而不是让它们互相矛盾地并列。 落地前检
Generated by Claude Code |
Fixes #15298
declaredPermissionSetscarried a docblock stating a short-circuit its code did not have:The code pushed the flattened top level and then every package body unconditionally, so on today's additive artifact (flattened level and
packages[]both present) every permission set was collected twice. Nothing observable came of it: the sole caller is private and takes the firstisDefaultset, which the flattened copy still supplied. So this corrects a false written contract on a security-path reader, not a live defect — and the sentence was load-bearing, because it was the stated reason the reader half was revertible on its own and safe to land ahead of the emitter half (#14512).The fix: start from the expression this program replaced
The expression card #15007 replaced was
appDefaultPermissionSetName(config.permissions)— a name, whose "came back empty" isundefined. The reader now walks exactly that discipline: resolve from the flattened level, and consultpackages[]only where it answered nothing.The condition is the resolved NAME, never the
permissionscontainer. Branching on the container re-creates the silent loss this program exists to remove, one shape further along. Reading the answer also retires the[]-is-truthy trap rather than patching around it, since the container is never the predicate.The package order is resolved BEFORE the top level is consulted, deliberately.
resolveArtifactPackageOrderrefuses a malformedpackages— not an array, an entry inlined instead of wrapped undermanifest:, a duplicate package id — with an ADR-0112 envelope this reader does not catch. Moving that line below the early return would make the refusal depend on whether the flattened level happened to name a default first. An artifact is either loadable or refused; which level answered is not part of that question. The docblock says so in the file, anticipating the reading that the line is misplaced.Why this reader's condition is not the sibling's.
resolveStackCollection(packages/cli/src/utils/stack-collections.ts, #15006, now on main) branches on the container —if (Array.isArray(top)) return top;— and is right to: it returns a whole collection, so a present top-level key has by construction already answered. This reader extracts a distinguished element out of the collection, so "the key is present" and "the key answers" are two different facts here. Same discipline, read against what each reader's expression actually returns. That reasoning is now in the docblock, so the convergence pass reads two readers that differ and say why, rather than two that differ while appearing to agree.The dispatched fix was corrected, and the record stays here
Preserved from the PM's own account on this PR, because the correction matters more than the card did.
The dispatch prescribed branching on the container:
with a warning that the bare
Array.isArrayform would re-create the silent loss via the empty-array truthiness trap. That warning was right and insufficient:length > 0fixes the empty case and leaves a worse one open. A config whose flattened level carries permission sets but marks none of themisDefault— legal today, hand-writable in anyobjectstack.config.ts— short-circuits the entirepackages[]pass under that version and resolvesundefined, with nothing thrown and nothing logged.That is no longer an argument. Ablation 1 below measures it: against the dispatched snippet, exactly that shape goes red.
Verification
Everything below ran at HEAD
274ab06bc, on this branch merged up toorigin/main3a4373f4c.1 · The acceptance clause: byte-identical returns, measured rather than argued
A differential harness drove
appSecurityPluginOptionsover 26 shapes and recorded every answer: 4 emitted shapes (the composed additive artifact, its option-B derivative, and the collection-zoo fixtures behind the #15004 acceptance pin), 14 configs the unit suite drives, 3 malformed-packagesrefusals recorded as throwncode+status, and 5 hand-authored mixed shapes the platform never emits. Three legs, each rebuilding@objectstack/plugin-securityand proving on disk what the suite would consume:dist/preflight0ab95e53, = HEAD blob)declaredDefaultPermissionSetNamepresent in 4 built files9690d114b(727c8455, = that blob); on disk:sets.push×2, new name ×0sets.pushpresent in 2 built files, new name absent from all 60ab95e53, = HEAD blob)diff before after→ empty, exit 0.diff after after2→ empty, exit 0, so the restore leg is proven by re-measurement, not by a checkout exit code.Two of the 26 rows are the ones the dispatched snippet would have moved silently: a flattened level naming a default plus a malformed
packagesstill throwsINVALID_ARTIFACT_PACKAGES/DUPLICATE_ARTIFACT_PACKAGEat 422. That identity is what resolving the package order first buys.2 · Ablations — the new tests are load-bearing
Each mutation was confirmed on disk before running (injected marker counted
1, replaced anchor counted0, file hash printed against the HEAD blob), and each restore was proven by hash equality plus a marker count of0.length > 0(the dispatched snippet)a NON-EMPTY flattened array that marks no default does not eitherlength > 0an EMPTY flattened array does not short-circuit itThe card's mandated positive test — top-level
permissions: []plus a package body carrying anisDefaultset — is the second failure, so it catches the trap the card named. The first failure is the one a container-shaped condition leaves open at any spelling.This ablation needs no rebuild: the unit suite imports
./app-default-permission-setrelative, inside its own package, so vitest resolves source. The rebuild-and-prove discipline is owed by the CLI pin, which reaches this code through the workspace link todist/, and it is paid in §1 and §3.3 · Suites
@objectstack/plugin-securityunit suite: 23 passed (the 18 that were on main, plus 5 new).packages/cli/test/option-b-reader-acceptance.pin.test.ts: 7 passed, against adist/proven to carry this change. The file is untouched;OPTION_B_LOSSES, its set-equality assertion, and the row fix(plugin-security): the app default permission set resolves frompackages[](#15007) #15226 deleted are all exactly as they were.4 · Gates
46 families derived from the real change set by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived after the merge because the first derivation warned it had read stale copies of its own inputs — and it had: the count moved 45 → 46, gainingcheck:cli-test-child-env. Every exit code was captured before any pipe (cmd > log 2>&1; rc=$?).All 46 green. Two needed a second pass and are worth naming, because their first result was neither a pass nor a failure:
check:dual-build-cjs-loadsandcheck:i18nfirst exited 3 —PREREQUISITE NOT MET, both saying so in their own words ("Nothing was checked", "This is NOT a pass: nothing was measured"). They read built output across the whole workspace, which a package-scoped closure does not provide. Afterturbo run buildover./packages/*and./packages/*/*(71/71 tasks successful) both re-ran green: 102 require entry points across 66 packages load, 610 emitted CJS files parse; 9 i18n packages all in sync.check:type-check-debtdid measure rather than refuse: 14 ledger entries re-measured in 601.6s, 153 raw tsc errors, none above its recorded number.Boundaries
packages/cli/test/option-b-reader-acceptance.pin.test.ts— untouched.content/docs/releases/— untouched.patch, per the card:declaredPermissionSetsis private, so no published surface moves. It also names the one sentence of the fix(plugin-security): the app default permission set resolves frompackages[](#15007) #15226 entry it supersedes in this same unreleased batch, rather than retro-editing that entry.🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Generated by Claude Code