Repository navigation
fix(plugin-security): a cloned permission set is not reported as an unowned declaration on boot (#21669) - #21692
Conversation
…ration (WIP) The boot loop tells an environment-owned set apart from a package declaration before it judges ownership. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ning, and a truly unowned one still warns Unit pins at the seeder seam (single and per-organization passes, the package-owned-row and unreadable controls, conservation) and a boot-level pin over the real showcase composition: clone through the shipped action's own payload on boot 1, assert the walk holds the clone and no unowned line names it on boot 2. Plus the patch changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 46f9a46a943cc2738784dd26479f45d0eff4134d && git checkout 46f9a46a943cc2738784dd26479f45d0eff4134d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 067aa4f58f1434dcd0d1675c5aee76c57b1636f1 && git checkout -B drift-repro 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 && git merge --no-ff 067aa4f58f1434dcd0d1675c5aee76c57b1636f1
node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8
|
Fixes #21669
Clause-②: no
What was wrong
bootstrapDeclaredPermissions(plugin-security) walks everypermissionitem in the engine SchemaRegistry. That registry also holds the permission sets an environment authored itself:loadMetaFromDbhydrates every env-widesys_metadatapermissionrow into the same collection. A set made with Setup's Clone action is one of them, and it has no package id there, because it has none. The loop judged "no owning package" before it looked at the set's row, so every boot, and everymetadata:reloaded, logged[permission_set_declaration_unowned] declared permission set "…" has no owning package — not materialized … the Setup admin surface reads sys_permission_set and cannot see this setfor a set whosemanaged_by: adminrow Setup lists and edits.Reproduced first, on the real showcase composition
Boot 1 of
bootStack(showcaseStack, { databaseFile }): the admin signs in and clonesshowcase_contributorthroughPOST /api/v1/data/sys_permission_set, with the shippedclone_permission_setaction's payload. Then a cold boot on the same file. Measured ona4fd82a8e6, with the plugin-securitydistbuilt from that tree:201managed_by: admin,package_id: null,organization_id: nullsys_metadatarowstate: active,organization_id: null,package_id: null_packageId, nopackageId;_provenance: 'org'(stamped at hydration)_packageId(com.example.showcaseorcom.objectstack.plugin-security) and_provenance: 'package'skippedUnowned: 1,skippedEnvAuthored: 8,total: 18(boot 1:skippedUnowned: 0,total: 17)The line that reported it is
upsertPackagePermissionSet'sif (!packageId)branch (reportPermissionSetDeclarationUnowned), reached from the boot loop withpackageId = ps._packageId ?? ps.packageId, which is undefined for the clone. The report became visible withcf39b83c09;git log -Son the call finds that commit alone.The same steps with this branch's
dist: 0 unowned lines, and the seeding summary readsskippedUnowned: 0,skippedEnvAuthored: 9.The fix
Before the loop judges ownership, it checks whether the environment already owns a row under the name. A registry item with no package id, whose
sys_permission_setrow is environment-owned, is the environment's own set. It lands inskippedEnvAuthored, where a package declaration over an environment row already lands, and nothing is logged.Which signal, and why. The signal is the row's
managed_by, read through the existing env-authored verdict. That is the one definition of environment ownership this seeder already had: the last branch ofupsertPackagePermissionSet, where a row that is notmanaged_by: 'package'is environment-authored and never clobbered (ADR-0086 two-doors). It is now namedisEnvironmentOwnedRow, and both that branch and the new check call it, so there is still exactly one spelling. Two alternatives were measured and not taken:_provenance: 'org'. It is on the clone's SchemaRegistry item. But the metadata service's copy of the same clone carries no_provenance; it carries the projection's_envProjectionmarker instead. A check keyed on it would depend on which registry copy the loop reads, and that question is still open on [finding] After #12892 step 2 an artifact boot with an engine still holds a THIRD, un-parsed copy ofpermissions/capabilities/sharingRulesin the ObjectQL SchemaRegistry (AppPlugin.init→manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491. Nothing in plugin-security reads_provenancetoday, so it would also be a second definition.declaredPackageIdOf. It answers "is this an artifact?", not "who owns this row?". It would leave the same "unowned" verdict for the clone.The check reads from the batched existence read the loop already makes, so no query is added. On a per-organization pass, it also counts the organization-less row the environment door writes.
permission-set-projection.tsis deliberately not per-organization, so a clone's row has noorganization_idthere. If the read cannot answer, that is not proof the environment owns the name, so the unowned refusal stands, exactly as before. The publish-time materializer (ADR-0086 P2) is unchanged.The warning stays honest. A declaration with no owning package and no environment row still logs
permission_set_declaration_unowned, with the same text, and still counts asskippedUnowned.seed-refusal-diagnostics.tsis untouched.#14491 (the third registry copy) was read first
It shares the registry walk, and the fix does not depend on which copy the loop reads.
readDeclared(ql, 'permission')reads the one SchemaRegistrypermissioncollection. On boot 2 that collection held 17 items frommanifest.register(the unparsed third copy [finding] After #12892 step 2 an artifact boot with an engine still holds a THIRD, un-parsed copy ofpermissions/capabilities/sharingRulesin the ObjectQL SchemaRegistry (AppPlugin.init→manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491 describes, each with_packageId), plus the clone, whichloadMetaFromDbhydrated fromsys_metadata. So [finding] After #12892 step 2 an artifact boot with an engine still holds a THIRD, un-parsed copy ofpermissions/capabilities/sharingRulesin the ObjectQL SchemaRegistry (AppPlugin.init→manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491's copy feeds this loop, but the clone does not come from that copy.sys_permission_setrow. The metadata service's copy of the clone also has no package id (measured above), so the decision would be the same if the loop read that copy instead.packages/objectqlorpackages/metadata*file is touched.Pins
bootstrap-declared-permissions.test.ts, under[#21669], 7 cases:managed_by: adminrow) logs nothing on any of the five console channels, countsskippedEnvAuthored: 1, and its row is untouched;declared permission set "crm_orphan" has no owning package — not materialized.; the same on a per-organization pass;managed_by: packagerow still warns; an unreadable read still warns;packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts. The unit seam cannot show that the real boot puts the clone in the walk, so this pin boots the showcase twice:target, itsbodyExtra, its typedlabel/name, and everydefaultFromRowfacet.permission_set_declaration_unownedline names the clone.Ablations (one-time, nothing left in the tree)
Each leg ran through
scripts/ablation-replace.mjswith an anchor that must hit, on top of the committed fix (067aa4f58f). Each leg rebuilt@objectstack/plugin-security, andscripts/ablation-dist-preflight.mjsconfirmed the marker had reacheddist/before any result was read. The direction was predicted before each run.environmentOwnsNamereturns falseenvironmentOwnsNamereturns trueRestore, both legs:
ablation-replacereported that the file's blob equals HEAD (56a5addec78e) and thatgit diff HEADis empty. A rebuild then followed, andablation-dist-preflight --absentreported both markers absent from all 6 built files and the tree clean.Verification, on HEAD
067aa4f58fpnpm --filter @objectstack/plugin-security test:Test Files 164 passed (164),Tests 3534 passed | 45 skipped (3579).pnpm --filter @objectstack/plugin-security typecheck: exit 0.check:test-typecheckOK, andtsc -p tsconfig.test.json --listFilesincludes the edited test file.pnpm --filter @objectstack/dogfood typecheck: exit 0, and the new pin is in the program.Tests 3 passed (3).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 69 commands; all 69 exited 0. Reconciled with--ran:69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3).check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 unrelated packages had nodist/). After those were built it exited 0.check-shard-attestation(dogfood shard matrix) andcheck-issue-citations --census.eslint --no-inline-config --format json: 3 file results, 0 errors, 0 warnings.eslint.config.mjsuses noparserOptions.project(no type-aware rules), so this diff cannot move the verdict on any untouched file. The repo-wide lint is CI's.content/docs/**(outsidereleases/) andskills/**have no mention ofpermission_set_declaration_unownedor "no owning package", so no sentence was made false.Acceptance notes
permissionisallowOrgOverride: false, so the clone'ssys_metadatarow lands env-wide and is hydrated in every posture.bootstrap-declared-capabilities.tshas the same order: "unowned" is judged before the row is read (capability_declaration_unowned). Whether an environment-authored capability ever reaches that registry walk without a package id was not measured. refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 (ADR-0131 C3) retires that seeder and will touch the file.sys_metadata. The write door strips derived provenance from it, so its registry item would also have no_packageId, while its row ismanaged_by: package. This fix deliberately does not treat such a row as environment-owned, so that set would still be reported as unowned. That population was not measured.permissions/capabilities/sharingRulesin the ObjectQL SchemaRegistry (AppPlugin.init→manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491 is not addressed here; that card remains open on its own surviving question.Generated by Claude Code