Skip to content

feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) - #22365

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-22307-cold-boot-catalog-refusal
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-22307-cold-boot-catalog-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22307
Clause-②: no

Executes the maintainer's ruling letter A on #22307 (ruling record 6063176077): the restart path refuses too. After sys_metadata hydration and before kernel:ready, the engine checks every package-held permission set and position name against the environment catalog, and a name the environment already holds fails the boot with the 422 NAMESPACE_CONFLICT envelope the package door uses, naming both holders. A cold boot, a hot install and an artifact boot now answer alike (Q4 = A, ruling record 6050490870).

The ADR-0048 addendum N.3 amendment is Tier H and rides its own draft PR, from branch claude/issue-22307-adr-0048-n3-amendment. This PR carries no docs/adr/** file.

What changed

  • packages/objectql/src/plugin.ts. ObjectQLPlugin.start() calls a new private refuseEnvironmentHeldSecurityCatalogNames() right after the hydration block (restoreMetadataFromDb, or the project-kernel skip line) and before Phase 3's schema sync. Any conflict throws SecurityCatalogNameConflictError with door: 'cold-boot', which fails start() and with it the boot. It runs whether or not the kernel hydrated.
  • packages/objectql/src/registry.ts.
    • A private SchemaRegistry.environmentHeldSecurityCatalogConflicts() returns every package-held position and permission-set name that also has a bare-slot item. Built-in names are skipped. Results are sorted by type, then name.
    • A private securityCatalogPackageHolders() reads the package half of the holder reading: composite slots and install claims, never the bare slot.
    • A module-level findEnvironmentHeldSecurityCatalogNames(registry) is the plugin's handle on that reading. It is not re-exported from index.ts or core.ts, so the public surface does not grow.
    • SecurityCatalogNameConflictError takes an optional { door: 'cold-boot' }, which changes only the message: which package declares each name, and a remedy stated for a restart. code, status, httpStatus and conflicts[] are unchanged.
  • packages/objectql/src/security-catalog-namespace.ts. ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES (position, permission: the two types the metadata-type registry declares allowRuntimeCreate: true), and a module-doc section, "The cold boot".
  • .changeset/22307-cold-boot-catalog-refusal.md (new). '@objectstack/objectql': major, the BREAKING banner, the ADR-0087 marker not-required (no-migration-prescription), the upgrade shape and the remedy.
  • .changeset/22135-security-catalog-one-holder.md (pending, not yet released). See Acceptance notes, "A pending release note this PR corrects".
  • scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json. The invariant gains the cold-boot half.

No new error code, no packages/spec change.

Where each refusal sits (for the merge with #22331, which landed first)

main was merged at e3ae92a, after #22331 landed. The merge was clean, and the order in ObjectQLPlugin.start() on this head is:

  1. feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) #22331's installDeploymentPlatformGlobalObjects(ctx), the first statement of start().
  2. restoreMetadataFromDb(ctx): sys_metadata hydration.
  3. This PR's refuseEnvironmentHeldSecurityCatalogNames(): right after the hydration if/else and before Phase 3's installRegisteredSchemas. It runs before any plugin that depends on the engine starts, and before kernel:ready.
  4. feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) #22331's assertDeploymentPlatformGlobalObjectsUnchanged(ctx), at the top of the kernel:ready hook.

The two changes share no hunk. This PR's new method sits directly after restoreMetadataFromDb's method body, and its import line comes after the picklist-resolution import block.

Mechanism assumptions, measured

  • M1, the admission today. Reproduced through bootStack on one database file, on the untouched base 28bff18. Boot 1 saved a permission set and a position through PUT /api/v1/meta/permission/NAME and PUT /api/v1/meta/position/NAME. Both answered 200; a new position name needs no OS_METADATA_WRITABLE. Boot 2, cold, added a package declaring both: it booted, with two [Registry] Collision warnings, and the by-name read answered the environment's definitions. Boot 3 hot-installed the same package: 422 NAMESPACE_CONFLICT, both names held by environment.
  • M2, where the check sits. As above. Boot shapes:
    • standalone os serve / os dev / bootStack: environmentId unset, hydration runs, the check runs (measured, dogfood);
    • the artifact boot (createStandaloneStack): environmentId: 'env_local' with hydrateMetadataFromDb: true, hydration runs, the check runs (measured, runtime pin);
    • a project kernel with environmentId and no hydrateMetadataFromDb: hydration is skipped, and the check runs over whatever reached the bare slot, normally nothing (code reading);
    • a host with no protocol service, or one without loadMetaFromDb: nothing hydrates, and the check runs with nothing to judge (code reading).
      loadMetadataFromService at the top of start() syncs object, view, app, flow and hook only, so no other boot-time path writes these two types into the bare slot.
  • M3, the holder reading. Partly falsified, route changed by the ruling's intent. At a cold boot the hydrated environment row is NOT an unstamped bare-slot item. Hydration runs after the package registered, and the protocol's artifact-protection merge grafts the package's envelope onto the stored row. Measured on base: the bare slot probe22307_set carries _packageId: com.probe.addon22307 and _provenance: package, so feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's stamp-based reading answers "the package itself" and finds no second holder. The check therefore reads every bare-slot item as the environment's, whatever stamp it wears: only a registration with no package writes the bare slot. A package holds a name through a composite slot or a claim, never through the bare slot. The envelope class, holder kinds and claims are feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's.
  • M4, built-ins. Built-in names are skipped. Through bootStack, with OS_METADATA_WRITABLE=position, environment saves under org_admin and everyone answered 200, and the restart boots, with GET /api/v1/meta/position/org_admin answering the saved definition. S2b's pins are green: builtin-positions.boot.test.ts is in the plugin-security suite below.
  • M5, the legacy shape. The save door refuses it now (PUT /api/v1/meta/permission/NAME over a package-held set answers 403, with or without ?package=), so the rows were written at the driver. A row bound to no package refuses the restart, naming both holders (pinned). So does a row bound to the package itself (package_id = the package; objectql pin). A hot install refuses that bound row alike: measured, holder environment. A legacy row over one of the platform security plugin's own permission sets (member_default) refuses the restart, naming com.objectstack.plugin-security. On base, all three boot.
  • M6, capabilities. PUT /api/v1/meta/capability/NAME answers 403 ("code-only … allowRuntimeCreate=false"), so the environment catalog holds no capability. The check reads permission sets and positions only, and no capability path reaches it.

Door table: base vs head

"Base" is the untouched 28bff18, or a15b8af with the check ablated, as each row says. "Head" is 72dcb8e (3c160a2 changes comments only). Boots go through @objectstack/verify's bootStack on one database file unless the row says otherwise.

Door Base Head
Cold boot: environment-saved permission set and position, then a package declaring both boots; two [Registry] Collision warnings; the by-name read answers the environment's definitions (28bff18 and ablated) refused: Plugin com.objectstack.engine.objectql failed to start, cause 422 NAMESPACE_CONFLICT, two conflicts, incoming the package, holder environment
Hot install (post-boot manifest.register) of that package refused, 422, holder environment, both names unchanged
Artifact boot (createStandaloneStack, file: database), a package added over environment-saved names boots (ablated: runtime pin red) refused, same envelope
Built-in shadow: environment saves under org_admin and everyone, restart boots (ablated) boots; the stored definition answers
Legacy row (written at the driver, bound to no package) over a package-held set and position, restart boots, one collision warning (28bff18) refused, holder environment, both names
Legacy row bound to the package itself, restart boots (ablated) refused, holder environment
Legacy row over the platform's member_default, restart boots (ablated) refused, incoming com.objectstack.plugin-security
Same-package restart; a package whose names the environment does not hold boots boots
Remedy: boot without the package, DELETE /api/v1/meta/permission/NAME and /position/NAME, boot with it (n/a) both 200, no row left, the boot with the package comes up
Environment save of a capability 403 code-only unchanged

In-repo census

The examples ship no sys_metadata rows, so the environment catalog holds no names on a fresh boot. Measured on a15b8af: a fresh boot of each example on a database file, then a restart.

Example Package-held items Environment rows (permission/position) after the boot Restart
app-crm 10 permission sets, 9 positions 0 boots
app-showcase 17 permission sets, 16 positions 0 boots
app-multi-package 8 permission sets, 6 positions 0 boots

The counts include the platform's own items (plugin-security's 8 permission sets and 6 built-in positions). Names held twice: 0. app-todo declares no catalog name (#22197's census) and is not a dogfood dependency, so it was not booted. Deployed environments: NOT MEASURED.

Tests

The head is 3c160a2. Against 72dcb8e it changes comment lines only, in the new dogfood file (5 added, 3 removed, 0 outside a // comment). The runs below are at 72dcb8e or earlier, as each line says.

  • @objectstack/objectql, whole suite at e3ae92a: 387 files / 7615 passed. At 72dcb8e, protocol-boot-hydration-scoped.test.ts: 16 passed (8 of them new).
  • @objectstack/plugin-security, whole suite at e3ae92a: 184 files / 3869 passed, 45 skipped. That includes S2b's builtin-positions.boot.test.ts and bootstrap-declared-positions.test.ts.
  • @objectstack/runtime, whole suite at e3ae92a: 340 files / 4777 passed, 19 skipped. standalone-stack-security-catalog-one-holder.test.ts has 6, 1 of them new.
  • Dogfood, the CI split, at e3ae92a:
    • 1/3: 76 files / 567 passed;
    • 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped);
    • 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped).
      The one red was this PR's own built-in control: its PUT /api/v1/meta/position/org_admin answered 403 with the hatch set. The protocol memoises OS_METADATA_WRITABLE at its first read in a process, and the control set it only after the file's first case had already saved through the metadata door. It passed in isolation before the second merge and failed in the full shard after it; what made that difference is NOT MEASURED. At 72dcb8e the file opens the hatch before its first boot. The new file and the re-shaped Discard Overlay file then ran: 2 files / 11 passed.
  • Before the second merge, at bdfba35: dogfood 1/3 76 files passed; 2/3 75 passed and 1 failed (the Discard Overlay file, re-shaped since); 3/3 74 passed and 1 skipped.
  • typecheck at 72dcb8e: objectql (tsc --noEmit plus check:test-typecheck: 40 files, 234 errors, 65 pinned signatures, no new signature) and dogfood, exit 0. runtime at e3ae92a, exit 0; no runtime file changed after it.
  • pnpm exec eslint --no-inline-config --format json over the 7 touched TypeScript files at 72dcb8e: 7 files, 0 errors, 0 warnings. This narrowed run is a measurement, not a skipped one, on three grounds:
    • the population comes from eslint.config.mjs itself (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED), and all 7 files are in it;
    • the count, 7, is read from the JSON output;
    • the config enables no type-aware linting (no parserOptions.project, as stated at eslint.config.mjs:328), so this diff cannot move any untouched file's verdict.
      The whole-repo pnpm lint is CI's.

Ablation

The call was neutralised through scripts/ablation-replace.mjs, which wraps the run and restores on exit. In plugin.ts, this.refuseEnvironmentHeldSecurityCatalogNames(); became the same call behind an always-false guard carrying the marker ABLATION_22307_MARKER, so the method stays referenced and the DTS build still runs.

  • Landed on disk: anchor 1 → 0, replacement 0 → 1, blob 399ddf47c127 → 2cf40c49e6e2. objectql was rebuilt (exit 0), and ablation-dist-preflight found the marker in 2 built files.
  • objectql pins (from src): 5 failed / 11 passed of 16 in protocol-boot-hydration-scoped.test.ts. All 5 refusal pins went red: per type, the environment-held name and the row bound to the package, plus every conflict in one refusal. The controls stayed green: distinct names per type, and a built-in name the platform declares beside a stored definition.
  • runtime pins (from dist): 1 failed / 5 passed. The artifact-boot case went red; feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's five stayed green.
  • dogfood pins (from dist): 2 failed / 2 passed. The cold-boot case and the legacy-row case went red; the built-in shadow and distinct-name controls stayed green.
  • Base readings under ablation (an uncommitted probe): the cold boot booted with two collision warnings; the row bound to the package booted cold and was refused hot; the member_default overlay booted; S2b booted.
  • Restore: blob back to 399ddf47c127 == HEAD, git diff HEAD empty, git status --porcelain empty. After a rebuild, ablation-dist-preflight --absent is green: the marker is absent from all 14 built files and the tree is clean.

The ablation ran at a15b8af. The second main merge (e3ae92a) brought #22331's plugin.ts hunks, none of them on this check's lines, and the refusal pins were re-run green at 72dcb8e.

Clause-② (measured on the built entry declarations at 72dcb8e)

packages/objectql/dist/{index,core}.d.ts and the shared chunk declare no new exported name. findEnvironmentHeldSecurityCatalogNames, ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES and SecurityCatalogNameConflictError are absent from the entries' export lists. The only new declaration text is three private member names (SchemaRegistry.environmentHeldSecurityCatalogConflicts, SchemaRegistry.securityCatalogPackageHolders, ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames) plus JSDoc. No widening was found, so Clause-②: no stands.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 81 commands at the head, 3c160a2. All 81 ran with exit codes recorded, and --ran reconciles 81/81 with 0 NOT-MEASURED (a derived zero). 80 exited 0. The same 81 were derived and run at 72dcb8e, with the same answers.

One exited 1, by design: check-empty-changeset --base origin/main. It is the deliberate correction of #22135's pending note (see Acceptance notes), and the gate's own text says to confirm that class on the PR, not restore the note.

On e3ae92a, check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) until eight packages outside this change were built: studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis and service-knowledge. On 72dcb8e and 3c160a2 it exits 0.

The changeset gates: check-changeset-no-major --base exits 0 (pre mode next), check:adr-0087-registration exits 0, and check:changeset-gate-self-tests exits 0.

CI's own lanes are declared to CI and are NOT MEASURED here: the Test Core shards, Temporal Conformance, Dogfood Verify CLI, Build Core and the workspace type-check lanes. origin/main is 7 commits ahead of the head, among them #22352 (plugin-security grant readers) and #22353 (metadata-protocol seed loader); none touches a file of this PR. git merge-tree against it is clean, so main was not merged again.

Acceptance notes

  • A pending release note this PR corrects (check-empty-changeset stays red by design). .changeset/22135-security-catalog-one-holder.md is 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's pending note, not yet consumed by a release (packages/objectql is at 17.7.0). Its "What is NOT refused" paragraph said a package added at cold boot over an environment-held name "is not refused at cold boot". On this PR's merge that sentence is false, and both notes would publish in the same release. That one sentence now says the door cannot see the name at cold boot, and that the engine checks it right after the environment catalog loads and refuses the boot. Nothing else in the note changed. The gate's own text names this shape a DELIBERATE CORRECTION, to be confirmed on the PR, not restored. If a release consumes the note before this PR lands, the edit no longer reaches a published CHANGELOG, and the correct move then is an erratum PR against that CHANGELOG entry.
  • The 2026-08-24 legacy-overlay remedies lose their boot-time population for code-package-declared sets. The overlay detection reading and the drift pass's overlay_shadow run in plugin-security's kernel:ready. A boot carrying an environment overlay of a package-declared set is now refused before kernel:ready, so on a deployment that boots, those branches see no such overlay. The same holds for the Discard Overlay action's discard path for such a set. The ruling names this cost ("including rows saved before the packaged locks"). The upgrade route is in the changeset: rename, or remove the row. A deployment can also run Discard Overlay on the release it runs now, before upgrading. permission-set-discard-overlay-eligibility.dogfood.test.ts (plugin-security: discard-overlay deletes the only stored row of a permission set saved into a writable runtime package — its eligibility reads "has a package id" as "package-declared", the defect #21789 fixes in the lock #21860's pin) wrote its legacy overlay before a cold boot, which is now refused. It now writes the overlay into the running deployment and runs the two passes the boot ran for it, by the functions the security plugin's boot calls (reconcilePermissionSetProjection, then the drift pass), so its preconditions and its control still hold.
  • The refusal leaves start(), so the kernel wraps it. bootstrap() rejects with Plugin com.objectstack.engine.objectql failed to start - rollback complete: …, and the envelope is the wrapper's cause, as with any start()-time refusal (feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's item-seam refusal from plugin-security.start included). The pins read cause.
  • Org-scoped rows are not judged. Boot hydration loads env-wide rows only (organization_id IS NULL), and org-scoped rows never reach the registry, so the check judges the env-wide catalog. That is the population hydration serves.
  • A refused boot over a sqlite-wasm file can still flush after the refusal. In a probe, removing the database directory right after the refused bootStack raised ENOENT from the driver's atomic write. The committed dogfood file keeps its database files in the test file's working directory, which the dogfood run removes at its end, and never boots a file again after it was refused. Noted, not filed: a boot that failed has no process left to serve.
  • Files outside the engine lane:
    • packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts (new) and packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts (re-shaped, above): domain:cli.
    • packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (one case added, and the artifact-stack helper takes a databaseUrl): domain:cli.
    • scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json.
    • .changeset/22135-security-catalog-one-holder.md (above).

Patch round 1 — the release note's remedy, completed

Both contract reviews passed: 6070947709 on this PR, which also confirms the correction of #22135's pending note, and 6070955792 on the ADR PR. This round changes text only. The code, the pins and .changeset/22135-security-catalog-one-holder.md are unchanged. The head is cf1a9dd.

  • .changeset/22307-cold-boot-catalog-refusal.md. "The upgrade shape" names the legacy plural types. "The one-line fix" now has three parts:
    • Before upgrading, for a permission set. The kernel:ready overlay reading names the sets this release refuses. The audited Discard Overlay action, or DELETE /api/v1/meta/permission/NAME, removes each overlay without touching the database, including on the platform's own sets.
    • After upgrading, for a package that can be left out. Boot without it, then delete through the metadata API.
    • After upgrading, for a name the platform security plugin declares. The SQL delete of the active, environment-wide rows under the type or its legacy plural.
      The changeset also says that no os command deletes a sys_metadata row offline.
  • content/docs/permissions/permission-sets.mdx. One clause under "Declared ≠ enforced", on the Discard Overlay remedy: discard such an overlay before you upgrade, because a deployment that still holds one does not boot.

Measured, clause by clause:

  • The current release. This branch with the check ablated through scripts/ablation-replace.mjs (blob 9b18363e90ef → b3701fcc3a70, marker in dist/), a legacy member_default overlay written at the driver, then a restart:
    • The boot logged one kernel:ready warning, "[security] 1 package-declared permission set(s) are being shadowed by an environment overlay — … use the audited "Discard Overlay" action on it …", naming member_default.
    • The record read drift_status: overlay_shadow, and Discard Overlay answered 200 and left no active row.
    • On the same release, DELETE /api/v1/meta/permission/viewer_readonly over a legacy overlay of that platform set answered 200 ("Customization overlay deleted — permission/viewer_readonly reset to artifact default") and left no active row. So Discard Overlay is not the only database-free remedy before the upgrade; the changeset names both.
  • The restore. ablation-replace put the blob back (== HEAD, git diff HEAD empty). After the rebuild, ablation-dist-preflight --absent was green on dist/ at once. It was green on the tree once this round's doc edit, the one dirty path at that moment, was committed (cf1a9dd).
  • The head, check live:
    • The database on which the current release ran Discard Overlay on member_default boots.
    • Rows of type permissions and positions (the legacy plurals) over package-held names refuse the restart, both named.
    • A draft row over a third package-held name is not loaded and not named.
    • loadMetaFromDb selects state: 'active' and organization_id: null, and folds the type through PLURAL_TO_SINGULAR, which maps permissions to permission and positions to position on main. It sets no package_id condition: a row bound to the package itself refuses too, measured in the first round.
  • The SQL. The changeset's DELETE statements, run through Python's sqlite3 against the refused database files (one per type, and one for member_default), deleted 1 row each. Each restart then booted.
  • The CLI. os meta delete and os data delete build an API client and require a token (createApiClient, requireAuth), and no command under packages/cli/src/commands deletes a sys_metadata row.
  • The action. discard_permission_set_overlay, labelled "Discard Overlay", on sys_permission_set, in the list-item and record-header locations, visible while drift_status is overlay_shadow. It is documented on content/docs/permissions/permission-sets.mdx under "Declared ≠ enforced — diagnosing a frozen package set". Positions have no overlay reading (it reads the permission / permissions types) and no such action.
  • NOT MEASURED: the metadata API delete on a set a non-platform package ships, and a position overlay before upgrading.

Gates at cf1a9dd. dispatch-gates --commands derived 107 commands; the doc page added the docs families. All 107 ran with exit codes recorded, and --ran reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0, including check-changeset-no-major --base, check-adr-0087-registration --base, check:doc-authoring, check:docs-*, check-doc-frontmatter, @objectstack/spec's check:docs and check:doc-formula-expressions. One exited 1 by design: check-empty-changeset --base origin/main, the confirmed #22135 correction. origin/main is 12 commits ahead; git merge-tree against it is clean, so main was not merged.

One more file outside the engine lane: content/docs/permissions/permission-sets.mdx (domain:devx).

Patch round 2 — the metadata-API delete reaches singular-typed rows only

The at-tier contract review on cf1a9dd (6071828819) failed two remedy sentences, and judged everything else right: the code, the #22135 correction (confirmed on that head), case 3's SQL, the CLI sentence, the docs clause and the semver. The two sentences are case 1's "So does DELETE /api/v1/meta/permission/NAME" and case 2's metadata-API delete. Both are false for a row stored under the legacy plural permissions / positions, a shape the changeset's own "upgrade shape" paragraph names. This round changes .changeset/22307-cold-boot-catalog-refusal.md only. No code, pin, docs page or .changeset/22135-security-catalog-one-holder.md change. The head is 39ef237.

Measured first; the review's reading holds.

  • The current release (this branch with the check ablated through scripts/ablation-replace.mjs, blob 9b18363e90ef → b3701fcc3a70, marker in dist/):
    • A legacy overlay of viewer_readonly stored under permissions: DELETE /api/v1/meta/permission/viewer_readonly answered 200 with {"success":true,"reset":false,"message":"No customization overlay found for permission/viewer_readonly — already at artifact default."}, and the permissions row stayed active. Discard Overlay on the same set answered 200 and left no active row.
    • mcp_agent_restricted with two active rows, one bound to no package and one bound to com.objectstack.plugin-security: the first DELETE answered 200 "Customization overlay deleted — … reset to artifact default" and removed one row, leaving the bound one. A second DELETE removed it.
  • The head, check live, case 2. A package's permission set and position stored under permissions / positions. Booted without the package, DELETE /api/v1/meta/permission/pr2_set answered 200 "No permission 'pr2_set' found — nothing to delete.", and DELETE /api/v1/meta/position/pr2_pos answered "No position 'pr2_pos' found — nothing to delete." Both rows stayed active, and the boot with the package added back was refused, both names held by environment.
  • The restore. Blob == HEAD and git diff HEAD empty. After the rebuild, ablation-dist-preflight --absent is green on dist/ and on the tree.

The text fix, as the record names it:

  • Case 1: "neither touches the database" now reads "neither needs direct database access".

  • Case 3's heading now reads "for a name the platform security plugin declares, or for any row the metadata API does not reach".

  • One paragraph after the three cases, before the CLI sentence:

    • the two DELETE routes reach a row stored under permission or position only, one row per call;
    • a plural-typed row is not reached: 200, nothing found, nothing removed;
    • where a name has two active rows, each call removes one;
    • a plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by the SQL above after upgrading, for any name.

    This also corrects round 1's summary above: the metadata-API delete is a database-free remedy before the upgrade only for a row stored under the singular type.

  • content/docs/permissions/permission-sets.mdx's clause does not name the metadata-API delete, so the page is unchanged.

Gates at 39ef237. dispatch-gates --commands derived 107 commands. All 107 ran with exit codes recorded, and --ran reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0; one exited 1 by design: check-empty-changeset --base origin/main, the confirmed #22135 correction. origin/main is 22 commits ahead. git merge-tree against it is clean, so main was not merged.


Generated by Claude Code

claude added 11 commits October 8, 2026 18:54
…mission-set name the environment catalog already holds

After sys_metadata hydration and before any plugin that depends on the
engine starts, ObjectQLPlugin.start asks the registry for every package-held
position and permission-set name the environment catalog also holds, and one
such name refuses the boot with the package door's envelope (422
NAMESPACE_CONFLICT, every conflict listed, both holders named). The
hydration write itself stays unjudged.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…eld catalog name

objectql: a real kernel boot (ObjectQLPlugin, the package door in Phase 1,
the real protocol hydrating stored rows) refuses a package-held position or
permission-set name the environment catalog holds, lists every conflict,
treats a row bound to the package itself as the environment's, and boots the
controls (distinct names, a built-in name the platform declares beside a
stored definition). runtime: the artifact boot over one database refuses the
same shape.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…n one database

Environment-saved names then a package declaring both: the hot install and
the cold boot are refused alike, naming both holders. A row saved over a
package-held name before the packaged locks refuses the restart. Controls:
stored definitions under built-in position names boot, and a package whose
names the environment does not hold boots and restarts.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…he one-holder entry's cold-boot sentence

The new changeset grades @objectstack/objectql major on the v18 pre-release
line and names the upgrade shape (an environment-wide stored position or
permission set under a name a configured package declares, pre-lock rows
included) and the remedy. The unreleased one-holder changeset said a cold
boot was not refused; on this change's merge it is, so that sentence now
says where the cold boot is judged. The ADR anchor gains the cold-boot half.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…o the running deployment

A cold boot whose environment catalog holds a package-held permission-set
name is now refused (ADR-0048 N.3), so the legacy overlay of the shipped set
can no longer ride the restart into the deployment. It is written after the
cold boot, and the two passes the security plugin's boot runs for it
(projection reconciliation and the drift pass) are run on it, so the control
still meets the field shape the action exists for.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…s string column

The overlay row's `metadata` column is a string; the cold-boot pins passed
the body object, a type error the test layer's exact ratchet refuses.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
The inline plugin erased its context and the manifest lookup to `any`, which
the service-lookup erasure rule refuses; it now takes `PluginContext` and
names each slot's contract.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…before its first boot

The protocol memoises OS_METADATA_WRITABLE at its first read in a process,
and since the package door answers a save before the body checks, the first
case's saves read it. Set inside the built-in control only, the hatch was
already memoised closed there and the save answered 403 in the shard run.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
The base reading came from a probe with the same steps, not from this file;
the file's own base reading is the ablation's.

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 9 documentable anchor(s).

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

  • content/docs/concepts/metadata-lifecycle.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via ObjectQLPlugin (symbol, a top-level class))

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

  • content/docs/releases/v17/17-0.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-4.mdx (via SchemaRegistry (symbol, a top-level class))

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
  • 2 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 — 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 345d3f3d86305eb3b5614a1788ef46598648e4f5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 30b787a65d338075737e048a248150bcbeef313f — the merge of head 39ef237d40b56c769ef4b917fa2227f207450594 into base 345d3f3d86305eb3b5614a1788ef46598648e4f5, 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 30b787a65d338075737e048a248150bcbeef313f && git checkout 30b787a65d338075737e048a248150bcbeef313f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 345d3f3d86305eb3b5614a1788ef46598648e4f5 39ef237d40b56c769ef4b917fa2227f207450594 && git checkout -B drift-repro 345d3f3d86305eb3b5614a1788ef46598648e4f5 && git merge --no-ff 39ef237d40b56c769ef4b917fa2227f207450594

node scripts/docs-audit/affected-docs.mjs --json 345d3f3d86305eb3b5614a1788ef46598648e4f5

⚠️ 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 345d3f3d86305eb3b5614a1788ef46598648e4f5 → 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: 3c160a23e3b5e7ba42505ba4bde65f2911ba5169
Local-runs: none

Read at 2026-10-08T23:17Z. Inputs, and nothing else: card #22307 (body; the ruling 6063176077, triage 6063800858, the claim 6066505265, the os-dev-report 6070693740, which carries both PR bodies); card #22135 and PR #22197 (its records 6061710772 and 6064469150; .changeset/22135-security-catalog-one-holder.md as it stands on origin/main 43fc50051c); the ruling 6050490870 on #15196 (Q4 = A); #21860 (body, dev report 5994526563) and the 2026-08-24 lock family on main (packaged-permission-set-lock.ts, packaged-permission-set-overlay-detection.ts, permission-set-drift.ts, permission-set-overlay-discard.ts, security-plugin.ts's kernel:ready passes, content/docs/permissions/permission-sets.mdx); PR #22365 (body, the 10-file list, the net diff from merge base e87070ed49 to the head) and PR #22366 (body, its 1-file diff); the check-runs on the head. Read-only: fetched refs and gh api reads; nothing built, run or re-run. Not fed: the dispatch order or the dispatching seat's conclusions.

① Derived judgments

Check-runs on the head: 35, all completed at the final read; 31 success, 3 skipped (Build Docs, Console Pin Gate, the opt-in Packed-tarball smoke), 1 failure (Check Changeset), none in_progress. The seven required contexts by name: TypeScript Type Check, Test Core (1–6/6), Dogfood Regression Gate (1–3/3), Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard — each success. Check Changeset is failure by design (③ item 1); it is not a required context and pr-automation.yml runs on pull_request only, never in merge_group. Governed surfaces: none in the file list (.changeset/**, packages/**, scripts/adr-anchors/**). packages/spec: untouched. Merge base e87070ed49; origin/main has moved to 43fc50051c and none of the PR's 10 files moved with it (git diff --stat over the file list: empty), so the readings below hold on main.

Accept-set and public-surface changes the diff implies, each judged:

  1. Placement — RIGHT, and it runs on every boot shape that hydrates. ObjectQLPlugin.start() at the head: Phase 1 installRegisteredSchemas; then if (environmentId === undefined || hydrateMetadataFromDb) restoreMetadataFromDb(ctx) else log; then this.refuseEnvironmentHeldSecurityCatalogNames(); then Phase 3 installRegisteredSchemas. Both arms of the if/else reach the call, so the standalone shapes (os serve, os dev, bootStack: environmentId unset), the artifact boot (createStandaloneStack passes hydrateMetadataFromDb: true, standalone-stack.ts:910) and a project kernel that hydrates all run it after hydration; a kernel that does not hydrate runs it over an empty bare slot; a host with no loadMetaFromDb lets restoreMetadataFromDb no-op first. start() precedes kernel:ready and the start() of every plugin that depends on the engine (plugin-security's among them). Against feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) #22331's two calls in the same method: installDeploymentPlatformGlobalObjects(ctx) is the first statement of start() (ADR-0131 D7: objects only; it registers no catalog item) and assertDeploymentPlatformGlobalObjectsUnchanged(ctx) sits at the top of the kernel:ready hook; the new call lies between the two, after restoreMetadataFromDb's if/else, and shares no hunk with either. Ruling letter A, first clause, met.
  2. The holder reading, and the M3 route — RIGHT. SchemaRegistry.environmentHeldSecurityCatalogConflicts() walks ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES = position, permission only; the environment's names are the bare-slot keys (no :) minus the built-ins; a package holds a name through a composite pkg:name slot (stamp, else key prefix) or an install claim — never through the bare slot. On main registerItem stores storageKey = packageId ? pkg:name : bareKey (registry.ts:4066), so the only writers of the bare slot are package-less registrations: hydrateOverlayIntoRegistry → registry.registerItem(type, mergeArtifactProtection(…), 'name') with no package id (protocol.ts:18690) and the write-through. mergeArtifactProtection copies the artifact's _packageId, _packageVersion and _provenance onto the stored row (protocol.ts:1789–1791), so at a cold boot — package registered first, row hydrated second — the bare row wears the colliding package's own stamp, exactly as the dev measured, and feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's stamp reading would answer "the package itself". Reading every bare-slot item as the environment's whatever it wears is the right inversion, and the ADR anchor now forbids re-reading the stamp. Confirmed it reads nothing else: no capability (allowRuntimeCreate: false, metadata-plugin.zod.ts:1148 — the environment holds none), no other type; both types it reads are allowOrgOverride: false (:1116–1117), so there is no ADR-0005-sanctioned in-place overlay of these two types for the check to refuse wrongly. The one production catalog-type registerItem call outside hydration is builtin-positions.ts:135, package-bound (composite). The pre-lock row bound to the package itself (package_id = the package) is refused too: inside the ruling's letter ("including rows saved before the packaged locks" — a row saved through ?package= is an environment-saved name a configured package also declares), and the changeset says "whether or not the row was bound to the package". The dev's measurement that a hot install refuses that bound row alike is consistent with the code: at boot the stored body carries no _packageId (the write door strips the provenance keys; only getMetaItems re-stamps the column), so feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's door reads the unstamped bare row as environment. Nothing the ruling did not name is refused.
  3. Envelope — RIGHT. SecurityCatalogNameConflictError(conflicts, { door: 'cold-boot' }): code = NAMESPACE_CONFLICT_CODE, status/httpStatus 422 and conflicts[] unchanged; the default message is byte-identical to main; the cold-boot message names each declaring package and the environment holder and states the restart remedy (rename in the package, or rename/delete the environment's env-wide sys_metadata row through the metadata API on a boot without the package, or in the database). The refusal leaves start(), so the kernel wraps it and the pins read cause. No new ledger code; no packages/spec change.
  4. Cold boot, hot install and artifact boot answer alike — RIGHT. Hot install: feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's package door unchanged (the new dogfood file pins both doors on one database file, holder environment, both names). Artifact boot: createStandaloneStack opts into hydration, so it is the same start(); pinned in runtime over a file: database. Three doors, one envelope. Letter A, second and third clauses, met.
  5. Built-in carve-out — RIGHT (feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 round 2, 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 S2b). !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(key) skips the six built-in positions; the platform declares those at the item seam in its own start() after this check, where the seam already omits the environment holder for a built-in name. Pinned: the objectql CONTROL (org_admin stored beside the platform's declaration) and the dogfood CONTROL (org_admin, everyone saved through the hatch; the restart boots and the stored definition answers). builtin-positions.boot.test.ts and bootstrap-declared-positions.test.ts are in plugin-security's suite, which Test Core runs: green on the head. The platform's eight permission sets are NOT built-ins (declared on plugin-security's manifest, security-plugin.ts:1543–1553, held like any package's), so a legacy overlay of member_default refuses the boot — inside the ruling; the remedy is ③ item 2.
  6. Public surface — no widening, RIGHT. At the head neither packages/objectql/src/index.ts nor core.ts names findEnvironmentHeldSecurityCatalogNames, ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES or SecurityCatalogNameConflictError (grep: no hit); the package's exports map is . and ./core; the three new members (SchemaRegistry.environmentHeldSecurityCatalogConflicts, securityCatalogPackageHolders, ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames) are private; the constructor's optional second parameter is on a class the entries do not export; NAMESPACE_CONFLICT_CODE was already exported. The cold-boot message text is observable behaviour, not a declared type. Clause-②: no is the right value (②).
  7. plugin-security: discard-overlay deletes the only stored row of a permission set saved into a writable runtime package — its eligibility reads "has a package id" as "package-declared", the defect #21789 fixes in the lock #21860's Discard Overlay pin, reshaped — RIGHT, subject kept. Before: the legacy overlay row went into sys_metadata at the driver before a cold boot, and the boot's kernel:ready passes made it a real overlay. Now: the row goes into the running second boot, and the same two passes run by the functions plugin-security's runBootstrap calls at kernel:ready (reconcilePermissionSetProjection, security-plugin.ts:4852; the drift compute and persist, :4888ff). The three refused shapes (runtime-package set, org-owned set, clone), the population pin and the control (overlay discarded, record healed) are unchanged; the preconditions (the list read's stamp, the record enforcing the overlay's grants and reported overlay_shadow) still prove what the refusals need. One step of realism is traded (hand-run passes for boot-run passes); the subject — eligibility decided by the one classifier — is intact. Reachability: on the release a deployment runs today, a legacy overlay of a code-shipped set survives a boot and Discard Overlay is its documented remedy; after this change that state exists in a booted deployment only pre-upgrade or by a driver-level write — the ruling's stated cost (③ item 3).
  8. ADR anchor — the invariant gains the cold-boot half and two ⛔ lines; check:adr-anchors is in Lint & Repo Gates: green.
  9. Pins and ablation — objectql (real kernel, 8 new cases: refusal per type, bound row per type, distinct-name CONTROL per type, all-conflicts-in-one, built-in CONTROL), runtime (artifact boot over one database), dogfood (both doors, legacy row, two CONTROLs); the dev's ablation reports every refusal pin red with the call neutralised and every CONTROL green. Read as reported, not re-run; the head's shards are green.
  10. Census — in-repo examples ship no sys_metadata rows; 0 names held twice; deployed environments NOT MEASURED, stated as such. RIGHT, producer-side.

② Semver level

  • .changeset/22307-cold-boot-catalog-refusal.md: '@objectstack/objectql': major, feat(objectql)!:, the BREAKING banner, the ADR-0087 marker not-required (no-migration-prescription) (a listed category; apt — no key, export or field moves, and a refused name is renamed or removed by a person), Clause-②: no. The one released package whose behaviour changes is objectql (runtime: a test; dogfood: private; scripts/adr-anchors: tooling). Level — RIGHT. .changeset/pre.json is {"mode":"pre","tag":"next"} on origin/main and on the merge base (a87d8be299, chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084, is an ancestor), so the changeset's sentence "shipped as major on the v18 pre-release line (.changeset/pre.json is in next pre mode on main)" is true; the false premise that failed feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's first review ("pre mode is not yet in on main") is not written here, and no sibling of it is. In pre mode check-changeset-no-major stands aside, the ADR-0087 gate finds its marker, and Lint & Repo Gates is green. The claim 6066505265 graded exactly this ("a BREAKING narrowing on the v18 pre-release line"). Every other sentence in the note checked against the diff and main: the placement, the two types read and why (403 on a capability save), the envelope fields, the upgrade shape (active env-wide row, pre-lock rows bound or not, the platform's own sets named) — true. The remedy sentences are judged in ③ item 2.
  • Clause-②: no — RIGHT. A boot admitted today is refused for a name the environment catalog already holds: an accept-set narrowing, nothing widened (① item 6). The bare no is the gate's spelling; the (narrowing) arm is optional. The PR body carries the same line. It owes this review before the queue, and this record is it.

③ Boundary flags

From the os-dev-report 6070693740 (the deviations, the open question, the out-of-scope findings) and the PR's Acceptance notes:

  1. The deliberate correction of another card's pending changeset — CONFIRMED (open question: A). The corrected note is .changeset/22135-security-catalog-one-holder.md, 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's pending release note, unconsumed (packages/objectql at 17.7.0, the file present on origin/main). The diff is +1/−1: the last sentence of the "What is NOT refused" paragraph; the paragraph's opening clause and every other line of the note are byte-identical to the merge base. Before: "At cold boot, packages register before the environment catalog loads from sys_metadata, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; a cold-boot refusal is ruled and tracked on [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307, which lands separately." After: "At cold boot, packages register before the environment catalog loads from sys_metadata, so this door cannot see an environment-held name then; the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot with this envelope (the cold-boot entry in this release)." Clause by clause: (a) "packages register before the environment catalog loads from sys_metadata" — kept, TRUE (Phase 1 AppPlugin.init → manifest.register; Phase 2 restoreMetadataFromDb). (b) Old "is not refused at cold boot (the registry's existing collision warning fires)" — FALSE once this PR lands: the boot is refused at three seams (① items 1, 4); the warning still prints at hydration, but no boot comes up. (c) New "this door cannot see an environment-held name then" — TRUE: the package door runs before the row is hydrated. (d) New "the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot" — TRUE against the diff (① items 1, 2). (e) New "with this envelope" — TRUE: same class, code, status, conflicts[] (① item 3). (f) New "(the cold-boot entry in this release)" — TRUE while the two notes are pending together, which they are on main today; if a release consumed 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's note first, this PR's edit would conflict at merge and the correction would become an erratum, as the dev's own note says. (g) Old "while a hot install of the same package is refused" — still true, dropped; parity is now the sentence's whole point and the summary line says "as a hot install does". Nothing lost. (h) Old "ruled and tracked on [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307, which lands separately" — FALSE after this PR lands; rightly removed. Nothing else in the note changes. The old sentence is false once this PR lands; the new one is true against the diff and main; the correction overreaches nowhere. Options B and C would each publish a false or contradictory sentence; A is right. This is the same-head, at-tier record the landing rule names as the confirmation; check-empty-changeset and Check Changeset stay red by design (the gate's own FOREIGN_CORRECTION_REMEDY), the PR body records the gate and the cause, and pr-automation.yml carries no merge_group trigger — the seat's three conditions for queuing with that red are met on reading.
  2. ⭐ The remedy for a platform-held name — a real offline remedy exists and the changeset names it: PASS on this point, with two changeset sentences owed. Measured by the dev: a legacy overlay of member_default (com.objectstack.plugin-security) refuses the boot. "Boot without the package" is no remedy for the platform security plugin. Read on main: OS_METADATA_COLLISION=warn does not downgrade this refusal (ruled); no env var skips the check; no CLI path deletes a sys_metadata row offline — os migrate meta --stored canonicalizes rows in place and deletes none, and the bulk os meta adopt-permission-sets was deliberately deferred by the 2026-08-20 ruling (permission-set-overlay-discard.ts header). What does exist: (a) before the upgrade, on the release the deployment runs now — the kernel:ready overlay reading prints [security] N package-declared permission set(s) are being shadowed by an environment overlay with the names, and the audited Discard Overlay action (POST /api/v1/security/permission-sets/:id/discard-overlay, Setup, tenant-admin) deletes exactly that row; documented under "Overlay shadow … Remedy" in content/docs/permissions/permission-sets.mdx; (b) after the upgrade — the database directly: delete the env-wide sys_metadata row (organization_id IS NULL, state = 'active') of the type and name the refusal names — the same physical row Discard Overlay deletes and the same operation the field remediation ran as raw SQL, recorded in that module's header. The changeset names (b) truly and actionably: "For a name the platform security plugin declares, delete the environment-wide sys_metadata row of that type and name in the database"; the refusal message says "or in the database" too. Judged true; no carve-out is needed, and none is recommended. Two precision gaps, each one sentence, owed in the changeset (the upgrading operator reads the CHANGELOG, not the PR): (i) loadMetaFromDb folds the legacy plural spellings permissions → permission and positions → position (PLURAL_TO_SINGULAR, manifest-collection-spelling.ts:76–77), so a pre-meta-plural-url-bypass: PUT /meta/fields/<name> walks around the whole two-tier registry gate — 4 registry types have no entry in PLURAL_TO_SINGULAR #7894 row stored under the plural type hydrates into the same bare slot and refuses alike, and an operator who deletes type = 'permission' only meets the same refusal again — say "type permission (or the legacy permissions)"; (ii) remedy (a), the pre-upgrade Discard Overlay, is in the PR body and nowhere in the changeset — one sentence.
  3. The 2026-08-24 remedies lose their boot-time population (out-of-scope finding, carrier "无") — the ruling's stated cost; a carrier is owed by the seat. After this change, for a code-package-declared set, reportPackagedPermissionSetOverlays, the drift pass's overlay_shadow and the Discard Overlay discard path meet an overlay only when it was written at the driver into a running deployment (what the reshaped pin does): every door that could mint one is locked (permission: with or without the hatch; position: fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262's lock, hatch aside — item 5), and a row that exists refuses the boot. The paths are not wrong; they are idle post-upgrade, and permission-sets.mdx's "Overlay shadow … Remedy: Discard Overlay" is true only pre-upgrade now. Whether to retire or keep them is the maintainer's call, so the finding should become a card rather than stay in acceptance notes, and the docs page owes one clause. Not this PR's code to change.
  4. M3 deviation — answered in ① item 2: inside the letter; refuses nothing the ruling did not name.
  5. The position hatch, post-upgrade (observation). OS_METADATA_WRITABLE=position lets an environment save land over a package-held position name — the dogfood CONTROL saves org_admin and everyone through it; a non-built-in package-held position saved the same way (NOT MEASURED) boots today and refuses the next restart under this change. The save door is outside the ruling (N.3) and the refusal names the row and the remedy; noted so the seat knows the ruled cost keeps a live producer for positions. Not a FAIL.
  6. sys_packages rehydrate (observation; feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's door, pre-existing). PackageServicePlugin.start() re-installs durable packages through registry.installPackage(rec.manifest) after this check (it depends on objectql); a rehydrated manifest declaring a name the environment holds is refused at feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's package door there, inside a best-effort try that logs at debug and ends the loop — the package is silently absent for the process and the boot comes up. A POST /api/v1/packages manifest carries no catalog collection, so the shape needs a durable manifest with one. Not this PR's (door and catch predate it); noted for the seat as the one boot path where "answer alike" is a swallow rather than a refusal.
  7. Org-scoped rows are not hydrated and not judged — the ruling's "environment catalog" is the env-wide catalog; boundary, fine. A refused sqlite-wasm boot flushing after the refusal — harness observation; fine.
  8. Files outside the engine lane (two dogfood files, the runtime test, the ADR anchor, 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's note) — declared in the claim 6066505265 and the report; fine.
  9. NOT MEASURED at the head (the dogfood rerun at the comment-only head 3c160a23e3, 5+/3− in // lines of the new file) — measured by CI: the head's Dogfood Regression Gate 1–3/3 are green.
  10. Local runs — none; the check-runs on the head answered every derived gate family.

Implemented-by: claude/issue-22307-cold-boot-catalog-refusal
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

Owed alongside, not blocking: the two changeset sentences in ③ item 2 (the legacy plural spelling; the pre-upgrade Discard Overlay path) and a carrier card for ③ item 3.


Generated by Claude Code

…nd after the upgrade

The changeset now names what an operator can do before upgrading (the
kernel:ready overlay reading lists the permission sets this release
refuses; Discard Overlay or the metadata API delete removes each overlay
without touching the database) and after it (boot without a package that
can be left out and delete through the metadata API; for a name the
platform security plugin declares, the SQL delete of the active,
environment-wide rows under the type or its legacy plural). No CLI command
deletes a sys_metadata row offline. The Permission Sets page tells the
reader to discard such an overlay before upgrading.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cf1a9dd6db406f5addff9cf884a715d2eb4380c5
Local-runs: none

Read at 2026-10-09T00:35Z. Patch round 1 of #22307's code PR. The same-head record 6070947709 (PASS on 3c160a23e3) asked for the changeset's remedy to be completed; this head is a text-only round, so it owes its own full at-tier record, and the deliberate correction of #22135's pending note is re-confirmed here on this head (③ item 1). Inputs, and nothing else: card #22307 (body; the ruling 6063176077, triage 6063800858, the claim 6066505265, the os-dev-reports 6070693740 and 6071587973); the records 6070947709 (this PR) and 6070955792 (#22366); card #22135 (body and comments) and .changeset/22135-security-catalog-one-holder.md on origin/main 16096e8d7b; #21860 and the 2026-08-24 lock family on main (packaged-permission-set-lock.ts, packaged-permission-set-overlay-detection.ts, permission-set-drift.ts, permission-set-overlay-discard.ts, security-plugin.ts's kernel:ready bootstrap, sys-permission-set.object.ts); protocol.ts's loadMetaFromDb, hydrateOverlayIntoRegistry, deleteMetaItem, refusePackagedBaseRemoval and assertLockAllowsDelete, sys-metadata-repository.ts, rest-server.ts's DELETE /meta/:type/:name handler, PLURAL_TO_SINGULAR (manifest-collection-spelling.ts); every non-test source under packages/cli/src/commands on main; PR #22365 (body, the 11-file list, the net diff from merge base e87070ed49, git diff 3c160a23e3..cf1a9dd6db); PR #22366 (body, its final 速读 6070998128, its merge state); the check-runs on the head. Read-only: fetched refs and gh api reads; nothing built, run or re-run. Not fed: the dispatch order or the dispatching seat's conclusions.

① Derived judgments

Check-runs on the head: 42, all completed; 36 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, the opt-in Packed-tarball smoke), 2 failure — both runs of the one context Check Changeset; none in_progress. The seven required contexts by name — TypeScript Type Check, Test Core (and 1–6/6), Dogfood Regression Gate (and 1–3/3), Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard — each success. Check Changeset red is the deliberate correction (③ item 1); it is not a required context and pr-automation.yml carries no merge_group trigger. No other red.

This round's scope — confirmed text-only. git diff 3c160a23e3..cf1a9dd6db --stat: 2 files, +11/−3 — .changeset/22307-cold-boot-catalog-refusal.md (+8/−2) and content/docs/permissions/permission-sets.mdx (+3/−1). The nine other paths of the PR (plugin.ts, registry.ts, security-catalog-namespace.ts, the objectql and runtime tests, the two dogfood files, the ADR anchor) and .changeset/22135-security-catalog-one-holder.md are byte-identical to 3c160a23e3, so ① items 1–10 of 6070947709 stand on this head unchanged; the check they describe was re-read at the head (SchemaRegistry.environmentHeldSecurityCatalogConflicts / securityCatalogPackageHolders, registry.ts:2498ff) to judge the remedy against it: every bare-slot key of position and permission that is not a built-in and that a composite pkg:name slot or an install claim also holds. Net diff vs merge base e87070ed49: 11 files, +703/−43. origin/main (16096e8d7b) has moved none of the 11 files since the merge base (git diff --stat over the file list: empty); it now carries #22366's merge (363b503cf9), so ADR-0048 N.3's dated cold-boot note is on main. No governed surface in the file list; packages/spec untouched. Accept-set and public-surface changes this round implies: none — a changeset body and a docs clause declare no type, export, route or key; the accept-set narrowing itself is the code's, judged on 3c160a23e3 and unchanged.

⭐ The remedy, sentence by sentence. Judged against main plus the diff. The population the boot check refuses is exactly the bare-slot items loadMetaFromDb hydrates: where: { state: 'active', organization_id: null } (protocol.ts:27173), the type folded through PLURAL_TO_SINGULAR — permissions → permission, positions → position, and no other spelling folds to either (manifest-collection-spelling.ts:76–77) — no package_id condition, and registered in the bare slot by hydrateOverlayIntoRegistry whatever package the row is bound to (protocol.ts:18631ff).

Case 1 — before upgrading, for a permission set.

  1. "a boot whose environment catalog overlays a package-declared permission set logs at kernel:ready: [security] N package-declared permission set(s) are being shadowed by an environment overlay, with the set names" — RIGHT. reportPackagedPermissionSetOverlays runs in plugin-security's kernel:ready bootstrap (security-plugin.ts:4888), the text is that line byte for byte (packaged-permission-set-overlay-detection.ts:161), and the names ride the log's structured argument ({ count, names, findings }).
  2. "Those are the permission sets this release refuses at boot" — RIGHT, generally and not only for the two measured shapes. The reading selects the same rows the boot hydrates — type in permission/permissions, state: 'active', organization_id null, no package_id filter (overlay-detection.ts:114–117) — and keeps a name iff classifyPackagedPermissionSet answers packaged, i.e. an item of that name in registry.listItems('permission') wears a package id and is not tenant-authored (packaged-permission-set-lock.ts:169–184, 213–227): that is a composite slot for the name, the check's "package-held". The platform's own sets qualify (composite, _packageId: com.objectstack.plugin-security); a row bound to the package itself is named, since the reading never reads package_id; a plural-typed row is named. Three edges, none the ruled shape, stated so the sentence is not read as a proof: the sweep is a 1000-row page per spelling (OVERLAY_PAGE_LIMIT, the module's own documented under-report) where the boot's load is unpaged; listItems drops a disabled package's items (registry.ts:4673ff), so a disabled package's set is refused but not named; a failed registry read answers unknown and is silent.
  3. "The audited Discard Overlay action on the set's record in Setup (POST /api/v1/security/permission-sets/ID/discard-overlay, …) removes the overlay and resyncs the set to the package's definition" — RIGHT. discardPermissionSetOverlay gates on the same classifier, deletes every active env-wide sys_metadata row of the name under both spellings (findActiveOverlayRows, permission-set-overlay-discard.ts:181–192, 256–267) and re-projects synchronously. One precision note, not a falsity: the Setup button is visible only while drift_status == 'overlay_shadow' (sys-permission-set.object.ts:93), which the drift pass sets only when the enforced grants differ from the artifact (permission-set-drift.ts:191–193); an overlay identical to the artifact is named by the boot warning and discardable through the named POST route, but shows no button.
  4. "So does DELETE /api/v1/meta/permission/NAME, which answers 'Customization overlay deleted … reset to artifact default'" — WRONG as stated: true for a row stored under permission, false for a row stored under the legacy plural permissions, a shape this changeset's own "upgrade shape" paragraph names as refusing the boot. For permission: refusePackagedBaseRemoval lifts for the supportsOverlay tier (protocol.ts:17279–17296), the repository path finds and deletes the row and answers that sentence (protocol.ts:26896) — the dev's viewer_readonly measurement. For permissions: the route hands req.params.type to deleteMetaItem (rest-server.ts:7480), canonicalizeMetaRequestType folds it to the singular, and SysMetadataRepository.whereFor matches type: ref.type exactly (sys-metadata-repository.ts:2071–2080, used by get at :531–543) — the probe finds no row, and the door answers 200 No customization overlay found for permission/NAME — already at artifact default (protocol.ts:26786), deleting nothing; the plural row still hydrates at the next boot (folded by loadMetaFromDb) and the upgrade is refused. A second gap of the same kind: the repository deletes ONE row per call (findOne → delete by id, sys-metadata-repository.ts:918–946); where an unbound row and a row bound to the package both exist under one name — both shapes the paragraph names — one DELETE removes one, and its receipt reads as done. Discard Overlay has neither gap. For the case the dev lists as NOT MEASURED (the metadata API delete on a set a non-platform package ships) the sentence does claim it, since case 1 is written for any package-declared set; by code the only gate that can differ there is ADR-0010's lock (assertLockAllowsDelete, ITEM_LOCKED 403, protocol.ts:17960ff), which a set must declare, so the claim holds for an unlocked permission-typed row.
  5. "Either works for the platform security plugin's own sets too, and neither touches the database" — RIGHT for Discard Overlay (the classifier reads the registry; measured on member_default) and for DELETE on a permission-typed row (measured on viewer_readonly). "Touches the database" is loose — both delete a sys_metadata row through the engine; what is meant, and true, is that neither needs database access. Wording only.
  6. "Positions have no such reading and no such action" — RIGHT. The reading reads permission/permissions only; the action is declared on sys_permission_set and nowhere for positions.

Case 2 — after upgrading, for a package you can leave out. "Boot once without the package in the configuration, rename or delete the environment's item through the metadata API (DELETE /api/v1/meta/permission/NAME, DELETE /api/v1/meta/position/NAME), then add the package back" — RIGHT for rows under permission/position (with the package absent the item is not artifact-backed; the repository hard-deletes the runtime row, Deleted permission 'NAME' — it no longer exists; measured in round 0: both 200, no row left, the boot with the package comes up) — WRONG for a row under the legacy plural, by the same exact-type match: No permission 'NAME' found — nothing to delete (200, reset: false), the row stays, and the boot with the package back is refused again. The "rename" half is a save under the new name plus this delete, so it carries the same hole.

Case 3 — after upgrading, for a name the platform security plugin declares. "Back the database up, then delete the row in it. The rows that refuse the boot are the active, environment-wide ones of that name: organization_id IS NULL and state = 'active', whatever their package_id, under the type or its legacy plural: DELETE FROM sys_metadata WHERE organization_id IS NULL AND state = 'active' AND type IN ('permission', 'permissions') AND name = 'NAME'; (for a position, type IN ('position', 'positions')). A draft row and an organization-scoped row are not loaded at boot and do not refuse it." — RIGHT, and exact. The SQL selects precisely what loadMetaFromDb hydrates for that name — active, env-wide, either spelling, whatever package_id — and nothing more: a draft row (state is not 'active') and an org-scoped row (organization_id set) are not selected, and neither is loaded at boot (the dev's draft-row probe agrees). It deletes the customization itself, which is the ruling's "remove", behind the prescribed backup; a row the operator wants to keep is the lead sentence's "rename", not spelled as SQL — fine. The heading under-routes: this SQL is the one path for ANY plural-typed row (either type, any package), not only the platform's names — see the fix below.

"No os command deletes a sys_metadata row offline: os meta delete and os data delete call a running server" — RIGHT on main. commands/meta/delete.ts:146–151 and commands/data/delete.ts:50–58 build createApiClient and requireAuth; a sweep of every non-test source under packages/cli/src/commands finds no engine or driver delete of sys_metadata (secret/orphans.ts:440 deletes sys_secret; serve.ts:4975 is the running server's own engine); os db clean is a VACUUM whose header says every row survives; os migrate meta --stored --apply rewrites bodies and folds no type column; os migrate duplicates is read-only; migrate audit-metadata-bodies --apply rewrites audit, activity and decision rows. "Nothing renames or removes either item automatically" — right.

Positions, every case. A position-typed row: before upgrading, no reading and no action (stated truthfully); after upgrading, case 2's DELETE /api/v1/meta/position/NAME (measured in round 0) — a true path. A positions-typed row: case 2's DELETE does not reach it; the SQL does, but sits under the platform-name heading, and the platform declares no non-built-in position (the six built-ins are skipped by the check), so case 3's position variant serves exactly the shape case 2 routes away. With the fix below, every position case has a stated true path; without it, the plural-position holder is sent to a door that answers "nothing to delete".

Minimal text fix — one sentence after the three bullets, before "No os command…": "DELETE /api/v1/meta/permission/NAME and /position/NAME reach a row stored under permission or position only, one row per call: for a row stored under the legacy plural they answer that nothing was found and remove nothing, so such a row is removed by Discard Overlay before upgrading (a permission set) or by the SQL below after upgrading, which works for any name, not only the platform's." And in case 3's heading: "for a name the platform security plugin declares, or for any row the metadata API does not reach". Nothing else in the changeset needs to move; items 1–3, 5–6, case 3's SQL and the CLI sentence are right as written.

The docs clause (permission-sets.mdx:371–373): "Discard it before you upgrade: from the release that adds ADR-0048's cold-boot check, a deployment that still holds such an overlay does not boot, so the action can no longer reach it." — RIGHT against the diff. "Such an overlay" is the bullet's own subject, an active sys_metadata overlay for a package-declared set's name; with the package configured, the check refuses that boot before kernel:ready (a permission set is never a built-in), and the action runs only in a booted deployment. It sits coherently beside the bullet's remedy sentence and the "Provenance skip" bullet (a managed_by row with no overlay, which the check does not read). Against the page's first bullet under "One authoritative store" (:332–339: editing a packaged set through Setup "becomes an environment overlay … it genuinely takes effect") — the dev's out-of-scope finding, pre-existing and out of scope: the two now contradict each other on their face (one invites the overlay, the other says it blocks the next boot). A reader cannot act on the stale bullet — the door answers 403 NOT_OVERRIDABLE since the 2026-08-24 lock — so the trip is a page that argues with itself, not a wrong action; the stale bullet owes a carrier card (③ item 4). Not this PR's to fix.

② Semver level

.changeset/22307-cold-boot-catalog-refusal.md: '@objectstack/objectql': major, feat(objectql)!:, the BREAKING banner, the ADR-0087 marker not-required (no-migration-prescription), Clause-②: no — as 6070947709 judged them; this round edits the body's upgrade-shape and remedy text only. Level — RIGHT. .changeset/pre.json on origin/main is {"mode":"pre","tag":"next"}, so "shipped as major on the v18 pre-release line (.changeset/pre.json is in next pre mode on main)" is true; no sentence about pre mode or the level is false, and the premise that failed #22197's first review is not written here. The added upgrade-shape clause — "or the legacy plural permissions / positions, which the boot's load folds to the same types" — is true (loadMetaFromDb, PLURAL_TO_SINGULAR) and is the sentence 6070947709's ③ item 2(i) asked for. Clause-②: no — RIGHT: code unchanged, no new export or route; a changeset body and a docs clause widen nothing. The PR body carries the same line.

③ Boundary flags

1. The deliberate correction of .changeset/22135-security-catalog-one-holder.md — CONFIRMED on this head. Byte-identical to what 6070947709 confirmed: blob fddd17ded7 at both 3c160a23e3 and cf1a9dd6db; main's copy is blob 980162dd16, unchanged since the merge base. The diff is +1/−1, the last sentence of the "What is NOT refused" paragraph; every other line of the note is byte-identical to main. Before: "At cold boot, packages register before the environment catalog loads from sys_metadata, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; a cold-boot refusal is ruled and tracked on #22307, which lands separately." After: "At cold boot, packages register before the environment catalog loads from sys_metadata, so this door cannot see an environment-held name then; the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot with this envelope (the cold-boot entry in this release)." Sentence by sentence: the old "is not refused at cold boot (the registry's existing collision warning fires)" is FALSE once this PR lands — ObjectQLPlugin.start() calls refuseEnvironmentHeldSecurityCatalogNames() after the hydration block, unchanged this round, and no boot comes up; the old "ruled and tracked on #22307, which lands separately" is FALSE after it lands; the old "while a hot install of the same package is refused" is still true and is dropped because parity is now the sentence's point. The new sentence is TRUE against the diff and main: the package door runs before hydration (Phase 1 installPackage vs restoreMetadataFromDb in start()), the engine checks right after the load, and the refusal is SecurityCatalogNameConflictError with the same code, status and conflicts[] — "this envelope"; "(the cold-boot entry in this release)" is TRUE while the two notes are pending together, which they are on main today. Nothing else in the note changes; the correction overreaches nowhere. Options B and C of the dev's open question would each publish a false or contradictory sentence; A stands. check-empty-changeset and Check Changeset red remain deliberate; this same-head, at-tier record is the confirmation the landing rule names.

2. Dev flags, 6071587973. (a) "The order said Discard Overlay before upgrading is the only remedy that does not touch the database; measured false — DELETE /api/v1/meta/permission/NAME also removes such an overlay, so the changeset names both" — half right: true on the measured permission-typed row, and the text generalises it to every row, which ① case 1 item 4 refuses; this is the FAIL. (b) "The changeset does not say the security plugin cannot be left out of a configuration" — right to leave unclaimed. (c) NOT MEASURED: the metadata API delete on a set a non-platform package ships — the text claims it; by code it holds for an unlocked permission-typed row (① case 1 item 4); a position overlay before upgrading — the text claims nothing about it. (d) open_questions: none this round. (e) The out-of-scope finding (the stale "One authoritative store" first bullet): judged in ①; class a, pre-existing; carrier owed — item 4.

3. The ADR PR #22366 (Tier H). Merged on the maintainer's APPROVED review (merge 363b503cf9, on origin/main), so there is nothing to record on it. Its body's 速读 draft and the final 速读 6070998128 stay truthful after this round's remedy change: the final text names Discard Overlay on the current release and the database delete afterwards, with the legacy plural types ("要连旧的复数类型名(permissions / positions)一起删"), and "没有 CLI 命令能做这件事" — each true against this head; it claims nothing about the metadata API DELETE. Note only; no record written.

4. Carriers owed by the seat, not blocking: a card for the stale docs bullet (the dev's class-a finding); #22371 already carries the 2026-08-24 remedies' post-upgrade population, as the final 速读 says.

5. CI — the seven required contexts are success on this head; the one red is Check Changeset, deliberate (item 1), not required, and outside the merge group; nothing is in progress. A new head will owe a fresh read.

6. Local runs — none.

Implemented-by: claude/issue-22307-cold-boot-catalog-refusal
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: FAIL

One text fix lifts it (① "Minimal text fix"): the DELETE /api/v1/meta/… sentences in cases 1 and 2 are false for a row stored under the legacy plural — a shape this changeset's own upgrade shape names as refusing the boot — and case 3's heading routes the only remedy for that row away from the packages it serves. Code, pins and the #22135 correction need no change; the next head owes a new record.

…only, one per call

DELETE /api/v1/meta/permission|position/NAME matches the stored type
exactly, so a row stored under the legacy plural permissions / positions is
not reached (200, nothing found) and stays to refuse the next boot; a name
with two active rows needs two calls. The changeset says so, routes a
plural-typed row to Discard Overlay before upgrading or to the SQL after
it for any name, and says the remedies need no direct database access
rather than that they do not touch it.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 39ef237d40b56c769ef4b917fa2227f207450594
Local-runs: none

Read at 2026-10-09T01:20Z. Patch round 2 of #22307's code PR, text only, from the FAIL record 6071828819 on cf1a9dd6db. A text-only head owes its own full at-tier record, and the deliberate correction of #22135's pending note is re-confirmed on this head (③ item 1); the earlier records do not carry over. Inputs, and nothing else: card #22307 (body; the ruling 6063176077, triage 6063800858, the claim 6066505265, the os-dev-reports 6070693740, 6071587973 and 6072153086); the records 6070947709 and 6071828819 (this PR) and 6070955792 (#22366), 6071828819 in full; card #22135 (body, comments) and .changeset/22135-security-catalog-one-holder.md on origin/main; on origin/main 117d34de3f: rest-server.ts's DELETE /meta/:type/:name handler and the discard-overlay route, protocol.ts's canonicalizeMetaRequestType, deleteMetaItem and loadMetaFromDb, sys-metadata-repository.ts's get, delete and whereFor, permission-set-overlay-discard.ts, packaged-permission-set-overlay-detection.ts, packaged-permission-set-lock.ts's classifier, security-plugin.ts's kernel:ready bootstrap and manifest, sys-permission-set.object.ts, builtin-positions.ts, PLURAL_TO_SINGULAR (packages/spec/src/meta-spelling/manifest-collection-spelling.ts), check-empty-changeset.mjs's correction remedy and landing-operations.md's confirmation rule; PR #22365 (body, the 11-file list, the net diff from merge base e87070ed49, git diff cf1a9dd6db..39ef237d40); the check-runs on the head. Read-only: fetched refs and gh api reads; nothing built, run or re-run. Not fed: the dispatch order or the dispatching seat's conclusions.

① Derived judgments

Check-runs on the head (read once, at posting time): 42, all completed; 36 success, 4 skipped (Auto Label, Check PR Size, Console Pin Gate, the opt-in Packed-tarball smoke), 2 failure — both runs of the one context Check Changeset; none in_progress. The seven required contexts by name — TypeScript Type Check, Test Core (and 1–6/6), Dogfood Regression Gate (and 1–3/3), Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard — each success. Check Changeset red is the deliberate correction (③ item 1); it is not a required context, and pr-automation.yml carries no merge_group trigger. No other red. The combined commit status is success. Governed surfaces: none in the 11-file list (.changeset/**, content/docs/**, packages/**, scripts/adr-anchors/**). packages/spec: untouched.

This round's scope — confirmed: the changeset alone. git diff cf1a9dd6db..39ef237d40 --stat: 1 file, +4/−2, .changeset/22307-cold-boot-catalog-refusal.md. Three edits: case 1's "neither touches the database" is now "neither needs direct database access"; case 3's heading gains "or for any row the metadata API does not reach"; one paragraph is added after the three cases, before the CLI sentence. The ten other paths of the PR (plugin.ts, registry.ts, security-catalog-namespace.ts, the objectql and runtime tests, the two dogfood files, the ADR anchor, permission-sets.mdx, .changeset/22135-security-catalog-one-holder.md) are byte-identical to cf1a9dd6db, so ① items 1–10 of 6070947709 and the docs-clause and CLI readings of 6071828819 stand. Re-read at this head all the same: ObjectQLPlugin.start() calls the private refuseEnvironmentHeldSecurityCatalogNames() right after the hydration if/else and before Phase 3's installRegisteredSchemas; SchemaRegistry.environmentHeldSecurityCatalogConflicts() walks ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES (position, permission), takes every bare-slot key that is not a built-in, and reports it for each composite-slot or install-claim holder; SecurityCatalogNameConflictError gains the door: 'cold-boot' message only, with code, status and conflicts[] unchanged. Net diff vs merge base: 11 files, +705/−43. Accept-set and public-surface changes this round implies: none; a changeset body declares no type, export, route or key. The narrowing itself is the code's, judged on 3c160a23e3 and unchanged: at the head packages/objectql/src/index.ts and core.ts name none of findEnvironmentHeldSecurityCatalogNames, ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES or SecurityCatalogNameConflictError.

main drift — none on anything this PR touches or the remedy describes. origin/main is at 117d34de3f, 22 commits past the merge base, #22366 (363b503cf9, ADR-0048 N.3's dated note) and #22374 among them. git log over the 11 files of the PR since the merge base: empty for each. Over the remedy's paths: protocol.ts moved once (746637e51e, one hunk near :11230, RunProvenanceContext), security-plugin.ts once (same commit, one hunk near :6058), rest-server.ts once (a45d5d8ab7, three hunks near :40, :3023, :3210, the session cookie) — none of those hunks is the meta DELETE handler (:7330–7500), the discard-overlay route (:12837ff), deleteMetaItem (:26578ff), loadMetaFromDb (:27162ff), the kernel:ready bootstrap (:4840–4900) or the manifest (:1553); sys-metadata-repository.ts, permission-set-overlay-discard.ts, packaged-permission-set-overlay-detection.ts, permission-set-drift.ts, packaged-permission-set-lock.ts, sys-permission-set.object.ts, the two CLI deletes and manifest-collection-spelling.ts are untouched. The readings below are on the current main.

⭐ The changeset's remedy, every sentence re-read on this head against main plus the diff.

The population the check refuses. loadMetaFromDb selects where: { state: 'active', organization_id: null } with no package_id condition, folds record.type through PLURAL_TO_SINGULAR (positions → position, permissions → permission, and no other key folds to either) and registers each non-object row into the bare slot through hydrateOverlayIntoRegistry(normalizedType, …) whatever package the row is bound to. The check reads every bare-slot key of the two types minus the six built-in positions. So the refused rows are exactly: active, environment-wide, either spelling, any package_id, of a name a configured package declares, built-in positions excepted. That is what "The upgrade shape" says, and the paragraph is unchanged and RIGHT.

Case 1 — before upgrading, for a permission set. (1) The kernel:ready line — RIGHT: reportPackagedPermissionSetOverlays runs in plugin-security's kernel:ready bootstrap, the text is that line byte for byte, the names ride the structured argument. (2) "Those are the permission sets this release refuses at boot" — RIGHT: the reading sweeps permission and permissions, active, env-wide, no package_id filter, and keeps a name iff the classifier answers packaged, which it does whenever listItems('permission') (every slot, bare and composite) holds an artifact of that name wearing a package id — the check's "package-held"; the three edges 6071828819 stated stand (the 1000-row page, a disabled package's items, a failed read). (3) Discard Overlay — RIGHT: discardPermissionSetOverlay gates on the same classifier, findActiveOverlayRows reads both spellings, env-wide only, every row of the name is deleted, the record is re-projected synchronously; the Setup button shows only under overlay_shadow, the named POST route works regardless (precision, not falsity). (4) "So does DELETE /api/v1/meta/permission/NAME, which answers 'Customization overlay deleted … reset to artifact default'" — RIGHT for a row stored under permission (measured: viewer_readonly; mcp_agent_restricted twice), and the sentence now carries its bound: the added paragraph, two bullets below in the same block, says the route reaches a singular-typed row only. By code: the route hands req.params.type to deleteMetaItem; canonicalizeMetaRequestType folds any plural URL spelling to the singular; singularTypeForRepo is the singular; repo.get and repo.delete match type: ref.type exactly in whereFor. No URL spelling reaches a permissions-typed row. (5) "Either works for the platform security plugin's own sets too, and neither needs direct database access" — RIGHT: Discard Overlay measured on member_default, DELETE on viewer_readonly (singular) and mcp_agent_restricted (two rows, two calls); both are HTTP doors through the engine, and the operator opens no database connection — the sentence the first record found loose ("touches the database") now says what is true. For DELETE the "either" is bounded by the paragraph exactly as for any package's set. (6) "Positions have no such reading and no such action" — RIGHT: the reading sweeps the two permission spellings only; the action is declared on sys_permission_set and nowhere for positions.

Case 2 — after upgrading, for a package you can leave out. RIGHT for a row stored under permission or position (measured in round 0: both 200, no row left, the boot with the package back comes up; with the package absent the item is not artifact-backed and the repository hard-deletes the row). For a plural-typed row the paragraph now states the exception in so many words and routes it to the SQL; the dev measured it at this head (pr2_set, pr2_pos: 200 "No permission 'NAME' found — nothing to delete", both rows active, the boot refused again). The "rename" half is a save under the new name plus this delete and carries the same, now stated, bound.

Case 3 — after upgrading, for a name the platform security plugin declares, or for any row the metadata API does not reach. The widened heading — RIGHT, and it closes the gap 6071828819 named: a position stored under positions is a row the metadata API does not reach, and the SQL's position variant (type IN ('position', 'positions')) is its remedy; so is a permission set stored under permissions for a package that is not the platform's. The SQL — RIGHT and exact: organization_id IS NULL AND state = 'active' AND type IN (singular, plural) AND name = NAME selects precisely the rows loadMetaFromDb hydrates for that name, whatever package_id, and nothing else. "A draft row and an organization-scoped row are not loaded at boot and do not refuse it" — RIGHT: state: 'active' and organization_id: null are the load's own conditions. The phrase "does not reach" holds on the deployment as it stands: with the package configured the server does not come up, so a singular row of a package the operator will not leave out is in practice unreached too, and the SQL is true for it; no sentence is false for that shape, and the lead sentence's "rename the item in the package" serves it as well.

The added paragraph, clause by clause. (a) "DELETE /api/v1/meta/permission/NAME and DELETE /api/v1/meta/position/NAME reach a row stored under permission or position only, one row per call" — RIGHT: whereFor matches the type exactly; SysMetadataRepository.delete is one findOne then one delete by id. (b) "A row stored under the legacy plural permissions / positions is not reached: the call answers 200 that nothing was found and removes nothing" — RIGHT: repo.get returns null and deleteMetaItem answers { success: true, reset: false } with "No customization overlay found for permission/NAME — already at artifact default" (artifact-backed) or "No permission 'NAME' found — nothing to delete" (not); the restoreArtifactRegistryView self-heal on that branch touches the in-memory registry only and deletes no row; the row hydrates at the next boot. Measured on both branches this round. (c) "Where a name has two active rows, for example one bound to no package and one bound to the package, each call removes one" — RIGHT and exact. The DELETE verb states no packageId (the route builds none; whereFor then leaves the dimension out), so which of the two rows a call finds first is the driver's order — the repository's own whereFor comment calls it a driver-order coin toss and leaves the verb's package scoping open under #6215. Could a call remove a row the operator did not mean? It removes one of the rows that refuse the boot; both must go for the boot to come up, either alone refuses it, so there is no row to keep and no order to get wrong; the sentence claims no order. Measured: mcp_agent_restricted, unbound then bound, two calls, zero rows. One precision note, not owed: the first call's receipt reads "reset to artifact default" while the second row still stands, so an operator counts rows or calls until the route answers that nothing was found. (d) "A plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by the SQL above after upgrading, for any name" — RIGHT: findActiveOverlayRows reads permissions as well as permission (measured: viewer_readonly under permissions, 200, no active row left); the SQL names both spellings and carries no package condition (measured in round 1 on an app package's names and on member_default).

The CLI sentence and "What is NOT refused" — unchanged and RIGHT: the two CLI deletes are untouched on main since the merge base; the built-in carve-out, the same-package restart and distinct names are in the diff and pinned.

Every refused shape has a true remedy. Permission set, singular, unbound or package-bound, declared by an app package or by the platform: Discard Overlay or DELETE before upgrading; after upgrading, case 2's DELETE (one call per row) for a package left out, the SQL for the platform's or for any name. Permission set, plural, any binding, any declarer: Discard Overlay before upgrading; the SQL after. Position, singular, any binding, app-declared: nothing before upgrading, stated truthfully; after, case 2's DELETE or the SQL. Position, plural, app-declared: nothing before, the SQL after — the shape the first record found routed to a door that answers "nothing to delete", now routed right. Position, platform-declared: no such refused shape — plugin-security's manifest declares permissions only (security-plugin.ts:1553), its positions are the six built-ins registered at the item seam (builtin-positions.ts:135), which the check skips, and no other platform plugin declares a catalog name on a manifest (the permissions: / positions: hits elsewhere are context objects and policy rows). No shape is left without a true remedy.

Promises not measured. Two, both stated here as code readings and not proofs: DELETE on a singular row of a set a non-platform package ships, before upgrading — the dev's NOT MEASURED; by code nothing on the path reads the package (refusePackagedBaseRemoval lifts by the type's tier, assertDeleteAllowed by OVERLAY_CAPABLE_TYPES), and the one gate that can differ is ADR-0010's _lock, which a set must declare. Discard Overlay on a plural row of a non-platform package's set — measured on the platform's viewer_readonly only; by code the finder reads the spelling whatever the package and the classifier reads the artifact. No sentence of the changeset is false; no text fix is owed.

② Semver level

.changeset/22307-cold-boot-catalog-refusal.md: '@objectstack/objectql': major, feat(objectql)!:, the BREAKING banner, the ADR-0087 marker not-required (no-migration-prescription) (apt: no key, export or field moves; a refused name is renamed or removed by a person), Clause-②: no. Level — RIGHT. objectql is the one released package whose behaviour changes (runtime: a test; dogfood: private; the ADR anchor: tooling). .changeset/pre.json is {"mode":"pre","tag":"next"} on origin/main 117d34de3f and at the head (no diff), and packages/objectql is at 17.7.0 on main, so "shipped as major on the v18 pre-release line (.changeset/pre.json is in next pre mode on main)" is true; no sentence about pre mode or the level is false, and the premise that failed #22197's first review is not written here. Clause-②: no — RIGHT: an accept-set narrowing at boot, nothing widened; the head exports no new name (above); this round adds a paragraph to a release note and no surface. The PR body carries the same line. The note is the only input the release consumes, and every sentence of it is now true against the diff and main.

③ Boundary flags

1. The deliberate correction of .changeset/22135-security-catalog-one-holder.md — CONFIRMED on this head. The corrected note is #22135's pending release note, unconsumed: present on origin/main as blob 980162dd16, unchanged since #22197's merge fe98cc63a4, with packages/objectql at 17.7.0. At this head the file is blob fddd17ded7 — the same blob at 3c160a23e3 (the head 6070947709 confirmed) and at cf1a9dd6db (the head 6071828819 confirmed): byte-identical to both. The diff against main is +1/−1, the last sentence of the "What is NOT refused" paragraph; the paragraph's other sentences and every other line of the note are byte-identical to main. Before: "At cold boot, packages register before the environment catalog loads from sys_metadata, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; a cold-boot refusal is ruled and tracked on #22307, which lands separately." After: "At cold boot, packages register before the environment catalog loads from sys_metadata, so this door cannot see an environment-held name then; the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot with this envelope (the cold-boot entry in this release)." Sentence by sentence, against the diff and main: the kept clause "packages register before the environment catalog loads from sys_metadata" is TRUE (Phase 1 installPackage; restoreMetadataFromDb in start()). The old "is not refused at cold boot (the registry's existing collision warning fires)" is FALSE once this PR lands: start() throws SecurityCatalogNameConflictError after hydration, and no boot comes up — the warning still prints at hydration, which is why the parenthesis had to go with the clause. The old "while a hot install of the same package is refused" is still true and is dropped because parity is now the sentence's point. The old "a cold-boot refusal is ruled and tracked on #22307, which lands separately" is FALSE after this PR lands. The new "this door cannot see an environment-held name then" is TRUE: the package door runs before the row is hydrated. The new "the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot" is TRUE against the diff (plugin.ts, registry.ts). "with this envelope" is TRUE: the same class, code, status and conflicts[]; only the message gains a cold-boot variant. "(the cold-boot entry in this release)" is TRUE while the two notes are pending together, which they are on main today; the PR body states the erratum route if a release consumes #22135's note first. Nothing else in the note changes; the correction overreaches nowhere. Of the dev's open question (6070693740) the options B and C would each publish a false or contradictory sentence; A stands. The repo's rule (landing-operations.md: a DELIBERATE CORRECTION red on check-empty-changeset is confirmed by a same-head at-tier PASS record, naming the corrected note and judging the rewritten sentence; check-empty-changeset.mjs's own FOREIGN_CORRECTION_REMEDY): this record is that confirmation for 39ef237d40; Check Changeset red on this head is deliberate.

2. Dev flags, 6072153086 (this round). open_questions: none. out_of_scope_findings: none. Deviations: the gate batch split in two at the harness's 590s cap (107/107 reconciled, 106 exit 0, the one designed red) — read as reported; the worktree recreated and removed — fine. review_reading: CONFIRMED by measurement — the three measurements agree with the code reading in ① (the plural row unreached on both receipt branches; two rows, two calls; Discard Overlay reaching the plural row). The NOT MEASURED pair carried from 6071587973 (the metadata-API delete on a set a non-platform package ships; a position overlay before upgrading): the first is answered in ① by code and said so; the second is claimed nowhere in the text.

3. Earlier rounds' flags — each answered on record and unchanged by this round: the M3 route and the pre-lock bound row (6070947709 ① item 2); the reshaped Discard Overlay pin (① item 7); the position hatch, the sys_packages rehydrate, org-scoped rows and the sqlite-wasm flush (6070947709 ③ items 5–7); the 2026-08-24 remedies' post-upgrade population, carried on #22371; the stale "One authoritative store" bullet on the Permission Sets page (6071587973's class-a finding), pre-existing, not this PR's, a carrier card owed by the seat; the files outside the engine lane, declared in the claim and the reports.

4. #22366 (Tier H) is merged on the maintainer's approval; its final 速读 claims nothing the metadata-API DELETE would now contradict. Nothing to record.

5. CI — the seven required contexts are success on this head; the one red is Check Changeset, deliberate (item 1), not required and outside the merge group; nothing is in progress, so no red on a required context is pending to be this PR's. A new head owes a fresh read.

6. Local runs — none.

Implemented-by: claude/issue-22307-cold-boot-catalog-refusal
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

The two sentences 6071828819 failed are now bounded by the added paragraph, case 3's heading routes every row the metadata API does not reach to the SQL, and every shape the check refuses has a true remedy before or after upgrading. The code, the pins and the #22135 correction are unchanged and confirmed on this head. Owed alongside, not blocking: the carrier card for the stale docs bullet (③ item 3).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 01:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit e030d43 Oct 9, 2026
42 of 44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22307-cold-boot-catalog-refusal branch October 9, 2026 01:59
os-litant pushed a commit that referenced this pull request Oct 9, 2026
…tion rows the way an older release left them; the hatch no longer opens that save (ADR-0131 D6)

#22365's control saved stored definitions under two built-in position names
through OS_METADATA_WRITABLE=position. The positions are shipped by the
platform's own package, so the seal now refuses that save with the hatch set
too. The control pins the 403 NOT_OVERRIDABLE refusal, writes the rows at the
driver as the file's legacy-row case already does, and keeps its assertions:
the restart boots and the stored definition answers.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
os-tesla pushed a commit that referenced this pull request Oct 9, 2026
 declare the current protocol, ^18

main added two manifest fixtures that declare engines.protocol '^17' and
stand for a valid current app, not for an old artifact:
- packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts
  (#22365): at protocol 18 the load-seam handshake refuses it, and all four
  cases fail with ProtocolIncompatibleError before reaching their subject.
- packages/cli/test/retry-policy-key-validate-door.test.ts (#22380): os
  validate only advises on the gap, so it stays green either way, but its
  subject is the retry-policy key door, not the protocol's age.
Both now read '^18', like the other current-app fixtures this change moved.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ge's claim against the environment catalog (ruling letter A on objectstack-ai#22307) (objectstack-ai#22366)

Refs objectstack-ai#22307

Records the maintainer's ruling letter A on objectstack-ai#22307 (ruling record
6063176077) in ADR-0048: addendum N.3 gains one dated note. The
hydration write stays unjudged as a write, and the post-hydration check
judges the package's claim against it.

This is the Tier H half of objectstack-ai#22307, split from the code PR (objectstack-ai#22365) so
the code can land on its own record. objectstack-ai#22307 stays open after this PR;
the code PR carries the card.

## What changed — `docs/adr/0048-cross-package-metadata-collision.md`
only

Additive: 7 lines, no existing line edited.

- **A dated note directly under N.3's list:** "Amended (2026-10-08) —
the cold boot". After `sys_metadata` hydration and before
`kernel:ready`, every package-held permission set and position name is
checked against the environment catalog, and a name the environment
already holds fails the boot with the N.2 envelope, naming both holders.
It cites the ruling record.
- N.3's existing bullet ("A write with no package provenance … stays
under ADR-0005 overlay precedence") is left as it is: the hydration
write is still not judged as a write, which is what the note says first.
- N.4 ("Where it is implemented") is not edited. The code PR leaves the
ADR id in the code
(`ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames`) and extends
the module's ADR anchor
(`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`).

## Gates (at f86b451)

`node scripts/pm/dispatch-gates.mjs --commands` derived 19 commands at
f86b451. All 19 ran with exit codes recorded, and `--ran` reconciles
19/19 with 0 NOT-MEASURED (a derived zero). All 19 exit 0.
`check:doc-formula-expressions` first answered PREREQUISITE NOT MET
(exit 3); it exited 0 after `@objectstack/formula` and
`@objectstack/lint` were built. `origin/main` has moved since the branch
point, and `git merge-tree` against it is clean.

## 维护者速读(草稿)

### 改了什么
只改 ADR-0048 的文字,没动代码。在附录 N.3 下面加了一段带日期的说明(7 行),原文一个字都没改。说明的内容就是您在 objectstack-ai#22307
上选的 A:重启时,环境里已经存着的权限集或职位名,如果某个包也声明了同名的,启动直接失败,报错点名双方。

### 为什么改
N.3
原来写的是"环境自己保存的数据不受这条规则管"。重启时,包先注册,环境数据后加载,所以按原来的写法,重启这条路正好漏过去:同一个包热安装会被拒,重启加进来却能装上,还被环境里的同名定义悄悄盖住。您裁定重启也要拒。这段说明把两件事分开写清楚:环境数据的加载本身照旧不判;加载完以后,拿包的声明去对环境目录,重名就拒。不写进
ADR,ADR 和代码就对不上。

### 风险与代价(含回滚)
- 这份 PR 本身只是文档,没有运行时风险。
- 真正的行为变化在配套的代码
PR(objectstack-ai#22365):已经处在"环境里存着同名权限集或职位、配置里又有声明同名的包"这种状态的部署,升级后会起不来,要运维改名或删掉其中一个才行。包括锁上线前保存的旧覆盖行,也包括平台安全插件自带的权限集(例如
`member_default`)上的旧覆盖行。仓库里的示例应用实测没有这种状态,真实部署的数量测不到。
- 回滚:撤销这份 PR,ADR 回到原文;代码 PR 可以分开回滚。

### 席位意见


### 你要做的
请看这段说明的措辞是否准确反映您的裁决,同意就批准(Approve)。这份 PR 属于 Tier H,只能由您批准后落地。

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…authoritative store" (objectstack-ai#22391)

Fixes objectstack-ai#22379
Clause-②: no

## What changed

Two docs pages: four passages that still said a set shipped in code can
be overlaid in the environment. +25 / -16 lines against `main`.

### `content/docs/permissions/permission-sets.mdx`, "One authoritative
store — the record is a projection (ADR-0094)"

- **First bullet.** It said a Setup edit of any declared set, packaged
sets included, "becomes an environment overlay" that "genuinely takes
effect", and that the Studio layered view diffs it and a reset removes
it. Since the packaged lock, that edit answers `403 NOT_OVERRIDABLE` and
stores nothing. The bullet now says:
- which sets are locked: a set shipped in code, meaning a set an
installed package declares (`*.permission.ts`, a stack's
`permissionSets`) or a platform default such as `member_default`;
  - what the edit answers (`403 NOT_OVERRIDABLE`, nothing stored);
- the remedy the refusal names: clone to a new name, with the **Clone**
action or `POST /api/v1/data/sys_permission_set`;
- which sets are still edited in place: one created in this environment,
a clone, or one saved into a writable runtime package.
- **Closing sentence.** "use an overlay where a packaged set must be
narrowed" becomes: clone it, narrow the clone, and bind the clone in its
place (restriction is done by not granting). Narrowing the packaged set
itself is the package author's change (ADR-0086 two-doors). The
position-binding half of the sentence did not change.
- **Third bullet** ("Deleting through the data door … resets it"). It
holds against `main`, so it is **not edited**. Evidence is under premise
check 5.

### `content/docs/permissions/permission-sets.mdx`, "Provenance —
package vs environment sets (ADR-0086)", `:311-312`

- Before: "(two-doors separation, evolved by ADR-0094 — ordinary edits
of a packaged set become environment overlays, see below)".
- After: "(two-doors separation, evolved by ADR-0094 — an edit of a set
shipped in code is refused; clone it instead, see below)".
- Why: its "see below" pointed at the corrected bullet, which now says
the opposite.
- Backed by the same pins as the bullet:
`permission-set-write-through-package-binding.dogfood.test.ts:167-173`
(a data-door edit of `showcase_contributor` answers `403
NOT_OVERRIDABLE`, no row minted), and
`permission-set-lock-row-provenance.dogfood.test.ts` (the shipped set is
refused at both doors, while a clone takes an edit). The clone remedy is
the `clone_permission_set` action in
`packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:115-160`.

### `content/docs/permissions/administrator-guide.mdx`, Step 5
(`:162-164`) and the FAQ (`:194-196`)

- **Step 5.**
- Before: "Editing a set that shipped with an app creates an
*environment overlay* — your change wins, survives upgrades, and can be
reset back to the vendor baseline; the API name is immutable after
creation."
- After: "A set shipped in code — by an app you installed, or as a
platform default such as `member_default` — can't be edited in the
environment (a save answers `403 NOT_OVERRIDABLE`): clone it and adjust
the clone, which is your own set while upgrades keep reaching the
original. Sets created here, clones, and sets saved into a writable
runtime package are edited in place. The API name is immutable after
creation."
- The page's order of preference is unchanged: bind an existing set to a
position, then clone and adjust, then author a new set.
- **FAQ, "Can I delete the built-in permission sets?"**
- Before: "Shipped sets can be overlaid (Step 5) or simply left
unassigned."
- After: "Shipped sets can't be edited in place either, but they can be
cloned and adjusted (Step 5) or simply left unassigned."
- Backed by:
- The installed-app half:
`permission-set-write-through-package-binding.dogfood.test.ts:167-173`
and `two-doors-permission.dogfood.test.ts` 块2 (`showcase_contributor`
answers 403, and no overlay is minted).
- The platform-default half: `two-doors-permission.dogfood.test.ts`
(last 块2 case) and `showcase-permission-projection.dogfood.test.ts` §2
pin `403` on a `member_default` edit with no overlay minted. The status
is pinned. The code is read from the producer
(`refusePackagedBaseOverride`, `NOT_OVERRIDABLE`) and is not pinned.
- The editable populations:
`permission-set-lock-row-provenance.dogfood.test.ts` shapes 1-3
(runtime-package set, org set, clone).
- The upgrade sentence: the lock's own `userMessage` in
`packaged-permission-set-lock.ts` ("The clone is your organization's own
permission set, and package upgrades keep reaching the original.").

Not touched: the "Declared ≠ enforced — diagnosing a frozen package set"
section. PR objectstack-ai#22365 (card objectstack-ai#22307) adds a clause there in a hunk starting
at `:368`, and the decision card objectstack-ai#22371 may rewrite that section.
`content/docs/releases/` is not touched either.

## Premise checks against `main` (base `117d34de3f`, now merged up to
`191543456f`)

1. **The two passages were still pre-lock text.** At `117d34de3f`,
`permission-sets.mdx:332-339` ("it genuinely takes effect") and
`:350-352` ("use an overlay where a packaged set must be narrowed") read
as the card quotes them. Holds.
2. **The Setup edit of a set a code package ships answers 403 and stores
nothing.** Holds:
- Pin:
`packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts:167-173`.
`PATCH /data/sys_permission_set/:id` on `showcase_contributor` answers
`{ status: 403, code: 'NOT_OVERRIDABLE' }`, and the active
`sys_metadata` rows are unchanged.
- Producer: `PackagedPermissionSetLockedError` in
`packages/plugins/plugin-security/src/packaged-permission-set-lock.ts`.
The data door's insert and update legs throw it
(`permission-set-projection.ts`, `createPermissionSetWriteThrough`), and
so does the metadata door (`packaged-permission-set-lock-gate.ts`).
3. **Scope: which package-held sets does the lock cover?** The caution
was right. "A packaged set cannot be edited" over-claims, so the text is
written to a narrower group: sets shipped in code.
- How the lock decides: `classifyPackagedPermissionSet` reads the engine
SchemaRegistry. `declaredPackageIdOf` skips projection echoes,
tenant-authored stored rows (`isTenantAuthored`, `_provenance: 'org'`),
and `_packageId: 'sys_metadata'` shadows.
- Editable shapes, pinned: a set saved into a writable runtime package
(`permission-set-write-through-package-binding.dogfood.test.ts:152-160`,
and `permission-set-lock-row-provenance.dogfood.test.ts` shape 1), an
org-created set (shape 2), and a clone (shape 3).
4. **Both remedies exist and work on `main`.** Holds.
- **Clone:** the `clone_permission_set` action
(`sys-permission-set.object.ts:115-160`) POSTs to
`/api/v1/data/sys_permission_set`. It carries every permission facet but
deliberately not `admin_scope`. The lock's `userMessage` and the
producer's regime sentence (`packaged-base-regime.ts:162-167`) both name
it.
- **Bind to a position:** `sys_position_permission_set` rows ("Assigning
permission sets" on this page; `positions.mdx:7-10`).
`showcase-permission-zoo.dogfood.test.ts:133-143` pins a tenant admin
binding the package-shipped `showcase_contributor` to a position.
- Binding only adds (union) and cannot narrow anything, so narrowing
goes through clone-and-bind-in-its-place.
5. **Third bullet: holds.** Measured per kind of set:
- *A set created in this environment is removed:*
`showcase-permission-projection.dogfood.test.ts` ("deleting a
runtime-only set retires both the definition and the record") and
`permission-set-projection.test.ts:785`.
- *A set shipped in code is not removed, and the row remains:*
`two-doors-permission.dogfood.test.ts` 块2 delete case and
`showcase-permission-projection.dogfood.test.ts` §3 both answer 2xx with
`success: false`, and the record stays.
- *"(the customization overlay is dropped)":* a pre-lock overlay is
removed by `deleteMetaItem` (the 2026-08-10 maintainer ruling, the
`mergesOverlayAtRead` carve-out in `refusePackagedBaseRemoval`). Pinned
by `protocol.legacy-overlay-delete.test.ts` and
`permission-set-projection.test.ts:926`. With no overlay present, the
delete changes nothing.
- NOT MEASURED: deleting a set saved into a writable runtime package. No
pin covers it. See Acceptance notes.
- CI's `Dogfood Regression Gate` concluded `success` on `117d34de3f`.
Confirming that each cited file appears in the job logs is NOT MEASURED:
the log host answered `Forbidden` from this container.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on head `fc3fdb1b69`: 43 commands, the same
list as round 1. All 43 ran on `fc3fdb1b69` and exited 0. Exit codes
were captured before any pipe.

- Prerequisite builds: the spec, lint and client/client-react closures
were built under the verify lock (`VERDICT command-exit 0`) before the
run, so the dist-reading gates measured this tree.
- Reconciliation (`--ran` with per-command exit codes): "43 derived
famil(ies) accounted for — 43 run, 0 NOT-MEASURED (a DERIVED zero …)".
- Sample verdict lines:
- `check:skill-examples`: "262 prose examples type-check across 3
surface(s)".
- `check:doc-anchors`: "468 internal #fragment link(s) across 417 source
file(s) all resolve to a real heading".
- `check:nul-bytes`: "OK (scanned 10373 text file(s) … no raw ASCII
control bytes)".
  - `check:doc-authoring`: clean.
  - `check:docs`: "225 generated files in sync with packages/spec".
- `origin/main` moved to `0ef9029da4` after this round's merge. That
commit only retitles `packages/spec` test files and touches no docs, so
it was not merged again; CI's merge ref covers it.
- Outside the derived list, for CI: the path-scheduled `Build Docs` and
`Test Core` jobs, and the type-check lanes.

## Changeset

None. The diff is docs-only under `content/docs/`, which no package
publishes, so the PR carries the `skip-changeset` label.

## Acceptance notes

Accepted by the seat as notes, not filed:

- **Stale code comments, not docs.** These are comment drift with no
runtime effect.
- In
`packages/plugins/plugin-security/src/permission-set-projection.ts:559-568`,
the comment says "objectstack-ai#6960 measures the ordinary delete path refusing to
lift" a legacy overlay. The 2026-08-10 ruling since lets that delete go
through (`protocol.legacy-overlay-delete.test.ts`).
- The `createPermissionSetWriteThrough` doc comment at `:1055-1058`
still says a package-owned row's update becomes an env-scope overlay.
- **Unmeasured: deleting a runtime-package set.** This is a code reading
only, not reproduced.
- The data door's delete leg calls `deleteMetaItem` with no package. The
repository delete matches any package (`sys-metadata-repository.ts`,
`whereFor`).
- `readDeclaredBody` (`permission-set-projection.ts:494`) skips
`_packageId: 'sys_metadata'` shadows and projection echoes, but not the
tenant-authored (`_provenance: 'org'`) rows the lock's classifier
learned to skip.
- A delete after a list read might therefore re-project instead of
retiring. A booted-stack probe would settle it.

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

---------

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/l tests tooling

Projects

None yet

2 participants