Skip to content

fix(metadata-protocol): a cel-dated seed row reaches every hook in its temporal field's stored form - #22353

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22301-verify-boot-parity
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22301-verify-boot-parity

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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 bootStack stores the rows objectstack dev stores. Item 1 (the handle mounts what requires names) 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 own plugins array 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/main 28bff18, one bootStack of a two-object app: a date field and a datetime field, seeded with daysFromNow(1) / daysFromNow(2) as cel expressions and with literal ISO values.

hook form saw for the cel date value saw for the literal row
in-process handler (a source config, what bootStack boots) a JS Date object, 2026-10-09T00:00:00.000Z '2026-10-10' (string)
sandboxed body (the compiled artifact objectstack dev boots) '2026-10-09T00:00:00.000Z' (string, a full instant on a date field) '2026-10-10' (string)
stored by the SQL driver '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_validation reads the dates only when they are strings, so it accepted the rows under objectstack dev and refused them under bootStack. The divergence starts one step earlier: resolveSeedRecord evaluates the expression to the Date the cel stdlib returns (ADR-0053 D1), and the seed loader handed that Date to 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 (a domain:engine file; the claim names it for this case). Right after resolution, each Date on a date / datetime / time field is put into its stored form by temporalStorageForm (@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/verify and packages/formula are not touched: the verify boot's seed path is the shared loader, and resolveSeedRecord has no field types to work with.

What changes: both hook forms now see the stored form ('2026-10-09' for a date). A body hook on a date field sees 2026-10-09 where it saw 2026-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-memory through the same temporalStorageForm, driver-sql and the two drivers that extend it (driver-sqlite-wasm, driver-turso) on their own write path, and driver-mongodb through its coerceTemporalValue. Changeset: @objectstack/metadata-protocol patch.

Pin: packages/verify/src/harness.seed-temporal-form.test.ts boots 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, through scripts/ablation-replace.mjs:

  • Mutation leg. The call was swapped for a no-op call carrying a marker. metadata-protocol was rebuilt, and ablation-dist-preflight found 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.
  • Restore leg. Blob 097d4adee50f equals HEAD, and git diff HEAD is empty. After the rebuild the marker is absent from all 24 built files and the tree is clean. Result: 5/5 passed.
  • A first mutation attempt (a bare void statement) 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 ef5396ca2 and reverted at b3186ec18:

  • H1, the home: @objectstack/core. The measured graph: @objectstack/cli depends on @objectstack/verify, so the CLI cannot be the home, and an @objectstack/cli subpath would close a cycle. Both packages already depend on @objectstack/core, which owns the package ordering (resolveArtifactPackageOrder) that the requires reader is built on. CAPABILITY_PROVIDERS, CapabilitySpec and providesCapability moved there. So did the reader stackDeclaredCapabilities and its rule (resolveStackCollection, from packages/cli/src/utils/stack-collections.ts). Serve.CAPABILITY_PROVIDERS and Serve.providesCapability became handles over the core declarations, pinned by identity (toBe), and the CLI drift tests were re-pointed. There was no second copy.
  • H2, the reader. bootStack read requires through stackDeclaredCapabilities. 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 (triggers brought job and queue). 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.
  • The blocker, measured. pnpm --filter @objectstack/dogfood exec vitest run --project shared-showcase failed 13 of 13 files at bootstrap: connector instance 'showcase_status_api' declares provider 'rest', but no provider factory is registered. The showcase declares requires: ['automation', …]. Automation's start materializes the app's connector instances (ADR-0097). Their providers sit in the showcase's own plugins array, which objectstack serve mounts and bootStack never has. An uncommitted experiment that also mounted the app's plugins instances moved the refusal one layer: providerConfig.spec './src/system/connectors/status-openapi.json' … resolved to '/tmp/os-dogfood-run-…'. serve anchors automation's packageRoot at the config's directory, and the handle only knows hostRoot. 117 dogfood files boot the showcase directly, and 11 of them pass a connector plugin. For hotcrm, which carries no plugins array, the same implementation boots green.

The open question is how the handle treats an app's own plugins array: mount it as serve does, keep it the caller's, or put the whole requires mounting 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.
  • typecheck for @objectstack/verify and @objectstack/metadata-protocol: green, with test-layer debt unchanged.
  • Gates: dispatch-gates --commands derived 62 commands on this tree. All 62 were run and exited 0. --ran reports 62 derived, 62 run, 0 NOT-MEASURED and 0 UNRUN.
  • Lint, narrowed: eslint --no-inline-config over the 2 changed TypeScript files (the population eslint.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 full pnpm lint is CI's.
  • NOT MEASURED locally: the full dogfood suite and the integration tiers. Both are CI's.

Acceptance notes

  • bootStack constructs its AnalyticsServicePlugin without the app's analyticsCubes, and serve passes them. This is a boot-parity gap in the same family as item 1, named in the card report for a later stage.
  • A literal JS Date written 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 literal Dates still differ by form. No producer of that shape was measured.
  • The engine's private resolveNowDefault carries its own date / time / instant table beside temporalStorageForm. The two agree today. This is an observation, with no carrier.

Patch round (contract review FAIL 6069291830 → seat order 6069311153 → 792bcc71d)

Written into this body by the domain:spec seat 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.

  • The changeset's sentence "The in-memory driver now stores the string instead of a Date object" was false, because driver-memory already 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 read driver-memory and driver-mongodb before writing it, and the seat confirmed that driver-sqlite-wasm and driver-turso extend SqlDriver. The "What changes" line above is corrected the same way.
  • One changeset commit (+1 / -1), no code. The 20 changeset/text gate families, plus check-changeset-fixed.mjs and check:empty-changeset, all exit 0. main was not merged (the push was accepted).

Generated by Claude Code

claude added 6 commits October 8, 2026 18:45
… 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>
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • 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 — 11 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 54c3ce10ce8cf89290b67813c34d1f10dbab6938 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6d1c0919a99a1fe297497a0d00d27338107d2924 — the merge of head 792bcc71daec25c295af3ec98ef690007fef9488 into base 54c3ce10ce8cf89290b67813c34d1f10dbab6938, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs: card #22301 (body and all 8 comments, 6061416383 through 6069107079), PR #22353 (body, 3-file list, net diff against the merge base 35afb1587 — .changeset/22301-seed-temporal-stored-form.md +16, packages/metadata-protocol/src/seed-loader.ts +39/-1, packages/verify/src/harness.seed-temporal-form.test.ts +147), and the 33 check-runs on this head. Nothing built, run or re-run; the code the diff calls into was read at this head with git show.

① Derived judgments

  1. Accept set: unchanged — RIGHT. The loader accepts the same seed shapes it did; no key is added or removed, no row that loaded before is refused now, no refusal is added. The change is to the FORM of a value the loader hands on, after resolution and before any hook. So Clause-②: no with no arm is the truthful reading (not a widening, not a narrowing of a published accept set).
  2. Public surface of @objectstack/metadata-protocol: unchanged — RIGHT. seedValuesInStoredForm is module-private; nothing is exported, renamed or removed. check:api-surface sits inside the green Lint & Repo Gates.
  3. The published behaviour that does change: what a hook receives from a seed write — RIGHT, and named in the changeset. Every Date on a declared date / datetime / time field reaches beforeInsert / beforeUpdate hooks as the ADR-0053 stored form (YYYY-MM-DD, UTC ISO instant, HH:MM:SS). An in-process handler sees a string where it saw a Date; a sandboxed body on a date field sees 2026-10-09 where it saw 2026-10-09T00:00:00.000Z. This is a fix, not a narrowing: one value had two forms depending on how the hook ran, and neither was the stored form a REST write of the same row hands the hook. The changeset body states the FROM → TO.
  4. "The one rule the drivers apply on write, no second copy" — RIGHT. The diff imports temporalStorageForm and temporalComparandKind from @objectstack/core (both export * from core's index; core is already a dependency of metadata-protocol). No table of its own.
  5. "Non-temporal, undeclared and Invalid Date values are handed over unchanged" — RIGHT. temporalComparandKind answers null for every type but the three; the diff only converts on a non-null kind. temporalStorageForm returns an Invalid Date unchanged on all three arms (its documented totality). A null objectDefinition short-circuits the helper, as before.
  6. "Stored values on SQL drivers do not change" — RIGHT. temporalStorageForm is idempotent on its own output (a YYYY-MM-DD string, an ISO instant, an HH:MM:SS), and SqlDriver.formatInput applies that same rule on write, so the column receives the same text it did.
  7. "The in-memory driver now stores the string instead of a Date object" (changeset body, last paragraph; PR body, "What changes") — WRONG. Not measured by the dev (the measurement table covers the SQL driver only), and the code at this head says the opposite: @objectstack/driver-memory puts every declared temporal field into temporalStorageForm on every write door (create, bulkCreate, update → toStoredRecord → toStorageForms, over the index syncSchema builds with indexTemporalFields; the engine's syncSchemas calls syncSchema at boot). So for a declared temporal field the in-memory stored value was already the string, and this diff does not change it. For a field the object does not declare, the loader leaves the Date as resolved, so nothing changes there either. The sentence describes a change the diff does not make, and it ships to consumers as CHANGELOG.md.
  8. Every seed mode is covered — RIGHT. The call sits right after resolveSeedRecord and before the insert / update / upsert branch, so an update-mode row's beforeUpdate hook sees the same form.
  9. Reach — observation, not a defect of this diff. The fix covers the SeedLoaderService path. AppPlugin's raw-insert fallback (no metadata service, or the loader throwing) still hands the raw record to engine.insert with the cel envelope unresolved; that is the path behind the card body's original "must be a valid datetime (ISO-8601)" WARNs. It is pre-existing, outside this claim's file surface, and the card's later measurements (6064196637: 46 [SeedLoader] Failed to write lines per boot, the app hook's own refusal) put the hotcrm refusal on the loader path, which this diff reaches and the pin reproduces (stf_guarded).
  10. packages/formula and packages/verify source untouched — RIGHT. resolveSeedRecord has no field types to apply a storage rule with; the verify boot's seed path is the shared loader.
  11. The pin — RIGHT. End to end through the real bootStack (default sqlite-wasm), imports only ./harness.js, so no cross-package test input. @objectstack/verify depends on @objectstack/runtime, which depends on @objectstack/metadata-protocol, so a loader change re-runs it under turbo. The ablation reading (4 failed / 1 passed, control green, the hotcrm-shaped refusal reproduced; restore leg equal to the HEAD blob) is consistent with the five assertions.
  12. Docs — RIGHT, none owed. content/docs/data-modeling/seed-data.mdx (Dynamic Values) names the functions and scope and states nothing about the form a hook receives; the Docs Drift Check lists nothing. No governed surface is touched.

② Semver level

  • @objectstack/metadata-protocol: patch — right. A bug fix in a released package, never skip-changeset (the diff publishes source). packages/verify receives a test file only, which is not published, so it is rightly ungraded.
  • Clause-②: no, no arm — right for this net diff (① items 1–3). The claim's yes (widening: bootStack mounts the providers …) belonged to item 1, which is reverted at b3186ec18 and absent from the net diff; no is the truthful reading of what lands. The line is present in both carriers (PR body and changeset), and Check Changeset is green.
  • The changeset body does not match what the diff publishes in one sentence — ① item 7. The level and the declaration are right; the body asserts an in-memory storage change this diff does not make. Remedy: delete that sentence from .changeset/22301-seed-temporal-stored-form.md (and the matching line in the PR body), or replace it with the measured statement — stored values do not change on any shipped driver; only what a hook receives changes — then push and take a fresh record on the new head.

③ Boundary flags

Dev report 6068984639, each flag:

  • Scope: item 9 only; item 1 built at ef5396ca2 and reverted at b3186ec18 — answered. The net diff is the three files above; packages/verify/src/harness.ts is untouched; item 1's pins are not in the diff and are not judged here.
  • PR Clause-②: no against the claim's yes — answered, right (②).
  • Pin wording changed from "sees what it sees under serve" to "both forms see the stored form" — answered, right: the serve/dev body form was itself a full instant on a date field, not the stored form; equality with what the column stores is the invariant that holds across hook forms and across a REST write of the same row.
  • Landing in domain:engine (seed-loader.ts), not packages/verify — the claim (6066102918) named this case conditionally; the seat review (6069096132) states the declaration on [PM seat] domain:engine — ⏳ vacant #6367 as 6069064251. That declaration is outside this record's inputs and is recorded here as the seat's statement, not verified.
  • origin/main merged twice; the uncommitted harness.ts experiment restored to the HEAD blob; a first gate battery overlapping the second — answered: judged on the net diff and on the check-runs of this head, which are the gate verdicts; the harness file shows no change in the diff.
  • hotcrm shallow-cloned read-only and removed — no boundary crossed.
  • open_questions[0] — how bootStack treats an app's own plugins array once it mounts requires — escalated, not ruled: the seat put it to the maintainer on the card (6069107079, options A / B / C, recommendation A). This diff does not depend on the answer.
  • Out-of-scope findings — AnalyticsServicePlugin without analyticsCubes: folded into the card as a later-stage item by the seat (6069096132), accepted. A literal JS Date in a source-config seed vs the artifact's JSON ISO string: a note with no producer, and the docs already forbid a literal new Date() in a seed; accepted as a note. The engine's private resolveNowDefault table beside temporalStorageForm: pre-existing, not this diff; accepted as a note. The local turbo build failure at @objectstack/setup: not on this head (Build Core green); accepted.

Check-runs on this head at the time of this record: 29 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in)), and Test Core (1/6) still in progress — one gate verdict is outstanding and is not assumed here.

Implemented-by: claude/issue-22301-verify-boot-parity
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

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 Date, a behaviour change this diff does not make (① item 7). Everything else in ①, ② and ③ is right; the fix is a one-sentence edit and a fresh record on the new head.

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 792bcc71daec25c295af3ec98ef690007fef9488
Local-runs: none

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 main at merge-base 35afb1587), and the check-runs on the head. The head was fetched into a ref of its own and read with git show / git diff; nothing was built, run or re-run.

① Derived judgments

  1. packages/metadata-protocol/src/seed-loader.ts — seedValuesInStoredForm, called once per resolved row before any write. Every mode takes the same record after the call: writeRecord (insert / update / upsert / ignore) and the bulk pendingInserts path. A JS Date on a field whose declared type is date / datetime / time becomes temporalStorageForm(value, kind) from @objectstack/core, with kind from temporalComparandKind; both are exported from core's index and core is already a dependency of metadata-protocol (no manifest change owed). A non-temporal field, a field the definition does not declare, a definition the loader could not resolve (objectDefinition == null) and an Invalid Date are handed over unchanged, because the rule is total. One rule, the drivers' own, no second copy; fixed at the producer with no consumer alias — right (Prime Directive Add comprehensive test suite for Zod schema validation #12 shape).
  2. Accept-set of the loader: unchanged. The same seed records are accepted and the same are refused; no new input shape is admitted. Clause-②: no with no arm is the truthful reading — right.
  3. What a hook observes during seed replay (public behaviour of the published loader) changes form, in both hook forms: an in-process handler saw the Date object; a sandboxed body saw the JSON-bridged full instant, even on a date field. Both now see the ADR-0053 stored form (YYYY-MM-DD for a date, the UTC ISO instant for a datetime, HH:MM:SS for a time). Judged a fix, not a narrowing: ADR-0053 fixes one logical stored form per temporal type, the old forms were off-contract and differed from what a REST write of the same row hands the same hook, and a hook that keyed on the full-instant spelling of a date field was reading a shape the contract never promised. The changeset names the one visible change in one sentence — right.
  4. Stored values: unchanged on every driver — verified at the head rather than taken from the body: driver-memory normalises on write through toStorageForms → coerceTemporalValue → core temporalStorageForm (memory-driver.ts about :2486–:2501, memory-temporal.ts:40–:53); driver-sql through formatInput on insert, update, upsert and bulk (sql-driver.ts:7508, :8737, :9499, :9900) with temporalStorageForm; SqliteWasmDriver extends SqlDriver (sqlite-wasm-driver.ts:67) and TursoDriver extends SqlDriver (turso-driver.ts:1586); driver-mongodb through coerceTemporalValue on write (mongodb-driver.ts:866). The corrected changeset sentence is true, and the sentence the earlier round failed on (card comment 6069311153) is absent from the net diff (0 hits) — right.
  5. Replay idempotence (isNoOpReplay / seedValueEquals): neutral. Before, a Date against the stored string compared by instant; after, string against string compares by identity. No replay that was a no-op stops being one, and none starts — no changeset text owed.
  6. packages/verify/src/harness.seed-temporal-form.test.ts: test only, publishes nothing. It boots the real bootStack → AppPlugin → the shared SeedLoaderService (so the verify boot's seed path is the shared loader, as the body claims), covers the three hook shapes (in-process handler, sandboxed body, hotcrm's string-only guard) with literal rows as controls, imports only ./harness.js and @objectstack/spec (no path escapes, so no cross-package-test-input spelling is owed), and the as never on defineStack is this package's established idiom (8 sibling tests). The pin asserts exactly the card's item 9 acceptance ("a verify boot of an app with cel-dated seeds stores those rows") plus the equality of the two hook readings — right.
  7. Nothing of item 1 is in the net diff: 3 files, +202 / −1; 0 hits for CAPABILITY_PROVIDERS / stackDeclaredCapabilities / providesCapability; no packages/cli, no packages/verify source, no new @objectstack/core export. ef5396ca2 is reverted at b3186ec18 on the branch history, as the body says — right.
  8. Citations hold: ADR-0053 D1 is the section that has today() / daysFromNow() / daysAgo() return a Date; now() is () => Date and the two day functions resolve through addDaysUtc (formula/src/stdlib.ts:235–:256), so the four functions the changeset names are the ones that produce the Date — right.
  9. No governed surface in the file list; Governed Surface Queue Guard is success. Not Tier S, not Tier H; this record is owed on the claim's Clause-②: yes and the hook-input form change, not on a governed path.
  10. Reach, as the card measured it: hotcrm's campaign_validation refusal (46 [SeedLoader] Failed to write lines per bootStack, card comments 6063450774 / 6064196637) sits on the loader path this diff fixes. The AppPlugin raw-insert fallback named in the card body is a different, pre-existing path and is not touched here — recorded, not a finding against this head.

② Semver level

  • .changeset/22301-seed-temporal-stored-form.md grades @objectstack/metadata-protocol patch. That is the only published package whose source the diff moves (17.7.0, not private). packages/verify receives a test file only, so nothing publishes from it and no second changeset is owed. skip-changeset does not apply and is not used — right.
  • Clause-②: no, no arm, in the PR body and in the changeset body. No accept-set is widened and no public surface expanded (①.2); no narrowing (①.3). A patch is the level no permits, and Check Changeset concluded success on both of its runs on this head. The claim's yes (widening: …) belonged to item 1, which this net diff does not carry; the declaration follows the diff, which is what the gate reads — right.
  • Not breaking, so no BREAKING banner and no ADR-0087 disposition is owed — right.
  • The changeset text ships as CHANGELOG and was read sentence by sentence against the diff: each sentence holds (①.4 for the driver sentence, ①.8 for the four functions, ①.1 for the unchanged cases).

③ Boundary flags

Dev flags (PR body "Acceptance notes"; report 6068984639 out_of_scope_findings and deviations):

  • F1 — bootStack builds AnalyticsServicePlugin without the app's analyticsCubes, which serve passes. Same boot-parity family as item 1; the seat folded it into the card as a later-stage item (6069096132). Answered: not this diff, no further carrier owed.
  • F2 — a literal JS Date in a source-config seed now reaches hooks in stored form, while the same seed compiled to an artifact arrives as a JSON ISO string the loader leaves unchanged. The artifact side is pre-existing and outside this diff; no producer of that shape was measured. Answered: it stays an acceptance note under Prime Directive chore: version packages #10 (a note until a producer is measured); the moment one is, it is item 9's class and takes its own card. Not blocking.
  • F3 — objectql's private resolveNowDefault keeps its own date / time / instant table beside temporalStorageForm; they agree today. A second copy of a rule is drift risk, outside this diff. Answered: noted, no carrier. Not blocking.
  • F4 — a local turbo run build --filter=@objectstack/cli failure (TS7016 at @objectstack/setup). Local build state, not reproduced on a clean tree; Build Core is success on the head. Answered: nothing to carry.
  • Deviations: scope is item 9 only (right — the PR is Part of #22301, and Part-of PR must not also close its card is success); Clause-② no against the claim's yes (right, ②); the landing in domain:engine is declared on [PM seat] domain:engine — ⏳ vacant #6367 per the seat (6069096132) — that thread is outside this review's inputs and is not re-verified here; origin/main merged twice with every final reading at e83540e29, and the patch commit 792bcc71d touches the changeset alone (1 file, +1 / −1, verified) so those readings carry to this head; the uncommitted harness.ts experiment was restored to the HEAD blob and the net diff confirms no harness.ts change.

open_questions[0] — item 1: how bootStack treats an app's own plugins array once it mounts what requires names (A / B / C). ESCALATED, not ruled here. The seat's four-axis analysis is on the card (6069107079) and the card moves to needs-user-decision after this PR lands. The net diff carries none of item 1 (①.7), so the question does not bear on this head; this review takes no position on A / B / C.

Check-runs on the head at this read — every completed run is green: Lint & Repo Gates, TypeScript Type Check (and its source / consumer / debt-ledger / workspace legs), Build Core, Dogfood Regression Gate (1/3, 2/3, 3/3 and the rollup), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Check Changeset, Check Documentation Links, Check PR Size, the four claim / single-writer / part-of guards, Test Core (2/6) to (6/6); Console Pin Gate, Build Docs and Packed-tarball smoke are skipped by their filters. Still pending at this read: Test Core (1/6), in_progress. Landing waits for it under its own rule (every check green); this record's verdict is on the contract and does not stand in for it.

Implemented-by: claude/issue-22301-verify-boot-parity
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 21:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 21:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 3f80f17 Oct 8, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22301-verify-boot-parity branch October 8, 2026 22:22
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants