Repository navigation
fix(spec,objectql)!: a permission set's body packageId that disagrees with the package shipping it is refused at the build and registration doors; both readers read the binding only - #23375
Conversation
…eId is judged against its binding at the build and registration doors, by one judge Moves the owner-binding judge (comparison and words) into @objectstack/spec/security, runs it from the stack schema (build door) and ObjectQL.registerApp (registration door), and makes the plugin-security lock reader and the cli collision reader read the binding only. Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude <noreply@anthropic.com>
…t the build and registration doors and both readers Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude <noreply@anthropic.com>
…n doors Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude <noreply@anthropic.com>
…n reference for the owner-binding judge Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude <noreply@anthropic.com>
…rmission-set-packageid-sites
📓 Docs Drift CheckThis PR changes 5 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 152 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 3b25313f1899ac8088b5a1cf2cddeaad87b1f177 && git checkout 3b25313f1899ac8088b5a1cf2cddeaad87b1f177
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eb6f7d65b11572c5eca0ece12793438c101af90 6c72fd851d17bc1e462504c651ec4f428ee7964d && git checkout -B drift-repro 6eb6f7d65b11572c5eca0ece12793438c101af90 && git merge --no-ff 6c72fd851d17bc1e462504c651ec4f428ee7964d
node scripts/docs-audit/affected-docs.mjs --json 6eb6f7d65b11572c5eca0ece12793438c101af90
|
Contract reviewServed-tier: Inputs read: card #23312 (body; comments 6152894289 triage, 6156007637 unlock, 6158206070 claim, 6160151300 os-dev-report), PR #23375 (body, 18-file list, the three-dot diff against Check-runs on the head, as found. The five required contexts are all ① Derived judgments
② Semver levelThe changeset ③ Boundary flags
Dev Inside the card's ruling and the claim's file surface. The diff delivers the card's two Wanted items (build door with the prescription-first wording, registration door no longer overriding silently, both readers reading the binding only, each with pins and negative controls) and the claim's named files; the two files it touches beyond the named list are the Implemented-by: VERDICT: PASS Generated by Claude Code |
…rmission-set-packageid-sites
Contract reviewServed-tier: Delta review after a base merge. The record this one builds on is Inputs read: card #23312 (body; comments 6152894289 triage, 6156007637 unlock, 6158206070 claim, 6160151300 os-dev-report, 6160330649 ACCEPT), PR #23375 (body, 18-file list, the two thread comments including the earlier record, zero reviews and zero review comments, the served diff against Check-runs on the head, as found (read after 03:59:56Z, the latest completion among the finished runs, and before 04:05:33Z; the runs started 03:58Z). 38 runs: 15 PR shape, unchanged by the merge: draft, base ① Derived judgments
② Semver levelUnchanged, because the changeset is byte-identical in the net diff: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23312
Clause-②: yes (narrowing)
What this does
The seat ruling on #23259, carried to the doors PR #23311 did not reach: a permission set's body
packageIdmust agree with its package binding, or it is refused with a prescription (ADR-0086 D3: "the owning package for a package-shipped set (absent ⇒ env-authored)").metadata-protocol(wherepermissionSetOwnerBindingRefusalwas module-private) into@objectstack/spec/securityasjudgePermissionSetOwnerBinding(new filepackages/spec/src/security/permission-set-owner-binding.ts). It returns data (declared,bound,message,issueMessage); each door builds its own envelope. It gains two doors,buildandregister.protocol.tskeeps its wrapper and its name (so its{@link}s stand); the edit there is that function's body, its docblock and the@objectstack/spec/securityimport line. The save, restore and publish words are byte-identical (measured below).ObjectStackDefinitionSchema(applyPermissionSetOwnerBindingGate, besideapplyApiEndpointGates, whose docblock gives the reason to put it on the schema:defineStack,os validate,os build, the lint scorer and the artifact door all run this one parse). Bindings: nopackages[], every top-level set againstmanifest.id(no manifest means no package); withpackages[], each body's sets against that body'sid, and a top-level set no body declares (the residual) againstmanifest.id. A top-level set that a body declares is that body's copy, which is never registered from the top level, so it is not judged at the top level.defineStackanswersSTACK_SCHEMA_INVALID/ 422 with the issue atpermissions.N.packageId. No code minted.ObjectQL.registerMetadataCollections(manifest and nested plugin) throwsVALIDATION_ERROR/ 400 (httpStatus400,issues[]atpackageId) beforeregisterItemfiles the set. Sibling matched:viewContainerNameRefusal, the same loop's other body-against-key refusal, thrown one statement earlier in the same envelope. It covers the manifests no schema parse judged: a programmaticregisterApp, a nested plugin, astrict: falsestack, a package from a stored manifest.plugin-securitydeclaredPackageIdOf: one line,item._packageId ?? item.packageIdbecomesitem._packageId(epic [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194's territory is not otherwise touched).clicollectPermissionSetDeclarations:manifestId ?? asId(ps.packageId)becomesmanifestId, and its docblock stops describing the fallback as the rule.PermissionSetSchema.packageIddescribe and TSDoc name every door (content/docs/references/security/permission.mdxregenerated). The step-18 entrypermission-set-package-id-binding-mismatch-refusedis widened to the build and registration doors; its old sentence "Package source (defineStack permissions) is not judged by this entry" is replaced.Zone 2, measured
permissionSetOwnerBindingRefusalwas module-private (protocol.ts:994at6d96721fa), andpackages/speccannot importmetadata-protocol. The diff adds a published export:api-surface/security.jsongainsjudgePermissionSetOwnerBinding(function),PermissionSetOwnerBindingRefusal(interface) andPermissionSetOwnerBindingDoor(type). No error code is added. SoClause-②: yes (narrowing)stands as claimed.os validateruns the sameObjectStackDefinitionSchema.safeParse(validate.ts:349), so one refinement serves both doors. Measured through the real CLI (bin/run.js validate --json, builtdist) on three scratch projects:defineStackwithpackageId: 'com.probe.other'under manifestcom.probe.pkg: exit 1,code: STACK_SCHEMA_INVALID,✗ permissions.0.packageId: Drop packageId from permission set 'probe_member', or ship the set from package 'com.probe.other' …(the refusal raised by the config's owndefineStackat load);{ strict: false }: exit 1,valid: false,errors[0]=customatpermissions.0.packageId(os validate's own parse);valid: true.The no-manifest stack: ADR-0086 D3 with PR fix(metadata-protocol)!: the save and publish doors refuse a permission set whose body packageId disagrees with its package binding, and the suggestion reader reads the binding only (#23259) #23311's rule for a set saved into no package settles it. A stack with no manifest registers its sets into no package (measured:
registerAppwith no id files the set with no_packageId), so a present key, the empty string included, is refused. No fork to decide. (AppPluginalready refuses to boot such a stack: an app payload with no id throws at construction.)6d96721fabefore any edit, with the builtObjectQL. A manifestcom.probe.pkgwhose set declarespackageId: 'com.probe.other'registers as{"_packageId":"com.probe.pkg","packageId":"com.probe.other","_provenance":"package"}, and a nested plugin does the same. Zeroconsole.warnorconsole.errorlines are captured. A manifest with no id registers{"packageId":"com.probe.other"}with no_packageIdat all, which is the one shape where the lock reader's fallback named the body key as the owner. After the change, all three are refused withVALIDATION_ERROR400 and nothing is filed. An equal key and an absent key register undercom.probe.pkgas before.packaged-permission-set-lock.ts:178readitem._packageId ?? item.packageId. A registry item (or code-layer envelope) with no binding and a body key was classifiedpackagedunder the body's package. It now readsorg. Cli reader:permission-set-name-collisions.ts:157readmanifestId ?? asId(ps.packageId). With no manifest id the collision check now reads no owner, so the declaration is dropped as unowned: the retired seeder'sif (!packageId)arm, never a collision. Pinned. Two sets of one name naming two different packages in their bodies used to be reported as a cross-package collision; now they are not.check-adr-0087-registrationacceptsnot-required (already-registered permission-set-package-id-binding-mismatch-refused), because the id predates the merge base. It can only verify that the id resolves, not that the entry covers the change, so the entry is widened in this diff to keep that claim true. The gate's verdict:1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (already-registered).Pins, each with a negative control, and the ablations
spec/src/security/permission-set-owner-binding.test.tsdefineStackrefuses (STACK_SCHEMA_INVALID/422, issuecustomatpermissions.0.packageId), no-manifest and''included, map format too; artifact bodies judged against their own id; residual judged againstmanifest.idspec/src/stack-permission-set-owner-binding.test.tsregisterApprefuses (VALIDATION_ERROR/400/httpStatus400,issues[]atpackageId), nested plugin and no-id manifest included, nothing filedobjectql/src/permission-set-owner-registration.test.tsplugin-security/src/packaged-permission-set-lock.test.tscli/src/utils/permission-set-name-collisions.test.ts(the old "packageId answers only where no package ships the set" case is rewritten)Ablations were run on committed
9903d68d4, throughnode scripts/ablation-replace.mjsin wrap mode (the anchor hit 1→0, the blob changed, and the tool restored and provedblob == HEADand an emptygit diff HEAD). Every subject is imported fromsrcby a relative path, so nodistrebuild was involved. Each turned red in the expected direction, and its controls stayed green:.superRefine(applyPermissionSetOwnerBindingGate)removed):5 failed | 2 passed (7), the 2 being the controls;undefined):3 failed | 1 passed (4);?? item.packageIdfallback restored):2 failed | 25 passed (27);?? asId(ps.packageId)fallback restored):1 failed | 10 passed (11).Byte-identity of the moved words.
permissionSetOwnerBindingRefusalwas extracted fromgit show 6d96721fa:packages/metadata-protocol/src/protocol.ts, transpiled, and compared withjudgePermissionSetOwnerBindingfrom the builtspecdist. The comparison covered 180 cases (3 doors × 10 bodies × 6 bindings): 63 refusals and 0 differing inmessageorissues[0].message. PR #23311's door pins (protocol.item-name-every-door.test.ts) pass unchanged.Verification (local; CI runs the full farm)
Package suites, run under the verify lock at
9903d68d4(pre-merge):@objectstack/spec(--project local): 672 files, 20434 passed, 1 todo.@objectstack/objectql(--project local): 414 files, 8070 passed.@objectstack/metadata-protocol: 228 of 238 files passed, 3 skipped, 28118 tests passed. 7 files failed to load withCannot find package '@objectstack/spec/api': a sibling gate rebuiltspec/distmid-run, a resolution failure that never reached a test body. Re-run: those 7 files plusprotocol.item-name-every-door.test.ts, 8 files, 264 passed.@objectstack/plugin-security: 189 files, 3881 passed, 45 skipped.@objectstack/cli(--project unit): 287 files, 4293 passed. The integration layer is declared to CI: the diff touches no integration-tier file and no spawn entry.Typecheck:
typecheckexits 0 for all five packages (SPEC_TC=0 OQL_TC=0 MP_TC=0 PS_TC=0 CLI_TC=0).Generated artifacts:
pnpm --filter @objectstack/spec check:generatedreports "All 14 generated artifacts are up to date" on the merged tree14b1c5a25, against aspecdist built from that tree.api-surface,export-originsanddocswere regenerated by--fix(only the 3 it proved stale).Gate families:
node scripts/pm/dispatch-gates.mjs --commands(no paths) derived 125 commands at9903d68d4, and all 125 ran. Reconciliation printed--ran:125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN. Five of them first refused on prerequisites and were re-run green on the same tree:check-engine-split-ratioand thecheck-plugin-teardown-shapeself-test, aftergit fetch --shallow-since=2026-07-09;check:skill-examples,check:dual-build-cjs-loadsandcheck:i18n-coverage, once the unbuilt packages they read haddist.pnpm check:type-check-debtwas measured:OK … 26 raw tsc error(s) total, none above its recorded number. The PM's 86-command lead list is a subset of the 125 (the 39 extra are the changeset, docs and ratchet families the final diff touches).Named gap. The gate union above was measured on
9903d68d4.origin/main(dc393266f, which carries #23345's other-region edit toprotocol.ts) was then merged as14b1c5a25, and the derived list there is identical (125, same commands). The post-merge re-run of the union and of the touched pins is in flight; its verdicts land in theos-dev-reportcomment on #23312.Lint:
pnpm exec eslint --no-inline-config --format jsonover the 14 changed TS files gives 14 files, 0 errors, 0 warnings. Three facts make that narrowing a measurement:files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED);eslint.config.mjsenables no type-aware linting (its own note: "noparserOptions.project, no typed@typescript-eslintrules"), so this diff cannot move a verdict on any untouched file.Consumer sweep:
examples/**holds 0 permission sets with a bodypackageId(the showcase's twopackageIdhits are capabilities).turbo ls --affectedwas not used to narrow anything: every consumer package named in the order was run in full. Its blind spot is stated anyway: it is built from the package graph, so it cannot see tests that read another package's source from disk.Acceptance notes
EnvironmentArtifactSchema.metadataembedsObjectStackDefinitionSchema, so an environment artifact now refuses on a disagreeing set too. Its producer is the cloud control plane, and the schema calls the field "the direct output ofobjectstack compile": package source, which this ruling covers. Not measured here, because no cloud checkout is in this container.os validate. The top-level residual of a multi-package artifact is judged by the same schema rule. The "claimed" test for a permission set (a body declares a set of that name) mirrorsunclaimedTopLevel's slot rule in@objectstack/metadata. That rule cannot be imported into spec, so the docblock names it./tests-3pkg.pid(6 bytes, a pid) was written at the filesystem root by a mistyped redirect in this session. Removing it was refused by the session's safety check, so it is left for a person. It is outside every repository.Generated by Claude Code