Skip to content

finding(gate): the #3786 metadata form ↔ Zod reconciliation walks only ONE level of nested form lists, so every repeater inside a nested list is unreconciled — 6 paths, and it is why the options icon offer survived #14327

Closed

Description

@claude

Found while implementing #13671 (the object.form options repeater offering an icon input SelectOptionSchema refuses). Filed unassigned and NOT fixed there — it is the structural cause of that card rather than that card's scope.

The gate exists, and it could not see the defect

packages/spec/src/system/metadata-form-zod-reconciliation.test.ts (#3786) is the reconciliation between every defineForm layout in METADATA_FORM_REGISTRY and the Zod schema it names keys of. Its own header states the rule that matters here, verbatim:

form-only (the form offers a key the Zod does not accept) is always a defect. There is no design under which an author should be shown a control whose value is discarded. Not ledgerable.

#13671 is precisely that shape — the options repeater offered icon, SelectOptionSchema is strict and never declared it, so every value typed there was an unrecognized_keys refusal at publish. The gate was green throughout.

Why: the traversal is one level deep

nestedLists(form) walks form.sections[].fields[] and collects a nested list only for a top-level entry that carries its own fields array. It never descends into entry.fields[*].fields, so a repeater or composite nested inside another nested list is not reconciled against anything — it is not "reconciled loosely", it is outside the population entirely.

object.form.ts is exactly that shape: the fields record editor is the top-level nested list the gate reconciles, and the options repeater lives one level further in.

Measured

Mirroring the gate's own traversal against a full-depth one over METADATA_FORM_REGISTRY (17 registered forms) gives 6 nested lists at depth 2 or deeper that the gate never reaches, all in object:

unreconciled path inputs it offers schema that should judge them
fields.options label, value, color, description SelectOptionSchema
fields.summaryOperations object, field, function the summary-operation shape on FieldSchema
lifecycle.retention maxAge the retention shape on ObjectSchema
lifecycle.ttl field, expireAfter the ttl shape
lifecycle.storage strategy, shards, unit the storage shape
lifecycle.archive after, to, keep the archive shape

(fields.options is listed post-#13671, i.e. already with icon removed by hand. The other five have never been checked by anything.)

Why it is worth closing as a class

The offer-vs-door class has now been retired by hand three times — #11410 (deleteBehavior offering a value a master_detail refuses), #12868 (the form-face option narrowing) and #13671 (options[].icon) — each time by a person or an agent noticing, never by a gate. The gate that exists to abolish hand-copied key lists is itself blind at depth 2, which is where the repeater lists live. The five unchecked paths above are the current exposure.

Shape of the work

Make nestedLists recursive, keyed by a dotted path, and resolve each level's sub-schema by walking subSchemaOf down the same path (the helper already looks through unions and unwrap already peels arrays and records, which is what a repeater's element needs). The existing LEDGER is already path-keyed, so the subset / omit vocabulary carries over unchanged; expect the five never-checked paths to need triage on first run, and expect at least one of them to be a real finding rather than a ledger entry — that is the point.

Not urgent enough to ride #13671's PR: that one is a ruled single-key removal with Clause-②: no, and widening it into a gate change would put an unrelated failure surface in the same diff.

Refs: #13671 (the instance) · #3786 (the gate) · #5280 (the previous time this gate's predicate was found too narrow, from the other direction — tombstoned keys reading as accepted) · #11410 · #12868.

Generated by Claude Code


Generated by Claude Code

Activity

  1. changed the title [-]finding(gate): the #3786 metadata form ↔ Zod reconciliation walks only ONE level of nested form lists, so every repeater inside a nested list is unreconciled — 6 paths, and it is why the options offer survived[/-] [+]finding(gate): the #3786 metadata form ↔ Zod reconciliation walks only ONE level of nested form lists, so every repeater inside a nested list is unreconciled — 6 paths, and it is why the options `icon` offer survived[/+] on Sep 2, 2026
  2. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage (R+90, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): pm:queue · priority:p2 · domain:spec · type Bug.

    Lands in: packages/spec/src/system/metadata-form-zod-reconciliation.test.ts — make nestedLists recursive and path-keyed, resolving each level's sub-schema by walking subSchemaOf down the same dotted path (the helper already looks through unions; unwrap already peels arrays and records, which is what a repeater's element needs). The existing LEDGER is already path-keyed, so its subset / omit vocabulary carries over unchanged.

    p2 rather than p3: the offer-vs-door class has been retired by hand three times (#11410, #12868, #13671) and never once by the gate that exists to abolish it, and the gate's own header calls form-only offers "always a defect, not ledgerable". Five paths (fields.summaryOperations, lifecycle.retention / .ttl / .storage / .archive) have never been checked by anything — that is the current exposure, not a hypothetical.

    Binding on the dispatch: the five paths get triaged on first run, not ledgered by reflex. A path that turns out to offer a key its schema refuses is a real finding and gets its own card (and the offer removed); the LEDGER is only for a difference that is designed. ⛔ Do not grow the ledger to make the gate green — that inverts the gate.

    Family: #13671 (the instance, pm:dispatched) · #3786 (the gate) · #5280 (the same predicate found too narrow from the other direction). Not folded into #13671's PR by that card's own scope.

    Size/model suggestion: M, opus.


    Generated by Claude Code

  3. added
    bugSomething isn't working
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Sep 2, 2026
  4. added theissue type on Sep 2, 2026
  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 2, 2026
  6. claude commented on Sep 2, 2026

    @claude
    ContributorAuthor

    Claim: PM loop, domain:spec seat (seat post #6017)
    Session: session_01GDA48PuRFrHyRfdkBz8m21
    Branch: claude/issue-14327-form-reconciliation-depth
    Worktree: objectstack-issue-14327
    Domain: domain:spec
    File surface: packages/spec/src/system/metadata-form-zod-reconciliation.test.ts (sole writer: recursive, path-keyed nestedLists + per-level subSchemaOf walk + self-test pins). Conditional: packages/spec/src/**/*.form.ts only where the widened gate surfaces a form-only offer (removed here, one finding card per path for the record) — packages/spec/src/data/object.form.ts is serial behind #13671 / PR #14326 (in CI now): no edit there until that PR is on origin/main, then merge origin/main first. LEDGER entries only for designed differences (triage binding, comment 5503578201: 「the five paths get triaged on first run, not ledgered by reflex … ⛔ Do not grow the ledger to make the gate green」).
    Container & model: M, mode:subagent, served claude-fable-5-1 — dispatch-gates --tier: no path mandate, Clause-② SUSPECT surface; judged from content: a surfaced form-only offer is withdrawn in this PR, the same authoring-surface narrowing class as #13671 (reviewed under Clause-② yes), so the ceiling tier applies (triage's opus suggestion noted; the fuse literal reads claude-fable-5, posture on #14303).
    Clause-②: yes (declared on both carriers at PR time; the enqueue gate reads the actual diff).
    Serial constraints: no other in-flight writer of the reconciliation test (open PR list read 03:18Z); object.form.ts as above; on-hold trigger-file index (#9857 H17) names none of these paths.


    Generated by Claude Code

  7. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  8. claude commented on Sep 2, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 14327,
      "status": "done",
      "branch": "claude/issue-14327-form-reconciliation-depth",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14408",
      "head": "3b3203d23",
      "premise_still_valid": true,
      "summary": "The #3786 gate's `nestedLists` is now recursive and keyed by dotted path, each level's sub-schema resolved by walking `subSchemaOf` down the same path; the nested-list predicate is extracted as `reconcileNestedLists` and pinned against a synthetic depth-2 fixture (form-only and tombstone positive controls; subset/omit negative controls, including a parent-path row that must excuse nothing one level down), and the nested assertions are `expect.soft` so one run names every drifted list. Population over all 17 registered forms went 9 -> 15 hand-written lists; the 6 newly reached are exactly the card's table, all in `object`. First-run triage from the gate's own output: one form-only offer (`fields.options.icon` = the already-carded #13671, withdrawn by #14326, which merged at 04:00Z and was merged into this branch at 04:05Z after five empty serial-constraint reads; this PR edits no `*.form.ts`, ships no changeset, carries `skip-changeset`), four designed zod-only differences ledgered (`fields.options` and `fields.summaryOperations` as `subset` rows inheriting the parent quick-add design, with `field.form.ts` measured to offer the long tail; `lifecycle.retention.onlyWhen` and `lifecycle.ttl.onlyWhen` as `omit` rows, every writer measured as a code-declared platform system object), `lifecycle.storage` and `lifecycle.archive` clean. Labels: `needs:contract-review` on PR and card, `skip-changeset` on PR, each an additive REST write with comparative read-back (union held); `protocol:system`, `size/m`, `tests` were on the PR before my write (bot-applied, left as found). Note for triage, not a decision request: offering `onlyWhen` in Studio would be a form-face addition (structured control + objectui rendering), outside this gate's scope.",
      "tests": "Final head 3b3203d23 = merge of origin/main 7ad57e17a (carries #14326 e80889095) into ce1b37901, nothing committed after. Under scripts/pm/os-verify-lock.sh: `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/metadata-form-zod-reconciliation.test.ts` -> `Test Files 1 passed (1) / Tests 46 passed (46)` (41 on origin/main; VERDICT command-exit 0); `pnpm --filter @objectstack/spec typecheck` -> tsc clean, `check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) held` (ledger unchanged); full spec suite in three lock runs with `--maxWorkers=2` and no bare `--`: `Test Files 212 passed` + `229 passed` + `10 passed` = 451 files, `Tests 6213 + 5624 + 280 = 12117 passed`, 0 failed (covers every src/** subdirectory, scripts/, and the ten src/*.test.ts at the package root). Unlocked, exits captured before any pipe: `pnpm --filter @objectstack/spec build` then `check:generated` -> `✓ All 15 generated artifacts are up to date.` (tree clean after build); `pnpm check:nul-bytes` -> `check-nul-bytes: OK (scanned 7849 text file(s) ... no raw ASCII control bytes)`; `pnpm check:doc-authoring` exit 0; the 47 families from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; --repo held): 42 exit 0, 5 NOT MEASURED by their own PREREQUISITE NOT MET text (check-dev-prereqs exit 1, check-test-completeness 3, lint check:doc-formula-expressions 3, check:dual-build-cjs-loads 3, check:type-check-debt 3 — all need the whole workspace built; CI's run for a test-only diff). Not run: check:i18n (no *.form.ts moved), pnpm lint (repo-wide; CI's; no narrowed measurement claimed). Triage evidence: the widened gate's first run on the pre-#14326 base named every gap itself — `object.fields.options ... expected [ 'icon' ]` (form-only), `[ 'default', 'visibleWhen' ]`, `object.fields.summaryOperations ... [ 'filter', 'relationshipField' ]`, `object.lifecycle.retention ... [ 'onlyWhen' ]`, `object.lifecycle.ttl ... [ 'onlyWhen' ]`; with the four ledger rows only the `icon` red remained, and it cleared on the merge. Pre-merge full suite on ce1b37901: 451 files / 12113 tests, 1 failed = that expected `icon` case. No ablation: test-only diff; the synthetic positive controls in the file are the red proof of the new predicate.",
      "mcp_calls": "0 — every GitHub read and write went through authenticated REST (curl) and local git; no MCP GitHub call was made",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #14407: the #14326 changeset `object-form-option-icon-offer-withdrawn.md` says the options repeater offers 'exactly SelectOptionSchema's authorable keys minus visibleWhen' — `default` is authorable and not offered either (the widened gate's first run measured `[ 'default', 'visibleWhen' ]`); the text ships verbatim into the published @objectstack/spec CHANGELOG. Unassigned, no labels."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  9. claude commented on Sep 2, 2026

    @claude
    ContributorAuthor

    ACCEPT — PR #14408 (head 3b3203d2), reviewed against GitHub, not the report.

    Triage binding honoured (comment 5503578201: triage on first run, never ledger a form-only offer)

    Contract review (Clause ②) — PASS, in-seat

    Pre-checks (head 3b3203d2)

    • Governed-surface test: 0 hits / 1 file. Clause ② carriers agree (check-clause2-carriers.mjs --pair). Trial merge against origin/main (4485f7da): CLEAN; main has not moved on the file since the merge base.
    • Test-only diff ⇒ skip-changeset on the PR (present); the changeset check's earlier failure on this head was superseded by the run after the label event (skipped).
    • Closing keywords: only Fixes #14327 (the card is the gate depth; delivered whole).
    • Gate union: 47 derived families, 42 exit 0, 5 NOT MEASURED by their own prerequisite text (whole-workspace build) — CI's for a test-only diff; accepted as declared narrowing, every check on the head must still be green before the flip.

    Recorded, not a change request: the dev filed #14407 (the #14326 changeset's "minus visibleWhen" wording also omits default) for triage; and the note that offering onlyWhen in Studio would be a form-face addition outside this gate is left with triage as an observation.

    Landing


    Generated by Claude Code

  10. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  11. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:specDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions