Skip to content

A Studio-saved form authored with groups still degrades on the REST public-form routes — the producer fold does not reach stored rows #7134

Closed

Description

@os-zhuang

Found while implementing #6926 (PR #7128), which folds FormViewSchema.groups onto sections at the producer. Filed, not fixed: the remaining half lands in packages/metadata-protocol, outside that PR's lane, and #6926's dispatch explicitly required reporting the placement rather than improvising it.

What #7128 fixed, and what it did not

#7128 makes the declared groups alias true for every consumer of a parsed form. The three /forms/:slug routes resolve match.form through protocol.getMetaItems({ type: 'view' }), which has two sources, and the fold reaches only one:

Source Path Folded after #7128
Code-authored views defineView / defineForm → ViewSchema.parse / FormViewSchema.parse → SchemaRegistry YES
Runtime-saved rows sys_metadata → convertStoredItem (the ADR-0087 stored-row conversion chain, not a zod parse) NO

So a form authored with groups and saved through Studio still reaches packages/rest/src/rest-server.ts carrying groups, and every public-form read there walks sections only. The three degradations #6926 described survive for that path:

Why the fix is cheap, and where it goes

saveMetaItem already parses the view through ViewMetadataSchema (view maps to it in packages/spec/src/kernel/metadata-type-schemas.ts), so as of #7128 it already computes the folded body — and then discards parsed.data on purpose, to keep the Studio-only round-trip keys (isPinned, isDefault, sortOrder) that a wholesale swap would strip.

There is an exact in-repo precedent for grafting exactly one normalization back out of that discarded result — graftNormalizedOperators, whose docstring states the argument this issue reuses verbatim:

The alias table VIEW_FILTER_OPERATOR_ALIASES therefore keeps acquiring new rows with every save, which is why it can never be retired: there is no point at which the last alias row is behind you.

That is precisely the situation groups is in: while saves keep persisting the authored spelling, the alias can never be retired, and the objectui boundary folds that #6926 expected to become dead code cannot actually be removed.

⚠️ Implementation note measured, not assumed: graftNormalizedOperators walks the authored and parsed values in lockstep by structure and copies across a changed scalar (operator). A groups fold is a key move — one key removed, another added — which that walk cannot express. This needs a sibling helper, not a new parameter on the existing one.

Guardrail (still binding, from #6926)

Do not fix this by adding ?? match.form?.groups in rest-server.ts. A lenient consumer is where AI-authored metadata errors hide, and it leaves the next consumer blind. The producer fold is already in place; what is missing is that the save path keeps the folded body.

Reachability

Reachable today by any user who authors a public form with groups in Studio rather than in code. No shipped example app authors groups, so this is not a live production report — but unlike the original #6926 filing, this one is a real user-facing path rather than an authoring-time hazard, so it is filed unlabeled for triage rather than as observation-class.

Related: #6926 (the producer fold), PR #7128, #6920 (the submit-side degeneration), #6601, ADR-0087, ADR-0049.

Blocked-by: #7128


Generated by Claude Code

Activity

  1. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Triage: pm:blocked + domain:metadata, body line Blocked-by: #7128 added.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Unlock sweep: blocker cleared — pm:blocked → pm:queue (domain:metadata seat, session session_01W6bLax4KMrSfnE1ydFU8Dw).

    The blocker cleared ~17 hours ago and nobody had looked

    PR #7128 merged as 1dd780f74 at 2026-08-09T19:32:26Z — six minutes after this card was filed (19:18:50Z) and nine minutes after the triage comment above (19:23:10Z). So the Blocked-by: line was already stale by the time the ink dried, and the card then sat blocked for about seventeen hours. Recording that plainly because it is the exact failure this seat has been bitten by three times: a blocker is a sentence someone wrote at a moment in time, and it stops being true without anyone editing it.

    Verified by unbounded subject-shape grep (--grep='(#7128)'), not a windowed one:

    1dd780f74 parents=62dd69a38  2026-08-09 19:32:26 +0000
    fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) (#7128)
    

    Premise re-verified at the merged ref, as the triage comment required

    That comment asked specifically that the re-verification re-read packages/metadata-protocol/src/protocol.ts at the merged ref, on the grounds that the fold PR was the likeliest place a save-path change could have landed in passing. Done — it did not:

    • The precedent is live: graftNormalizedOperators is declared at protocol.ts:807 and applied at :9481, with the design rationale at :400 and :790 — "We do NOT replace the persisted document with parsed.data", because a wholesale swap strips the Studio-only round-trip keys.
    • The gap is still open: nothing under packages/metadata-protocol/src/ folds groups. The only groups hits in that tree are unrelated (SQL grouping in protocol.ts:5627/:6228, one test fixture). The stored-row path still carries the authored spelling through to rest-server.ts.

    So both halves hold: the producer fold exists and computes the folded body, and the save path still discards it. The card is implementable exactly as written.

    Two things that changed under it since filing — anchor on names, not line numbers

    packages/metadata-protocol/src/protocol.ts has taken three landings from this lane since this card was written: f16e54e1d (ADR-0029 D9), 30bed706d (#6960, deleteMetaItem + the repository delete gate), and 89d7b35a7 (#4717, which changed saveMetaItem's success return to carry advisories). None of them touches the parse-and-discard this card is about, but every line number in the body and in the triage comment is now wrong. Whoever takes this anchors on saveMetaItem / graftNormalizedOperators by name.

    Carried forward, unchanged and still binding

    • ⚠️ A sibling helper, not a new parameter. graftNormalizedOperators walks authored and parsed in lockstep by structure and copies across a changed scalar. groups → sections is a key move — one key removed, another added — which that walk cannot express.
    • ⛔ Do not fix this in rest-server.ts with ?? match.form?.groups. The finding: FormViewSchema.groups is declared as "alias to sections" but nothing folds it — every consumer reads only sections #6926 guardrail stands: a lenient consumer is where AI-authored metadata errors hide, and it leaves the next consumer blind.
    • target: unchanged — no shipped example authors groups via Studio, so the exposure did not move at unlock. Re-grading is the triage seat's call, not mine.

    Dispatchable now; it is queued rather than dispatched because this seat is at a wave boundary with the batch deliberately empty.


    Generated by Claude Code

  3. self-assigned this
    on Aug 10, 2026
  4. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 17 (domain:metadata seat, sticker #6367)

    Session: session_01W6bLax4KMrSfnE1ydFU8Dw
    Branch: claude/issue-7134-groups-fold-save-path
    Worktree: objectstack-issue-7134
    Domain: domain:metadata
    File surface: packages/metadata-protocol/src/protocol.ts (saveMetaItem + a new sibling helper beside graftNormalizedOperators), plus its pin file
    Container & model: M, mode:cloud, model: opus
    Serial constraints cleared: no in-flight PR in this lane (batch was empty at claim time); #4716 shares saveMetaItem/promoteDraftForPublish with this card and is therefore excluded from this batch — it remains queued behind its missing sys_metadata corpus regardless. Sibling cards this round (#7408 metadata-fs test, #7426 deleteMetaItem) are region-disjoint from this one.

    Premise re-verified at the merged ref in the unlock comment above — graftNormalizedOperators live at protocol.ts:807, no groups fold anywhere under packages/metadata-protocol/src/. ⚠️ Every line number in the card body and the triage comment predates three landings on this file (f16e54e1d, 30bed706d, 89d7b35a7); anchor on function names.


    Generated by Claude Code

  5. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 7134,
      "premise_still_valid": true,
      "premise_evidence": {
        "base_sha": "08363a09f88457ee96db6dc1869d201122681928",
        "producer_fold_live": "foldFormGroupsIntoSections attached to FormViewSchema via .overwrite() in packages/spec/src/ui/view.zod.ts; its own docstring names this gap under 'What this does NOT reach' — a sys_metadata row saved through saveMetaItem is persisted VERBATIM",
        "save_path_gap_confirmed": "no groups fold anywhere under packages/metadata-protocol/src/; the only groups handling in packages/spec/src/conversions/registry.ts walks sections and groups as PARALLEL keys for the visibleOn -> visibleWhen rename, never folding one onto the other",
        "measured_directly": "ViewMetadataSchema.safeParse on a groups-authored form yields sections; graftNormalizedOperators returns the authored body unchanged (identity), so the row keeps groups",
        "card_claim_reverified": "CONFIRMED — graftNormalizedOperators iterates Object.entries(authored) and only writes into keys the authored object already has, so it can neither drop `groups` nor introduce `sections`. A sibling helper was required; adding a parameter could not have worked."
      },
      "pr": 7468,
      "pr_state": "draft",
      "branch": "claude/issue-7134-groups-fold-save-path",
      "head_sha": "161f5ec",
      "file_surface": [
        {"path": "packages/metadata-protocol/src/protocol.ts", "change": "new exported graftFoldedFormSections beside graftNormalizedOperators; saveMetaItem composes the two grafts, fold first"},
        {"path": "packages/metadata-protocol/src/index.ts", "change": "export the new helper alongside its sibling"},
        {"path": "packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts", "change": "NEW — 16 cases, storage-row path through the real saveMetaItem plus helper-level structural pins"},
        {"path": "packages/rest/src/public-form-routes.stored-row.test.ts", "change": "NEW — 8 cases, acceptance surface on the real route handlers. NO production change in packages/rest"},
        {"path": ".changeset/groups-fold-reaches-stored-rows.md", "change": "NEW — @objectstack/metadata-protocol patch"}
      ],
      "stop_conditions_hit": [],
      "guardrails_honoured": {
        "no_rest_server_fallback": "packages/rest carries no production change at all — only a new test file. No `?? match.form?.groups`",
        "sibling_not_parameter": "graftFoldedFormSections is a separate walk; graftNormalizedOperators is unmodified",
        "spec_acceptance_untouched": "packages/spec not edited — this narrows what is STORED, never what is ACCEPTED; `groups` stays legal at input",
        "studio_roundtrip_keys_untouched": "isPinned / isDefault / sortOrder still survive the save; parsed.data is still discarded",
        "anchored_on_names_not_line_numbers": true
      },
      "acceptance_surface": {
        "pinned_path": "STORED ROW — pins drive the real saveMetaItem against a stub repository and read the persisted sys_metadata row back; the REST tests then feed that genuine stored body to the real route handlers",
        "reader_stubbed": "yes, deliberately — getMetaItems hands the routes the stored body rather than replaying the ADR-0087 conversion chain, which is a different seam this card did not touch",
        "GET_forms_slug": "FIXED — publishes the declared field schema instead of an empty one, and still only the declared set",
        "POST_forms_slug_submit": "FIXED — accepts the declared fields instead of refusing on an empty allowedFields; #3022 anchor boundary re-asserted on the now-accepting path",
        "GET_forms_slug_lookup_field": "NOT FIXED, cause measured and filed — the route's `publicPicker` opt-in is declared in NO schema in packages/spec (grep: zero hits), so ViewMetadataSchema refuses a form carrying one and it can never become a row. The fold DOES reach this route's sections walk; there is no picker in them to find. Filed as #7467 and pinned here as a BOUNDARY case that goes RED the day #7467 lands"
      },
      "reverse_verification": {
        "probe": "revert the call-site composition to graftNormalizedOperators(request.item, parsed.data), keeping the helper so helper-level pins stay compilable",
        "base_sha_reverted_against": "08363a09f88457ee96db6dc1869d201122681928",
        "dist_rebuilt_before_each_read": true,
        "directions_declared_before_running": true,
        "predicted_red_went_red": {
          "count": 10,
          "cases": ["flattened form overlay -> sections", "ViewItem config.groups -> config.sections", "container form.groups + formViews.*.groups", "row carries the AUTHORED array not parsed.data's defaulted one", "sections wins when both authored, empty array included", "fold and Studio round-trip keys coexist on one save", "REST GET /forms/:slug publishes the declared schema", "REST POST submit accepts the declared fields", "REST stored row is the canonical spelling", "REST empty groups folds to empty sections"]
        },
        "green_in_both_directions_guards": {
          "count": 7,
          "labelled_GUARD_in_test_files": true,
          "cases": ["round-trip keys survive the save", "canonical-sections form stored byte-identical", "LIST overlay untouched", "operator graft still fires", "REST published schema unchanged for a canonical form", "REST submit whitelist unchanged", "REST empty-declaration refusal (#6601/#6920) survives the fold"]
        },
        "green_both_ways_by_construction": {
          "count": 6,
          "note": "helper-level structural pins — the call-site-only revert does not reach them. Reported as such, NOT counted as guards and NOT counted as evidence",
          "plus": "the REST BOUNDARY lookup-403 case, green both ways by construction"
        },
        "missed_predictions": {
          "count": 2,
          "cause": "both mine, both in the FIRST pass: two cases labelled GUARD bundled a fix-dependent `sections` assertion into an otherwise guard-shaped test, so they went red on revert",
          "resolution": "split — guards now assert route/round-trip behaviour only; the fix-dependent halves moved into the evidence blocks under their own names. SECOND pass had zero missed predictions, and that is the run reported above"
        },
        "left_unmeasured": []
      },
      "gates": {
        "check:meta-type-normalized": "pass",
        "check:route-envelope": "pass",
        "check:nul-bytes": "pass",
        "metadata-protocol suite": "pass (70 files, 1025 tests)",
        "rest suite": "pass (81 files, 1323 tests)",
        "objectql suite": "pass (176 files, 3127 tests)",
        "rest typecheck": "pass",
        "metadata-protocol typecheck": "n/a — package has no typecheck script (type-check-coverage ledger)",
        "ci": "in_progress",
        "note": "the rest suite and typecheck first reported failures that were unbuilt dependency dist in this fresh container (objectql, service-analytics, service-package — file-LOAD errors with zero test failures); results above are after building those. Full lint farm deliberately NOT run locally per dispatch — CI runs it once"
      },
      "issues_filed": [
        {"number": 7467, "title": "publicPicker is enforced by the REST lookup route but declared nowhere in packages/spec", "reason": "found while measuring this card's third acceptance route; closing it is a packages/spec acceptance-face change, which is STOP condition (1) for this card"}
      ],
      "open_questions": [
        {
          "q": "Should already-stored rows carrying `groups` be healed at READ as well, via an ADR-0087 conversion-registry entry?",
          "self_adjudicated": "NO, out of scope — decided and shipped as save-path-only",
          "why": "the card's file surface is protocol.ts plus its pin, and a conversion entry lives in packages/spec/src/conversions/registry.ts. Existing rows heal on the author's next save, exactly as #4542's flow rows do; this is stated in the changeset and the PR body rather than left implicit. If the PM wants read-side healing it is a separate card — note that Prime Directive #12 names the conversion-layer entry as the graduated pattern, so it is a defensible follow-up rather than a gap"
        },
        {
          "q": "The flattened form-overlay branch stores `groups`/`sections` at the TOP level, but findPublicFormView in rest-server.ts resolves a stored public form only through `view.form.sharing`, `view.formViews.*.sharing`, or `view.viewKind === 'form' && view.config.sharing`.",
          "self_adjudicated": "covered where it matters, flagged where it is not this card's",
          "why": "the save-path fix covers all three storage depths (each with its own test). Whether a flattened top-level overlay is resolvable by the public-form routes at all is a rest-server resolution question, not a fold question — so the REST acceptance tests are spelled on the ViewItem `config` shape, which those routes DO resolve. Not filed as an issue because I did not measure whether the ADR-0087 read chain reshapes a flattened overlay into `config` before rest sees it; saying more than that would be guessing"
        },
        {
          "q": "Ordering of the two grafts (fold first) is defended as structural hygiene, not a measured fix.",
          "self_adjudicated": "shipped as-is, stated honestly in the code comment",
          "why": "form sections carry no `operator` today (`visibleWhen` is a CEL string), so no current input distinguishes the two orderings. Fold-first is the ordering that stays correct if one ever does. The call-site comment says exactly this rather than claiming a bug was fixed"
        }
      ]
    }

    Generated by Claude Code

  6. added a commit that references this issue on Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions