Repository navigation
fix(metadata-protocol): a cel-dated seed row reaches every hook in its temporal field's stored form - #22353
Conversation
… in its temporal field's stored form A cel-dated seed value (`daysFromNow(n)`, `daysAgo(n)`, `today()`, `now()`) resolved to a JS Date and was handed to the engine as one, so a hook saw a Date object when it ran in-process (a source config, as the verify handle boots it) and a full ISO instant when it ran as a sandboxed body (the compiled artifact `os dev` boots). The row now leaves the loader in the ADR-0053 stored form, by the rule the drivers apply on write. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…es names, by serve's own reader and table The `requires` token -> provider table and its exact identity match move from the `Serve` command to `@objectstack/core`, beside the package-owned collection reader `os serve` reads `requires` with (moved there from the CLI's utils, which re-export it). `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` become handles over the core declarations. `@objectstack/verify`'s `bootStack` then constructs the providers the app's `requires` names (top level when present, otherwise each package body), skips any provider the boot already holds (an explicit instance wins), and mounts the always-on providers a mounted provider hard-depends on. Every provider package in the table becomes a dependency of `@objectstack/verify`. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…poral form Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…eep the seed-loader fix and its pin `bootStack` mounting the providers an app's `requires` names cannot land on its own: the showcase declares `automation`, whose start materializes the app's connector instances, whose providers are in the app's own `plugins` array, which `bootStack` does not mount, and whose file refs resolve against the config's directory. Every showcase boot in the dogfood gate fails at bootstrap. The implementation stays in this branch's history; how the handle treats the app's own plugins goes to a decision first. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6d1c0919a99a1fe297497a0d00d27338107d2924 && git checkout 6d1c0919a99a1fe297497a0d00d27338107d2924
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 54c3ce10ce8cf89290b67813c34d1f10dbab6938 792bcc71daec25c295af3ec98ef690007fef9488 && git checkout -B drift-repro 54c3ce10ce8cf89290b67813c34d1f10dbab6938 && git merge --no-ff 792bcc71daec25c295af3ec98ef690007fef9488
node scripts/docs-audit/affected-docs.mjs --json 54c3ce10ce8cf89290b67813c34d1f10dbab6938 |
Contract reviewServed-tier: Inputs: card #22301 (body and all 8 comments, ① Derived judgments
② Semver level
③ Boundary flagsDev report
Check-runs on this head at the time of this record: 29 success, 3 skipped ( Implemented-by: VERDICT: FAIL FAIL reason (one): the changeset body — and the PR body's "What changes" — asserts that the in-memory driver now stores the string instead of a |
Each driver already normalises a declared temporal field on write (driver-memory through the same temporalStorageForm), so the sentence saying the in-memory driver now stores a string instead of a Date was false. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read at 2026-10-08T21:39Z, read-only: card #22301 (body and all 9 comments), PR #22353 (body, 3-file list, net diff of the head against ① Derived judgments
② Semver level
③ Boundary flagsDev flags (PR body "Acceptance notes"; report
Check-runs on the head at this read — every completed run is green: Implemented-by: VERDICT: PASS Generated by Claude Code |
…ssion-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) (objectstack-ai#22365) Fixes objectstack-ai#22307 Clause-②: no Executes the maintainer's ruling letter A on objectstack-ai#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 objectstack-ai#22331, which landed first) `main` was merged at e3ae92a, after objectstack-ai#22331 landed. The merge was clean, and the order in `ObjectQLPlugin.start()` on this head is: 1. objectstack-ai#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. objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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; objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#22352 (`plugin-security` grant readers) and objectstack-ai#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 objectstack-ai#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` (objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#22135 correction. `origin/main` is 22 commits ahead. `git merge-tree` against it is clean, so `main` was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #22301
Clause-②: no
This PR carries stage 1's item 9 only: a cel-dated seed row reaches every hook in the stored form of its temporal field, so
bootStackstores the rowsobjectstack devstores. Item 1 (the handle mounts whatrequiresnames) was implemented and measured on this branch, and it is held back: as built, it stops every showcase boot in the dogfood gate. How the handle treats an app's ownpluginsarray goes to a decision first (details below). Items 2 to 8 stay on the card for later stages.Item 9: what the two boots handed the hook
Measured on
origin/main28bff18, onebootStackof a two-object app: adatefield and adatetimefield, seeded withdaysFromNow(1)/daysFromNow(2)as cel expressions and with literal ISO values.datevaluehandler(a source config, whatbootStackboots)Dateobject,2026-10-09T00:00:00.000Z'2026-10-10'(string)body(the compiled artifactobjectstack devboots)'2026-10-09T00:00:00.000Z'(string, a full instant on adatefield)'2026-10-10'(string)'2026-10-09''2026-10-10'So the two boots hand the hook different values. The app's hook is not the divergence. hotcrm's
campaign_validationreads the dates only when they are strings, so it accepted the rows underobjectstack devand refused them underbootStack. The divergence starts one step earlier:resolveSeedRecordevaluates the expression to theDatethe cel stdlib returns (ADR-0053 D1), and the seed loader handed thatDateto the engine as-is. Neither hook form then saw the value the column stores.Where it lands, and why:
packages/metadata-protocol/src/seed-loader.ts(adomain:enginefile; the claim names it for this case). Right after resolution, eachDateon adate/datetime/timefield is put into its stored form bytemporalStorageForm(@objectstack/core). That is the one rule the drivers apply on write, so the loader carries no second copy of it. A non-temporal field, an undeclared field and an Invalid Date are handed over unchanged.packages/verifyandpackages/formulaare not touched: the verify boot's seed path is the shared loader, andresolveSeedRecordhas no field types to work with.What changes: both hook forms now see the stored form (
'2026-10-09'for adate). A body hook on adatefield sees2026-10-09where it saw2026-10-09T00:00:00.000Z. Stored values do not change on any driver, because each driver already normalises a declared temporal field on write:driver-memorythrough the sametemporalStorageForm,driver-sqland the two drivers that extend it (driver-sqlite-wasm,driver-turso) on their own write path, anddriver-mongodbthrough itscoerceTemporalValue. Changeset:@objectstack/metadata-protocolpatch.Pin:
packages/verify/src/harness.seed-temporal-form.test.tsboots through the real handle. It asserts that the cel rows are stored, that the handler and the body each see exactly the stored value, that a hook requiring string dates (hotcrm's shape) lets the row through, and that the literal control rows are unchanged.Ablation, at
e83540e29, throughscripts/ablation-replace.mjs:metadata-protocolwas rebuilt, andablation-dist-preflightfound the marker in 2 built files. Result: 4 failed / 1 passed. The control passed. The guarded hook logged[SeedLoader] Failed to write stf_guarded record #0 (name=guarded-cel): both dates are required, which is hotcrm's failure reproduced.097d4adee50fequals HEAD, andgit diff HEADis empty. After the rebuild the marker is absent from all 24 built files and the tree is clean. Result: 5/5 passed.voidstatement) did not build. The unused helper failed the d.ts pass, and the preflight found no marker. That run's reading is void and is not counted above.Item 1: implemented, measured, held
What was built and measured, kept in this branch's history at
ef5396ca2and reverted atb3186ec18:@objectstack/core. The measured graph:@objectstack/clidepends on@objectstack/verify, so the CLI cannot be the home, and an@objectstack/clisubpath would close a cycle. Both packages already depend on@objectstack/core, which owns the package ordering (resolveArtifactPackageOrder) that therequiresreader is built on.CAPABILITY_PROVIDERS,CapabilitySpecandprovidesCapabilitymoved there. So did the readerstackDeclaredCapabilitiesand its rule (resolveStackCollection, frompackages/cli/src/utils/stack-collections.ts).Serve.CAPABILITY_PROVIDERSandServe.providesCapabilitybecame handles over the core declarations, pinned by identity (toBe), and the CLI drift tests were re-pointed. There was no second copy.bootStackreadrequiresthroughstackDeclaredCapabilities. Pins covered the top level, the package bodies, the top level winning over the bodies, and a control. A caller's instance won (extraPlugins), and hard dependencies came along (triggersbroughtjobandqueue). Every provider package in the table became a dependency of@objectstack/verify, with no cycle (measured over deps, devDeps and peers). The pins were 7/7 green. With the mount removed by ablation, 4 went red, while the control, explicit-wins and manifest cases stayed green. Afterwards the blob equalled HEAD. The CLI unit and integration pins over the moved code were 260/260 and 11/11.pnpm --filter @objectstack/dogfood exec vitest run --project shared-showcasefailed 13 of 13 files at bootstrap:connector instance 'showcase_status_api' declares provider 'rest', but no provider factory is registered. The showcase declaresrequires: ['automation', …]. Automation's start materializes the app's connector instances (ADR-0097). Their providers sit in the showcase's ownpluginsarray, whichobjectstack servemounts andbootStacknever has. An uncommitted experiment that also mounted the app'spluginsinstances moved the refusal one layer:providerConfig.spec './src/system/connectors/status-openapi.json' … resolved to '/tmp/os-dogfood-run-…'.serveanchors automation'spackageRootat the config's directory, and the handle only knowshostRoot. 117 dogfood files boot the showcase directly, and 11 of them pass a connector plugin. For hotcrm, which carries nopluginsarray, the same implementation boots green.The open question is how the handle treats an app's own
pluginsarray: mount it asservedoes, keep it the caller's, or put the wholerequiresmounting behind an option. It is in the card report with each option's cost.Tests (at
e83540e29)pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2: 19 files / 138 tests passed.pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 223 passed, 3 skipped files; 28,338 passed, 19 skipped tests.typecheckfor@objectstack/verifyand@objectstack/metadata-protocol: green, with test-layer debt unchanged.dispatch-gates --commandsderived 62 commands on this tree. All 62 were run and exited 0.--ranreports 62 derived, 62 run, 0 NOT-MEASURED and 0 UNRUN.eslint --no-inline-configover the 2 changed TypeScript files (the populationeslint.config.mjs's**/*.{ts,…}/packages/**/*.{ts,…}objects match), counted from--format json: 2 files, 0 errors, 0 warnings. The config enables no type-aware linting, so this diff cannot move the verdict of any untouched file. The fullpnpm lintis CI's.Acceptance notes
bootStackconstructs itsAnalyticsServicePluginwithout the app'sanalyticsCubes, andservepasses them. This is a boot-parity gap in the same family as item 1, named in the card report for a later stage.Datewritten into a source-config seed now reaches hooks in stored form. The same seed compiled to an artifact arrives as a JSON ISO string, which the loader leaves unchanged. Cel values agree in both boots, but literalDates still differ by form. No producer of that shape was measured.resolveNowDefaultcarries its own date / time / instant table besidetemporalStorageForm. The two agree today. This is an observation, with no carrier.Patch round (contract review FAIL
6069291830→ seat order6069311153→792bcc71d)Written into this body by the
domain:specseat 2 at 2026-10-08T21:30Z. The comments before it on this PR are read: the docs-drift bot's, and the contract review this round answers.Dateobject" was false, becausedriver-memoryalready normalises every declared temporal field on write (toStorageForms→coerceTemporalValue→temporalStorageForm). It is replaced with: stored values do not change on any driver, because each driver already normalises a declared temporal field on write. The dev readdriver-memoryanddriver-mongodbbefore writing it, and the seat confirmed thatdriver-sqlite-wasmanddriver-tursoextendSqlDriver. The "What changes" line above is corrected the same way.check-changeset-fixed.mjsandcheck:empty-changeset, all exit 0.mainwas not merged (the push was accepted).Generated by Claude Code