Skip to content

fix(objectql): register stack-declared positions under their package so the save door refuses overrides - #22262

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22203-position-allow-org-override
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22203-position-allow-org-override

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22203
Clause-②: no

What changes

ObjectQL.registerApp() and the nested-plugin seam now register a stack's positions collection into the engine SchemaRegistry under the owning package. They stamp the same ADR-0010 provenance that permissions and capabilities already get (METADATA_ARRAY_KEYS, packages/objectql/src/engine.ts). The metadata save door's existing type-level packaged-base check then covers position the same way it covers the other allowOrgOverride: false types.

Landing: the producer side, in packages/objectql. The save door in packages/metadata-protocol was correct and was given the wrong input, so its code is unchanged. There is no packages/spec edit; the flag was already declared.

Measured: why the type-level check answered for one type and not the other

  • The check is already type-level. The packaged-base check reads the registry's allowOrgOverride in two places: refusePackagedBaseOverride inside saveMetaItem (environment-scoped kernel) and SysMetadataRepository.assertAllowed under the override-artifact intent (host-config kernel).
  • Its input comes from the engine registry. Both decide "a code package ships this item" through isArtifactBacked → lookupArtifactItem → SchemaRegistry.getArtifactItem. That lookup only finds entries a package registered with _packageId.
  • The input was missing for positions. The only seam that puts stack collections into that registry under their package is registerMetadataCollections over METADATA_ARRAY_KEYS. That list had permissions and capabilities. For positions it still had the retired roles spelling: ADR-0090 D3's rename reached ARTIFACT_FIELD_TO_TYPE (packages/metadata/src/plugin.ts) and never reached this list. check:stack-collection-maps recorded the absence as a waiver. The waiver's reason pointed at the metadata service's registry, not the SchemaRegistry.
  • Result. For every stack-declared position, isArtifactBacked('position', NAME) answered false. The save took the runtime-only intent, and allowRuntimeCreate: true accepted it.
  • Where the permission-set 403 comes from. On the showcase, plugin-security's packaged permission-set lock answers first. That lock is registerPackagedPermissionSetLockGate, an authoring gate registered for permission only. Under it, the type-level door also refuses a package-declared permission set. The pin below shows this with no security plugin composed. The lock stays as it is, a stricter type-specific layer on top of the type-level door.

Population of the pin, read from the registry: every domain: 'security' row with allowOrgOverride: false, which today is permission, position and capability.

Door table

Showcase (pnpm dev -- --fresh), host-config kernel, seeded admin. The same requests were sent both times:

  • without fix: this branch with the one added positions entry ablated. objectql was rebuilt, and the preflight proved the change reached dist/.
  • with fix: head 7ed88a6081 (merged origin/main f4bed58341).

The same position, permission, capability and control answers were also measured before the merge, at base 7b926f7600 and head f7d8d0e172.

item request without fix with fix
package-declared position PUT 200, saved 403 NOT_OVERRIDABLE
package-declared position PUT naming the package (?package=) 422 WRITABLE_PACKAGE_REQUIRED 403 ITEM_LOCKED
package-declared position GET by name after the PUT serves the environment row serves the package's position
built-in position (plugin-security) PUT 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE (unchanged)
package-declared permission set PUT, with or without ?package= 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE
package-declared capability PUT 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE
position no package declares PUT 200 200
package-declared dashboard (allowOrgOverride: true) PUT 200 overlay 200 overlay
position list GET /meta/position 16 names 16 names, no duplicates

Boot diagnostics: 4 warnings on both boots, the same four. The seeded sys_position rows carry the declared labels and descriptions.

Pins

  • New: packages/objectql/src/engine-security-catalog-package-door.test.ts. It uses a real ObjectQL, a real ObjectStackProtocolImplementation and the population above, read from DEFAULT_METADATA_TYPE_REGISTRY. For each type it checks:

    • the provenance seam registers the stack-declared item under its package;
    • a save over the package-declared item is refused with envelope { code: 'NOT_OVERRIDABLE', status: 403 } and stores nothing, on both topologies.

    It also checks that the by-name read still serves the package's position after the refusal. Controls:

    • a position no package declares still saves, on both topologies;
    • email_template (allowOrgOverride: true) still saves over its packaged item, on both topologies.
  • Re-measured: packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts. Its positions case asserted the old absence on an artifact that declared no position. The probe artifact now declares one, and the case asserts the registry holds it under the artifact's package beside the six built-ins. This is a real createStandaloneStack boot with SecurityPlugin.

  • Widened: packages/objectql/src/engine-nested-plugin-collections.test.ts. positions joins the property candidates: both seams register it identically.

  • Gate: scripts/check-stack-collection-maps.mjs. The stale missing: ['positions'] waiver on METADATA_ARRAY_KEYS is removed. The gate fails on a stale waiver, and its reason was wrong about which registry it meant.

Reverse verification (ablation)

The run was committed first, then mutated through scripts/ablation-replace.mjs. The mutation removed 'positions' from METADATA_ARRAY_KEYS: anchor count 1 → 0, blob 8465f68a50a1 → c4414cf91029. objectql was then rebuilt, and scripts/ablation-dist-preflight.mjs @objectstack/objectql '"positions"' --absent --source-marker="'positions'" exited 0.

Predicted before running: 4 red in the new pin (seam, both topology refusals, by-name read) and 1 red in the runtime pin, everything else green. Measured exactly that:

  • objectql: Tests 4 failed | 47 passed (51);
  • runtime: Tests 1 failed | 12 passed (13).

Restore leg: blob == HEAD, git diff HEAD empty. objectql was rebuilt, and the preflight in default mode found the marker present in 4 built files with a clean tree. Both suites were green again: 51/51 and 13/13.

The new pin was also run at base 7b926f7600 before any fix existed. Exactly the 4 position cases were red, and the by-name read returned the environment fork.

Local verification

All at head 7ed88a6081 unless noted.

  • pnpm --filter @objectstack/objectql test: 381 files / 7536 tests passed, at 5188b2ad45. The merge brought no objectql, metadata-protocol or core change.
  • pnpm --filter @objectstack/objectql typecheck and pnpm --filter @objectstack/runtime typecheck: OK, test layers included.
  • Post-merge, targeted runs:
    • objectql pin, seam and capability-provenance suites: 58/58;
    • runtime standalone-stack, standalone-stack-seeder-declaration-copy, standalone-stack-security-registrar and app-plugin-artifact-forward-conversion: 48/48;
    • verify artifact-collections: 8/8;
    • pnpm --filter @objectstack/plugin-security test: 3739 passed, 45 skipped;
    • dogfood security-catalog-showcase, showcase-declarative-rbac-seeding, position-address-readers and rls-runner: 53/53. These resolve dist/, rebuilt at this head.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands gives 86 families, all run at 7ed88a6081 and reconciled with --ran.
    • 84 exit 0.
    • check-empty-changeset exits 1, by design. See the next section.
    • pnpm check:pm-dispatch-gates: exit 0, with all 1976 self-test cases passing. The 900 s per-gate cap in the batch runner killed it first, so it was re-run on its own with its exit code captured.
  • ESLint, narrowed:
    • What was checked: the 5 changed .ts/.mjs files, at 7ed88a6081.
    • Result: --format json reports 5 files linted, 0 errors, 0 warnings, none ignored.
    • Why the narrowing excludes nothing: eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot change the verdict on any file it does not touch.
  • Integration tier, full lint and the rest of the farm: declared to CI.

A pending release note this PR corrects: please confirm

.changeset/15196-core-security-catalog-read.md (pending, @objectstack/core) says the engine registry carries "no stack-declared position, and the metadata service carries the stack-declared positions". This PR makes that sentence false, so the sentence is rewritten in place to say the engine registry also carries stack-declared positions. No other text changed.

check-empty-changeset stays red on it until a person confirms the correction. That is the gate's own route for a deliberate correction, and the file is not restored. If the seat prefers, the correction can be split into its own docs-only PR instead.

For #22220 (serial, same region)

This PR does not touch saveMetaItem, refusePackagedBaseOverride, packagedBaseRefusal or SysMetadataRepository.assertAllowed. The override check keeps its position and order, where its inputs come from, and its emitters. What changes is the value of one input: isArtifactBacked(type, name) now answers true for a stack-declared position. So the check now fires for positions where it already fired for permission sets: in saveMetaItem behind environmentId, and in the repository's assertAllowed on a host-config kernel. #22220's reorder of the package door against the authoring gate will see position behave like permission on the type-level path, without the plugin-security authoring lock, which is registered for permission only.

Acceptance notes (noted, not filed)


Generated by Claude Code

claude added 4 commits October 8, 2026 08:01
The metadata save door refuses a save over a package-declared item of a
type with no overlay channel, and decides "a package ships this" from the
engine SchemaRegistry entry the package registered. METADATA_ARRAY_KEYS
carried permissions and capabilities but still carried the retired
`roles` spelling instead of `positions`, so a stack-declared position
had no such entry and a save over it took the runtime-create tier.

Adds `positions` to the provenance seam, drops the stale waiver row in
check-stack-collection-maps, re-measures the seeder declaration-copy
pin on a real artifact boot, and pins every security-domain
allowOrgOverride:false type at the door on both topologies.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 2 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via sharingRules (literal, a string literal in METADATA_ARRAY_KEYS))
  • content/docs/getting-started/quick-start.mdx (via sharingRules (literal, a string literal in METADATA_ARRAY_KEYS))
  • content/docs/kernel/services-checklist.mdx (via sharingRules (literal, a string literal in METADATA_ARRAY_KEYS))
  • content/docs/permissions/authorization.mdx (via sharingRules (literal, a string literal in METADATA_ARRAY_KEYS))
  • content/docs/permissions/permissions-matrix.mdx (via sharingRules (literal, a string literal in METADATA_ARRAY_KEYS))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sharingRules (literal, a string literal in METADATA_ARRAY_KEYS))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 17 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 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 20ac611edde4da6e30354f252ec6307af2402a2e — the merge of head 7ed88a608124b912c391119d1b2d0e111ee7d5cf into base 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a, 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 20ac611edde4da6e30354f252ec6307af2402a2e && git checkout 20ac611edde4da6e30354f252ec6307af2402a2e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a 7ed88a608124b912c391119d1b2d0e111ee7d5cf && git checkout -B drift-repro 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a && git merge --no-ff 7ed88a608124b912c391119d1b2d0e111ee7d5cf

node scripts/docs-audit/affected-docs.mjs --json 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a

⚠️ 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 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a → 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: 7ed88a608124b912c391119d1b2d0e111ee7d5cf
Local-runs: none

Inputs: card #22203 (body; comments 6054396188 triage, 6055084143 claim, 6057851358 os-dev-report), PR #22262 (body, its 7-file list, the net diff against main — merge base f4bed58341, +326/−18), and the 34 check-runs on the head. Every required context is green (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). One red, Check Changeset, whose annotation names .changeset/15196-core-security-catalog-read.md under the #17712 foreign-changeset rule — judged in ③. main code is cited at the fetched tip 8cbe255ef6. No governed surface in the file list; head repo equals base repo; 344 changed lines.

① Derived judgments

  1. 'positions' added to METADATA_ARRAY_KEYS (packages/objectql/src/engine.ts), 'roles' kept — right. registerMetadataCollections is the one body both seams run (registerApp and the nested-plugin seam), and its registerItem(pluralToSingular(key), item, 'name', ownerId) is the only seam that stamps ADR-0010 _packageId / _provenance: 'package' on a stack collection. ObjectStackDefinitionSchema declares positions (packages/spec/src/stack.zod.ts:466, ADR-0090 D3), metadata-collection.zod.ts:85 carries the plural, and ARTIFACT_FIELD_TO_TYPE (packages/metadata/src/plugin.ts:140) already maps positions to position — the engine list was the one enumeration the D3 rename never reached. 'roles' stays: no schema-valid stack carries the key, it is waived under the gate's extra DRIFT row with six other retired kinds, and removing it is Six independent enumerations of the stack-collection set, none answerable to stack.zod.ts — one is mis-aimed and two still list retired kinds #6242 row 3's lane. Keeping it is scope discipline.

  2. The save door's accept set narrows, exactly as the type registry already declared — right. isArtifactBacked(type, name) (packages/metadata-protocol/src/protocol.ts:16424) is lookupArtifactItem over SchemaRegistry.getArtifactItem, which answers only an entry carrying a real _packageId. With the entry present, refusePackagedBaseOverride (inside saveMetaItem, environment-scoped kernel) and SysMetadataRepository.assertAllowed under the override-artifact intent (host-config kernel) refuse a save over a package-declared position with 403 NOT_OVERRIDABLE, because position is allowOrgOverride: false (packages/spec/src/kernel/metadata-plugin.zod.ts:1117, the 2026-08-08 rollback ee58392e1; ADR-0005's amendment: artifact-backed item on a type without the flag is not_overridable). The door code is untouched — the net diff has no hunk under packages/metadata-protocol. The refusal set is read off the registry (domain: 'security' with allowOrgOverride: false — permission, position, capability) and the new pin enumerates it the same way, on both topologies, with three controls (the runtime-create tier, an email_template overlay, the by-name read). The PR's door table measures 200 → 403 on the showcase; its ?package= row (422 WRITABLE_PACKAGE_REQUIRED → 403 ITEM_LOCKED) is the named-read-only-base limb the door already answers for permission: a refusal recoded truthfully, not an acceptance withdrawn.

  3. The /meta read envelope follows the door — right, and consumer-visible. servedLockState (packages/metadata-protocol/src/protocol.ts; the /meta read envelope and the diagnostics tile call it with isArtifactBacked) derives editable / deletable / lock from packagedBaseRefusal, so a stack-declared position now reads editable: false where it read editable: true. Route rule 4 (machine-readable surfaces must not lie) wants exactly this; the changeset does not name it, which is acceptable because the envelope is defined to mirror the doors ("a door that moves moves this read with it").

  4. A pre-fix environment fork stays removable — right (repair path). A row saved under a package position's name before this fix hydrates into the bare slot and still wins registry.getItem (ADR-0005 precedence). After this PR it cannot be edited (403), but refusePackagedBaseRemoval exempts position through mergesOverlayAtRead (supportsOverlay: true), so DELETE /api/v1/meta/position/NAME removes the row and removeRuntimeShadow restores the package's definition. No lock-out. The dev's S2b heads-up is accurate: the diff neither adds nor removes a refusal for built-in names, which registerBuiltinPositions registers independently of this list.

  5. The security catalog read (packages/core/src/security/security-catalog.ts) — right; names unchanged, source changes. list and resolve ask the registry first and the metadata service only for names the registry does not hold. A stack-declared position now answers source: 'registry' with packageId from the entry's _packageId instead of source: 'metadata'; the NAME set is unchanged because AppPlugin's SECURITY_FIELDS → metadata.registerInMemory (packages/runtime/src/app-plugin.ts:1007) and the artifact door's ARTIFACT_FIELD_TO_TYPE still fill the metadata-service copy, and the reader dedupes by name. The dogfood pin packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts holds names == declarations == door in three postures and its header says a position reaching the engine registry must not turn it red; Dogfood Regression Gate is green on this head. The reader is unreleased (its note is the pending feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 changeset), so this needs no @objectstack/core changeset — it needs the note corrected, which is ③'s subject.

  6. bootstrapDeclaredPositions (plugin-security) — right; rows unchanged. It seeds from the catalog read (readDeclaredPositions), projects label / description only (Audit sibling declared-metadata↔record two-store types (sys_position, sys_sharing_rule, sys_capability) per ADR-0094 addendum #2909 T2) and excludes the six built-ins by name. The registry copy is the raw stack bytes under the owning package; the metadata-service copy is the same bytes (in-memory registration) or the forward-converted artifact copy, whose conversion rewrites the collection KEY (roles → positions), not item fields. Same names, labels and descriptions; the dev measured the seeded sys_position rows and ran the plugin-security suite; Test Core is green.

  7. Other registry readers that now see stack positions — judged, none wrong. readDeclared(ql, 'position') has no caller on main (permission and capability only); listItems('position') outside the catalog read has no non-test caller. unregisterItemsByPackage and isPackageDisabled now cover a stack's positions as they cover its permission sets (a disabled package's positions leave the registry list; the catalog read already hides owner-disabled definitions, and the metadata-service copy carries no owner, so the catalog's answer for a disabled package is unchanged). narrowObjectsToPackageClosure (packages/lint/src/runtime-gate.ts) filters objects only. Cross-package collision at registerItem: on main the per-item cross-package throw is retired (registry.ts:3603), so two packages declaring one position name both register and the by-name read answers the first-registered — the answer the catalog read pins today. PR feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 (card feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135; open, not in this head) will refuse the second holder for position as ADR-0048 §3.4 now narrowed (f4bed58341, this PR's merge base) decides; this PR is what lets that gate see a stack-declared position at all — the ADR's intent, not a side effect to escalate.

  8. Public surface: no export, key, route or accepted value is added or removed; METADATA_ARRAY_KEYS is module-private. The one measured accept-set change is the refusal in 2, on a payload the published registry excluded already.

  9. Tests and the edited gate script — right. The new pin boots a real ObjectQL and a real ObjectStackProtocolImplementation over an in-memory driver that honours the caller's limit (the 5188b2ad45 fix for check:objectql-double-limit); the dev reports base-red / head-green and a committed-state ablation (anchor 1 → 0, dist preflight, 4 + 1 predicted reds, restore leg) with numbers; I did not re-run any of it — Test Core green is the gate verdict. engine-nested-plugin-collections.test.ts adds positions to the two-seam candidates (both seams run one body). standalone-stack-seeder-declaration-copy.test.ts re-measures the [finding] After #12892 step 2 an artifact boot with an engine still holds a THIRD, un-parsed copy of permissions / capabilities / sharingRules in the ObjectQL SchemaRegistry (AppPlugin.init → manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491 positions case on a probe artifact that now declares one position and asserts the registry copy under the artifact's package beside the six built-ins; the old comment attributed "none of the stack's" to ADR-0131 D2, which in fact puts declared positions in the registry — the new text is the correct reading. scripts/check-stack-collection-maps.mjs: the waiver list is a ratchet ("a waiver that no longer applies FAILS"), so the missing: ['positions'] row had to leave once the key joined; the replacing comment records why its reason was wrong (it named the metadata service's registry, not the SchemaRegistry the save door reads). Lint & Repo Gates green covers the gate and its self-test.

② Semver level

.changeset/22203-position-package-door.md: @objectstack/objectql: patch, summary fix(objectql): …, Clause-②: no — right; the PR body carries the same bare line at the start of its second line.

  • Clause ② asks whether the card widens an accept set or expands the public surface (scripts/pm/clause2-line.mjs). Nothing is added — no key, export, route or accepted value (① 8). no is the truthful value.
  • The arm. The diff narrows what the /meta save door accepts (① 2), and the arm rule reads a narrowing as breaking. I judge the bare no right because the narrowed set is one the published contract already excluded: DEFAULT_METADATA_TYPE_REGISTRY (a published export of @objectstack/spec/kernel) has declared position allowOrgOverride: false since 2026-08-08, ADR-0005's table names the artifact-backed / no-flag case not_overridable, and triage graded the 200 as "the defect" and the fix as "execution, not a decision". The repo's precedents for this shape — a door that leaked past an already-declared rule, brought back to it — are graded the same way: the org-override-registry-gate: the field overlay lock is not enforced — an artifact-backed field PUT is accepted 200 (and is inert) #7743 field fix (the identical mechanism, isArtifactBacked blind to one type so allowOrgOverride: false was never reached; released under @objectstack/metadata-protocol Patch Changes) and the pending .changeset/22113-revert-package-code-shipped-members.md (ADR-0070 D2 read-only packages; publish 200 → 422; patch, bare Clause-②: no). The pending (narrowing) stock (22032, 22046, 22168, 22201, 15196-builtin-positions-declared-metadata) each tightens or newly declares a rule at a door; none restores one already declared. The changeset still carries the consumer's prescription ("create a position with a different name"), the half of a breaking note that matters, so a reader of either reading is served.
  • Level: a fix( that changes no public surface stays patch (the WHICH LEVEL ruling, 2026-09-04). @objectstack/runtime moves a test file only; plugin-security and core move no source. No ADR-0087 disposition is owed on a non-breaking changeset.
  • Check Changeset's red is the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712 foreign-changeset rule alone (its annotation), not the level axis.

③ Boundary flags

The deliberate correction — .changeset/15196-core-security-catalog-read.md (card #15196, @objectstack/core: minor, pending): CONFIRMED, option A — keep it in this PR

Before (one sentence of the "Where it reads" bullet): “Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items but no stack-declared position, and the metadata service carries the stack-declared positions but not the platform's permission sets.”

After: “Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items, stack-declared positions included, and the metadata service carries the security collections an app registers in memory but not the platform's permission sets.”

  • Is the old sentence false once this PR lands? Yes. "but no stack-declared position" is exactly what ① 1 reverses: registerApp now registers a stack's positions into the engine SchemaRegistry under its package — pinned on a bare engine by the new objectql test's seam case and on a real createStandaloneStack boot by the runtime [finding] After #12892 step 2 an artifact boot with an engine still holds a THIRD, un-parsed copy of permissions / capabilities / sharingRules in the ObjectQL SchemaRegistry (AppPlugin.init → manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491 pin. The sentence's other clause ("the metadata service carries the stack-declared positions") stays true, and the new wording keeps that fact.
  • Is the new sentence true against the diff and main? Yes. "every package manifest's catalog items, stack-declared positions included" — true for the positions key, the only spelling ObjectStackDefinitionSchema accepts (stack.zod.ts:466); the dev's measured carve-out (an artifact whose floor predates ADR-0090 D3 and spells roles, converted only on the metadata-service copy) is not a positions declaration and is handled below. "the metadata service carries the security collections an app registers in memory" — true: AppPlugin's SECURITY_FIELDS → metadata.registerInMemory (app-plugin.ts:1007–1100) and, under the artifact-door registrar, the artifact loader's ARTIFACT_FIELD_TO_TYPE; the phrase is the module doc's own (security-catalog.ts: "the stack-declared security collections an app registers in memory"). "but not the platform's permission sets" and "Neither holds the whole catalog" — unchanged facts, still true: the metadata service lacks the platform's bootstrap sets, and the registry lacks loader-held and legacy-key items.
  • Does it change anything else the note says? No. The diff is +1 / −1 on that line; frontmatter, level, Clause-②: yes (widening), the precedence bullet, the "what it does not answer" bullet and the loud-failure bullet are byte-identical. (Aside for the feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 carrier, not this PR's doing: the note's last line "Nothing calls the reader yet" is already stale on main — bootstrap-declared-positions.ts and grant-permission-set-name-backfill.ts call it; the same carrier takes the stale security-catalog.ts module doc and its construction TypeError text, as the dev noted.)
  • The red Check Changeset is the gate's own DELIBERATE CORRECTION class — its annotation on this head reads "do NOT restore it -- say so on the PR and get it confirmed; restoring it from the base would put the false sentence back". Option B (a docs-only PR) would let the false sentence ship if the two landings reorder. This record is the confirmation the gate routes to.

Other dev flags (6057851358 deviations, open_questions, out_of_scope_findings, seat_heads_up_s2b)

Implemented-by: claude/issue-22203-position-allow-org-override
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing note: Check Changeset is red by design on this head · 2026-10-08T11:15Z

domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant). Recorded before the queue, as the landing rule for a by-design red requires.

  • The gate and its cause: Check Changeset (scripts/check-empty-changeset.mjs) refuses this PR in its DELIBERATE CORRECTION class: the PR rewrites one sentence of feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196's pending .changeset/15196-core-security-catalog-read.md, which this PR makes false. The script's own remedy text is "do NOT restore it -- say so on the PR and get it confirmed".
  • The confirmation: the at-tier contract review on this head, 6058620975, names the note and judges the rewritten sentence (old clause false once positions registers under its package; new sentence true against the diff and main; nothing else in the note changes).
  • Why it does not block the queue: the job runs in pr-automation.yml, triggered by pull_request only, never merge_group, and it is not in the main ruleset's required set. Every required context is success on 7ed88a6081.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 11:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 0b997ea Oct 8, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22203-position-allow-org-override branch October 8, 2026 11:52
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… name per deployment — a second holder is refused at registration, naming both (objectstack-ai#22197)

Fixes objectstack-ai#22135
Clause-②: no

Executes the maintainer's ruling Q4 = A on objectstack-ai#15196 (ruling record
6050490870): positions, permission sets and capabilities each hold one
name per deployment. A package registering a name that an installed
package, the environment catalog or a built-in already holds is refused,
and the error names both holders. ADR-0048 §3.4's coexistence stands for
every other metadata type.

The ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, objectstack-ai#22198,
now on `main`. This PR carries no `docs/adr/**` file.

## What changed

- **`packages/objectql/src/security-catalog-namespace.ts` (new).** The
rule in one place: the three types, the built-in names, the holder
vocabulary (`package` / `environment` / `built-in`), and the reader of a
manifest's declared names. It reads the same sources the engine's
registration seams read: the manifest's own `positions` / `permissions`
/ `capabilities` and each nested `plugins[]` entry's, arrays only. A
manifest-stage `permissions` grant block is never read as permission
sets.
- **`SchemaRegistry.installPackage` — the package door.** It refuses
ahead of every mutation, beside the namespace gate, so a refused package
leaves no record, no namespace ownership and no claim. Every conflict is
listed in one refusal. The package's claims are recorded after a
successful install and released by `uninstallPackage`. The claims are
what the door reads for names no registered item records, and
`installPackage` itself registers no items. Through
`ObjectQL.registerApp` (every boot and hot-install door), a package's
permission sets and capabilities are also registered items under the
package, and so are its positions since objectstack-ai#22262 landed on `main`. There
the claims agree with the items. With the claims ablated, the
`registerApp` doors still refuse and the direct `installPackage` door
does not (Patch round 3), so the claims stay.
- **`SchemaRegistry.registerItem` — the item seam.** A package-bound
registration of a catalog type over a name another holder holds is
refused before anything is stamped or stored. Built-ins are not asked
here: the platform registers its own built-in positions at this seam,
under its own package id (the S2 stage, now on `main`), and that
registration is the built-in holder's own. For a built-in name the
environment holder is not asked either; see Patch round 2. A
registration with no package is the bare slot, which is what every
`sys_metadata` hydration and metadata write-through writes. It is never
judged: an environment save over a package-held name is outside the
ruling.
- **The envelope** reuses the namespace gate's shape and registered
code: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE`, already
exported), `status: 422`, `httpStatus: 422`. The condition is the same
one, a name in a deployment-wide namespace already taken, and so is the
remedy: rename, or uninstall the other holder. The class
(`SecurityCatalogNameConflictError`) stays unexported, as the registry's
other refusal classes are. It is not the namespace gate's class, whose
message names a `manifest.namespace` and offers the
`OS_METADATA_COLLISION=warn` downgrade. Neither is true here, and
`collisionPolicy: 'warn'` does not downgrade this refusal (pinned).
- **No new error code; no `packages/spec` change.**

### Where the doors are, measured

The card names three doors. What this PR measured is that all three are
reached through ONE: `ObjectQL.registerApp` →
`SchemaRegistry.installPackage`, which every package registration hits
in the kernel's Phase 1, before any `start()`.

- `AppPlugin`'s security registrar (`registerInMemory`, the
`'app-plugin'` registrar) and the artifact door
(`MetadataPlugin._registerArtifactBodyCollections`, the
`'artifact-door'` registrar) both run in Phase 2.
- Neither runs for a package the engine has not installed:
`AppPlugin.init` registers every package of its bundle through the
`manifest` service first, a multi-package artifact package by package.
- So the producer-side fix is the package door, and
`packages/metadata/src/plugin.ts` and
`packages/runtime/src/app-plugin.ts` are unchanged. The runtime pins
boot both registrars' real compositions and see the boot refused before
either runs.

## Door table: base vs head

"Base" is the same tree with both gates ablated, at 1604e09 (rows 1,
2 and 6 were also measured on the untouched base 7ef50a4, with the
same answers). "Head" is 8ad6385. Boots go through
`@objectstack/verify`'s `bootStack`; the artifact rows go through
`createStandaloneStack`.

| Door | Base | Head |
|---|---|---|
| Boot, door-less (`new AppPlugin(stack)`): two stacks sharing a
position, a permission set and a capability name | boots. The by-name
read answers the position from the LAST stack (metadata-service slot)
and the set and the capability from the FIRST (registry order) | boot
refused: `422 NAMESPACE_CONFLICT`, 3 conflicts, second stack vs first
stack |
| Boot: an app declaring `everyone` / `manage_users` /
`admin_full_access` | boots. The by-name read of `admin_full_access`
answers the APP's set | refused. Holder `built-in` for the first two.
For `admin_full_access` the app registers before `plugin-security` in
`bootStack`, so the platform's registration is the one stopped, naming
the app |
| Artifact boot: two packages of one artifact sharing names; a package
declaring `everyone` | boots (runtime pins red under ablation) | refused
in Phase 1, before the artifact door registers anything |
| Hot install: post-boot `manifest.register` over a held name |
accepted, package record written | refused, no record |
| Hot install: `POST /api/v1/marketplace/install-local`, inline manifest
| `200`, installed | `422 PLUGIN_REGISTER_FAILED`, the route's own code,
with this refusal's message in `error.message`; no record |
| `POST /api/v1/packages` | `400`: the strict body refuses `positions`,
the retired `capabilities` and a flat `permissions` list | unchanged. No
catalog collection can arrive here |
| Environment catalog holds a permission set, then a package declaring
it is hot-installed | accepted | refused, holder `environment` |
| Same-package hot reload | accepted | accepted |
| Environment save over a package-held permission set (`PUT
/api/v1/meta/permission/NAME`, with or without `?package=`) — not
covered by the ruling | `403 NOT_OVERRIDABLE` (the packaged
permission-set lock) | unchanged |
| Environment save of a position over a package-held position name (`PUT
/api/v1/meta/position/NAME`) — not covered by the ruling | `200`, and
the saved position then answers the by-name read ahead of the package's
| unchanged by this PR. Since objectstack-ai#22262 landed on `main`: `403
NOT_OVERRIDABLE` (see Acceptance notes) |

Named but not measured:

- **The artifact door's HMR reload**
(`MetadataPlugin._reloadAndAnnounce`). It re-registers into the metadata
service without `registerApp`, so a dev-loop edit giving a package a
held name is served until restart. The restart's boot refuses it.
- **`install-local`'s cloud-sourced install.** Its existing code
tolerates a register failure: it warns, persists the ledger entry, and
answers success. The next boot's rehydrate logs the refusal at `error`
and skips the package. That is code reading only (it needs a control
plane).

## In-repo collision census (M2)

**Instrument.** A tsx census over `examples/app-crm`,
`examples/app-showcase`, `examples/app-todo` and
`examples/app-multi-package`: each config's top level, its `packages[]`
bodies and its nested `plugins[]`. Against those it reads the built-ins:
`BUILTIN_IDENTITY_NAMES` + `AUDIENCE_ANCHOR_POSITIONS`,
`PLATFORM_CAPABILITY_NAMES`, and `plugin-security`'s
`securityDefaultPermissionSets`.

**Result at 1604e09:** 50 declarations — crm 3 positions / 2 sets;
showcase 10 / 9 / 2 capabilities; todo 0; multi-package 0; built-ins 6
positions / 10 capabilities / 8 sets. Names with more than one holder:
**0**. Same-holder repeats: 0.

**The guard, measured with the gate in place at 8ad6385:** `crm`,
`showcase` and `multi-package` boot through `bootStack`, and
`security-catalog-showcase.dogfood.test.ts` (3 postures) and
`multi-package-artifact.dogfood.test.ts` are green. `app-todo` declares
no catalog name. Deployed and marketplace packages are NOT MEASURED.

## The P1.2 pin, flipped

S1's shared-name pin is the `security catalog read — a name two packages
ship` describe in
`packages/objectql/src/protocol-boot-hydration-scoped.test.ts`. It added
no `P1.2` label, which is why a `git grep` misses it. It now pins the
ruled answer at the same seams:

- a second package registering the name is refused (envelope + both
holders), and every reader answers the one holder;
- an override the holder stored for itself answers for every caller;
- a position two packages declare is refused at the package door, so one
stack's declaration reaches the metadata service.

`core`'s `security-catalog.test.ts` pointed at a non-existent
`security-catalog-shared-name.test.ts`. It now names that describe and
the new door pins. `security-catalog.ts`'s module doc said the
shared-name answer was "pinned until it is ruled", and is rewritten to
the ruled answer. `engine-capability-provenance.test.ts` pinned two
packages' same-named capabilities coexisting, the exact behaviour the
ruling removes. It flips to the refusal.

## Tests (at 8ad6385)

- `@objectstack/objectql`: `registry-security-catalog-namespace.test.ts`
(new, 28 cases), `protocol-boot-hydration-scoped.test.ts`,
`engine-capability-provenance.test.ts`,
`registry-collision-order.test.ts` and
`registry-artifact-co-ownership.test.ts`: 5 files, 64 passed. Full
objectql suite before the merges: 382 files, 7553 tests. The one red was
the coexistence pin flipped above; it is green after the flip.
- `@objectstack/runtime`:
`standalone-stack-security-catalog-one-holder.test.ts` (new, 4) and
`standalone-stack-security-registrar.test.ts`: 2 files, 6 passed.
- `@objectstack/core` `security-catalog.test.ts`: 14 passed.
`@objectstack/plugin-security` `builtin-positions.boot.test.ts` +
`builtin-positions.test.ts` (S2's): 18 passed.
- dogfood: `security-catalog-showcase`, `multi-package-artifact`, plus a
local door probe that is not committed: 3 files, 44 passed.
- Downstream sweep before the merges, against the rebuilt `objectql`
dist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 /
5120, verify 18 / 133, cloud-connection 41 / 505. All green.
- `typecheck` for objectql, core and runtime (with
`check:test-typecheck`): exit 0. No new test-typecheck debt.

## Ablation

Both gates were ablated together through `scripts/ablation-replace.mjs`,
which wraps the run and restores on exit:

- the package-door call became a `globalThis` marker write;
- the item-seam condition gained an always-false marker conjunct.

Both mutations landed on disk: anchor 1 → 0, blob `b96099a12688` →
`12c018d406ab`. `objectql` was rebuilt and `ablation-dist-preflight`
found both markers in `dist/`. The DTS step failed on the now-unused
private method, and the JS bundle the suites read was emitted.

- **objectql pins (read from `src`):** 22 failed / 21 passed of 43.
Every refusal pin went red, including both flipped P1.2 cases and the
flipped capability pin. The controls stayed green: same-package reload,
uninstall releases the name, the environment-registration carve-out,
non-catalog coexistence, the grant-block reader, and the platform's own
built-in registration.
- **runtime boot pins (read from `dist`):** 3 failed / 1 passed. The
control stayed green.

**Restore:** the blob is back to `b96099a12688` and `git diff HEAD` is
empty. After a rebuild, `ablation-dist-preflight --absent` is green for
both markers (dist and whole tree).

## Gates

`node scripts/pm/dispatch-gates.mjs --commands` derived 78 commands on
8ad6385; all 78 were run, and `--ran` reconciles 78/78 with exit
codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two
needed a prerequisite first: `check-engine-split-ratio` refused the
shallow clone (deepened with `git fetch --shallow-since=2026-07-03`),
and `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET until
eight unrelated packages were built. On 8ad6385 both ran green with
the rest. CI's own lanes (Test Core shards, Temporal Conformance, the
Dogfood shards, Build Core, the workspace type-check) are declared to CI
and are NOT MEASURED here. After the run, `origin/main` moved 6 commits,
none of which touches a file in this PR.

## Acceptance notes

- **Environment save of a position over a package-held position name.**
Before objectstack-ai#22262 it was accepted (`200`), and the saved position then won
the by-name read; permission sets were protected on the same door by the
packaged permission-set lock (`403`). Since objectstack-ai#22262 landed on `main`, a
package's positions are registered items under the package, and the same
save answers `403 NOT_OVERRIDABLE` ("'position' is not allowOrgOverride
in the registry"), measured on f16fcd0 with `PUT
/api/v1/meta/position/shared_pos?package=w`. Outside this ruling either
way; this PR changes nothing there.
- **Cold boot vs the environment-catalog holder.** A package registers
through `ObjectQL.registerApp` in Phase 1, and `sys_metadata` hydrates
in Phase 2 (`ObjectQLPlugin.start`). A package added to a deployment
whose environment catalog already holds one of its names is therefore
NOT refused at cold boot: the env row hydrates over it, with the
registry's existing collision warning. It is refused on a hot install.
From the registry's seat, that arrival is indistinguishable from an
environment save over a package-held name, which the ruling leaves out.
The `CONTROL` case in `registry-security-catalog-namespace.test.ts` pins
that the bare slot is not judged. A plugin's own `start()` is different:
every plugin that depends on the engine starts after that hydration, so
a package-bound registration it makes at the item seam DOES meet the
environment holder, and is refused (holder `environment`). The exception
is a built-in name, which the platform declares there itself (Patch
round 2). A `git grep` for literal catalog-type `registerItem` calls in
production source finds one such registration: `plugin-security`'s
built-in positions. Carrier: objectstack-ai#22307 (ruled A: the cold boot refuses too;
it lands separately).
- **Order and the platform's permission sets.** `plugin-security`
declares the platform's sets on its own manifest (configurable through
`defaultPermissionSets`), so they are package-held. When an app
registers before it, as `bootStack` composes, the platform's
registration is the one refused, naming the app. The boot fails either
way, and both holders are named.
- **`install-local` inline import** answers its own
`PLUGIN_REGISTER_FAILED` for any register refusal (this one and the
namespace gate's alike), so `error.code` does not carry
`NAMESPACE_CONFLICT` there. The refusal's text is in `error.message`.
Not changed here.
- **The ledger row comment for `NAMESPACE_CONFLICT`** in
`packages/spec/src/api/error-code-ledger.zod.ts` describes the
manifest-namespace condition only. The spelling, owner key and face are
unchanged, and the provenance gate is green. A one-line comment noting
the second condition is a spec-lane follow-up, not made here.
- **Files outside the engine lane:**
`packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts`
(new test; `runtime` is `domain:cli`'s package);
`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`
(new ADR anchor); and, from patch round 1, five `domain:cli` dogfood
files: `packages/qa/dogfood/test/showcase-security.ts`,
`showcase-d7-default-profile.dogfood.test.ts`,
`authored-row-write-scope.dogfood.test.ts`,
`bulk-widener-probe.dogfood.test.ts` and
`owd-public-read-write-write-floor.dogfood.test.ts` (each: the
`SecurityPlugin` construction, with its comment and imports).

## Patch round 1 — the dogfood fixtures declared one permission set
twice

The Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In
every failing boot, `plugin-security` registered a permission set that
the app package already held.

The fixtures handed an app-declared set to `SecurityPlugin`'s
`defaultPermissionSets`, which plugin-security declares on its own
manifest, while the app declared the same set too:

- `showcase_member_default`, through `showcaseAppDefaultSecurity()` and
the D7 test;
- `wscope_*`, `probe_widener` and `owdw_*`, in three fixtures.

Measured:

- `os serve` / `objectstack dev` never composes this. It hands the
plugin only the default's NAME (`appSecurityPluginOptions`), and the app
registers the set. A real `objectstack dev --fresh` boot of
`examples/app-showcase` came up with the gate in place: health 200.
- The two copies were the same definition: 101 of 101 leaves equal.

Fixed at the producer: each fixture declares the set once, as the app's,
and wires the default by name, as the CLI does. A runtime pin holds the
refused composition.

All three dogfood shards are green locally on 089b1c8 (74 + 74 + 74
files) and in CI.

## Patch round 2 — the platform's built-in positions met an environment
row at boot

The merge queue removed this PR (record 6056019838). `plugin-security`'s
`registerBuiltinPositions` was refused at the item seam: position
`org_admin`, incoming `com.objectstack.plugin-security`, holder
`environment`. That refusal failed `SecurityPlugin.start`, and with it
the boot. S2b's pins went red: `builtin-positions.boot.test.ts`, "a
stored definition under a built-in name" (3 postures), and
`bootstrap-declared-positions.test.ts`, "a stored definition shadowing a
built-in name is neither seeded nor restamped".

Measured on 19c86b7 (this branch with `main` merged, before the fix):

- **Boot order.** `SecurityPlugin` depends on the engine. So
`ObjectQLPlugin.start` hydrates `sys_metadata` into the bare slot BEFORE
`SecurityPlugin.start` declares the built-in positions. Through a real
door: with `OS_METADATA_WRITABLE=position`, `PUT
/api/v1/meta/position/org_admin` answered `200`, and the cold restart
failed ("Plugin com.objectstack.security failed to start", with this
refusal). Without that setting the save answers `403 NOT_OVERRIDABLE`.
- **Who registers.** `registerBuiltinPositions` registers exactly the
six static built-in names (`BUILTIN_IDENTITY_NAMES` +
`AUDIENCE_ANCHOR_POSITIONS`), under the platform's own package id. That
is the built-in holder declaring its own names, not a second holder.
- **What S2b needs.** The stored definition keeps answering first from
the bare slot (ADR-0005), and the platform's declaration sits beside it.

Fixed at the producer, the item seam in `SchemaRegistry.registerItem`:
for a built-in name, it no longer asks the environment holder. An
environment item under a built-in name exists only because an
environment save went over the platform's name, which is outside the
ruling. Unchanged:

- a second PACKAGE registering a built-in name at the item seam is
refused, in either order;
- the package door refuses a package declaring a built-in name (holder
`built-in`);
- for any other name, an environment item still refuses a package-bound
registration at the item seam (holder `environment`).

No same-definition exception, no `collisionPolicy` change, and S2b's
pins are untouched. `registry-security-catalog-namespace.test.ts` gained
three cases, one per behaviour above (the third is a `CONTROL`).

**Reverse verification.** The new condition was mutated through
`scripts/ablation-replace.mjs` to ask the environment holder again (blob
`c60d9bad21bc` → `eeca074e4f80`). `objectql` was rebuilt, and
`ablation-dist-preflight` found the marker in `dist/`. The queue's
signature came back: S2b's 3 boot postures and the
bootstrap-declared-positions case went red with
`SecurityCatalogNameConflictError` (`org_admin` held by the environment
catalog), and so did the new admit case. Restored: blob == HEAD, and
`git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight
--absent` is green.

All suites, the three dogfood shards and the 82 derived gates were green
at ffa6d51, and so was CI.

## Patch round 3 — objectstack-ai#22262 landed on `main` first

objectstack-ai#22262 (squash 0b997ea) adds `positions` to the engine's
`METADATA_ARRAY_KEYS`, so `ObjectQL.registerApp` now registers a
package's positions under the package. This branch merged `main` at
fbcbcf1. The merge touched none of this PR's files, and
`registry.ts`'s logic is unchanged.

**Comments only.** Four comments this PR added said a package's
positions never reach the engine registry's item store. Each now reads
true on `main`: the `securityCatalogClaims` doc and the `installPackage`
comment in `registry.ts`, the declared-names reader's note in
`security-catalog-namespace.ts`, and the header of
`standalone-stack-security-catalog-one-holder.test.ts`. No behaviour
changed, so no reverse leg was re-run.

**Measured with objectstack-ai#22262 in the tree** (f16fcd0, this branch with
`main` merged; through `bootStack`; local probes, not committed):

- A package's own positions arrive both as claims and as registry items
under the same package, and stay one holder. The showcase boots with 10
positions under `com.example.showcase` and 6 under
`com.objectstack.plugin-security`, and 10 claims. Re-registering the
showcase is not refused. A second package declaring `contributor` is
refused, holder `com.example.showcase`.
- The built-in case is unchanged. With environment saves under
`org_admin` and `everyone` (`OS_METADATA_WRITABLE=position`), the cold
restart boots, and both names resolve to the environment's saved
definitions.
- `PUT /api/v1/meta/position/shared_pos?package=w` over a package-held
position answers `403 NOT_OVERRIDABLE`.
- The door probes behind the table above answer as before: crm, showcase
and multi-package boot; the two-stack boot, the built-in names, the hot
install and `install-local` are refused, each naming both holders; the
same-package reload is accepted.

**The claims, ablated.** This was measured on a throwaway local merge of
objectstack-ai#22262's head 7ed88a6, never pushed. All seven files objectstack-ai#22262 landed
are byte-identical to that head's. The claim recording was replaced by a
no-op (`scripts/ablation-replace.mjs`), `objectql` was rebuilt, and the
marker was proven in `dist/`. The runtime boot pins stayed green (5 of
5): every `registerApp` door refuses through the registered items alone.
Two objectql pins went red: the P1.2 position case and the
`collisionPolicy: 'warn'` pin. Both reach the package door through a
direct `installPackage` call, which registers no items. So the claims
stay. Restored: blob == HEAD; after a rebuild, `ablation-dist-preflight
--absent` is green.

**Tests at f16fcd0**, all under `os-verify-lock`:

- `@objectstack/objectql`, whole suite: 383 files / 7572 passed.
- `@objectstack/plugin-security`, whole suite: 179 files / 3775 passed,
45 skipped.
- `@objectstack/runtime`, whole suite: 340 files / 5505 passed, 19
skipped.
- Dogfood, the CI split: shard 1/3, 74 files / 557 passed; 2/3, 74 files
/ 535 passed, 1 skipped; 3/3, 73 passed + 1 skipped files / 661 passed,
8 skipped.
- `typecheck` for `objectql` and `runtime` (`tsc --noEmit` +
`check:test-typecheck`): exit 0.
- Gates: `dispatch-gates --commands` derived 82 on f16fcd0. All 82
ran and exited 0, and `--ran` reconciles 82/82, 0 NOT MEASURED.

CI on f16fcd0: 32 checks success, including Dogfood Regression Gate
1/3 to 3/3 and Test Core 1/6 to 6/6. Three were skipped (Build Docs,
Console Pin Gate, Packed-tarball smoke).

## Patch round 4 — the changeset level, one comment, the cold-boot
carrier

The contract review on f16fcd0 (record 6061710772) failed two texts
and one missing carrier. The code stands; this round changes text only.

- **The changeset level.**
`.changeset/22135-security-catalog-one-holder.md` graded
`@objectstack/objectql` `minor`, on the premise that Changesets pre mode
was not yet on `main`. It is: `.changeset/pre.json` (mode `pre`, tag
`next`) landed with objectstack-ai#22084 (a87d8be), an ancestor of this branch's
merge base. The changeset now grades `major`, and its BREAKING sentence
says the change ships as `major` on the v18 pre-release line. The
ADR-0087 marker and `Clause-②: no` stay. `pnpm changeset status`
resolves `@objectstack/objectql` to `18.0.0-next.0`.
- **The cold-boot carrier.** One sentence in the changeset states the
boundary: at cold boot, packages register before the environment catalog
loads from `sys_metadata`, so a package newly added over a
permission-set or position name the environment catalog already holds is
not refused at cold boot, and the registry's existing collision warning
fires. A hot install of the same package is refused. objectstack-ai#22307 has since
been ruled A (the cold boot refuses too); see Patch round 5. Measured on
9e4ed5d with a local probe (not committed): the cold boot with the
package added came up with no refusal and two `[Registry] Collision`
warnings, one for the permission set and one for the position. The hot
install answered `422 NAMESPACE_CONFLICT`, both names held by
`environment`, and left no package record. A capability cannot be saved
in the environment (`403`, a code-only type), so the sentence names the
two types an environment can hold.
- **One comment.** The runtime pin's comment called the position one "no
registry slot holds". That stopped being true when objectstack-ai#22262 put a
package's positions into the registry under the package. The comment now
says the refusal reports the position, the permission set and the
capability the first package holds. A sweep of this PR's added lines
finds no other sentence saying positions do not reach the registry.
- **The `start()`-time half** of the round-2 question is settled as A
(keep): the ruling names the environment catalog as a holder and does
not distinguish phase. No change.

**Checks at 9e4ed5d:**

- The changeset gates: `check-changeset-no-major` `--self-test` and
`--base`, exit 0 (pre mode, tag `next`, so the no-major guard stands
aside; given this PR's body, the level axis reads `Clause-②: no`);
`check-empty-changeset` `--self-test` and `--base`, exit 0;
`check-changeset-fixed`, exit 0; `check:adr-0087-registration`, exit 0
(1 declared-breaking changeset, carrying its ADR-0087 disposition);
`check:changeset-gate-self-tests`, exit 0.
- `pnpm --filter @objectstack/runtime exec vitest run
src/standalone-stack-security-catalog-one-holder.test.ts`: 1 file / 5
passed. `pnpm --filter @objectstack/runtime typecheck`: exit 0.
- Gates: `dispatch-gates --commands` for the two touched paths derived
61 commands. All 61 ran and exited 0, and `--ran` reconciles 61/61, 0
NOT MEASURED. `git merge-tree` against `origin/main` 4e4111c is
clean, so `main` was not merged.

## Patch round 5 — objectstack-ai#22307 was ruled

The maintainer ruled objectstack-ai#22307 A while round 4 ran: a cold boot is to
refuse too. That work lands in its own PR, not in this one. The
changeset's cold-boot sentence keeps its measured clauses and now ends
"a cold-boot refusal is ruled and tracked on objectstack-ai#22307, which lands
separately", which is true on this PR's merge and stays true after
objectstack-ai#22307 lands. Nothing else changed.

**Checks at 99fba80:** `check-changeset-no-major --base`, exit 0 (pre
mode, tag `next`; given this PR's body, the level axis reads `Clause-②:
no`); `check-empty-changeset --base`, exit 0;
`check:adr-0087-registration`, exit 0. `dispatch-gates --commands` for
the one touched path derived 20 commands; all 20 exited 0, and `--ran`
reconciles 20/20, 0 NOT MEASURED. `git merge-tree` against `origin/main`
4e4111c is clean, so `main` was not merged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/m tests tooling

Projects

None yet

2 participants