Skip to content

fix(metadata-protocol,spec)!: a saved view stores the parsed value of every key its body carried, and a ViewItem record's top-level options is refused by name (stage iv of #20051) - #20868

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20051-persist-parsed-view-body
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20051-persist-parsed-view-body

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20051
Clause-②: no

Stage (iv), the last stage, of ruling 甲 (5856781584, confirmed 5856866699), carried out under letter B of ruling 5910106571. Maintainer verbatim: 「同意 批次 #256」. The stored-row census stays waived: 「20051 不考虑现有的数据」 (5909085121). The at-tier contract review is the domain:spec seat's, and it is owed before this PR is enqueued. This PR carries no review record of its own.

What changes

1. A saved view stores the parsed value of every key its body carried. saveMetaItem now stores projectStorableViewBody(request.item, parsed.data, reparse) for view, where it used to store the request body with two grafts. The rules:

  • An undeclared key is dropped. Every key the console reads back is declared (VIEW_CONSOLE_ROUND_TRIP_KEYS), so nothing it needs is lost.
  • A declared key keeps its parsed, normalized value.
  • A moved key is stored under its canonical spelling: groups → sections, and visibleOn → visibleWhen (never grafted before).
  • A schema default the author did not write is not stored. This is ADR-0087's storable rule, the one flows follow.

Keys that are moved and defaults look alike in the parse output, so the projection re-parses the body it built. A key the parse added is kept only when the re-parse does NOT reproduce it. The loop ends when the stored body re-parses to exactly the save's parse. If it does not converge, the fallback stores the whole parse output: no key is lost, and a warning is logged once per view. Nothing in today's view schema reaches that fallback.

The projection is gated on singularType === 'view', the same way the flow branch is. Every other type keeps its body verbatim with the two grafts. Both grafts still serve other types: ViewFilterRuleSchema is reused by page, component and skill schemas, and FormViewSchema by component, sharing and field schemas. A page save stays pinned verbatim here as the control.

2. A ViewItem record's top-level options bag is refused by name (Q3 = A). It is pinned absent on both ViewItemWireSchema arms. The response is 422 INVALID_METADATA at options, with the prescription to write config.KIND. It uses z.never(), not the form overlay's z.undefined(), because ViewItemWire.json is published on its own and undefined has no JSON Schema form. With z.undefined() the build refused ("1 previously published schema(s) disappeared"). The ADR-0087 disposition is registered view-item-options-bag-refused, a new step-18 semantic entry.

3. ADR citation. The five "ADR-0005 §Validation" citations in protocol.ts now cite ADR-0005 appendix (c), the "Addendum — 2026-05-16 (c): spec validation on overlay save". The same goes for the one in view.zod.ts. scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json gains ADR-0005 and ADR-0087 with the invariant. No docs/adr/** file is edited.

Measured before the change (the console's own writes at .objectui-sha db11afd4967c, through the real saveMetaItem)

  • W1 to W5, the toolbar saves of sort, density, hidden fields, column widths and inline edit: the parse adds type: 'grid'. Now none is stored. W1 also stops storing its sort row id.
  • W6b, the saved-view toolbar toggle, which writes the whole tab back: id and objectName are no longer stored.
  • W7 to W9, config save, create, pin, set-default, reorder and rename: stored unchanged.
  • Form overlay: it no longer stores sharing.enabled: false or the section collapsible / collapsed / columns defaults.
  • Why letter B beats A, measured by ablation 2 below: storing parsed.data whole would have minted toolbar rows that FAIL their own re-save. A column-less patch carrying the grid default is refused as "sets type but lists no columns".

Pins

  • Re-judged in one round (the full-repo sweep):
    • metadata-protocol protocol.graft-folded-form-sections.test.ts: the two GUARDs and the coexistence case.
      • The Studio round-trip GUARD moves to the list overlay, where the keys are declared. It now covers every key VIEW_CONSOLE_ROUND_TRIP_KEYS maps to listOverlay.
      • The form overlay now drops isPinned / sortOrder (undeclared there) and keeps isDefault.
      • "A form with canonical sections is stored as authored": no section default, no sharing.enabled, and no undeclared isPinned.
    • objectql protocol-meta.test.ts: the declared round-trip keys are stored, and objectName is dropped (object carries the binding).
    • objectql protocol-publish-rollback.test.ts: the diff's added key is the declared rowHeight, and the undeclared extra never reaches a version.
    • objectql protocol-view-identity-overlay.test.ts: the sort row id is not stored.
    • spec view-union-retirement-prescription.test.ts: the never-leaf population admits a closed two-entry family of pinned-absent refusals. Each entry must be reached and must match its curated text.
  • New, in the same metadata-protocol file on its pinned engine double:
    • W1 to W5 store no type.
    • An undeclared key is dropped (id, objectName, a form-only layout).
    • A declared key keeps its normalized value (exportOptions, operator).
    • A key moved inside a moved key is stored canonical, with no default beside it.
    • The persisted body parses to exactly what the save parsed.
    • A record's options is refused on both arms, asserting envelope code + status + path + message, with 0 rows written.
    • The page control.
    • Four structural cases for the helper.
  • New in spec view-overlay-options-bag.test.ts: the record refusal on both arms, plus a control.
  • The card body's three pins still stand:
    • A direct and an options-wrapped out-of-contract key get the same refusal (door-half block).
    • A legal options.map round-trips.
    • The persisted body equals the parse result: the new parse-equivalence pin.

Evidence (head 41913270bf)

  • Suites, locked runs:
    • metadata-protocol full: 191 files, 2819 passed, 19 skipped.
    • objectql full: 345 files, 6778 passed.
    • rest full: 237 files, 4641 passed, 99 skipped.
    • spec src/ui + src/migrations: 97 files, 3560 passed. The whole spec local project also ran before the origin/main merge, all green once the retirement-population pin was re-judged.
    • runtime, the 19 files that save or read a view: 990 passed.
  • Typecheck: spec, metadata-protocol, objectql and lint all exit 0. The spec run chains check:scripts-typecheck and check:test-typecheck.
  • Ablation 1 (the view branch disabled, and the record refusal renamed away, with dist/ rebuilt and ablation-dist-preflight markers present in both packages):
    • metadata-protocol file: 9 red, 40 green.
    • objectql's three re-judged files: 3 red.
    • spec: 4 red. One of them is the population pin reading the renamed leaf.
    • Restore: blob equals HEAD for both files, markers absent from both dist/, and git status --porcelain empty.
  • Ablation 2 (store parsed.data whole, which is option A): 20 red of 49 in the metadata-protocol file, all five toolbar pins among them. Restored to the HEAD blob.
  • Gates: dispatch-gates --commands --repo objectstack-ai/objectstack derived 125.
    • 123 exit 0. check:engine-split-ratio first refused on the shallow clone and passed after fetch --shallow-since. check:skill-examples first refused on a missing client-react dist and passed after building it.
    • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 PREREQUISITE NOT MET (they need a whole-repo build; CI runs it).
    • --ran reconciliation: 0 UNRUN.
    • check:generated: all 15 artifacts current; content/docs/references/ui/view.mdx was regenerated.
  • eslint (narrowed):
    • Measured over the 15 changed .ts files; each resolves under eslint --print-config.
    • --no-inline-config --format json: 15 files, 0 errors, 0 warnings.
    • eslint.config.mjs enables no type-aware linting (its own comment says so), so this diff cannot move a verdict on an untouched file.

Docs follow-up (Tier H, separate PR for the maintainer's hand)

ADR-0005 must change in three places:

  1. The Status line clause: "and the storage switch itself is the ruling's last stage, not yet landed — see the amendment note under that addendum's persisted-document bullet".
  2. Appendix (c): "⚠️ Ruled, not yet in force: the storage switch is the ruling's last stage and has not landed, so today saveMetaItem still stores the request body (request.item)."
  3. Appendix (c)'s bullet "The persisted document is the parsed body (parsed.data) once every round-trip key is declared". Under letter B it becomes: the parsed value of every key the body carried, with no schema default materialised.

Acceptance notes

  • Other types. resolveOverlaySchema validates every type getMetadataTypeSchema registers, not only view and dashboard, and all of them still store the request body. Widening the projection is its own decision.
  • An objectui read-side drop, present today and independent of this PR. objectui's adapter flattens a ViewItem record in listViews and keeps only config plus name / label / isDefault. A saved view's toolbar toggle writes that tab back whole, so it rewrites the row without isPinned / sortOrder / visibility / columnState. This is a code read at the pin, not measured in a browser. Its carrier is objectui#11013, the objectui card on aligning console reads.
  • Other stale citations. "ADR-0005 §Validation" is still cited outside the files this PR touches (for example packages/metadata-core/src/injected-system-columns.ts:132 and three objectql object-roundtrip test headers). Those stay for the next change that touches them.

Generated by Claude Code

…every key its body carried; a ViewItem record's top-level options is refused by name

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
Co-authored-by: Claude <noreply@anthropic.com>
… is representable

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
Co-authored-by: Claude <noreply@anthropic.com>
…stored view projection

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
Co-authored-by: Claude <noreply@anthropic.com>
… refusal at the spec layer

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
Co-authored-by: Claude <noreply@anthropic.com>
…w save now say what the save stores

Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6
Co-authored-by: Claude <noreply@anthropic.com>
…view-item-options-bag-refused

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

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 14 documentable anchor(s).

7 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), /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • 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), /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/index.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/ui/forms.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))

⛔ 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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 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 72f8c3820154c69cdf9faa6f887dc544e4c4b823 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 72f8c3820154c69cdf9faa6f887dc544e4c4b823

⚠️ 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 72f8c3820154c69cdf9faa6f887dc544e4c4b823 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…, its non-converging fail-safe, and the once-per-view fallback warning

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9924c95b0b603c34729ac2706d48b6bb7d7ff24b
Local-runs: none

Inputs read: card #20051 (body and all 26 comments, rulings 甲 5856781584 / 5856866699, the maintainer's record 5909085121 「20051 不考虑现有的数据」, the director's ruling 5910106571 on Q2 = B / Q1 = A / Q3 = A, the dev's reports 5909437934, 5912512351, 5912995677); PR #20868 body, its 19-file list, and git diff origin/main... at this head; the check-runs on this head. Claims verified against origin/main (ADR-0005 appendix (c) at docs/adr/0005-metadata-customization-overlay.md:312, ADR-0087 addendum 2026-08-01b at docs/adr/0087-metadata-protocol-upgrade-contract.md:506, VIEW_CONSOLE_ROUND_TRIP_KEYS at packages/spec/src/ui/view.zod.ts:6260, the flow branch of saveMetaItem at packages/metadata-protocol/src/protocol.ts:16738) and against objectui at the pin db11afd4967c (runtime-metadata-persistence.ts viewEnvelope, ObjectView.tsx buildPersistedViewBody / VIEW_ROW_STATE_KEYS / getListViewConfigKeys, data-objectstack/src/index.ts viewItemObjectName and the listViews flatten).

Check-runs on this head, read 2026-09-30T14:18Z: 35 runs. 32 success, 2 skipped (Console Pin Gate: no .objectui-sha move; Packed-tarball smoke: opt-in), 1 in_progress: Lint and Repo Gates. Test Core (all six shards and the aggregate), Build Core, Build Docs, the four Type Check jobs, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Dogfood gates and Temporal Conformance all read success. The runs on 41913270 were cancelled by the push and are not counted. Landing waits for the one run still in progress to read green.

① Derived judgments

  1. @objectstack/spec accept set NARROWED: ViewItemWireSchema, both arms, gains options: z.never({ error }).optional() through viewItemWireFields(), which is spread into exactly the two arms (view.zod.ts:5324, :5328 on the branch) and nowhere else. A ViewItem record ({ name, object, viewKind, config }) carrying a top-level options is now refused at path options with the curated prescription (move each options.KIND to config.KIND); an absent key still parses. The narrowing reaches VIEW_METADATA_MEMBERS.viewItem, the viewItem branch of ViewMetadataSchema, the published ViewItemWire.json (not: {}), the ViewItemWire type (options?: never) and the two regenerated rows in content/docs/references/ui/view.mdx. RIGHT: Q3 = A as ruled and as the seat answered (5909486308). Verified at the pin that no console write puts the bag on a record: viewEnvelope returns { name, object, viewKind: 'list', label, config }, the config-save filters keys to SpecListViewSchema.shape plus VIEW_ROW_STATE_KEYS, and the whole-tab toolbar write is the FLAT overlay shape (no config), judged by listOverlay, not by this member. The list overlay's options: ListViewOverlayOptionsSchema (:6181) and the form overlay's z.undefined() refusal (:6213) are untouched, so the door half (PR fix(spec)!: judge a flattened list view overlay's legacy options bag at the view write door (door half of #20051) #20183) and objectui's options.map pin stand. z.never over the form overlay's z.undefined is RIGHT for a member published on its own as JSON Schema (undefined has no JSON Schema form; Build Core is green on this head, which is where "a previously published schema disappeared" would have refused).

  2. @objectstack/spec public surface WIDENED, registry only: one new step-18 semantic entry view-item-options-bag-refused (entries/semantic/18.view-item-options-bag-refused.ts plus the generated region of registry.ts). RIGHT: ruling 甲 item 4 requires the ADR-0087 disposition; a semantic (D3) entry rather than a conversion is right because which of two spellings of one block the author meant is not mechanical; step 18 matches the 18.ui__ViewItemWire__owner / __hidden retired-key entries already in the tree (spec is at 17.5.0). The changeset's marker adr-0087: registered view-item-options-bag-refused names it. Its strings carry no tracker number.

  3. @objectstack/metadata-protocol stored shape CHANGED for view only; the door's accept set is unchanged (same schema.safeParse gate, same 422 envelope). saveMetaItem now stores projectStorableViewBody(request.item, parsed.data, reparse) when singularType === 'view': the parsed value of every key the body carried, undeclared keys dropped, schema defaults not materialised, moved keys under their canonical spelling (groups to sections, visibleOn to visibleWhen), sort[].id dropped (it is stripped inside the parse preprocess at view.zod.ts:6682, so the projection sees it as a key the parse did not keep). RIGHT under letter B (5910106571): views join the rule ADR-0087 addendum 2026-08-01b records for flows ("storable excludes schema defaults, and this is load-bearing"), and the gate mirrors the existing singularType === 'flow' branch. Every other type keeps request body plus the two grafts plus the page type default. RIGHT: the ruling names views; ViewFilterRuleSchema and FormViewSchema are reused by page, component, skill, sharing and field schemas, so the grafts still have consumers, and widening the projection to dashboard and the rest is its own decision (noted in the PR's acceptance notes, as Prime Directive chore: version packages #10 asks).

  4. projectStorableViewBody is exported from protocol.ts for its sibling test but is NOT re-exported from packages/metadata-protocol/src/index.ts (verified on the branch), so the package's public entry does not widen; no api-surface artifact tracks it. The helper's own invariant is the right one: the stored body must re-parse to exactly the save's parse (a GET then PUT is judged the same), bounded at 8 rounds, key-move detection by re-parse evidence rather than a hand list. The converged: false fail-safe stores parsed.data whole and warns once per type|name. Log level judged against AGENTS.md "Degradation log levels": after the fallback, everything the author wrote has landed (normalised) and the row is served as stored, so the system is not "looking normal while a claimed persist did not land"; this is a functional degradation (that row's defaults pinned), warn is RIGHT, and it is an if arm rather than a catch around a durability callee, so check:durability-log-level owes nothing. The message states consequence and fix and carries no tracker number. Unreachable by today's view schema; pinned through the production seam registerMetadataTypeSchema('view', …) with the built-in restored in finally.

  5. objectui consequences at the pin, derived from the diff: console-written keys that no longer reach a stored row are sort[].id (W1, W7), and a top-level id and objectName (W6b, the whole-tab toolbar write). Every reader falls back: viewItemObjectName reads data.object, then object, then objectName; viewRowId prefers name; the sort and filter builders mint a fresh row id. object is present on the W6b body because normalizeViewMetadata runs with the registry baseline before the parse. The five row-state keys the console writes (isDefault, isPinned, sortOrder, visibility, columnState) and _isOverride are each declared in VIEW_CONSOLE_ROUND_TRIP_KEYS and kept; the re-judged list-overlay GUARD asserts every key that record maps to listOverlay. RIGHT: Q1 = A as ruled; nothing the console reads back is lost. Console Pin Gate skipped correctly (no pin move) and no objectui change is owed for this landing.

  6. Pins, each read in the diff: the three verbatim-save pins re-judged rather than flipped (graft-folded coexistence and GUARD moved from a form overlay, where the keys are undeclared, to the list overlay; protocol-meta "preserves Studio-only auxiliary fields verbatim" now asserts the declared keys stored and objectName dropped with object carrying the binding); publish-rollback (rowHeight declared, extra never reaches a version) and view-identity-overlay (sort row id not stored); the ruling's named pins (W1 to W5 store no type; form stores no sharing.enabled and no section defaults; undeclared key dropped; declared key keeps its normalised value); nested moved key; parse-equivalence over toolbar, flat form, record and container bodies; the record options refusal on both arms with envelope code, status, path, message and zero rows written; the page control; the helper's arms (a) to (d) driven directly; the warn dedupe. The card body's three pins hold: the direct and wrapped refusal and the options.map round-trip are the door half's, on main; "the persisted body equals the parse result" is, under letter B, the parse-equivalence pin. view-union-retirement-prescription §2 admits a closed two-entry pinned-absent family, each entry required to be reached and to match its curated text, so the never-leaf population stays lit. RIGHT.

  7. Comment-only re-citations from "ADR-0005 §Validation" (a heading the ADR does not have) to "ADR-0005 appendix (c)" in protocol.ts and view.zod.ts, and scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json gaining ADR-0005 and ADR-0087 with the stage (iv) invariant. RIGHT: the code cites what the ADR says (Prime Directive [WIP] Add Chinese version of the documentation #13). No docs/adr/**, .claude/**, skills/**, AGENTS.md or CLAUDE.md path is in the file list, so this is not a governed diff; the Governed Surface Queue Guard reads success. The at-tier record is owed by the path limb (packages/spec/src/** non-test), and this is it.

  8. Generated artifacts: view.mdx regenerated and in the diff; JSON schemas and the api surface are build outputs, not tracked (verified by git ls-tree); registry.ts's generated region is in the diff beside its entry. Build Core and Build Docs read success on this head.

② Semver level

Changeset .changeset/20051-persist-parsed-view-body.md: @objectstack/metadata-protocol: minor, @objectstack/spec: minor, **BREAKING** banner, Clause-②: no (narrowing), one adr-0087: registered … marker, a FROM to TO table, the one-line fix, and an existing-rows section quoting the maintainer verbatim. It matches what the diff publishes: spec narrows a wire accept set and adds a registry entry; metadata-protocol changes what a view save stores. The level is RIGHT under ADR-0087's launch-window section as amended 2026-09-13: "Pre-GA, a metadata-facing retirement or break ships minor, carrying the **BREAKING** banner and its ADR-0087 disposition entry", which is also how ruling 甲 item 4 spells stage (iv). @objectstack/objectql and @objectstack/lint change tests only and publish nothing, so no changeset is owed for them. check-adr-0087-registration reads the arm from the changeset (readClause2Line(parsed.body)), and Check Changeset reads success on this head.

Clause-②: line: the PR body carries Clause-②: no, the changeset carries Clause-②: no (narrowing). Judged: not a conflict. The value is no in both and is truthful (nothing widens the accept set; the one new function is not on the package entry; the registry entry is the disposition the narrowing owes), the arm is optional per AGENTS.md Post-Task item 3, and it sits in the one text the gate that reads arms reads. The widening-tells gate recognises z.never( as a refusing value opener (REFUSING_RETURN), so the added options key line declines T1 rather than firing under no.

③ Boundary flags

Every dev flag and every open_questions entry, answered:

  • Q1, Q2, Q3 of 5909437934: answered by the director's ruling 5910106571 (B; A; A) and implemented as ruled; open_questions: [] in both final reports.
  • Clause-② spelling (no on the PR, no (narrowing) in the changeset): answered above, not a conflict.
  • Moved keys found by re-parse evidence rather than a named list: RIGHT within ruling B's "one projection replacing two grafts"; the convergence invariant, not the detector, is what the stored row is held to, and it is pinned. One corner named for the seat, not blocking: when a moved container has a dropped sibling key of the same shape (soleMovedFrom finds more than one candidate), the moved value is stored whole, so that container's nested defaults are pinned although the row still re-parses to the parse. No console write at the pin produces that shape. Carrier: the next change to projectStorableViewBody; a pin for that corner would close it.
  • The fail-safe arm stores parsed.data whole; ablation 2 on this card showed that for a column-less toolbar patch that shape is refused on re-save ("sets type but lists no columns"), so the warning's "re-save the view" prescription could itself be refused in that arm. Loud to the caller (a 422), unreachable today, pinned. Noted, not blocking.
  • z.never() rather than z.undefined() and the re-judge of the retirement-prescription population: RIGHT, see ① 1 and 6.
  • File surface beyond the claim line (migrations entry and regenerated registry, view.mdx, spec tests, objectql pins, comment-only test headers): each is either what ruling 甲 item 4 requires, what Post-Task item 5 requires, or a pin the sweep found; none is scope growth.
  • Process notes (a JS-only closure build repaired before typecheck, one single-file run re-run under the lock, the origin/main merge via os-regen-merge.sh, the shared .git deepened for the engine-split gate, no dogfood reading owed, a test block moved and strengthened in the patch round, the warn-dedupe pin riding the graft-folded file's engine double): nothing committed from an unclean state; no action owed.
  • Out-of-scope findings: other types stay verbatim (carrier none, noted in the PR; the ruling names views); the objectui listViews flatten drops isPinned / sortOrder / visibility / columnState before the whole-tab write (present today, independent of this PR, carrier objectui#11013, code read only); stale "§Validation" citations in files this PR does not touch (carrier: the next change to those files). Each has a named carrier or is prose only; consistent with Prime Directive chore: version packages #10.
  • Docs follow-up: ADR-0005's Status line, the "Ruled, not yet in force" marker and the parsed.data bullet go stale the moment this lands, until the seat's separate Tier H PR for the maintainer's hand. That sequencing is what ruling 5910106571 prescribes; no docs/adr/** edit here is right.
  • The stored-row census: waived by the maintainer (5909085121), so it is not a landing gate for this PR; a stored record carrying a top-level options is served as stored and refused only on its next save, which the changeset and the D3 entry both say.
  • Not escalated: nothing here needs the maintainer's word beyond what the three rulings already gave.

Implemented-by: claude/issue-20051-persist-parsed-view-body
Reviewed-by: session_018fxqvRJW12TaHC7DUQ89Y6

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 14:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 9905e61 Sep 30, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20051-persist-parsed-view-body branch September 30, 2026 14:54
os-justin pushed a commit that referenced this pull request Sep 30, 2026
…the connector sync retirement

The merge of origin/main (which carries #20868's registry entry) owed a
regeneration of three os-regen artifacts; they are regenerated here from
the merged tree. The same commit writes this branch's two registry hunks,
which the serial constraint held until that landing: the generated
regions of migrations/registry.ts (gen:migration-registry over the entry
files), the D2 conversion connector-sync-keys-removed in
conversions/registry.ts (MAJOR_18_CONVERSIONS, order 50) and its
STEP18_RATIONALE fragment (order 52). gen:schema cannot run without the
second half (build-schemas gate (b)), so the two cannot be split.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
… options bag, as the interface page does (objectui#10380) (objectstack-ai#11571)

Fixes objectstack-ai#10380

Clause-②: no. No published schema, type, input or accept set moves.
`@objectstack/spec` 17.6.0 already declares and judges the list
overlay's legacy `options` bag (`ListViewOverlayOptionsSchema`), and
this change makes the object page render what that door accepts, the way
the interface page already does. The one new export,
`storedLegacyOptions` in app-shell's `ObjectView` module, is a helper
exported for its pin, like its siblings `kanbanViewOptions` and
`timelineViewOptions`.

## What was wrong

The flattened list overlay in `@objectstack/spec` 17.6.0 declares a
legacy `options` bag. The view write door judges each `options.KIND`
block key by key with that kind's own block schema. It refuses an
out-of-contract key by name and stores what it accepts. The spec
describes the bag as an underlay: the top-level `KIND` block "wins per
key where both set one".

`InterfaceListPage` forwarded a stored row's bag to `ListView`, and
`ObjectView` dropped it. So one stored row handed the renderer two
different configs. Measured on `origin/main` `ab18797`, with rows the
17.6.0 door accepts:

- a timeline was grouped by `region` on the interface page and ungrouped
on the object page (`options.timeline.groupByField`);
- a timeline item or kanban card was titled by `region` on one page and
by `name` on the other (`options.timeline.titleField`,
`options.kanban.titleField`);
- a map had a `locationField` binding on the interface page and none on
the object page (`options.map`).

Ruling A (5824043998) keeps the interface page's forward ("what passes
the door is legal"). The seat's answer 5971906253 directs the object
page to honour the bag too, and to reuse `ListView`'s existing per-key
merge, with no second merge in app-shell.

## What changed

- **`ObjectView` (app-shell).** A new helper,
`storedLegacyOptions(viewDef)`, returns the stored row's bag, or `{}`
when the row has no bag or the bag is not an object. The relay then:
- forwards the bag in `options`. For each kind the bag carries, the
bag's block replaces this page's synthesized block (`kanbanViewOptions`
and its siblings). This is the rule `InterfaceListPage` applies to its
`mapCfg` (`view.options.map ?? derived`). A synthesized block stands in
only for a kind the bag does not carry, so a row with no bag is relayed
exactly as before;
- forwards the view's own top-level block for each kind the bag carries,
at the top level, as `InterfaceListPage` does. `ListView`'s existing
per-key merge (`{ ...options.KIND, ...KIND }` in each render branch)
then lays the declared block over the bag. That is the spec's
precedence, and no merge is written in app-shell.
- **`InterfaceListPage` (app-shell).** A default binding the page
derives from the object (`defaultKanbanFromObject`,
`defaultCalendarFromObject`, `defaultGalleryFromObject`,
`defaultGanttFromObject`) no longer fills a kind the row's bag carries.
Before this change, the derived block sat at the top level and outranked
the row's own bag declaration on every key it set. The object page,
which derives nothing, therefore rendered such a row differently. This
is the rule `mapCfg` already follows, extended to the other derived
kinds. The forward of `options` is unchanged (ruling A), and so is the
`options.map` legacy path and its CONTROL pin.
- **The relay census** (`ObjectView.relayRungCensus-7559.test.ts`). The
eight per-kind blocks now have a conditional top-level rung that reads
`viewDef`, so the census counts them as relayed. It requires their
`relayed-nested` ledger rows to go ("an absence that came back is not an
absence"), and they are replaced by a comment that says why. Their
nested `options.KIND` paths are still written for every row, and are
still asserted by name in the census's derivation checks.
- **Changeset:** `@object-ui/app-shell` patch.

## What an existing row that still carries `options` does

Both pages forward the stored bag in the same way, including a key the
17.6.0 door now refuses (for example `options.timeline.metaFields`). The
renderers ignore the retired keys: `ObjectTimeline`'s chip list has been
the constant status / priority pair since the `metaFields` retirement
(objectui#10222), and `ListView` strips the stray kanban `groupBy` and
the calendar `dateField` / `endField`. The row is served as stored until
its next save, which the door refuses by name. No migration is done, per
the maintainer's 「20051 不考虑现有的数据」.

## Tests

New pin: `ObjectView.storedOptionsBag-10380.test.tsx`. Each case renders
one stored flat overlay row through both pages, captures the schema each
page hands `ListView`, mounts the REAL `ListView` on it with a spy
renderer for the kind, and compares what the renderer receives. The
cases are:

- four rows carrying a bag (`options.timeline.titleField`;
`options.map`; `options.timeline.groupByField` under a declared
timeline; `options.kanban.titleField` under a declared kanban);
- a CONTROL row with no bag;
- two collision rows where the top-level block must win per key on both
pages (the timeline `titleField`; the kanban lane);
- a unit case for `storedLegacyOptions`.

Every row is one the 17.6.0 door accepts, judged with the spec's own
`ViewMetadataSchema.safeParse` and `diagnoseViewMetadata` from the
installed `@objectstack/spec` 17.6.0: `success=true`,
`branch=listOverlay`, with `options` kept in the parse.

The direction of each run was written before it ran. Each run used
repo-root `pnpm exec vitest run FILE`, under `os-verify-lock`:

- **Red on `origin/main` first.** The fix was committed (`4d0668d`),
then `ObjectView.tsx` and `InterfaceListPage.tsx` were checked out at
`ab18797`. `grep -c legacyOptions` read 0 in both files. Result: `Tests
6 failed | 2 passed (8)`.
- The four bag rows are RED, as predicted. The control and the timeline
collision are GREEN, as predicted. `storedLegacyOptions` is RED because
the export does not exist there.
- ⚠️ The kanban collision was RED, which was NOT predicted. Its lane
agreed (`region` on both pages). It failed on the card title: the object
page floored `titleField: 'name'` and the interface page sent none for a
row whose bag carries a kanban block. The fix removes that difference,
because the bag's block replaces the synthesized one.
- Restored with `git checkout HEAD --`. The blob hashes matched HEAD and
`git diff HEAD` was empty.
- **Ablation on the fixed tree.** The declared block was merged UNDER
the bag in app-shell (`{ ...viewDef.KIND, ...legacyOptions.KIND }` at
the top level, for timeline and kanban). That is the second merge, with
the wrong precedence, that the direction forbids. The anchors were
checked on disk before the run: 1 → 0, and the injected text 0 → 1.
Predicted: both collision cases RED. Observed: `Tests 3 failed | 5
passed (8)`, which is both collisions plus the
`options.timeline.groupByField` row. That row turned red because
`ListView` reads `groupByField` as a flat timeline key off the top-level
block only, so the merged block exposed it on the object page alone.
Restored and verified the same way.
- **On the final head `44e55cf`:**
- `pnpm --filter @object-ui/app-shell type-check` exit 0. Its
`tsconfig.test.json` covers the new test: it reported a type error there
on an earlier commit.
- `pnpm exec vitest run packages/app-shell/src/views/ObjectView
packages/app-shell/src/views/InterfaceListPage
packages/app-shell/src/views/__tests__/PageView
packages/app-shell/src/no-refresh-key-remount.ratchet.test.ts
packages/app-shell/src/views/view-filter-fold.ratchet.test.ts` gives
`Test Files 55 passed (55)` and `Tests 504 passed (504)`. This covers
every `ObjectView.*` and `InterfaceListPage.*` suite, including the
`options.map` CONTROL pin, the relay census, `titleFieldConvergence`,
the calendar / gallery / gantt / timeline binding pins and
`hostRerenderRefetch-10046`.
- NOT MEASURED: the full app-shell suite (979 test files). The run was
narrowed to the suites that render or read the two edited modules, and
CI runs the rest.
- **Build:** `turbo run build --filter='@object-ui/app-shell^...'
--concurrency=2` gave 28 of 28 tasks successful.
- **Lint, narrowed and stated as such:**
- `eslint --no-inline-config` on the four touched `.ts`/`.tsx` files
gives `files=4`, 0 errors.
- The population is `eslint.config.js`'s `**/*.{ts,tsx}` block. It uses
`tseslint.configs.recommended` with no `parserOptions.project` and no
`projectService`, so linting is not type-aware and this diff cannot move
a verdict on an untouched file.
- Warnings against `ab18797`, base → head: `ObjectView.tsx` 171 → 173
(one `any` in the helper's return type, and the
`react-refresh/only-export-components` warning every sibling exported
helper carries), `InterfaceListPage.tsx` 41 → 41, the census 0 → 0. The
new test has 19 `no-explicit-any` warnings, in line with its siblings.
No `--max-warnings` is set in this repo.
- **Gates on `44e55cf`, all exit 0:**
- `pnpm check:control-bytes`, `pnpm check:new-line-citations` (0 new
citations), `pnpm check:pending-changeset-literals`, `pnpm
check:spec-symbols`, `pnpm check:self-import` and `pnpm
check:phantom-deps`;
- `pnpm check:changeset-claims`: report-only. It names
`7070-no-invented-gantt-date-fields.md` and
`7218-rowcolor-host-relay.md`. Both paragraphs were re-read and both are
still true;
- `node scripts/check-changeset-presence.mjs`,
`check-changeset-no-major.mjs`, `check-test-path-roots.mjs`,
`check-vi-mock-specifiers.mjs`, `check-vi-mock-inherit.mjs` and
`check-vi-mock-override-shape.mjs`.

## Acceptance notes

- **Residual, the chart view's own route.** A `type: 'chart'` row whose
chart block lives ONLY in the bag passes the 17.6.0 door. The object
page renders chart views through its dedicated `ObjectChart` route,
which reads `viewDef.chart` alone and never reaches `ListView`. So that
row still renders the bag's chart on the interface page and the default
binding on the object page. Aligning it means either a second chart
resolution in app-shell (the `chart || options.chart` rule of
`ListView`'s `resolveListChartBinding`), which the direction forbids, or
exporting that resolver from `@object-ui/plugin-list`, which adds a
published name. That is left to the seat. Evidence: the door acceptance
was measured, and the route was read from source and not rendered.
- **Pre-existing, not this card: the interface page drops a declared
top-level `tree` / `chart` block.** `InterfaceListPage`'s schema literal
forwards `kanban`, `calendar`, `gallery`, `timeline`, `gantt` and `map`,
and has no `tree` or `chart` rung. `tree` and `chart` are allowed
visualizations, and `CreateViewDialog` writes both blocks on the views
it creates. A view's `tree.parentField` or chart binding therefore
reaches `ListView` on the object page and not on the interface page.
This is reported for the seat and not fixed here, because it is
independent of the bag and would change the interface page for rows
without one. Read from source.
- **Pre-existing, posture: page-derived defaults.** An interface page
still derives a binding for a whitelisted kind that the row declares
nowhere, and the object page never invents one (objectui#3129,
objectui#7029). That is a deliberate difference between the pages and is
untouched. This change only stops the derivation from outranking a bag
declaration. The kanban `titleField: 'name'` floor of
`kanbanViewOptions` likewise still applies to rows without a bag.
- **Legacy rows (the dev report's out-of-scope finding).** A stored row
whose bag holds a key the door now refuses is served as stored until its
next console save (rename or pin, through data-objectstack's
`updateView`, which merges the current row). The door then refuses that
save with a 422 that names the key. This is landed objectstack behaviour
(objectstack-ai/objectstack#20868, `9905e61c`). Noted here and not
filed, per the seat's answer.
- **Docs:** none touched. No page under `content/docs/` or the app-shell
README describes the bag or the interface page's derived defaults. The
two pages that mention a `listViews` entry's `options` bag describe the
authored object schema, where the bag is still refused, and they stay
true.

Session: `https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2`
(dispatched dev of `domain:ui` seat 1, claim 5971770388, as amended by
5971906253).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ew's own shape) and keyboardNavigation (objectstack-ai#20882)

Fixes objectstack-ai#20694
Clause-②: yes

`ComponentPropsMap['object-grid']` (`ObjectGridPropsSchema`) gains the
three members named in triage's direction (`5897622232`). This is a
widening. Nothing that parsed before is refused, and the row still
refuses every key it does not declare.

## What changes

- **`description`**: `I18nLabelSchema.optional()`. At the
`.objectui-sha` pin `db11afd4967c`,
`plugin-grid/src/ObjectGrid.tsx:5677` resolves it with
`resolveInlineI18nLabel(schema.description, displayLocale)` and draws it
above the rows at `:5784`, `:6303` and `:6342`. The card calls the
function `resolveI18nLabel`; at the pin it is named
`resolveInlineI18nLabel`.
- **`emptyState`**: the list view's own empty-state shape, taken **by
reference**. It used to be an inline `strictObject` at
`ListViewShapeSchema.emptyState` (`view.zod.ts`). It is now the exported
`EmptyStateSchema` (author type `EmptyState`), and `list-view` and
`object-grid` both hold that one schema. At the pin it is read at
`ObjectGrid.tsx:6225` and drawn through `DataEmptyState` at `:6233`.
- **`keyboardNavigation`**: `z.boolean().optional()`. Its describe is
the one triage specified: arrow-key cell navigation on the WAI-ARIA grid
pattern, on by default when `editable` is set, and a read-only grid
keeps its Tab behaviour unless this is `true`. It is prefixed
`[EXPERIMENTAL — not enforced]` because nothing reads it at the pin.
Under objectui `packages/` and `apps/`, excluding tests, CHANGELOGs,
READMEs and `packages/types` (which holds its type and zod twin), it has
0 hits; the control `schema.editable` has 3 hits in `ObjectGrid.tsx`.
- The block docblock records each key's read point at the pin, following
its convention, and each member repeats its own.

### The extraction, and the one text change

- A parse corpus, run as a scratch script and not committed, covers 28
`emptyState` values on each of the four list-view doors:
`ListViewSchema`, `ObjectListViewSchema`, `ViewSchema.list` and the
flattened overlay arm of `ViewMetadataSchema`. That is 112 cells,
compared between BASE `72f8c38201` and head.
  - `success` and parsed `data` are identical in 112 of 112 cells.
- Issue codes, paths and keys are identical in 112 of 112 cells (every
`message` field stripped at every depth).
- After substituting only the guidance sentence, the full JSON of each
result is byte-identical in 112 of 112 cells.
- The 12 cells that differ at all are the `action`, `button` and mixed
inputs on each of the four doors. In those cells the `action`/`button`
guidance is reworded so that it also reads true on `object-grid`, which
has no `addRecord` block. It still points at `addRecord`
(`view-strictness-batch18.test.ts` still passes), and it follows the
`VIEW_ROW_BOUND_GUIDANCE` precedent for text that two doors share.
- The members gain short describes. The list view's reference docs lose
the inherited sentence claiming the inline locale map is "resolved at
render time"; see Acceptance note 1 for why that claim did not hold.

### Liveness record for `keyboardNavigation`

The liveness ledger has no row for any `ComponentPropsMap` key. The page
ledger leaves `page/regions` undrilled (it is listed in
`undrilled-containers.baseline.json`), and `PageComponent.properties` is
an open record, so the walk never reaches `ObjectGridPropsSchema`; a row
in `page.json` would be an orphan. The record is therefore the describe
marker `[EXPERIMENTAL — not enforced]`, which is the ledger's own
spelling for a declared key that is not enforced (`MARKER_RE` in
`check-liveness.mts`). The member docblock says to drop the marker in
the change that lands the reader. `check:liveness` is green.

### Files outside the claimed surface

- `packages/spec/src/type-alias-convention.pin.test.ts`: `gen:docs`
refuses to document a schema that has no type alias, so `EmptyState` is
exported. ADR-0122 then requires an isomorphism pin
(`Iso_ui_view__EmptyStateSchema`), which moves the pin count from 779 to
780. The schema's tree has no default and no transform, so a synonym
`EmptyStateParsed` would be wrong.

## Evidence (head `98dad84e21`, which includes a merge of `origin/main`
at `07356a6ab0` through `os-regen-merge.sh`)

- `object-grid` corpus: 3 of 36 cases parsed at BASE and 14 of 36 parse
at head. The newly accepted cases are exactly the valid values of the
three members, and no case is newly refused. Both
`examples/app-showcase` `object-grid` nodes (command-center `cc_queue_g`
and the my-work queue) parse before and after.
- **Pins:** `component-object-grid-declared-keys.pin.test.ts` passes 18
of 18. §1 checks that the three members parse byte-identically. §2
checks that an unknown top-level key is refused with `unrecognized_keys`
at the row, and that a wrong value in each member is refused (code and
path). §3 checks that the grid and the list view hold `EmptyStateSchema`
by identity, and that the grid answers exactly what that schema answers.
- **Ablations**, run on the committed state through
`scripts/ablation-replace.mjs`. Each restore was proven: the blob equals
HEAD and `git diff HEAD` is empty.
  - Renaming the `keyboardNavigation` member: 4 tests fail and 14 pass.
- Replacing the grid's `EmptyStateSchema` with a copied
`z.strictObject`: only the identity pin fails, and 17 pass.
- `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2`: 581 files, 17154 passed, 1 todo.
- `pnpm --filter @objectstack/spec typecheck`: exit 0. This includes
`check:test-typecheck`, and the debt ledger is unchanged.
- **Consumer `@objectstack/lint`.** Its closure was built upstream with
`turbo run build --filter='@objectstack/lint...'` (lint plus its
dependencies).
  - Tests: 117 files, 5437 passed. `typecheck`: exit 0.
- Component-props reading through `validateComponentProps` on an
`object-grid` node:
    - The three keys produce 0 findings.
- The control `emptyStat` produces `component-props-unknown-key` at
`properties.emptyStat`.
- The control `emptyState.heading` produces the same rule at
`properties.emptyState.heading`. That path exists only through the new
declaration, which shows the rule read the rebuilt dist.
- `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts
are up to date.
- **Derived gates** (`dispatch-gates.mjs --commands`, 108 families, run
at head): 104 exited 0. Four exited 3 with PREREQUISITE NOT MET, so they
are **NOT MEASURED**:
  - `check:skill-examples`: the client SDK closure is not built.
- `check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt`: each needs the whole-repo package build that CI
does.
- Reconciled with `--ran`: all 108 accounted for, 104 run, 4 NOT
MEASURED.

## Acceptance notes

1. **objectui renderer gap: an inline locale map in `emptyState.title`
or `.message`.** At the pin, `ObjectGrid` passes both to
`DataEmptyState` unresolved (`:6241-6242`), unlike `description`. React
throws on an object child, measured on react 19.2.8: "Objects are not
valid as a React child". `ListView` guards with `typeof === 'string'`
(`plugin-list/src/ListView.tsx:5112` and `:5114`), so a map there
silently falls back to the default text. No producer in `examples/**` or
`platform-objects` writes a map there; all of them use strings. So this
is not filed here. **Carrier:** the objectui follow-up the seat files at
ACCEPT, which lists the three keys in `GRID_QUERY_INPUTS`. Its fix is
one resolver, `resolveInlineI18nLabel`, applied at both renderers.
Meanwhile the changeset tells authors to write strings on `object-grid`.
2. `origin/main` moved to `9905e61ca2` after the merge; objectstack-ai#20868 edits
`view.zod.ts` and `view.mdx`. A merge-tree probe from a bare clone with
no merge driver shows a clean merge, and the hunks in both files are
disjoint from this branch's. CI validates the merge ref.
3. The `object-grid` follow-up in objectui, per triage: after the
release is installable, list `description`, `emptyState` and
`keyboardNavigation` in `GRID_QUERY_INPUTS` (the reverse direction of
the parity gate).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…g / fieldMappings retired, mapping gains the connectorSource pull binding (objectstack-ai#20281 stage 1) (objectstack-ai#20903)

Part of objectstack-ai#20281
Clause-②: yes (narrowing)

Stage 1 (spec) of ruling A (`5904845660`) on objectstack-ai#20281, as claimed in
`5913026572`:

- `mapping.connectorSource` — the target-side pull binding (`connector`,
`action`, optional `input` / `recordsPath` / `watermark { field, param
}`); `rest` / `openapi` only, one-way, no cadence key (a `job` owns it),
no credential. Declared, not executed: every row `planned`, the
container `authorWarn`.
- The 14 connector keys retire: `syncConfig` and `fieldMappings` are
`retiredKey()` tombstones pointing at that binding and a `job`;
`DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution`,
`ConnectorFieldMapping` leave whole; D2 `connector-sync-keys-removed`
(strip, never writes a mapping); D3 `connector-sync-keys-retired`.
- `packages/spec/docs/SYNC_ARCHITECTURE.md` stops presenting connector
sync as delivered (rewritten, 494 to 464 lines).
- The serial constraint held: the two registry hunks were written only
after merging a `main` containing objectstack-ai#20868's landing (`9905e61ca2`), via
`scripts/pm/os-regen-merge.sh`.

Local, at `674abedfd9`: spec `local` 583/583 files green (17163 passed);
spec typecheck exit 0; `check:generated` 15/15 current. The derived-gate
union ran at `cb54c889b9`, before the second `main` merge: 111 of 114
green. `check:platform-checklist` is red identically on `origin/main`
(not this diff); `check:dual-build-cjs-loads` and
`check:type-check-debt` are NOT MEASURED (they need the whole-repo build
CI does). Measurements, ablations and findings are in the
`os-dev-report` comment on the card.

## Acceptance notes

- `packages/lint` still says the `mapping` ledger warns on nothing (a
comment in `authoring-rules.ts` and a group-C test title in
`runtime-gate.inert-type-writes.test.ts`). The ledger now warns on
`connectorSource`. The tests stay green because they do not author the
key. Carrier: stage 2 of objectstack-ai#20281.
- `shared/FieldMapping` (`./shared` `FieldMappingSchema`) no longer has
a carrier in the spec, because its only extender left. No carrier.
- `packages/spec/scripts/build-schemas.ts` comments still cite
`integration/DataSyncConfig` as a live specimen. No carrier.

objectstack-ai#20281 remains open for stages 2 and 3.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… view, and a saved view stores the parsed value of every key its body carried (ruling B) (objectstack-ai#20899)

Fixes objectstack-ai#20879
Clause-②: no

This is the documentation step that ruling `5910106571` on objectstack-ai#20051 names
(batch objectstack-ai#256 item 3, Q2 = B, maintainer 「同意 批次 objectstack-ai#256」): "ADR-0005 appendix
(c)'s `parsed.data` wording and its two "not yet in force" markers
change in a separate Tier H documentation PR for the maintainer's hand."
Tier H (`docs/adr/**`): this PR stays draft until the maintainer's word.

## What this changes

One file, `docs/adr/0005-metadata-customization-overlay.md`, four
places:

1. **Status line.**
- The ruling 甲 entry's clause "the storage switch itself is the ruling's
last stage, not yet landed" now says the switch "is in force for `view`
since PR objectstack-ai#20868 (`9905e61ca2`)". It points at "the amendment notes"
(plural).
- One new `**Amended** (2026-09-30, objectstack-ai#20051 ruling B — …)` entry: the
parsed body a `view` stores is the parsed value of every key its request
body carried, not the whole parse output. Undeclared keys are dropped,
schema defaults are not materialised, and a moved key is stored under
its canonical spelling. Every other type still stores its request body.
2. **Appendix (c)'s persisted-document bullet** is rewritten from the
landed code:
- A `view` stores the parsed value of every key its request body carried
(`projectStorableViewBody`).
   - The stored row re-parses to exactly what the save accepted.
   - Every other type still stores its request body (`request.item`).
- It has been in force since PR objectstack-ai#20868. Rows stored before it are not
migrated.
- The "⚠️ Ruled, not yet in force" marker is gone, because the bullet
now states the rule in force. The bullet links ADR-0087's addendum
2026-08-01b for the `storable` rule.
3. **The stage (iii) note keeps its text.** A "⚠️ **Historical —
superseded.**" paragraph now sits above its items 5 (the interim state)
and 6 (the stage (iv) plan). Both items are kept verbatim. This is
ADR-0005's own convention: its "Overlay-uniqueness index" block is left
standing as written under the same marker, because an accepted record is
not edited to make the past look like the present.
4. **A new dated note, "Amended (2026-09-30)"**, directly after the
first one, in the stage (iii) form:
- **Provenance.** Ruling B `5910106571` with the maintainer's 「同意 批次
objectstack-ai#256」. The maintainer's 「20051 不考虑现有的数据」 (`5909085121`), which waived
the stored-row count. PR objectstack-ai#20868, merged as `9905e61ca2`.
- Item 1 quotes the stage (iii) bullet and the Status clause verbatim,
marked **Superseded**.
   - Item 2: what a `view` save stores.
- Item 3: the two grafts no longer run on a `view` save, and every other
type still stores its request body.
- Item 4: a view item record's top-level `options` is refused by name
(Q3 = A). This settles the judgement the first note's item 6 left open.
   - Item 5: the pins.

The heading `Addendum — 2026-05-16 (c): spec validation on overlay save`
is byte-stable: 0 hits in the diff and 1 in the file. So
`scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json`
and the code comments that cite that heading stay as they are, and
nothing outside this file changes.

Per the triage direction (`5914934930`), the text says nothing about
types the switch does not cover, beyond one fact: every other type still
stores its request body, as the landed code does.

## Each sentence against the landed code (`origin/main` `00a92e18d`)

- `saveMetaItem` (`packages/metadata-protocol/src/protocol.ts`) stores
`projectStorableViewBody(request.item, parsed.data, reparse)` inside `if
(singularType === 'view')`. Every other type goes to the `else` branch,
which keeps
`graftNormalizedOperators(graftFoldedFormSections(request.item,
parsed.data), parsed.data)`.
- The rules come from `buildStorableViewNode`'s docblock and body:
  - an authored key the parse kept is stored with its parsed value;
  - an authored key the parse did not keep is dropped;
- a key the parse added is stored only when the re-parse does not
reproduce it (a moved key), and is not stored when it does (a default).
- The loop returns `converged: true` only when
`storableEqual(again.data, parsed)`. Otherwise it stores `parsed` whole
and warns once per view (`storableViewFallbackWarned`). Item 2 of the
new note says both.
- "with the normalizations `saveMetaItem` applied to it before the
switch" (item 3) covers what the other types already had, and nothing
new:
  - the two grafts;
- the page `type` default (`withDeclaredPageTypeDefault`, objectstack-ai#20101, which
predates objectstack-ai#20868);
  - the flow canonicalization that runs before the gate.
- The `options` refusal: `VIEW_ITEM_OPTIONS_REFUSED` and `options:
z.never(…)` in `packages/spec/src/ui/view.zod.ts`, and the ADR-0087
entry `view-item-options-bag-refused` in
`packages/spec/src/migrations/registry.ts`.
- The pins: `protocol.project-storable-view-body.test.ts` covers the
converged arms and the fail-safe.
`protocol.graft-folded-form-sections.test.ts` has the stage (iv)
describe blocks: W1–W5 store no `type`, an undeclared key is dropped,
the persisted body parses to exactly what the save parsed, and a page is
stored as sent.
- `protocol-meta.test.ts` re-judged "preserves Studio-only auxiliary
fields verbatim" as "stores the console's declared round-trip keys, and
drops the `objectName` alias".

## Census of the file after the change

`not yet in force` · `not yet landed` · `has not landed` · `parsed.data`
· `request.item`. Each remaining hit is either true or a dated quote:

| line | text | why it stays |
|---|---|---|
| 6 | ruling 甲 entry: "reversing the verbatim `request.item` save" |
true, since the same entry now says it is in force for `view` |
| 349 | bullet: "Every other type still stores its request body
(`request.item`)" | true (the `else` branch) |
| 367–370 | first note, item 1: the 2026-05-16 bullet quoted | dated
quote (stage (iii)) |
| 406 | first note, item 5: "has not landed" | under the new "Historical
— superseded" marker, verbatim |
| 436–443 | new note, item 1: the stage (iii) bullet quoted | dated
quote, marked Superseded |

## Evidence (head `950180d84`)

- **Gates.** Derived with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands`, with no paths. The tree is
`950180d84` and the change set is 1 path. It derived 19 commands, the
same 19 as the dispatch. All 19 exit 0:
  - `check-adr-links` (+ `--self-test`);
  - `check-adr-symbol-anchors` (+ `--self-test`);
  - `check-ci-filter-parity`;
  - `check-closing-keyword-parity` (+ `--self-test`);
  - `check-comment-mask-corpus`;
  - `check:doc-formula-expressions`;
  - `check:adr-anchors`;
  - `check:cross-package-test-inputs`;
  - `check:doc-authoring`;
  - `check:driver-memory-census`;
  - `check:gitlink-declared`;
  - `check:nul-bytes`;
  - `check:pm-governed-merges`;
  - `check:pm-prior-rulings`;
  - `check:refd-timer-probe`;
  - `check:watch-hint-literal`.
- **Reconciliation.** `--ran` with `COMMAND :: exit CODE` lines: "19
derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED (a DERIVED
zero — all 19 recorded an exit code and none of them is 3)".
- **`check-adr-symbol-anchors`:** "2165 anchors across 140 records
resolve — 325 symbol (299 declaration, 26 literal), 1813 file-level, 27
cross-repo, 6 exempt, 9 continuation".
- **`check:adr-anchors`:** "OK (56 anchored file(s), every governing ADR
still referenced; … 40272 citation(s) across 4997 file(s) resolve …)".
- **`check:doc-formula-expressions`.** I built `@objectstack/formula`
and `@objectstack/lint` first, through the verify lock (`turbo run
build`, 4 of 4 tasks, exit 0). The gate then printed "22 record-scoped
formula example(s) across 458 files / 1381 TS blocks judged clean".
- **The ADR-0087 link fragment.** `check-adr-links` strips `#fragment`
and never checks it (its own header says so). So I computed the slug of
`## Addendum (2026-08-01b) — flows reach the finish line too (objectstack-ai#4454)`
with `github-slugger` 2.0.0:
`addendum-2026-08-01b--flows-reach-the-finish-line-too-4454`, which is
what the link uses. That heading occurs once in ADR-0087.
- **Ablation** (one-off, no permanent file).
- Setup: with the change committed, I ran `scripts/ablation-replace.mjs`
in wrap mode. It renamed the new anchor
`view.zod.ts#VIEW_ITEM_OPTIONS_REFUSED` to `…REFUSEZ`: anchor count 1 to
0, blob `62fe9587` to `af2e8fa2`.
- The gate printed "❌ check-adr-symbol-anchors: 1 finding(s)" and
"[unresolved-symbol]
docs/adr/0005-metadata-customization-overlay.md:472".
- Restore: the blob is back to `62fe9587` (the HEAD blob) and `git diff
HEAD` is empty.
- So the gate judges the new anchors, including a non-exported `const`.
- **eslint (narrowed, measured).**
- The population is read from `eslint.config.mjs`: `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`. No config object matches
`.md`.
- `eslint --no-inline-config --format json` on the changed file: 1 file,
0 errors, 1 warning, "File ignored because no matching configuration was
supplied".
- The config enables no type-aware linting (its own comment at `:327`).
So a markdown-only diff moves no eslint verdict on any file.
- **No changeset.** `docs/adr/**` publishes nothing. Measured: 82
tracked `package.json` files, 69 not private (the root is private). None
has a `files[]` entry that names `docs/adr` or climbs out of its package
directory. So `skip-changeset` applies, and the seat applies the label.

## Acceptance notes

These are observations, not filed.

1. **The anchor's "only" is narrower than the code.** The invariant in
`scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json`
says every other type is stored "with only the operator and `groups` →
`sections` grafts applied".
- The same file's code also applies `withDeclaredPageTypeDefault`
(objectstack-ai#20101) to every type.
   - A flow body is its canonicalized `storable` before the gate.
- The `resolveOverlaySchema` docblock names the page default. The anchor
text does not.
- That file is outside this card's surface (dispatch item 2), so it is
not touched here. Carrier: none.
2. **The 2026-05-16 test list stays as written.** The line "Tests added
in `packages/objectql/src/protocol-meta.test.ts` … unknown extras
preserved …" is the 2026-05-16 addendum's dated record of what was added
then, so it stays. The new note's item 5 records that the case was
re-judged at stage (iv).
3. **Two Status-line entries carry the same date, 2026-09-30.** The
ruling 甲 entry keeps its own provenance and gains the in-force clause.
The ruling B entry records the refinement. The file's convention is one
entry per decision-changing amendment.

## 维护者速读(草稿)

**改了什么**:只改了 ADR-0005 这一份文档,四处。

- 状态行:裁决甲那一条原来写着"存储切换尚未落地",现在改成"自 PR objectstack-ai#20868 起对 `view`
生效"。另外新加一条"已修订",记录裁决 B。
- 附录 (c) 的"持久化文档"条目:原来写的是"存解析结果
`parsed.data`,已裁定、尚未生效"。现在照已合并的代码写:视图保存时,只存请求体里带了的每个键的解析值。没声明的键丢掉,作者没写的
schema 默认值不落库,旧拼写的键按规范拼写存(`groups` → `sections`,`visibleOn` →
`visibleWhen`)。其它类型仍然存请求体。存量行不迁移。
- 第 (iii) 步那段注记的第 5、6 项(过渡状态、第 (iv) 步计划)原文保留,上方加了"⚠️ 历史——已被取代"标记。
- 新加一段 2026-09-30 注记。它写了出处(裁决 B、维护者原话、PR 与合并 sha),并原文引用被取代的第 (iii)
步条目。它还写了视图保存具体存什么、`options` 按名拒收,以及对应的测试。

**为什么改**:裁决 `5910106571` 点名要求这一步。第 (iv) 步(PR
objectstack-ai#20868,`9905e61ca2`)已经合并,所以这三处"尚未生效 / 存 `parsed.data`"的文字在 `main`
上已经是错的。

**风险与代价(含回滚)**:

- 纯文档改动,不动代码、不动测试、不发布任何包。
- 附录 (c) 的标题一字未改,所以代码注释和锚点文件都不用跟着动。
- 回滚:revert 本 PR 的提交即可。

**席位意见**:

**你要做的**:审阅后在本 PR 上给 APPROVED。这是 Tier H 治理面 `docs/adr/**`,之后由席位落地。

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

---------

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 protocol:ui size/xl tests tooling

Projects

None yet

2 participants