Skip to content

fix(spec): give the two declared-but-unauthorable metadata keys their form rows - #19182

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-19085-metadata-form-missing-rows
Sep 19, 2026
Merged

os-bill merged 3 commits into
mainfrom
claude/issue-19085-metadata-form-missing-rows

Conversation

@os-bill

@os-bill os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes #19085

Clause-②: yes

Two keys the served schema declares had no row in the form METADATA_FORM_REGISTRY supplies for their type, so the generic metadata form never rendered them and an author's only door was the Source tab's free-text JSON.

Measured first, on origin/main

The card's reading was relayed from the objectui#9912 dev, so it was re-taken here rather than trusted:

probe result
relatedListFilter / validations rows across every packages/spec/src/**/*.form.ts 0
lit control — maskingRule 2 hits (field.form.ts, object.form.ts)
dark control — a name no form carries 0 hits
field.relatedListFilter declared FilterConditionSchema.optional(), field.zod.ts:1501
object.validations declared z.array(ValidationRuleSchema).optional(), object.zod.ts:2050

So the zero is a live probe, and both premises hold.

Neither key is declared-but-dead, so the ADR-0049 retirement fork does not fire: the liveness ledger reads object.validations live (evaluateValidationRules, the write-path evaluator), and field.relatedListFilter planned + authorWarn — whose stated flip condition (objectui#4664) has since landed; it is carded as #19187.

The face each row gets is a measurement, not a preference

Both keys serve as JSON-Schema pointer rows — the shape the card warned must not be assumed renderable.

  • field.relatedListFilterwidget: 'filter-condition'. The served node is { $ref: '#/$defs/…' } onto the recursive Query-DSL FilterCondition, whose derivation is allOf: [open record, { $and/$or/$not }] with no top-level type — there is nothing for the generic renderer to derive a control from. filter-condition names the FilterCondition wire, and this file already uses it one section down for summaryOperations.filter, the sibling FilterConditionSchema key. What the hint renders as today, measured at the pinned .objectui-sha, is the announced raw-JSON editor carrying the hint — not a criteria builder: the renderer that consumes this registry is the metadata-admin SchemaForm, whose own WIDGETS map registers no filter-condition (the FilterConditionField of that name lives in @object-ui/fields, on the ComponentRegistry path ObjectForm uses), and with the pointer unresolved neither structural fallback applies, so resolveFieldFace lands on { kind: 'raw-json', hint } — the same face summaryOperations.filter gets. That editor hands JSON.parse output through verbatim and the save door judges it, so the wire is exact either way; the hint is the forward-looking half. ⛔ Deliberately not filter-builder: that widget consumes a rule ARRAY (what view.filter, dataset.filter and page.filterBy store), so routing this key there would write metadata the runtime refuses — the authoring trap this row exists to close, re-created one layer up. visibleWhen mirrors the key's own contract text (lookup / master_detail), a meaningfulness gate rather than a parse gate: FieldSchema accepts the key on every type, but the related-list derivation only ever reads it on the child-side FK.
    Deliberately not filter-builder — the boundary the card and the triage both drew. That widget consumes a rule ARRAY (what view.filter, dataset.filter and page.filterBy store); routing this key there would write metadata the runtime refuses, re-creating the trap one layer up.
    visibleWhen mirrors the key's own contract text ("meaningful on a child's master_detail/lookup field"): a meaningfulness gate, not a parse gate — FieldSchema accepts the key on every type, but the related-list derivation only reads it on the child-side FK, and offering a knob the runtime does not deliver is what Prime Directive chore: version packages #10 forbids.
  • object.validationswidget: 'json'. The served node is an array whose items are a double-hop pointer (items.$ref$defs/__schema1$defs/__schema2) landing on a oneOf over the six ValidationRule members. A repeater would have to resolve both hops and pick a union branch before it could render a row; neither half is measured for this node, and a repeater that resolves neither renders an empty row whose values never land — the offer-vs-door defect the reconciliation gate beside it exists to catch. The Zod parse still refuses a malformed rule loudly at publish. Precisely: json is in that renderer's passthrough set, but the set is consulted after the structural fallbacks, not instead of them — so this row reaches the raw-JSON editor because the unresolved double-hop pointer derives nothing, not because the hint suppresses derivation. Once the pin moves past objectui's pointer resolution the same hint derives an object-rows repeater over the first oneOf branch; that is the renderer's precedence, not this repo's contract. Same treatment as the sibling structured-array rows permission.rowLevelSecurity and email_template.variables. Upgrading it to a structured control is a form-face addition, ⛔ not a reconciliation.
    ⛔ No new form primitive was invented. Upgrading this to a structured control is a form-face addition — the same boundary the reconciliation ledger draws for lifecycle.*.onlyWhen.

Why nothing failed while both keys sat unauthorable

metadata-form-zod-reconciliation.test.ts reconciles the two directions asymmetrically, and only one of them at the top level:

direction top level nested lists
form-only (offered, not declared) checked checked
retired (offered, tombstoned) checked checked
zod-only (declared, not offered) unchecked checked (ledgerable)

This card's class is exactly the unchecked cell. Closing it generally is not in this PR: measured on this tree, the top-level zod-only set is 276 keys across the 17 forms, each needing an offer or a ledgered reason. That census is filed as a finding; the new pin holds the two keys #19085 measured in the meantime.

Tests

metadata-form-declared-rows.pin.test.ts pins both rows, both faces and the visibleWhen, and adds a registry-wide assertion — every row of every form, at every depth — that no form routes a FilterCondition-typed key to the rule-array builder. The negative half is written so it can fail: a lit control asserts the walk actually reaches both field.relatedListFilter and field.summaryOperations.filter before the misrouted set is reported empty.

⛔ Not measured here: the browser half. At the pinned .objectui-sha the face is the announced raw-JSON editor carrying the hint (resolveFieldFace{ kind: 'raw-json', hint }), so what is unmeasured is whether that editor renders the hint as announced — ⛔ not a criteria builder's field picker, which is a different renderer on a different path and is not reached from this registry today. The picker question becomes live only once the pin moves past objectui's pointer resolution; whoever does that bump owns re-measuring this row and its two shape-siblings, permission.rowLevelSecurity and email_template.variables.

Acceptance notes

  • METADATA_FORM_REGISTRY itself is a pure import map — it declares no rows. The rows live in packages/spec/src/data/{field,object}.form.ts, so the landing file named at dispatch could not carry this change; the registry file is unmodified.
  • The regenerated platform-objects metadata-form bundles are the mechanical consequence of two new rows (os i18n extract walks the registry): 44 additive lines, translated locales filled with source text and still needing translation.
  • Noted, not filed: relatedList, relatedListTitle and relatedListColumns — the three siblings of relatedListFilter — are also declared and unoffered. They are part of the 276-key census above, not separate cards.

Generated by Claude Code

⚠️ Seat correction to this body, 2026-09-19T10:29Z — applied by the domain:spec seat 2 PM (session_01JbZnqu8bt6YqfJsr9vaFb3), ⛔ not by the implementer, whose standing clauses write a PR body exactly once and hand later corrections to the seat.

Four edits, all of them corrections of this body's own claims, ⛔ none of them a change to the diff:

  1. A dangling ; see the finding below pointed at a finding that lives in the delivery report rather than in this body. It now points at the card the seat filed for it, [finding] field.relatedListFilter 的 liveness 行仍是 planned + authorWarn,而它自己写明的 flip 条件早已落地 —— os lint 正在用一句假话把作者劝离一个可用的键 #19187.
      1. The two face bullets are replaced verbatim with their corrected versions from .changeset/19085-metadata-form-declared-rows.md, which the implementer re-measured at the pinned .objectui-sha in the patch round: the face today is the announced raw-JSON editor carrying the filter-condition hint, ⛔ not FilterConditionField (a different renderer on the ComponentRegistry path ObjectForm uses); and json is in the renderer's passthrough set but consulted after the structural fallbacks, so the row reaches the raw editor because the unresolved double-hop pointer derives nothing — ⛔ not because the hint suppresses derivation.
  2. The "browser half" paragraph rested on the same refuted renderer and is rewritten to match.

⭐ The widget choices and the diff are unchanged; what was wrong was this body's account of which renderer consumes them. The durable artefacts — the changeset and the form comments — already carry the corrected wording; this edit makes the body agree with them instead of contradicting them.


Generated by Claude Code

…he form rows their declarations implied

Both keys were declared by the served schema and offered by no form in
`METADATA_FORM_REGISTRY`, so the generic metadata form never rendered them
and an author's only door was the Source tab's free-text JSON.

The face each row gets is measured, not assumed:

- `field.relatedListFilter` -> `widget: 'filter-condition'`. The served node
  is a `$ref` onto the recursive FilterCondition, whose derivation is
  `allOf: [open record, { $and/$or/$not }]` with no top-level `type`, so the
  generic renderer has nothing to derive from. `filter-condition` is the
  registered widget whose storage contract is that object shape; the
  rule-ARRAY `filter-builder` would write metadata the runtime refuses.
  `visibleWhen` mirrors the key's own contract text.
- `object.validations` -> `widget: 'json'`. The served node is an array whose
  items are a double-hop `$ref` landing on a `oneOf` over the six
  ValidationRule members; a repeater would have to resolve both hops and pick
  a branch before rendering a row, neither of which is measured for this node.

A new pin keeps both rows and both faces, including a registry-wide assertion
that no form routes a FilterCondition-typed key to the rule-array builder.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
…nd add the changeset

`os i18n extract` walks METADATA_FORM_REGISTRY, so the two rows added in the
previous commit are new `metadataForms.*` keys. Regenerated with
`node scripts/check-i18n-bundles.mjs --write`; 44 additive lines across the
platform-objects bundles, translated locales filled with source text.

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

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 10 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json eeaa882459edffd077c1cda2972af06d1f10550b.

8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see

Coarse fallback — 136 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 eeaa882459edffd077c1cda2972af06d1f10550bpackageMentionDocs.

Which tree this was computed on

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

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

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

os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 608f0ed2619b98e800a37803027866cf061b6beb

Isolated, adversarial contract review of PR #19182 (card #19085), judged against origin/main at the merge-base eeaa882459 and the head above. Every reading below was taken first-hand in a scratch worktree of that head (spec tests via source imports; the packages/lint test against a freshly built closure; check-i18n-bundles.mjs in check mode against a built CLI; the objectui readings against the pinned .objectui-sha 53ded82bf7). The os-dev-report on the card and the seat comments were read for what they assert; none of them is evidence here. No maintainer or director-seat ruling sits on the card thread; the triage grading's acceptance line is quoted where this record leans on it.

① Derived judgments

Accept/reject behaviour of a published contract: 0 changes. The diff touches no *.zod.ts, no conversion registry, no tombstone; FieldSchema and ObjectSchema accept and refuse exactly what they did at the merge-base. Measured: packages/spec/api-surface-declarations/, api-surface/, authorable-surface/ and json-schema.manifest/ are byte-identical between the two trees (git diff --quiet, all four directories); no export is added or removed.

Item by item, with the implementer's classification judged:

  1. packages/spec/src/data/field.form.tsrelatedListFilter row, widget: 'filter-condition', visibleWhen: "data.type in ['lookup','master_detail']". Classification "form-face addition on an already-declared key, no wire byte moves" — RIGHT, with one correction to its face claim below.

    • Served node, reproduced with the same call getMetaTypes() makes (z.toJSONSchema(FieldSchema, { unrepresentable: 'any' })): { description, $ref: '#/$defs/__schema0' }, target an allOf of two members (an open record; an object with $and/$or/$not) and no top-level type — as described.
    • Wire the row opens onto: FieldSchema.safeParse with a rule ARRAY (what filter-builder writes) — REFUSED (relatedListFilter Invalid input: expected record, received array); with a FilterCondition object { status: { $ne: 'deleted' } }ACCEPTED, round-trips equal; on a text field — ACCEPTED, so the visibleWhen is a meaningfulness gate, not a parse gate — as described. Its literal set equals the reference row's gate (data.type == 'lookup' || data.type == 'master_detail', line 78).
    • defineForm normalises the predicate to { dialect: 'cel', source }, and the lint corpus test that resolves every shipped predicate against its schema reports nothing for it at this head (measured: 53 of that file's 54 tests pass; the one that fails is the count, below).
    • Face claim — PARTLY WRONG. The PR names FilterConditionField in @object-ui/fields as "the registered widget". The renderer that consumes METADATA_FORM_REGISTRY is the metadata-admin SchemaForm, whose own WIDGETS map at the pin (packages/app-shell/src/views/metadata-admin/widgets.tsx) registers filter-builder, condition, ref:object, … and no filter-condition; no $ref/$defs resolution exists anywhere in that directory at the pin. resolveFieldFace therefore lands on { kind: 'raw-json', hint: 'filter-condition' } — the announced raw-JSON editor, the same face summaryOperations.filter gets today. Consequence for the wire: none — that editor hands JSON.parse output through verbatim and the save door judges it (③). The PR's own hedge ("never worse than the Source tab it replaces") holds; its naming of the face does not. Prose correction, not a contract defect.
  2. packages/spec/src/data/object.form.tsvalidations row, widget: 'json'. Classification "passthrough door onto a declared key, no reconciliation, no new primitive" — RIGHT as to the wire; one precision note.

    • Served node reproduced the same way: type: 'array', items: { $ref: '#/$defs/__schema1' }#/$defs/__schema2oneOf of six inline object members with type consts script, state_machine, format, cross_field, json_schema, conditional — the double hop, as described.
    • Wire: the helpText's own worked example parses — ACCEPTED (the parse applies defaults active/events/priority/severity and folds condition to { dialect, source }; the save door stores request.item verbatim, so what round-trips is the author's text); [{ foo: 'bar' }]REFUSED; an object instead of an array — REFUSED; a state_machine member missing message — REFUSED with the path named. Nothing the row can carry is stored unjudged.
    • Face at the pin: WIDGETS miss → not an object form → objectRowSchema finds no object items behind the unresolved $refjson is in KNOWN_PASSTHROUGH_WIDGETS → scalar chain declines an array of non-scalars → last-resort { kind: 'raw-json' }. Same face as the siblings the PR cites (permission.rowLevelSecurity, email_template.variables). Precision note: json is not "a passthrough widget that derives nothing" — the passthrough set is consulted after the structural fallbacks, so the hint reaches the raw editor today only because an unresolved pointer derives nothing. Once the pin moves past objectui's pointer resolution, the same hint on this node derives an object-rows repeater over the first oneOf branch (script). That is the renderer's precedence, not this repo's contract; carried into ③ Q2.
  3. packages/spec/src/system/metadata-form-declared-rows.pin.test.ts (new). Test-only, no surface. Runs green at the head together with metadata-form-zod-reconciliation.test.ts (2 files, 52 tests). Its registry-wide walk's lit control reaches exactly field.relatedListFilter and field.summaryOperations.filter before reporting the misrouted set empty — the negative half can fail. Classification RIGHT.

  4. packages/platform-objects/src/apps/translations/*.generated.ts (7 files, +44). node scripts/check-i18n-bundles.mjs in check mode at the head: OK (9 package(s) — all bundles in sync, no undeclared authoring keys) — the regeneration is exact. The new keys metadataForms.field.fields.relatedListFilter.* and metadataForms.object.fields.validations.* are data in a published package's generated bundle; the exported constants' type annotation (NonNullable of TranslationData['metadataForms']) is unchanged. Classification "mechanical consequence, patch" — RIGHT.

  5. What the served surface actually gains. getMetaTypes() (packages/metadata-protocol/src/protocol.ts, TYPE_TO_FORM = METADATA_FORM_REGISTRY) serves entries[].form, so GET /api/v1/meta/types — route ledger disposition server-only, Studio's read — carries two more rows. Rows are array elements with the keys every row already has (field, widget, helpText, visibleWhen, span); no new key appears in any payload's shape. The implementer's "no wire byte moves and no export changes" is RIGHT for the declared surface and, read literally, wrong for the runtime payload, which the PR itself names in the next sentence.

  6. What the diff does NOT carry, and why this head is red. packages/lint/src/validate-predicate-path-refs.test.ts:559 pins the shipped-predicate corpus at 52 with a per-change census note. The new visibleWhen makes it 53 — measured at the head by an independent walk (field::relatedListFilter::data.type in ['lookup','master_detail'] is the one added entry). Test Core (2/6) on this head fails on exactly that assertion (expected 53 to be 52, job 105870414358); Test Core (2/6) on the merge-base eeaa882459 is success, so the same-signature exemption does not apply, and Test Core is one of the seven required contexts. The implementer's report ran the spec and platform-objects packages only; its "exit 0" never covered the package its diff breaks, and its gate derivation left that job to CI. The remaining 53 tests in that file pass at this head, so what is owed is the count and its census note, not the predicate.

② Semver level

  • @objectstack/spec: minor — additive: two rows on the served form payload, a new authorable door onto keys already declared; nothing removed, renamed, narrowed or newly refused. Clause-②: yes takes at least minorCONSISTENT.
  • @objectstack/platform-objects: patch — regenerated bundles, additive keys, unchanged type; precedent in that package's CHANGELOG has earlier metadata-form bundle regenerations landing as fix(platform-objects) patch entries — CONSISTENT.
  • Not breaking. check-adr-0087-registration judges only changesets that declare a breaking change; none is declared, no disposition marker is owed and none is present — RIGHT. Check Changeset is green on this head. Noted, not a finding: the changeset body carries no Clause-② line (87 of 473 changesets on main do); the gates read it from the claim comment and the PR body, both yes.

③ Boundary flags

Clause-② declaration — STANDS; its stated basis is overturned. Claim comment 5740363216 declared yes on the ground that "adding rows to METADATA_FORM_REGISTRY puts new keys on a payload that packages/spec/api-surface-declarations/system.txt publishes". Measured: system.txt is byte-identical across the two trees and its line 56114 declares the registry opaquely (a Readonly record of string to FormView), so row contents were never on that surface; no new exported symbol; no new key on any declared or authorable surface. The implementer's falsification of that half is correct. The declaration itself stands on the charter's other arms: this is a conformance-class change — 「conformance 类 ⛔ 不机械化:填充已声明字段、在两个已发布码之间重选输入类都需判断」 — and 「claim 拿不准 ⇒ 按 yes 挂标走达档契约复核」; the spec lane owes this review each round regardless. By design; no fault of any seat. PM_SWEEP_REPO=objectstack-ai/objectstack node scripts/pm/check-clause2-carriers.mjs --pair 19182 read exit 0 at 2026-09-19T09:29Z — both carriers agree on the fixed spelling.

Do the two rows make an invalid document newly authorable, or only open a door onto keys the schema already declares? — A door, and only a door. Measured, not adopted: (a) both keys were declared before the diff (field.zod.ts:1501, object.zod.ts:2050), and no schema moved; (b) the wire readings in ① — rule array refused, FilterCondition object accepted, malformed or non-array validations refused; (c) the save door: saveMetaItem resolves the type's Zod through resolveOverlaySchemagetMetadataTypeSchema and safeParses request.item in an unconditional block (no draft/publish branch), throwing 422 INVALID_METADATA with the issue paths — every face's output is judged before storage, and the refusal names the field; (d) at the pin both rows render as the raw-JSON editor, whose onChange hands JSON.parse output through verbatim — no widget reshapes the value; (e) the filter-builder misroute the card warned of is pinned against by the new test and, even if routed, its array is refused at the door — loud, not silent. The implementer's reasoning holds for both widget choices; only its description of the rendered face was wrong.

Triage acceptance (grading, not a ruling), quoted as written: 「验收:该行在 object 表单里出现并能往返(写入→读回同值),⭐ 带一个已有行作点亮对照。」 The server half is measured here (parse accepts, the door stores verbatim; the pin's lit control is maxLength). The browser half — a real render of either row in the metadata-admin — is unmeasured by the PR (it says so) and by this review (no objectui renderer was run). Recorded; not a FAIL cause, because the wire is judged at the door regardless of face.

Open question 1 — stale liveness row for field.relatedListFilter. Verified: packages/spec/liveness/field.json still reads status: planned, authorWarn: true, flip condition "when objectui#4664 lands"; at the pinned objectui 53ded82bf7, packages/app-shell/src/utils/deriveRelatedLists.ts:276-277 reads fieldDef.relatedListFilter, and d796c8dde is an ancestor of the pin (git merge-base --is-ancestor, exit 0). So the row is stale and the os lint authorWarn hint is false against the pin — while this PR's helpText asserts the opposite in the same tree. Answer: A. The flip is a ledger act that the row's own note conditions on an evidence pointer at a named objectui sha; it now has its own carrier, #19187 (filed after the report). Folding it in (B) stays permissible and would close the contradiction in one stroke, but it is not owed here. Not a FAIL cause.

Open question 2 — structured repeater for object.validations. Answer: A — keep json. A structured face is objectui's contract, not this repo's, and per ① item 2 the face will change by itself when the pin moves past pointer resolution (the json hint's precedence derives object-rows over the first branch). Whoever bumps the pin owns re-measuring this row and its two shape-siblings (permission.rowLevelSecurity, email_template.variables) — that is a Console Pin Gate concern at bump time, not a card this repo needs now. The census of unoffered top-level keys the PR names has its own carrier, #19188.

Owed before re-review — the reason for the verdict: at packages/lint/src/validate-predicate-path-refs.test.ts:559, move the corpus pin from 52 to 53 with a census note in that file's own convention (differenced by form::field::source: one entry added, field :: relatedListFilter :: data.type in ['lookup','master_detail']; none removed), and run pnpm --filter @objectstack/lint test before pushing. Optional prose corrections in the PR body and changeset: name the pinned face as the announced raw-JSON editor with the filter-condition hint, and drop "derives nothing". Nothing else is owed; ①–③ are otherwise sound and a re-review of the next head should be short.

Implemented-by: claude/issue-19085-metadata-form-missing-rows — mode:subagent; the dev subagent's branch, read off claim comment 5740363216
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3 — mode:subagent; isolated contract-review subagent, recorded as the seat session that adopts this verdict

VERDICT: FAIL

Reading taken at 2026-09-19T09:37Z by the isolated contract-review subagent; scratch under the reviewing seat's scratchpad/pr-19182/; no label, draft, auto-merge or push was touched.


Generated by Claude Code

… relatedListFilter gate

The `relatedListFilter` row added for #19085 carries a `visibleWhen`, so the
shipped-predicate corpus the #7010 test pins grows by one. `Test Core (2/6)`
failed on exactly that assertion at the previous head; the merge base is green
on the same job, so no same-signature exemption applies.

Census written in the file's own convention and MEASURED rather than inferred
from the delta: the corpus was enumerated on this tree and on the merge base
eeaa882 and differenced by `<form>::<field>::<source>`. Exactly one entry
added -- `field :: relatedListFilter :: data.type in ['lookup','master_detail']`
-- and none removed. The card's second row, `object.validations`, carries no
predicate and does not enter the census.

Also adopts the two prose corrections from the contract review, in the form
comments and the changeset. Both were re-measured here against the pinned
`.objectui-sha` rather than taken from the review:

- the face is the ANNOUNCED RAW-JSON EDITOR carrying the `filter-condition`
  hint, not a criteria builder. The renderer consuming this registry is the
  metadata-admin `SchemaForm`, whose `WIDGETS` map registers no
  `filter-condition`; `resolveFieldFace` falls past the registry and both
  structural fallbacks (the pointer is unresolved) to `{ kind: 'raw-json' }`.
- `json` is a passthrough only AFTER the structural fallbacks, so "derives
  nothing" was imprecise: this row reaches the raw editor because the
  unresolved double-hop pointer derives nothing, not because the hint
  suppresses derivation.

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

os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 801690faaae1d01e8cde716caf00fb121b249c1e

Isolated, adversarial contract review of PR #19182 (card #19085), judged at the head above against its merge base eeaa882459 (three commits on top; the base is an ancestor of the head, measured with git merge-base). This record SUPERSEDES the earlier FAIL record — PR comment 5740807745, which judged head 608f0ed2619b98e800a37803027866cf061b6beb. That record was read as a record of what was owed, not as an instruction: every ①②③ conclusion below was re-derived on this head, first-hand, in scratch worktrees of the head and of the merge base (spec tests via source imports, the packages/lint suite against a freshly built closure, the objectui readings against the pinned .objectui-sha 53ded82bf7, which the diff does not move). The two os-dev-report comments on the card, the seat's claim and correction comments, and the seat's edit note at the foot of the PR body were read for what they assert; none of them is evidence here. No maintainer or director-seat ruling sits on the card thread; the triage grading's acceptance lines are quoted where this record leans on them.

① Derived judgments

Accept/reject behaviour of a published contract: 0 changes. The diff touches no *.zod.ts, no ADR-0087 conversion registry, no tombstone. FieldSchema still declares relatedListFilter: FilterConditionSchema.optional() (field.zod.ts:1501) and ObjectSchema still declares validations: z.array(ValidationRuleSchema).optional() (object.zod.ts:2050), byte-for-byte as at the base. Measured: packages/spec/api-surface-declarations/, api-surface/, authorable-surface/ and json-schema.manifest/ are each byte-identical between the two trees (git diff --quiet, all four); pnpm --filter @objectstack/spec run check:generated at the head reads All 16 generated artifacts are up to date, exit 0. No export is added or removed.

Item by item, with the implementer's classification judged:

  1. packages/spec/src/data/field.form.tsrelatedListFilter row, widget: 'filter-condition', visibleWhen: "data.type in ['lookup','master_detail']". Classification "form-face addition on an already-declared key, no wire byte moves" — RIGHT.

    • Served node, reproduced with the call getMetaTypes() makes (z.toJSONSchema(FieldSchema, { unrepresentable: 'any' }), protocol.ts:477): { description, $ref: '#/$defs/__schema0' }; the target is an allOf of two members — an open record (additionalProperties a schema) and an object declaring $and/$or/$not — with no top-level type. As described.
    • Wire at the door (FieldSchema.safeParse on a lookup field): a rule ARRAY — what filter-builder writes — REFUSED (relatedListFilter: Invalid input: expected record, received array); { status: { $ne: 'deleted' } }ACCEPTED, round-trips equal; an $and/$or composite — ACCEPTED; a bare string — REFUSED; the same object on a text field — ACCEPTED, so the visibleWhen is a meaningfulness gate, not a parse gate. As described.
    • defineForm normalises the predicate to { dialect: 'cel', source }; every shipped predicate still resolves against its schema (the lint corpus file at the head: 54 of 54 tests pass).
    • This round moved only the comment block above the row (the round diff 608f0ed261..801690faaa on this file is 29 lines, all inside the comment); the row line itself is untouched.
  2. packages/spec/src/data/object.form.tsvalidations row, widget: 'json'. Classification "passthrough door onto a declared key, no reconciliation, no new primitive" — RIGHT.

    • Served node reproduced the same way: type: 'array', items: { $ref: '#/$defs/__schema1' }; __schema1 is itself { $ref: '#/$defs/__schema2' }; __schema2 is a oneOf over six members whose type consts are script, state_machine, format, cross_field, json_schema, conditional. The double hop, as described.
    • Wire at the door (ObjectSchema.safeParse): the helpText's own worked example — ACCEPTED (the parse applies defaults active/events/priority/severity and folds condition into a { dialect, source } envelope); [{ foo: 'bar' }]REFUSED (validations.0.type: Invalid discriminator value, the six members named); an object instead of an array — REFUSED; a state_machine member missing message — REFUSED with the path named; a bare string — REFUSED. Nothing the row can carry is stored unjudged.
    • This round moved only the comment block above the row (21 lines, all comment); the row line is untouched.
  3. packages/spec/src/system/metadata-form-declared-rows.pin.test.ts (new; unchanged this round). Test-only, no surface. Green at the head together with metadata-form-zod-reconciliation.test.ts and field.test.ts (3 files, 320 tests). Its negative halves were ablated in a separate scratch worktree of this same head: flipping the relatedListFilter widget to filter-builder fails 2 of its 6 tests (the routing pin and the registry-wide walk, which names field.relatedListFilter as misrouted); deleting the validations row fails 1 of 6. The pin can fail. Classification RIGHT.

  4. packages/platform-objects/src/apps/translations/*.generated.ts (7 files, +44). The added entries are metadataForms.field.fields.relatedListFilter.{label,helpText} and metadataForms.object.fields.validations.{label,helpText} in the four locale bundles plus their three source-hash rows; no existing line moves and the exported constants' type annotations are unchanged. node scripts/check-i18n-bundles.mjs in check mode at the head, against a freshly built CLI closure: OK (9 package(s) — all bundles in sync, no undeclared authoring keys), exit 0 — the regeneration is exact. Classification "mechanical consequence, patch" — RIGHT.

  5. packages/lint/src/validate-predicate-path-refs.test.ts — the shipped-predicate corpus pin, 52 to 53 — the change the patch round owed. Measured rather than adopted: an independent walker over METADATA_FORM_REGISTRY imported from source (the same node set the test's counting walk visits — every object node's visibleWhen/visibleOn, string or { source } envelope — keyed form::field::source) reads 17 forms / 52 predicates at the merge base eeaa882459 and 17 forms / 53 at the head; the line difference is exactly one added entry, field :: relatedListFilter :: data.type in ['lookup','master_detail'], and none removed; object :: validations carries no predicate (0 entries on either tree). 53 is the right number. The census note follows the file's own running convention (count, direction, the card, the differenced entries, the "measured rather than inferred from the delta" clause, and the note that the card's second row carries no predicate). The file at the head: 54 of 54 tests pass; the whole @objectstack/lint suite at the head: 105 files, 3978 tests pass, exit 0. On GitHub, Test Core (2/6) — the job that failed on 608f0ed261 with expected 53 to be 52 — is success at this head, as are all six Test Core shards. Classification RIGHT.

  6. What the served surface gains. getMetaTypes() serves entries[].form from TYPE_TO_FORM = METADATA_FORM_REGISTRY (packages/metadata-protocol/src/protocol.ts:190, read at :6524), so GET /api/v1/meta/types carries two more rows. Each row is an array element carrying only keys every row already has (field, widget, helpText, visibleWhen); no payload's shape gains a key. The changeset's "no wire byte moves and no export changes" is RIGHT for the declared surface and, read literally, wrong for the runtime payload — which the changeset itself names in its next sentence. Not a defect.

  7. What else moved this round, and whether the round owed it. 608f0ed261..801690faaa touches four files, +54/−17: the lint test (item 5, owed), field.form.ts and object.form.ts (comment blocks only, items 1–2), and .changeset/19085-metadata-form-declared-rows.md (the two face bullets rewritten). The three prose edits are the optional corrections the earlier record named; no row, widget, visibleWhen, helpText, translation bundle or test moved. Nothing moved that the round did not owe. The PR body's edit note is a seat statement and was not used as a measurement; read on its own, the body's two face bullets now agree with the changeset text and the form comments.

  8. Governed surfaces and size. None of the twelve paths is under docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md or CLAUDE.md; Governed Surface Queue Guard is success. +328/−1 across 12 files, under the 5,000-line human-merge threshold.

② Semver level

  • @objectstack/spec: minor — additive: two rows on the served form payload, a new authorable door onto keys already declared; nothing removed, renamed, narrowed or newly refused. Clause-②: yes takes at least minor (AGENTS.md Post-Task Checklist step 3), and the gate's own rule (scripts/check-changeset-no-major.mjs header: a PR that declares clause ② must grade at least one package whose packages/**/src/** it moves at minor or above) is met by this line — CONSISTENT.
  • @objectstack/platform-objects: patch — regenerated generated bundles, additive keys, unchanged types; the declaration is PR-scoped, so a patch beside the minor is legal — CONSISTENT. Both packages are published (private: false, 17.4.0).
  • Not breaking. No major, no skip-changeset, no ADR-0087 disposition marker owed and none present; Check Changeset is success at this head. Noted, not a finding: the changeset body carries no Clause-② line; the gates read it from the claim comment and the PR body, both yes.

③ Boundary flags

Clause-②: yes — STANDS on this head; its stated basis is overturned. Claim comment 5740363216 declared yes because "adding rows to METADATA_FORM_REGISTRY puts new keys on a payload that packages/spec/api-surface-declarations/system.txt publishes". Measured: that directory is byte-identical across the two trees, and the registry is declared there opaquely (a read-only record from string to FormView), so row contents were never on that surface. The declaration stands on the charter's other arms — 「conformance 类 ⛔ 不机械化:填充已声明字段、在两个已发布码之间重选输入类都需判断」 and 「claim 拿不准 ⇒ 按 yes 挂标走达档契约复核」 — and the spec lane owes this review each round regardless. By design; no fault of any seat. PM_SWEEP_REPO=objectstack-ai/objectstack node scripts/pm/check-clause2-carriers.mjs --pair 19182 read exit 0 at 2026-09-19T10:35Z against this head: both carriers agree on the fixed spelling.

The two prose corrections the implementer accepted, measured at the pin 53ded82bf7 — both RIGHT; neither replaces a false claim with another.

  • (a) The face at the pin is the announced raw-JSON editor carrying the filter-condition hint, not FilterConditionField. The renderer that consumes this registry is the metadata-admin SchemaForm (packages/app-shell/src/views/metadata-admin/SchemaForm.tsx). Its WIDGETS map (widgets.tsx:2557–2578) holds twenty keys — ref:object, filter-mode, filter-builder, condition, master-detail, code, … — and no filter-condition and no json. A row's schema is props[f.field] (SchemaForm.tsx:1051), the served node as-is: no $ref/$defs resolution exists in SchemaForm.tsx at the pin (grep over the directory: none outside a comment and a preview). resolveFieldFace (SchemaForm.tsx:453–538) then runs registry lookup → object-form check → array-of-objects check → passthrough check; with the pointer unresolved the node has no type, so both structural checks decline, and filter-condition is not in KNOWN_PASSTHROUGH_WIDGETS, so it returns { kind: 'raw-json', hint: 'filter-condition' } — the same face the existing summaryOperations.filter row gets. FilterConditionField lives in packages/fields/src/widgets/FilterConditionField.tsx, registered in packages/fields/src/index.tsx:2814's field-widget loader map; SchemaForm.tsx imports nothing from @object-ui/fields and names no FieldEditWidget or ComponentRegistry. So the earlier body's "registered widget" naming was wrong and the correction is right. Two further readings the correction does not state but that make it safe: that far-end widget's own storage contract is "a MongoDB-style object filter" — the FilterCondition wire — so even the forward-looking half of the hint would not write a refused shape; and with the pointer resolved (counterfactual, simulated on the resolved allOf) the face is still raw-json with the hint, because the target still has no top-level type.
  • (b) json's passthrough is consulted AFTER the structural fallbacks, not instead of them. Inside the if (widget) block the order is registry → isObjectForm(effective)objectRowSchema(effective)if (!KNOWN_PASSTHROUGH_WIDGETS.has(widget)) return raw-json with hint; json IS in that set (SchemaForm.tsx:85–92), so the row falls through to the scalar chain (an array of non-scalars declines) and the last-resort structural pass (an unresolved $ref derives nothing) and lands on { kind: 'raw-json' }. The earlier "derives nothing" was imprecise in exactly the way the correction says: the raw editor is reached because the pointer is unresolved, not because the hint suppresses derivation. Counterfactual with both hops resolved (simulated on __schema2): objectRowSchema resolves the union with no value to its first branch, script, an object with properties, so the face becomes object-rows — the consequence the comment and changeset now name, and correctly assigned to the renderer's precedence rather than this repo's contract.

Do the two form rows let an author write a document the runtime refuses? — NO, measured. (i) Both faces at the pin are the raw-JSON editor, whose onChange hands JSON.parse output through verbatim (SchemaForm.tsx:2583–2585; empty text becomes undefined) — no control reshapes the value. (ii) The door: saveMetaItem (protocol.ts:15258) resolves the type's Zod through resolveOverlaySchemagetMetadataTypeSchema and safeParses request.item in an unconditional block — mode decides only the stored state at draft/publish, never whether the parse runs — throwing 422 INVALID_METADATA with the issue paths; what it then persists is the authored body with exactly two grafts from the parsed value (a differing operator scalar, and a groupssections fold), neither of which touches either key, so what round-trips is the author's text. (iii) The door readings in ① items 1–2: the only control whose storage shape the schema rejects is filter-builder (its value is a FilterRuleLite[], widgets.tsx:2181), and neither row routes there — the misroute the card warned of is refused at the door and pinned against by a test whose negative half was ablated red above. No row routes a key to a control whose storage shape the schema rejects.

Triage acceptance (grading, not a ruling), quoted as written: 「验收:该行在 object 表单里出现并能往返(写入→读回同值),⭐ 带一个已有行作点亮对照。」 and 「field.relatedListFilter —— ⛔ 不得直接指到 filter-builder」. The server half of the first is measured (the row is offered; the worked example parses; the pin's lit control is maxLength); the second is honoured, measured and pinned. The browser half — a real render of either row in the metadata-admin — is unmeasured by the PR (it says so) and by this review (no objectui renderer was run). Recorded; not a FAIL cause, because the wire is judged at the door regardless of face.

Open question 1 — the stale liveness row for field.relatedListFilter. Verified on this tree: packages/spec/liveness/field.json still reads status: planned, authorWarn: true, with a note conditioning the flip on objectui#4664; at the pin, packages/app-shell/src/utils/deriveRelatedLists.ts:276–277 reads fieldDef.relatedListFilter. Answer: A — a ledger act with a cross-repo evidence pointer, carried by #19187 (open). Not owed here; not a FAIL cause.

Open question 2 — a structured repeater for object.validations. Answer: A — keep json; the face changes by itself when the pin moves past pointer resolution (③(b)), and whoever bumps the pin owns re-measuring this row and its shape-siblings permission.rowLevelSecurity and email_template.variables. The 276-key census of unoffered top-level keys is carried by #19188 (open).

Checks at this head: all seven required contexts — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — read completed / success; no check on this head is red. Locally at the head: @objectstack/lint 105 files / 3978 tests pass; @objectstack/platform-objects 40 files / 575 tests pass (against a built closure; a first run without it refused on unresolvable @objectstack/metadata-core and @objectstack/core entries — a prerequisite, not the diff); spec check:generated exit 0.

Owed before landing: nothing. The change the earlier record named is discharged and its number is right; the two accepted corrections are themselves correct at the pin; no undeclared change rode in with the round.

Implemented-by: claude/issue-19085-metadata-form-missing-rows — mode:subagent; the dev subagent's branch, read off claim comment 5740363216
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3 — mode:subagent; isolated contract-review subagent, recorded as the seat session that adopts this verdict

VERDICT: PASS

Reading taken at 2026-09-19T10:45Z by the isolated contract-review subagent; scratch under the reviewing seat's scratchpad/pr-19182-r2/; no label, draft, auto-merge or push was touched. This PASS supersedes the FAIL in PR comment 5740807745 (head 608f0ed).


Generated by Claude Code

os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Clause-② provenance — carriers cleared on an at-tier PASS (second head, superseding a FAIL)

domain:spec seat 2 (session_01JbZnqu8bt6YqfJsr9vaFb3), 2026-09-19T10:48Z.

Record adopted: comment 5741170393, a ## Contract review naming head 801690faaae1d01e8cde716caf00fb121b249c1e — the head this PR carries now. Verdict PASS, and it states on its face that it supersedes the earlier FAIL (5740807745) at head 608f0ed2619b98e800a37803027866cf061b6beb. Adopted verbatim — ⛔ nothing rewritten, trimmed or polished.

Both records stand on the thread. The FAIL is ⛔ not deleted and ⛔ not edited: it is the record of what was true at that head, and the patch round is legible only beside it.

Tier, measured rather than taken from the reviewer's word. A fresh isolated at-tier subagent, fed only the card, its rulings and this PR, told that seat comments and the os-dev-report are not evidence, and told that the earlier FAIL is a record to re-derive from, ⛔ not conclusions to adopt. Evidence is the subagent's own transcript: 146 of 146 type:"assistant" lines stamped claude-fable-5-1 against CONTRACT_REVIEW_TIER imported live; 0 at any other stamp. ⛔ get_session not used.

Carriers cleared in one act, both read back at 2026-09-19T10:47Z: card #19085bug, priority:p2, pm:dispatched, domain:spec; this PR → documentation, size/m, tests, tooling, protocol:data, protocol:system.

Clause-②: yes STANDS, on the conformance arm — ⚠️ and the review records that its stated basis is false: packages/spec/api-surface-declarations/system.txt is byte-identical base→head, because METADATA_FORM_REGISTRY is declared there opaquely, so no new key lands on a declared surface. The implementer falsified that basis itself in round 1; the declaration survives on the other half (the runtime form payload and the metadataForms.* translation keys), ⛔ not on the half this seat cited when it claimed.

Landing preconditions, each measured at 2026-09-19T10:47Z, ⛔ not recalled:

check reading
① at-tier PASS of record on this head ✅ 5741170393; --pair 19182 C6-RECORD names it, Served-tier: reads at tier
② both carriers cleared, machine-read check-clause2-carriers --pair 19182 true exit 0
③ every check green, ⛔ not the required subset ✅ 35 check-runs, 0 non-success; the two contexts that were RED at the previous head — Test Core and Test Core (2/6) — re-read by name as success, ⛔ not inferred from a count; all 7 required contexts green
governed surface / size check-governed-merges --pr 19182 exit 0 — 0 of 12 paths governed; 329 lines ≤ 5000
os-regen git check-attr merge read per file: all 12 read unspecified ⇒ no regeneration sequence owed

⚠️ This body was edited by the seat at 2026-09-19T10:29Z, before this review was posted, to correct four of its own claims about which renderer consumes these rows — the note at the foot of the body says which four and why. The review therefore judged the corrected body, and it independently re-measured both corrections at the pinned .objectui-sha rather than adopting them.


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 19, 2026 10:48
@os-bill
os-bill added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 74554a3 Sep 19, 2026
47 checks passed
@os-bill
os-bill deleted the claude/issue-19085-metadata-form-missing-rows branch September 19, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants