Skip to content

fix(metadata-protocol): a page saved without type is stored and served with PageSchema's declared default (#20101) - #20133

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20101-page-type-default-served
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20101-page-type-default-served

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20101

Clause-②: no

What this changes

PageSchema declares type: PageTypeSchema.default('record') (packages/spec/src/ui/page.zod.ts), so a page authored without type is a record page. saveMetaItem parsed the body with that default, then stored the authored body verbatim, and every /meta read serves a stored row unparsed. A page saved without type was therefore stored and served without it.

One helper, withDeclaredPageTypeDefault in packages/metadata-protocol/src/protocol.ts, reads the default from the registered page schema (getMetadataTypeSchema('page').shape.type, the schema the save gate validates with). It fills the default only when type is absent, so no 'record' literal is added. It runs at two seams:

  • Save. It runs in saveMetaItem after the schema gate accepts the body, beside the two existing grafts. New rows store the key, on draft and publish saves alike.
  • Read. It runs in convertStoredItem, the rehydration seam every stored row passes through. Rows stored before this change are served with the default. The row itself is never rewritten.

The read fill sits in convertStoredItem and not in convertStoredItemDetailed. It is not an ADR-0087 conversion and emits no notice, so migrateStoredMetadata (the Detailed caller) has nothing to rewrite.

Scope is PageSchema.type only. The regions of protocol.ts that draft PR #20125 edits are not touched: this diff's hunks are at the resolveOverlaySchema docblock, after graftFoldedFormSections, in convertStoredItem and in the saveMetaItem schema-gate block.

Why both seams (H2, measured)

  • Read alone covers old rows, but it breaks [P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326's invariant that a GET → PUT round-trip is byte-identical. The served body would carry a key the row does not have. The first re-save of every such page would then write a one-key change, a new checksum and a history row that nobody authored. Ablation leg B below shows this: with the save fill removed, the round-trip pin goes red.
  • Save alone leaves every row stored before this change typeless on every read. Covering those rows would need a stored-row rewrite, which triage point 2 forbids. Ablation leg A below shows this.
  • Both together is the smallest fix that is correct. Measured below: a new row round-trips with the same bytes, checksum and version, and an old row is served typed without being rewritten.

Serve paths: base 226e00c038 vs head (H1)

The measurement used a typeless page, an explicit type: 'app' control, a typeless draft, and a typeless row seeded directly into the store (the "old row"). It ran at three levels: the real RestServer over the real protocol, the protocol alone, and the real composition (bootStack(showcase) plus MetadataPlugin over a build-shaped artifact, over real HTTP).

Path base: typeless base: app control head: typeless head: app control
saveMetaItem publish: stored body absent app record app
saveMetaItem draft: stored draft body absent n/a record n/a
publishMetaItem of that draft: stored and served absent n/a record n/a
Registry write-through on save (unscoped kernel) absent app record app
GET /meta/page (getMetaItems), cache on and off, new and old row absent app record app
GET /meta/page?preview=draft absent n/a record n/a
GET /meta/page/:name, cached arm (getMetaItemCached) absent, ETag 7811092d app, ETag 6164defa record, ETag 69c54eb6 app, ETag 6164defa (unchanged)
GET /meta/page/:name, uncached arm (getMetaItem) absent app record app
GET /meta/page/:name?state=draft absent n/a record n/a
getMetaItem with previewDrafts absent n/a record n/a
getMetaItemLayered, old row (overlay / effective) absent / absent n/a record / record n/a
searchAll page hit pageType absent app record app
loadMetaFromDb boot hydration into the registry absent app record app
Old row at rest, after all the reads above absent n/a absent (not rewritten) n/a
migrateStoredMetadata preview scanned 4, canonical 4, pending 0 scanned 4, canonical 4, pending 0
Real composition over HTTP: list, single, ?state=draft, published draft absent app (ETag 100766d6) record app (ETag 100766d6, unchanged)

Consumers of the served page body (H3)

Every measured reading below used the explicit app page as its control.

  • Server-side record-page selection by type: none in this repo. A grep of non-test packages/** for reads of a page's type finds two readers. One is the searchAll page sweep (pageType, measured). The other is the lint rule in validate-page-visualization-bindings.ts, which asks page.type !== 'list'; its verdict cannot change, because record is not list. Record-page selection lives in objectui's usePageAssignment, per the card, and that is UNMEASURED here because objectui is not reachable from this session.
  • Measured to carry the fill: the list, single, cached, draft-state, preview and layered reads; boot hydration; the registry write-through; and the searchAll sweep. See the table above.
  • Carry the fill by code reading, not separately exercised: these read through convertStoredItem or getMetaItems. They are the runtime authoring gate's stored universe (foldStoredCollection), the duplicatePackage copy (which is then saved through saveMetaItem), and the runtime package-export sweep (getMetaItems).
  • Deliberately raw: historyMetaItem and diffMetaItem history bodies, which are a record of what was written, and getEffectiveLock, which reads protection only.
  • Outside this card's surface, not filled: the metadata service's DatabaseLoader.rowToData (packages/metadata). In the composition measured (showcase plus MetadataPlugin), every read of the test pages served the typed body.
  • Hashes. The row checksum (the sha256: token If-Match compares) is computed over the stored body, so a read cannot move it. The cached read's ETag is a hash of the served content. It changes once for a typeless page and is unchanged for the app control.
  • Round-trip. Serving the new row, then PUTting it back, stores the same bytes, checksum and version: the repository's identical-body short-circuit fires and nothing is written. Serving the old row and PUTting it back stores type once, and the served body is identical before and after. That is the author's re-save, not a migration.

Tests

All runs are at head e3b83df825. The probes ran at c2a29b4573, and protocol.ts is byte-identical between the two commits.

  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 188 files passed, 3 skipped; 2686 tests passed, 19 skipped; exit 0.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. tsc --listFiles includes all 191 test files, among them the three edited here.
  • REST /meta route tests: every packages/rest/src/*meta*.test.ts, 47 files, 667 tests passed, exit 0. They read the rebuilt metadata-protocol dist/, which contains withDeclaredPageTypeDefault (4 hits).
  • Dogfood metadata round-trip tests: dashboard-designer-roundtrip, meta-published-and-state-routes, package-first-authoring, meta-types-create-seed, showcase-object-extension-meta-read and showcase-object-extension-scalar-divergence. 6 files, 33 tests passed, exit 0.

New pins:

  • protocol.stored-conversions.test.ts: a stored typeless page row is served with the declared default by the list and single reads, the draft read and the list preview, and boot hydration. An explicit app row is untouched. The row's bytes survive the reads, and migrateStoredMetadata preview and apply report it canonical, with rewritten: 0.
  • protocol.read-decorations.test.ts: publish and draft saves store the default. An explicit app is stored and served unchanged. GET → PUT gives the same stored body, checksum and version.
  • protocol.search-published-pages.test.ts: a typeless page's hit carries pageType equal to the declared default.

Every pin reads the expected value from the schema. A precondition asserts that value is record.

Ablation. Each leg ran through scripts/ablation-replace.mjs (anchor hit 1 → 0, blob changed) with a trap restore, and restore was proven each time (blob 1df09c2eb4 equals HEAD, git diff HEAD empty). These suites import ./protocol.js from source, so no rebuild was involved. ablation-dist-preflight --absent confirms the markers never reached dist/.

  • Leg A, read fill removed: 4 failed of 48. These are the three stored-conversions read pins and the search pin.
  • Leg B, save fill removed: 2 failed of 48. These are the stored-body pin and the GET → PUT byte-identity pin.
  • Restored: 48 of 48 passed.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived at e3b83df825: 61 commands. All 61 were run with exit codes recorded, and --ran reports 61 run, 0 NOT-MEASURED (a derived zero).
  • pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 packages had no dist/). It passed after those were built.
  • node scripts/check-issue-citations.mjs --base 226e00c038: 5 citations, all resolve.
  • Lint, narrowed: eslint --no-inline-config --format json over the 4 changed .ts files reports 4 files, 0 errors, 0 warnings. isPathIgnored is false for them. calculateConfigForFile shows neither parserOptions.project nor projectService set, so linting is not type-aware and this diff cannot change the verdict on any file it does not touch. The full pnpm lint is left to CI.

Acceptance notes

  • Other declared defaults (triage point 3, census, not in this diff). Read from the built spec at 226e00c038: 22 stored metadata types were walked (types with allowRuntimeCreate or allowOrgOverride). 15 of them declare 39 top-level defaults. The positive control page.type is found, and the negative control page.object (optional, no default) is not. external_catalog has no registered schema. The save path stores all of these verbatim. Only page.type was measured through the serve paths. The 38 besides page.type:
    • object: isSystem, datasource
    • hook: priority, async, onError, runAs
    • seed: externalId, mode, env
    • mapping: sourceFormat, mode
    • view: type (simple)
    • page: template, regions, isDefault, kind (full)
    • app: active, isDefault
    • action: type (script), refreshAfter
    • report: type (tabular), drilldown
    • flow: version, status, runAs
    • datasource: active, autoConnect, schemaMode, origin
    • email_template: category, locale, variables, active, isSystem
    • permission: isDefault
    • position: delegatable
    • skill: surface, active
  • The renderer half of triage point 4 ("is picked as the object's record page") is objectui's usePageAssignment, and it is UNMEASURED from this session. What this PR pins is the served body: type equal to the declared default.
  • The cached single read's ETag for a page stored without type changes once, because the served content changed. The app control's ETag is unchanged.
  • The first re-save of an old typeless page stores type once, with one history row. From then on its round-trip writes nothing.
  • A stored row that spells the page type under a key PageSchema refuses (for example the pageType alias key, which is reachable only by a write that went around the save gate) is now read with type: 'record' beside that key. Such a row fails the schema either way and carries _diagnostics. No such row was measured in any data.

Generated by Claude Code

…ved with PageSchema's declared default

PageSchema declares `type: PageTypeSchema.default('record')`. The save gate
parsed with that default and then stored the authored body verbatim, and every
read served stored rows unparsed, so a record page authored without `type` was
served without it and never picked as the object's record page.

One helper, withDeclaredPageTypeDefault, reads the default from the registered
`page` schema and fills it only when the key is absent. It runs at two seams:
saveMetaItem, after the schema gate accepts the body, so new rows store the
key and a GET then PUT round-trip stays byte-identical; and convertStoredItem,
the rehydration seam, so rows stored earlier are served with the default and
never rewritten.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 4 documentable anchor(s).

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 bc80e16260597d52b062b7e1ee810c7b9a99aed2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e3b83df825bb8e15242aae7fb317d3dd8774f4d2

Scope: 5 files on merge base 226e00c038:

  • packages/metadata-protocol/src/protocol.ts (+67/−2): withDeclaredPageTypeDefault, a read fill in convertStoredItem, a save fill in saveMetaItem, and one docblock line;
  • three test files;
  • the changeset.

No spec, docs or governed file is touched.

Measured at base and head through two stacks:

  • the real RestServer over the real protocol dist and a sys_metadata double;
  • the real composition (bootStack(showcase) + MetadataPlugin, real HTTP, real sqlite).

objectui is unreachable from this session, so every renderer-side claim is UNMEASURED.

① Derived judgments

  • Every save and serve path: TRUE. At head, a page PUT without type (publish and ?mode=draft) is stored with type: "record", and it is served with it on all of these paths:

    • GET /meta/page: both overlay-cache arms, and ?preview=draft;
    • GET /meta/page/:name: the cached (ETag) and uncached arms, ?state=draft and ?preview=draft;
    • /layers and getMetaItemLayered;
    • searchAll's pageType;
    • loadMetaFromDb;
    • publishMetaItem.

    Every one of them omits it at base, and the composition over HTTP agrees. An explicit type: 'app' control is byte-identical at base and head on every path, ETag included.

  • No stored-row rewrite: TRUE. A typeless row keeps its bytes after every read and is served typed. migrateStoredMetadata preview and apply report 6 scanned, 0 pending and 0 rewritten, and the bytes are unchanged.

  • Round trip: TRUE.

    • A new row: GET then PUT leaves the bytes, checksum and version identical. No history row is added, and the ETag is stable.
    • An old typeless row: the first re-save stores type once (version 1 → 2, one history row), with the served body and ETag unchanged. The second re-save is a no-op.
    • The typeless page's ETag changes once from base to head. The control's does not.
  • The default is read from the schema: TRUE. The helper reads getMetadataTypeSchema('page').shape.type, with no 'record' literal in the added code, and fills only an absent type. type: '' and type: null are refused 422 at both trees, so the accept set is unchanged.

  • The ablations reproduce: with the read fill removed, 4 of 48 fail; with the save fill removed, 2 of 48. Each was restored by blob. The head tests fail 6 of 48 against the base protocol.ts.

  • Scope: TRUE. The helper touches only page.type. The census of the 38 other stored-type defaults is in the Acceptance notes, and re-derived here. PR feat!: retire the saved-report stack — /api/v1/reports, client.reports, IReportService, the reports capability, sys_saved_report / sys_report_schedule, @objectstack/plugin-reports #20125's protocol.ts hunks are disjoint from this PR's.

  • CI at this head: 34 runs, 31 success, 3 skipped, 0 failures, and all seven required contexts are success. mergeable_state: clean.

② Semver level

@objectstack/metadata-protocol: patch, Clause-②: no: CORRECT. PageSchema is untouched, and the write door accepts exactly the same bodies. The served body gains a key the published spec already declares with a default, the #20059 shape.

③ Boundary flags

  • Changeset and PR body: no FALSE sentence. Two imprecisions, neither false:
    • "GET /meta/page cache on and off": the list's real arms are the overlay-cache hit and miss, and both were measured typed.
    • "fills only when type is absent": an explicit undefined through the protocol API is also filled, which JSON cannot distinguish from absent.
  • The objectui record-page pick (triage point 4, renderer half): UNMEASURED. The server-side pin is what this PR can carry.
  • packages/metadata DatabaseLoader.rowToData still serves the typeless body through the in-process metadata service. Every /meta path and the protocol serve record, and no in-process consumer reads pages through that service. Accepted as a note.
  • The disclosed edges are accepted: the first re-save of an old row, an explicit undefined, and a row carrying the refused pageType alias.

Implemented-by: claude/issue-20101-page-type-default-served
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 08:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 586934e Sep 25, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20101-page-type-default-served branch September 25, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants