Skip to content

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

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23312-permission-set-packageid-sites
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23312-permission-set-packageid-sites

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 packageId must 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)").

  • One judge. The comparison and its words move out of metadata-protocol (where permissionSetOwnerBindingRefusal was module-private) into @objectstack/spec/security as judgePermissionSetOwnerBinding (new file packages/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, build and register. protocol.ts keeps its wrapper and its name (so its {@link}s stand); the edit there is that function's body, its docblock and the @objectstack/spec/security import line. The save, restore and publish words are byte-identical (measured below).
  • The build door. A refinement on ObjectStackDefinitionSchema (applyPermissionSetOwnerBindingGate, beside applyApiEndpointGates, 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: no packages[], every top-level set against manifest.id (no manifest means no package); with packages[], each body's sets against that body's id, and a top-level set no body declares (the residual) against manifest.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. defineStack answers STACK_SCHEMA_INVALID / 422 with the issue at permissions.N.packageId. No code minted.
  • The registration door. ObjectQL.registerMetadataCollections (manifest and nested plugin) throws VALIDATION_ERROR / 400 (httpStatus 400, issues[] at packageId) before registerItem files 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 programmatic registerApp, a nested plugin, a strict: false stack, a package from a stored manifest.
  • Both readers read the binding only. plugin-security declaredPackageIdOf: one line, item._packageId ?? item.packageId becomes item._packageId (epic [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194's territory is not otherwise touched). cli collectPermissionSetDeclarations: manifestId ?? asId(ps.packageId) becomes manifestId, and its docblock stops describing the fallback as the rule.
  • PermissionSetSchema.packageId describe and TSDoc name every door (content/docs/references/security/permission.mdx regenerated). The step-18 entry permission-set-package-id-binding-mismatch-refused is 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

  • H1 holds. permissionSetOwnerBindingRefusal was module-private (protocol.ts:994 at 6d96721fa), and packages/spec cannot import metadata-protocol. The diff adds a published export: api-surface/security.json gains judgePermissionSetOwnerBinding (function), PermissionSetOwnerBindingRefusal (interface) and PermissionSetOwnerBindingDoor (type). No error code is added. So Clause-②: yes (narrowing) stands as claimed.
  • H2 holds, and more widely than stated. os validate runs the same ObjectStackDefinitionSchema.safeParse (validate.ts:349), so one refinement serves both doors. Measured through the real CLI (bin/run.js validate --json, built dist) on three scratch projects:
    • strict defineStack with packageId: 'com.probe.other' under manifest com.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 own defineStack at load);
    • the same with { strict: false }: exit 1, valid: false, errors[0] = custom at permissions.0.packageId (os validate's own parse);
    • the key equal to the manifest id: exit 0, 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: registerApp with no id files the set with no _packageId), so a present key, the empty string included, is refused. No fork to decide. (AppPlugin already refuses to boot such a stack: an app payload with no id throws at construction.)
  • H3 holds. Re-measured on 6d96721fa before any edit, with the built ObjectQL. A manifest com.probe.pkg whose set declares packageId: 'com.probe.other' registers as {"_packageId":"com.probe.pkg","packageId":"com.probe.other","_provenance":"package"}, and a nested plugin does the same. Zero console.warn or console.error lines are captured. A manifest with no id registers {"packageId":"com.probe.other"} with no _packageId at 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 with VALIDATION_ERROR 400 and nothing is filed. An equal key and an absent key register under com.probe.pkg as before.
  • H4 holds. Lock reader: packaged-permission-set-lock.ts:178 read item._packageId ?? item.packageId. A registry item (or code-layer envelope) with no binding and a body key was classified packaged under the body's package. It now reads org. Cli reader: permission-set-name-collisions.ts:157 read manifestId ?? asId(ps.packageId). With no manifest id the collision check now reads no owner, so the declaration is dropped as unowned: the retired seeder's if (!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.
  • H5: the gate does not ask for widening. check-adr-0087-registration accepts not-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

pin file control
the judge's verdict table at all five doors; build and register words prescribe first spec/src/security/permission-set-owner-binding.test.ts equal and absent keys accepted at every door
defineStack refuses (STACK_SCHEMA_INVALID/422, issue custom at permissions.0.packageId), no-manifest and '' included, map format too; artifact bodies judged against their own id; residual judged against manifest.id spec/src/stack-permission-set-owner-binding.test.ts equal/absent accepted; a both-halves artifact's top-level copy NOT judged against the artifact id
registerApp refuses (VALIDATION_ERROR/400/httpStatus 400, issues[] at packageId), nested plugin and no-id manifest included, nothing filed objectql/src/permission-set-owner-registration.test.ts equal/absent register under the package
the lock never reads the body key (registry and code layer) plugin-security/src/packaged-permission-set-lock.test.ts a binding decides, a disagreeing body key does not move it
the cli reader never reads the body key cli/src/utils/permission-set-name-collisions.test.ts (the old "packageId answers only where no package ships the set" case is rewritten) a manifest-bound stack declares every set under its manifest id

Ablations were run on committed 9903d68d4, through node scripts/ablation-replace.mjs in wrap mode (the anchor hit 1→0, the blob changed, and the tool restored and proved blob == HEAD and an empty git diff HEAD). Every subject is imported from src by a relative path, so no dist rebuild was involved. Each turned red in the expected direction, and its controls stayed green:

  • build door (.superRefine(applyPermissionSetOwnerBindingGate) removed): 5 failed | 2 passed (7), the 2 being the controls;
  • registration door (the refusal call replaced by undefined): 3 failed | 1 passed (4);
  • lock reader (the ?? item.packageId fallback restored): 2 failed | 25 passed (27);
  • cli reader (the ?? asId(ps.packageId) fallback restored): 1 failed | 10 passed (11).

Byte-identity of the moved words. permissionSetOwnerBindingRefusal was extracted from git show 6d96721fa:packages/metadata-protocol/src/protocol.ts, transpiled, and compared with judgePermissionSetOwnerBinding from the built spec dist. The comparison covered 180 cases (3 doors × 10 bodies × 6 bindings): 63 refusals and 0 differing in message or issues[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 with Cannot find package '@objectstack/spec/api': a sibling gate rebuilt spec/dist mid-run, a resolution failure that never reached a test body. Re-run: those 7 files plus protocol.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: typecheck exits 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:generated reports "All 14 generated artifacts are up to date" on the merged tree 14b1c5a25, against a spec dist built from that tree. api-surface, export-origins and docs were regenerated by --fix (only the 3 it proved stale).

Gate families: node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 125 commands at 9903d68d4, 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-ratio and the check-plugin-teardown-shape self-test, after git fetch --shallow-since=2026-07-09;
  • check:skill-examples, check:dual-build-cjs-loads and check:i18n-coverage, once the unbuilt packages they read had dist.

pnpm check:type-check-debt was 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 to protocol.ts) was then merged as 14b1c5a25, 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 the os-dev-report comment on #23312.

Lint: pnpm exec eslint --no-inline-config --format json over the 14 changed TS files gives 14 files, 0 errors, 0 warnings. Three facts make that narrowing a measurement:

  • all 14 are inside the config's population (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED);
  • the count, 14, is read from the JSON output;
  • eslint.config.mjs enables no type-aware linting (its own note: "no parserOptions.project, no typed @typescript-eslint rules"), so this diff cannot move a verdict on any untouched file.

Consumer sweep: examples/** holds 0 permission sets with a body packageId (the showcase's two packageId hits are capabilities). turbo ls --affected was 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

  • The environment-artifact door, by reading only. EnvironmentArtifactSchema.metadata embeds ObjectStackDefinitionSchema, 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 of objectstack compile": package source, which this ruling covers. Not measured here, because no cloud checkout is in this container.
  • Residual sets in 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) mirrors unclaimedTopLevel'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

…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 reference for the owner-binding judge

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/export-origins/security.json, packages/spec/src/security/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6eb6f7d65b11572c5eca0ece12793438c101af90.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/export-origins/security.json, packages/spec/src/security/index.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 152 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb6f7d65b11572c5eca0ece12793438c101af90 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3b25313f1899ac8088b5a1cf2cddeaad87b1f177 — the merge of head 6c72fd851d17bc1e462504c651ec4f428ee7964d into base 6eb6f7d65b11572c5eca0ece12793438c101af90, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb6f7d65b11572c5eca0ece12793438c101af90 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 14b1c5a254c7831d5b1bc4b43b37d8ff349f40bb
Local-runs: none

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 main at merge base dc393266f), the 41 check-runs on the head, and the committed tree at the head (git show / git grep on the sha) with origin/main for the base. Not read: the dispatch order or the dispatching seat's conclusions.

Check-runs on the head, as found. The five required contexts are all completed / success: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard. Every other run is success (Test Core 1/6 to 6/6, Build Docs, Dogfood Regression Gate, Auto Label, Check Changeset, Check PR Size, Check Documentation Links, Spec property liveness, the Lint and Type Check sub-lanes, the four single-writer and card-claim guards, Flag docs affected by code changes, filter) except five completed / skipped, all full-battery or opt-in jobs a PR run does not carry by design: Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (matrix shards), Console Pin Gate, Dogfood Verify CLI, Packed-tarball smoke (opt-in). Nothing is in progress. The PR's file list touches no governed surface (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, .github/workflows/**); head repo equals base repo; 750 additions and 112 deletions, under the 3,000-line class; draft, authored by the fleet bot, assigned to the card's assignee.

① Derived judgments

  1. New @objectstack/spec/security exports: right. The diff adds exactly three published names, judgePermissionSetOwnerBinding (function), PermissionSetOwnerBindingRefusal (interface), PermissionSetOwnerBindingDoor (type), through security/index.ts, and the two generated projections carry exactly those three (api-surface/security.json, export-origins/security.json); no other shard moved, so no root star-export of security exists to drift, and check:api-surface, check:export-origins, check:dual-source-exports ride inside the green TypeScript Type Check job. The judge is pure (builds no error, throws nothing, returns declared, bound, message, issueMessage) and is a validation predicate the stack schema's refinement needs, the shape Prime Directive ✨ Set up Copilot instructions #2 allows in spec and the claim's file surface named ("it moves into packages/spec/src/security/ and is exported from there"). No error code is minted at any door; VALIDATION_ERROR and STACK_SCHEMA_INVALID are both pre-existing ledger codes. The verdict table (absent accepted, equal accepted, other package refused, any string including the empty string on no binding refused, non-string left to z.string()) is byte for byte the old module-private function's guard sequence.

  2. ObjectStackDefinitionSchema refinement, and every schema that embeds it: right. stack.zod.ts now reads strictObject({...}).superRefine(applyApiEndpointGates).superRefine(applyPermissionSetOwnerBindingGate) inside lazySchema. Under this tree's zod (lazy-schema.ts talks to _zod and $ZodRegistry, i.e. zod 4) a superRefine returns the same object type, so the readers that take .shape off the schema, shapeKeys in packages/cli/src/utils/stack-collections.ts (which throws if shape is absent) and packages/runtime/src/artifact-collections.ts, and metadata-authoring-lint's key enumeration, are unchanged; Build Core, Test Core and check:skill-top-level-keys green agree. Embedders, enumerated at the head: defineStack and composeStacks (spec), os validate (validate.ts:349), os build (compile.ts:408), os migrate meta, the lint scorer (lint/score.ts:89), author-time-rules.ts, scaffold-validate.ts, the metadata plugin's artifact ingestion (plugin.ts:1321, bare definition branch) and, through EnvironmentArtifactSchema.metadata: ObjectStackDefinitionSchema (system/environment-artifact.zod.ts), the environment artifact's envelope branches at plugin.ts:1308 and :1315. Every one of them refuses the same set in the same words now, which is the one-judge-many-doors shape the ruling asks for. Binding rules judged right against the real residual rule: a stack without packages[] binds every top-level set to manifest.id (none without a manifest); with packages[], each body's sets bind to that body's id (the element schema is ArtifactPackageSchema whose manifest is AssembledPackageBodySchema = ManifestSchema.extend(assembledPackageBodyShape()), so id is present and permissions is the normalised array), and a top-level set no body declares binds to manifest.id. The "claimed by name" skip mirrors @objectstack/metadata's unclaimedTopLevel, whose artifactItemSlot claims a permission set on its name; and the ObjectQL plugin's manifest service registers "the BODIES of a stack that carries packages[] and never its top level", so the skipped top-level copy reaches no door at all, which is what makes the both-halves CONTROL in stack-permission-set-owner-binding.test.ts right rather than lenient. ManifestSchema.id is a required z.string(), so the build door's manifest.id-only binding cannot diverge from registerApp's id || name on a parsed stack. The issue lands at permissions.N.packageId (the post-normalisation index; the map-format pin records that), code: custom, and defineStack wraps it as STACK_SCHEMA_INVALID / 422 as it does every other issue. Refinement order is safe: applyApiEndpointGates adds non-fatal issues, so the owner gate still runs after it.

  3. Registration-door refusal: right. permissionSetOwnerRegistrationRefusal is called in registerMetadataCollections under key === 'permissions', one statement after the sibling viewContainerNameRefusal under key === 'views', before registerItem files the set; the envelope matches that sibling (VALIDATION_ERROR, status 400, httpStatus 400, issues[] at packageId, code: custom). Both registerApp callers in the tree reach it: the manifest seam (engine.ts:7840, ownerId is manifest.id || manifest.name, so a manifest with neither registers into no package and any key is refused) and the nested-plugin seam (:7974). The multi-package load path (objectql/src/plugin.ts:695) calls registerApp per ordered body, so each body's sets are judged against the id they register under; the metadata door's registerUnclaimedTopLevel is not behind this seam, but it only runs after the schema parse at _parseAndRegisterArtifact has judged the residual against manifest.id, so no set files anywhere unjudged. permission-set-owner-registration.test.ts pins the envelope, the nested plugin, the no-id manifest with '' included, that nothing is filed, and the equal/absent controls.

  4. The two readers: right. plugin-security declaredPackageIdOf reads item._packageId only (the isTenantAuthored and RUNTIME_SHADOW_PACKAGE_ID guards around it are untouched); pinned on the registry item, on the layered read's code layer, with a binding-decides CONTROL. cli collectPermissionSetDeclarations: the single-package arm reads manifestId only (asId(manifest.id) ?? asId(manifest.name), the same id || name spelling the registry keys on), and the packages[] arm already read pkg.id; a stack with no manifest now declares no owner and so can never be a collision, which the rewritten case pins together with a CONTROL that a manifest-bound stack declares every set under its manifest id. The docblock's "mirrored from the seeder" rule is rewritten to say the fallback is not mirrored, so the file no longer describes a rule it does not apply.

  5. Save, restore and publish words: unchanged. Compared the removed lines of protocol.ts against the judge's save / restore / publish branch: the guard sequence, into, intoDeclared (including the ?package= REST spelling), owner, tail, all three messages and the issues[0].message for restore versus save/publish are the same template text, token for token; permissionSetOwnerBindingRefusal keeps its name, its singularType !== 'permission' precondition and its envelope (VALIDATION_ERROR, status 400, issues[] at packageId), so 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 {@link}s and door pins (protocol.item-name-every-door.test.ts, 29 passed in the dev's post-merge run; Test Core green on the head) stand. The protocol.ts hunks are exactly the import line and that function's docblock and body, as the claim's file surface required.

  6. Describe and migration texts: right. PermissionSetSchema.packageId's describe names every door; content/docs/references/security/permission.mdx is the regenerated projection of it (AUTO-GEN, not hand-edited; check:docs green). The step-18 entry's surface carries no backtick (the build-upgrade-guide constraint its own comment states), its old sentence "Package source (defineStack permissions) is not judged by this entry" is replaced, and its migration text now prescribes the rebuild for a pre-change artifact.

② Semver level

The changeset .changeset/23312-permission-set-owner-binding-build-and-registration.md grades @objectstack/spec minor, @objectstack/objectql minor, @objectstack/plugin-security minor, @objectstack/metadata-protocol patch, @objectstack/cli patch, and that matches what the diff publishes: spec adds three public exports and narrows the stack schema's accept-set; objectql's registerApp accept-set narrows; plugin-security's lock verdict narrows (a set bound to no package is org whatever its body says); metadata-protocol's verdicts and words are unchanged (a refactor onto the shared judge); cli's collision report drops a dormant fallback with no measured producer and its os validate refusal arrives through spec. Clause-②: yes (narrowing) is the right declaration and is carried identically on the claim comment, the PR body and the changeset: yes because the diff adds published exports to @objectstack/spec (the triage's earlier Clause-②: no predates the decision to move the judge into spec and was overtaken by the dispatch's claim, which the diff bears out), (narrowing) because the accept-set shrinks, which is BREAKING and ships as minor under the launch-window convention that check-changeset-no-major encodes (at least one moved package graded minor or above: three are). The breaking changeset carries its FROM → TO table and the one-line remedy, and its ADR-0087 disposition not-required (already-registered permission-set-package-id-binding-mismatch-refused) is a legal use of that category: the gate requires the id to exist at the merge base and resolve at HEAD, which it does (PR #23311's entry), and the entry is widened in this same diff so the "already registered" claim covers the new doors rather than resting on an entry that excluded them. Check Changeset and Lint & Repo Gates (which runs check-adr-0087-registration and check-changeset-no-major) are green on the head. The PR title carries the ! marker. The PR body ends with the session-URL footer, the form for PR bodies.

③ Boundary flags

open_questions: empty. Nothing to answer.

out_of_scope_findings, each answered:

  • An environment artifact built before this change now refuses at load; the cloud control plane is an unmeasured producer. Inside the ruling, not a breach. The card inherits spec/metadata: a permission set's body packageId that disagrees with its package binding is accepted at publish and silently overridden, so a declared packageId never decides the owner #23259's ruling that a disagreeing body packageId is refused with a prescription wherever a set is filed under a package, and names the build door (defineStack / os validate) with "where the check lives is the owning seat's measurement"; the seat put it on the schema, and EnvironmentArtifactSchema.metadata is that schema by declaration ("the direct output of objectstack compile", package source). The artifact door files every body's sets under that body's id and the residual under manifest.id, so a disagreeing set in a stored artifact is a set the ruling says must be refused, and it now is, with the build-door prescription, instead of being filed under the shipping package in silence. A post-change objectstack compile cannot produce such an artifact, so only a pre-change artifact carrying an authored disagreement is affected; the in-tree corpus holds zero such sets (examples and HotCRM, per the card and the PR's sweep), and the remedy ("rebuild it without the key") ships in the changeset's FROM → TO row and in the step-18 entry's migration text, the ADR-0087 carrier an upgrading consumer greps. A cloud-side reading of stored artifacts is a cloud-repository measurement, outside this card and this repository; it needs no card here, and the dev's 承接者:无 · noted, not filed with the PR acceptance note is the right disposition under Prime Directive chore: version packages #10 (declared, not buried).
  • The build door's residual test mirrors unclaimedTopLevel's slot rule, which spec cannot import. Verified at the head: artifactItemSlot claims a non-container item on its name and unclaimedTopLevel skips a top-level item whose slot a body claims; the gate's claimed set is that rule for permission sets, and its docblock names the source. Two spellings of one rule is a known shape in this tree (the viewContainerNameRefusal copies in objectql and metadata carry the same note); the CONTROL pins the copy, and the ObjectQL plugin's bodies-only registration means the skipped copy reaches no door. Acceptable as noted.

Dev deviations, each answered: (1) attribution: the branch's four commits carry Claude-Session: plus Co-authored-by: Claude with no model identifier and the PR body the session-URL footer, which is AGENTS.md's form; the harness reminder's model-named trailer is the reporting exemption, correctly reported rather than imitated. (2) The PR body's "named gap" (post-merge gate union in flight) is closed by the os-dev-report: 125 derived, 125 run, 0 unrun at 14b1c5a25, and the head's check-runs are the gate verdicts of record. (3) and (4) a mid-run dist rebuild and a build-lock timeout, both re-run green: process notes, no bearing on the diff. (5) deepening the shared clone with git fetch --shallow-since is a read-side fetch that moves no ref another seat restores from. (6) /tests-3pkg.pid at the filesystem root is outside every repository and absent from the diff; left for a person, as reported. (7) worktree removed after push with the local head equal to the remote. None is a breach.

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 security/index.ts barrel (implied by "exported from there") and permission.zod.ts's describe and TSDoc on the very key the card is about, with the generated reference regenerated from it. The packages/spec generated artifacts are regenerated (check:generated reported all 14 current on the merged tree, and the green TypeScript Type Check job is the gate). The PR mirrors the card's assignee and closes the card with Fixes #23312; no label, assignee or draft flag it did not set is touched.

Implemented-by: claude/issue-23312-permission-set-packageid-sites
Reviewed-by: session_01YRp5z4kK6vgYkrdH97ztsy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6c72fd851d17bc1e462504c651ec4f428ee7964d
Local-runs: none

Delta review after a base merge. The record this one builds on is 6160320785 on this PR, PASS at head 14b1c5a254c7831d5b1bc4b43b37d8ff349f40bb. The head moved by exactly one commit, the merge 6c72fd851 (parents 14b1c5a25 and 6eb6f7d65, which is origin/main at the time of the read), so this record judges what that merge changed; nothing the earlier record settled is re-argued.

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 main at 6c72fd851), the 38 check-runs on the head, and the committed objects by sha (git diff / git show / git grep on 14b1c5a25, 6c72fd851, 6eb6f7d65, dc393266f, d89f491ad). Not read: the dispatch order, the dispatching seat's conclusions, the earlier record.md in scratch.

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 completed / success, 5 completed / skipped, 18 in_progress, 0 failed. Of the five required contexts: Governed Surface Queue Guard is completed / success; Build Core is in_progress; Test Core has its six shards (1/6 to 6/6) in_progress and its aggregating context not yet reported; Lint & Repo Gates has Lint · PM dispatch-gates and Lint · scoped · tooling self-tests at success and Lint · CI, release & engine gates, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus in_progress, aggregator not yet reported; TypeScript Type Check has Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace in_progress, aggregator not yet reported. Also success: Auto Label, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, filter. Also in_progress: Build Docs. skipped, by design on a PR run: Console Pin Gate, Dogfood Regression Gate (matrix shards), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL). Not polled, not awaited: these are the verdicts as found, and ③ says what they mean for the landing.

PR shape, unchanged by the merge: draft, base main, head repo equals base repo, authored by the fleet bot, assigned to the card's assignee, no reviews, auto-merge not armed, 18 files, 750 additions, 112 deletions (862 changed lines, under the 3,000-line class), no governed surface in the file list (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, .github/workflows/**). The ADR and skills edits main brought are main's, not the PR's net diff, and the queue guard is green on this head.

① Derived judgments

  1. The net diff is the same change, byte for byte. git diff 6eb6f7d65 6c72fd851 (the PR against main at the new head) and git diff dc393266f 14b1c5a25 (the PR against its previous merge base, what record 6160320785 judged) differ in nothing but the blob ids on the index lines: same 18 files, same 1135 diff lines, same hunks, +750/-112. GitHub's served diff for the PR equals the local one line for line. So the accept-set changes (the build-door refinement applyPermissionSetOwnerBindingGate on ObjectStackDefinitionSchema, the registration-door refusal in ObjectQL.registerMetadataCollections, the two readers reading the binding only) and the public-surface changes (the three @objectstack/spec/security exports judgePermissionSetOwnerBinding, PermissionSetOwnerBindingRefusal, PermissionSetOwnerBindingDoor, carried by api-surface/security.json and export-origins/security.json) are exactly what the earlier record judged; its six findings stand without re-argument.

  2. The merge is a trivial merge of what main brought, with no hand-made bytes. 6c72fd851 has parents 14b1c5a25 and 6eb6f7d65; git diff-tree --cc -p 6c72fd851 prints no hunk, so every byte in the merged tree comes from one parent's side. git diff 14b1c5a25 6c72fd851 is exactly main's window dc393266f..6eb6f7d65: 9 commits, 67 files, +2850/-221 (docs(adr-0138): the guest's bind door is built implementation-first, with its shape recorded (D3 item 5, ruling C) #23370 ADR-0138 doc, fix(automation,metadata-protocol,rest,spec): a stored flow the engine refuses is served beside its verdict, not as running #23358 flow-registration verdict, fix(plugin-sharing, runtime): a password-protected signed_in share link answers an anonymous visitor SIGN_IN_REQUIRED, whatever password was sent #23369 share-link refusal order, skills(objectstack-upgrade, catalog READMEs): the upgrade preflight re-installs the catalog from @objectstack/skills on a 17.x to 18 move; both READMEs give the scaffolder's pinned --copy sync #23366 upgrade skill, fix(runtime)!: the /ui and /data dispatcher domains deny an anonymous caller as their first statement (ADR-0138 D2) #23364 and fix(runtime)!: an active ADR-0069 gate that cannot read the session answers 503 at the dispatcher's seam #23363 dispatcher floors, skills(automation): value slots take a CEL envelope or a literal; the single-brace dialect leaves the skill text #23355 automation skill, docs(adr): record the stage-8 retirement of the four security-catalog row objects in the ADRs that name them #23362 ADR retirement notes, fix(plugin-auth)!: the platform-admin actions refuse a session the ADR-0069 authentication policy holds #23360 platform-admin gate). The only file both sides changed is packages/metadata-protocol/src/protocol.ts.

  3. protocol.ts: the PR's hunks survive byte for byte, and main's edits do not touch their meaning. The PR's three hunks at the new head (@@ -9,7 +9,7 @@, the @objectstack/spec/security import gaining judgePermissionSetOwnerBinding; @@ -956,34 +956,22 @@, the docblock; @@ -999,40 +987,12 @@, the body of permissionSetOwnerBindingRefusal becoming the call to the judge plus the VALIDATION_ERROR / 400 / issues[] envelope) are identical to the old head's, headers included. Main's one commit on the file, d89f491ad (fix(automation,metadata-protocol,rest,spec): a stored flow the engine refuses is served beside its verdict, not as running #23358), edits nine regions: the @objectstack/spec/api type import at lines 3 to 6 (adds FlowRegistration; a different statement from the PR's value import at line 12), and the flow-registration readers at 6569, 10263, 11093, 11117, 15409, 15423, 15476 and 15490. None overlaps the function (lines 982 to 1000) or its three call sites, which only shifted by main's net +75 lines (restore door 6766 to 6814, save door 21895 to 21970, publish door 23767 to 23842) with their bodies unchanged. The import resolves at the head: export function judgePermissionSetOwnerBinding at packages/spec/src/security/permission-set-owner-binding.ts:88, re-exported by security/index.ts; main touched nothing under packages/spec/src/security/.

  4. Main changed no door, judge, refinement or export the PR depends on. Measured by name-only diff over the window: nothing under scripts/, packages/spec/scripts/, .github/, packages/spec/src/security/, packages/spec/src/migrations/, packages/spec/src/shared/, packages/spec/src/stack.zod.ts, packages/spec/src/system/environment-artifact.zod.ts, packages/objectql/, packages/plugins/plugin-security/, packages/cli/, packages/metadata/, the spec package.json and tsconfig.json, pnpm-lock.yaml, turbo.json or .gitattributes. So the generators and gates that judged the old head are the ones judging this one; applyApiEndpointGates and lazySchema (the refinement's neighbours in stack.zod.ts) are untouched; viewContainerNameRefusal (the registration door's sibling in engine.ts) is untouched; the step-18 ADR-0087 entry the changeset's already-registered disposition names still exists at the new merge base and resolves at the head; the two readers' files are untouched. Main's packages/spec changes are the api shard of five generated artifacts, api/protocol.zod.ts (the new FlowRegistrationSchema and FlowRegistrationRefusalSchema), contracts/automation-service.ts, api/meta-item-response-shapes.test.ts, type-alias-convention.pin.test.ts (783 to 785 pins) and variant-docs.json, all disjoint from the PR's security shard and from the stack schema.

  5. Generated artifacts are jointly consistent by reading, pending the gate. The spec artifacts are sharded per entry: main moved api.json in five directories, the PR moves security.json in two, and no path .gitattributes routes to merge=os-regen changed on both sides, so the os-regen driver never ran, no os-regen-pending deferral is owed by this merge, and git's trivial resolution is the union a regeneration of the merged source would write. content/docs/references/index.mdx is main's regenerated copy (1531 to 1533 schemas, the two new api schemas); the PR adds no schema (its new file has no import and declares no zod schema: a function, an interface and a string-literal type), so that count is right on the merged tree, and neither type-alias-convention.pin.test.ts nor check:spec-parsed-alias is moved by the PR. TypeScript Type Check, which carries check:generated's family, is the gate of record and is in progress.

  6. Main brought no producer the new doors would now refuse. Over the window's added lines there is no permission set carrying a packageId, no registerApp( and no defineStack(; the only packageId hit is ADR-0086 prose. The dogfood and runtime tests main added (authz conformance, dispatcher floors, share links, held platform-admin sessions) exercise none of the three doors.

  7. The ruling's ADR moved on main, in the PR's favour. a581c57ea (docs(adr): record the stage-8 retirement of the four security-catalog row objects in the ADRs that name them #23362) amends ADR-0086, ADR-0094 and ADR-0126 to record the stage-8 retirement of the sys_permission_set and sys_position_permission_set row objects (ADR-0131 D2/D3/D13); the ADR-0086 note states that D3's packageId / managedBy stay on the definition. The rule the PR enforces, ADR-0086 D3, is reaffirmed, not refined; the PR's describe text, docblocks, changeset and step-18 entry cite D3 and need no edit. The retired projector and row objects are not what the PR's lock reader reads (it reads registry items and the layered read's code layer), and main touched no plugin-security code.

② Semver level

Unchanged, because the changeset is byte-identical in the net diff: .changeset/23312-permission-set-owner-binding-build-and-registration.md grades @objectstack/spec, @objectstack/objectql and @objectstack/plugin-security minor and @objectstack/metadata-protocol and @objectstack/cli patch, carries Clause-②: yes (narrowing), the FROM → TO table, the one-line remedy and the ADR-0087 disposition not-required (already-registered permission-set-package-id-binding-mismatch-refused). That still matches what the net diff publishes at this head: three new @objectstack/spec/security exports (yes), a narrowed accept-set at the build and registration doors and in the lock verdict (narrowing, BREAKING, shipped as minor under the launch-window convention check-changeset-no-major encodes), no error code minted, metadata-protocol verdicts and words unchanged. The seven changesets main brought (#22585, #23225, #23245, #23276, #23316, #23328, #23335) are main's own and none is modified or deleted by the net diff, so check-empty-changeset has nothing to refuse; the step-18 entry's id exists at the new merge base 6eb6f7d65 and resolves at the head, so already-registered remains legal. The PR body's line 2 Clause-②: yes (narrowing), the claim 6158206070 and the changeset agree. Check Changeset is completed / success on this head; Lint & Repo Gates, which runs check-adr-0087-registration and check-changeset-no-major, is in progress. The PR title carries the ! marker; the body ends with the session-URL footer.

③ Boundary flags

  • Four of the five required contexts were in progress at the read. Build Core, Test Core, Lint & Repo Gates and TypeScript Type Check had not concluded (their lanes started 03:58Z); only Governed Surface Queue Guard had. This record's PASS is the contract judgment on the delta and does not stand in for them: under Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14 and Multi-agent discipline §7, the owning seat reads those four completed / success on 6c72fd851 before readying or arming, and a red on any of them is a new head to fix, never a re-reading of this record.
  • The merge commit carries no Claude-Session: / Co-authored-by: trailer pair (its message is git's default, Merge remote-tracking branch 'origin/main' into claude/issue-23312-permission-set-packageid-sites). It is the same form as the earlier merge 14b1c5a25, which record 6160320785 passed, and it is what scripts/pm/os-regen-merge.sh step 1 itself produces (git merge --no-edit origin/main auto-commits with git's message and no hook run); the step-3 commit that script writes arises only when step 2 took main's side of a both-sides routed path, and here no routed path moved on both sides, so there was nothing for step 2 to take and no step-3 commit to expect. No model identifier appears in any commit on the branch, the PR title or body, or the changeset. Reported for the seat's own trailer discipline; not a breach this record can rule.
  • The seat's ACCEPT note (6160330649) named the sync path and the re-review condition: a non-pure-regeneration move re-runs the review on the new head. This merge is that move (it carries main's 67 files, not a regeneration), and this record is that re-run; no Regen-provenance: line applies.
  • The docs drift comment (6159817025) was computed on the merge tree 3b25313f1 of this head into 6eb6f7d65, advisory and precision-first; its "uncommitted changes" caveat is the CI checkout's, not this PR's. The three files it could not anchor are the PR's generated security.json shards and the barrel, the same set the earlier record read directly.
  • Nothing else the merge introduced bears on the earlier record's findings. The environment-artifact door, the residual-set mirror of unclaimedTopLevel, the dev's seven deviations and the /tests-3pkg.pid note were answered there and are unchanged by this merge; open_questions remains empty.

Implemented-by: claude/issue-23312-permission-set-packageid-sites
Reviewed-by: session_01YRp5z4kK6vgYkrdH97ztsy

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants