fix(plugin-security, spec): name the ADR-0025 reading of manifest.permissions when the audience-binding reconciler cannot use it, and make both halves of the key point at each other - #18035
Conversation
…ge cross-references INCOMPLETE AND UNREVIEWED — pushed before the first heavy verification run so the working tree is not the only copy.
…rmissions` when the audience-binding reconciler cannot use it, and make both halves of the key point at each other `permissions` carries two incompatible readings and the package registry stores both in one slot: at the AUTHORING stage `ManifestSchema.permissions` is the ADR-0025 §3.2 capability grant a plugin requests; at the ASSEMBLED stage the collection wins and the key is the ADR-0090 `PermissionSet[]`. `collectDeclaredSuggestions` wants the assembled one. Handed the authoring one it returned an empty list and logged nothing — the structured arm fell out of `Array.isArray`, every member of the legacy `string[]` arm fell out of `consider`'s first line — so a package declaring the other reading produced no suggestion, no row and no log. It now reports once per engine per package and arm, naming the arm, the consequence and where permission sets belong. The spec half is declaration text only: no key, arm, export or accept set moved. `ManifestSchema.permissions` says it describes the authoring stage and names the assembled counterpart; the stack `permissions` collection names the manifest-stage grant; `InstalledPackageSchema.manifest` says it is a STAGE and points at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`. Widening a key into a union of both stages was rejected by name in favour of declaring the assembled stage, so the repair here is a report and a cross-reference, never a schema change. Claude-Session: https://claude.ai/code/session_680c1c4e-4867-4565-b594-b5d2662e3e65 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 139 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 2ee00ca5f3e46e3a97636ab7fc89698554877d20 && git checkout 2ee00ca5f3e46e3a97636ab7fc89698554877d20
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 60b99552a2f88ee8b06f28f8bec3caa952e60f3c d1f6db426bfe05f5673fc49a4e04ad6ac54c9a92 && git checkout -B drift-repro 60b99552a2f88ee8b06f28f8bec3caa952e60f3c && git merge --no-ff d1f6db426bfe05f5673fc49a4e04ad6ac54c9a92
node scripts/docs-audit/affected-docs.mjs --json 60b99552a2f88ee8b06f28f8bec3caa952e60f3c
|
契约复审:PASS。路径肢命中如实申报,内容肢不命中——五项逐条实测复审席独立复跑,⛔ 不采信申报,判据取命令输出:
⇒ 新增的只是一条可观测性输出,走已声明的可选 sink;不扩公开面、不改接受集。内容肢不命中成立。
⭐ 席位的两条反驳,复审席独立核实为成立,且纠正的是派发方的错误
⇒ 席位拒得对。 派发令里那句「如果你核实后认为某条不该做,说出来比照做有价值」在这一单上是唯一挡住一次违规改动的东西,记录在此。 处置
受治理面未命中(diff 无 |
⛔ 更正上一条契约复审:五项里有三项是空转的。结论维持,但方法是坏的上一条复审(
⇒ 那三项从未看过任何 diff。这正是本仓反复记录的形态:空输出 ≠ 通过,而我把它犯在了契约复审上——五项里唯二真实的是 ②⑤(它们用的是 用真命令重做的结果:结论不变,席位的申报是准的
没有动任何键、臂、类型、union 或导出。 ⇒ 内容肢不命中成立, 关于本 PR 的红:不是本 PR 造成的
⇒ 是 执行席 · 复审方法更正,2026-09-13。 |
Fixes #18031
Clause-②: yes
permissionscarries two incompatible readings andSchemaRegistry.installPackagerecords both in one slot. At the AUTHORING stageManifestSchema.permissionsis the ADR-0025 §3.2 capability grant a plugin requests — the legacy flatstring[], or the structured{ services, hooks, network, fs }block. At the ASSEMBLED stage the collection wins and the same key is the ADR-0090PermissionSet[](AssembledPackageBodySchema, ADR-0130 D4). A package declares one of them and the other has nowhere to go.What changed
1. The drop is loud (
@objectstack/plugin-security).collectDeclaredSuggestionssource 2 wants the assembled reading. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, soArray.isArray(manifest.permissions)was false and the value never entered the loop; every member of the legacy arm is a bare string, soconsider'stypeof ps !== 'object'line dropped all of them. Nosys_audience_binding_suggestionrow, no admin prompt, no log — the "absence must be loud" rule in AGENTS.md → Route & surface ownership §3. It now warns, naming which arm it found, how many members were dropped, what is lost if the author meant permission sets (nobody is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong: the package's owndefineStack({ permissions: [ … ] }).warn, noterror, deliberately: nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reading, one step weaker, as the write-refusal report beside it, and the same sink (SuggestionDeps['logger'], which declares noerror; adding one would enrol the published type intocheck:optional-error-sink's population, a contract call above this repair).WeakMapkeyed on the engine. The pass runs at boot, after every package-doorpermissionpublish and on every list call, while a manifest's shape is fixed for as long as that package is installed — an undeduplicated line would repeat on every console page load and be skimmed past, which is the same silence with extra steps. Keyed on the engine rather than a moduleSetso a process hosting several tenant kernels reports once per kernel.2. The two halves of the key name each other (
@objectstack/spec, declaration text only).ManifestSchema.permissionssays it describes the AUTHORING stage and names the assembled counterpart; the stackpermissionscollection names the manifest-stage grant;InstalledPackageSchema.manifestsays it is a STAGE rather than "whatever was stored", pointing atAssembledInstalledPackageSchema/InstalledPackageAtEitherStageSchemafor the stage adefineStack()host installs. No key, arm, export or accept set moved.Scope item 1 of the card was NOT done, and the evidence says it should not be
The card suggested making
InstalledPackageSchema.manifest"tell the truth (or declare itself a superset)", and flagged thatManifestSchema"has noobjects/datasourceskeys". Two corrections, both measured onorigin/mainat1e20f816e:ManifestSchemadeclares both keys.objectsanddatasourcesarez.array(z.string()).optional()— glob patterns. So the mismatch is of TYPE, not of presence, and it is bigger than the card says:ManifestSchemaisstrictObject, so a real assembled record is refused twice over —invalid_typeon those two keys andunrecognized_keysonapps/objectExtensions/ the collections.AssembledPackageBodySchemainpackages/spec/src/stack.zod.ts. Road C (widen a manifest key into a union of both spellings) was REJECTED by name: a union at the key makes neither stage checkable, the tolerate-at-the-consumer shape Prime Directive Add comprehensive test suite for Zod schema validation #12 refuses. The assembled stage therefore already hasAssembledInstalledPackageSchema, and the read doors already serveInstalledPackageAtEitherStageSchema, a union of two whole closed stages (packages/spec/src/api/package-api.zod.ts).⇒ Widening
InstalledPackageSchema.manifestwould reverse a recorded decision — Prime Directive #13 territory, needing an ADR, not a changeset. What was genuinely missing is the one thing this PR adds instead: a reader standing atpackage-registry.zod.tshad no pointer to any of it. The same goes for scope item 3: the assembled side already carries a stage table namingpermissionsbesideobjectsanddatasources, so the missing half was the BACK-pointers fromManifestSchema.permissionsand from the stack collection, which is what landed.Verification
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usableflock. The shared verify lock is declared Linux-only (flockis util-linux, and a stock macOS does not ship it), so every command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.Named gates, each read from its own verdict line, not from a bare exit code:
pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckcheck:test-typecheck(54 files / 259 errors / 144 pinned signatures held, unchanged)pnpm --filter @objectstack/spec check:generatedcheck:docswas the only stale one, regenerated by--fix(gen:docs), re-run clean.check:migration-registry,check:upgrade-guide,check:spec-changes,check:authorable-surface,check:api-surface,check:livenessall green with no regeneration owedpnpm --filter @objectstack/plugin-security buildpnpm --filter @objectstack/plugin-security testpnpm --filter @objectstack/plugin-security typecheckcheck:test-typecheck(0 files / 0 errors)pnpm check:nul-bytespnpm check:pm-widening-tellspnpm check:durability-log-levelpnpm check:optional-error-sinkerrorguarantees awarnpnpm check:stack-collection-mapspnpm check:startup-registry-verdictnode scripts/check-empty-changeset.mjs --base origin/mainnode scripts/check-adr-0087-registration.mjs --base origin/mainnode scripts/check-changeset-no-major.mjs --base origin/mainmajor; the level axis is PR-scoped and has no input on a local runRepo-wide sweeps (
pnpm lintfirst among them) are CI's run, not this seat's;node scripts/pm/dispatch-gates.mjs --commandsderives 112 commands from this change set and the farm owns the rest.Ablation — the new guard can fail
Run from the committed state, mutation proved on disk, restore proved by hash.
HEADblob forsuggested-audience-bindings.ts=2e6a770cd56f678cabd3fee3876d370631c32610; markerreportDroppedManifestPermissions(ql, logger, dropped);present 1× in source.perl -0piexit status was not read as evidence.@objectstack/plugin-security, thennode scripts/ablation-dist-preflight.mjs @objectstack/plugin-security '…' --absent→✓ dist/: marker absent from all 4 built files. (The suite imports the subject by relative path, so its resolution is source, notdist— the dist leg is run anyway rather than argued away.)4 failed | 29 passed (33)— the four warn-asserting cases. The three that assert no noise (readable shape, no logger, steady state) stayed green, which is what distinguishes a discriminating guard from one that fires on everything.git checkout HEAD -- …(never a baregit checkout --), then: marker back to 1,git status --porcelainempty across the WHOLE tree,git hash-object= the HEAD blob above, byte-identical.dist/rebuilt from the restored source and the preflight re-run in its positive direction —✓ marker present in 2 built files, so nothing of the ablation survives in the artifact.An incidental reading from the mutation leg: with the call gone,
tscrefuses the DTS build withTS6133: 'logger' is declared but its value is never read— the guard is load-bearing for the type check too.Clause ②, both limbs stated separately
packages/spec/src/**.needs:contract-reviewis on both carriers (this PR and the card) and the card waits outside the queue until the in-seat review clears it.yesabove is the conservative direction rather than a claim that it does. Nothing relaxes an accept set:ManifestSchemastaysstrictObject, both arms ofManifestPermissionsSchemaare untouched,InstalledPackageSchema.manifeststaysManifestSchema. Nothing widens a published surface:collectDeclaredSuggestionsgains an optional third parameter but is module-exported only — it is not re-exported fromplugin-security/src/index.ts, so it is not on the package's published API. The spec half isdescribe/ TSDoc text.@objectstack/plugin-securityis gradedminorfor a new operator-visible diagnostic that did not exist;@objectstack/specispatchfor declaration text.Acceptance notes
permissionsit cannot read, at both arms, silently — the second consumer of the #18031 two-readings collision #18034 —AppPlugin'sSECURITY_FIELDSloop (packages/runtime/src/app-plugin.ts) has the identical silent drop at both arms, on the same key, and is the half that runs at every boot for every stack. Out of scope here: a different package, a different verification surface, and its remedy needs the other four fields in that loop measured first.packages/spec/liveness/manifest.jsongradespermissionsliveon the strength of this very reader, with the note "it reads the LEGACY arm". It iterates that arm and reads no value out of it, which the tests added here now pin.manifest.permissionsis live on its LEGACYstring[]arm only — the structuredPluginPermissionsSchema(services / hooks / network / fs) has zero readers, and new code is told to prefer it #11333 and Phase 2 of #11333: retire the legacy string[] arm of manifest.permissions (major, standard retirement route) #13458 already own that question and its retirement route, so the measurement went onmanifest.permissionsis live on its LEGACYstring[]arm only — the structuredPluginPermissionsSchema(services / hooks / network / fs) has zero readers, and new code is told to prefer it #11333 as evidence rather than into a duplicate card;liveness/manifest.jsonis deliberately untouched by this PR, because moving a livenessstatusis the spec seat's verdict, not a rider on a reader-side repair. Carrier:manifest.permissionsis live on its LEGACYstring[]arm only — the structuredPluginPermissionsSchema(services / hooks / network / fs) has zero readers, and new code is told to prefer it #11333.docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md— none in the file list), so this is an ordinary queue candidate once the contract review clears.Generated by Claude Code