Repository navigation
A Studio-saved form authored with groups still degrades on the REST public-form routes — the producer fold does not reach stored rows #7134
Description
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:
GET /forms/:slugpublishes an empty field schema;POST /forms/:slug/submitcomputes an emptyallowedFieldswhitelist (see finding: a section-less public form accepts ANY non-anchor field from an anonymous visitor (the write-side twin of #6601) #6920);GET /forms/:slug/lookup/:fieldanswers403 LOOKUP_NOT_PUBLICfor every field.
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_ALIASEStherefore 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.
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
os-zhuang commentedon Aug 9, 2026
Triage:
pm:blocked+domain:metadata, body lineBlocked-by: #7128added.graftNormalizedOperatorsgrafting thegroups→sectionskey move back into the save path). Queue-shaped — but the stated premise "the producer fold is already in place;saveMetaItemalready computes the folded body" holds only on PR fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) #7128's branch: fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) #7128 is an open draft, not merged (checked at triage time). Until it lands,ViewMetadataSchema.parsecomputes no fold and there is nothing to graft. Hencepm:blockedrather thanpm:queue; unlock sweep returns this card when fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) #7128 merges, and the re-verification then should re-readpackages/metadata-protocol/src/protocol.tsat the merged ref (the fold PR itself is the likeliest place a save-path change could have landed in passing).saveMetaItemandgraftNormalizedOperatorsboth live inpackages/metadata-protocol/src/protocol.tsonorigin/main@445a0c2(git grephits inprotocol.ts, plusmetadata-diagnostics.ts:13documenting the save path).packages/metadata*⇒domain:metadataper the domain table.convertStoredItem groupsreturns only this card. finding:FormViewSchema.groupsis declared as "alias to sections" but nothing folds it — every consumer reads onlysections#6926 is the producer half (in flight via fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) #7128, which explicitly reports this remainder in its own body — the two cross-reference each other by design); finding: a section-less public form accepts ANY non-anchor field from an anonymous visitor (the write-side twin of #6601) #6920 is the submit-side degeneration, distinct. Clean.origin/main@445a0c2the stored-row path (convertStoredItem, ADR-0087 chain) performs no zod parse and no fold — the defect's mechanism is as described, pending fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) #7128 for the fix's precondition.target:judgment: not release-blocking — no shipped example or production report authorsgroupsvia Studio (filer's own reachability note), and the producer half is already in review. Re-judge at unlock if fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) #7128's merge changes the exposure.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 10, 2026
Unlock sweep: blocker cleared —
pm:blocked→pm:queue(domain:metadataseat, sessionsession_01W6bLax4KMrSfnE1ydFU8Dw).The blocker cleared ~17 hours ago and nobody had looked
PR #7128 merged as
1dd780f74at 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 theBlocked-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: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.tsat 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:graftNormalizedOperatorsis declared atprotocol.ts:807and applied at:9481, with the design rationale at:400and:790— "We do NOT replace the persisted document withparsed.data", because a wholesale swap strips the Studio-only round-trip keys.packages/metadata-protocol/src/foldsgroups. The onlygroupshits in that tree are unrelated (SQL grouping inprotocol.ts:5627/:6228, one test fixture). The stored-row path still carries the authored spelling through torest-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.tshas taken three landings from this lane since this card was written:f16e54e1d(ADR-0029 D9),30bed706d(#6960,deleteMetaItem+ the repository delete gate), and89d7b35a7(#4717, which changedsaveMetaItem's success return to carryadvisories). 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 onsaveMetaItem/graftNormalizedOperatorsby name.Carried forward, unchanged and still binding
graftNormalizedOperatorswalks authored and parsed in lockstep by structure and copies across a changed scalar.groups→sectionsis a key move — one key removed, another added — which that walk cannot express.rest-server.tswith?? match.form?.groups. The finding:FormViewSchema.groupsis declared as "alias to sections" but nothing folds it — every consumer reads onlysections#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 authorsgroupsvia 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
os-zhuang commentedon Aug 10, 2026
Claim: PM loop round 17 (
domain:metadataseat, sticker #6367)Session:
session_01W6bLax4KMrSfnE1ydFU8DwBranch:
claude/issue-7134-groups-fold-save-pathWorktree:
objectstack-issue-7134Domain:
domain:metadataFile surface:
packages/metadata-protocol/src/protocol.ts(saveMetaItem+ a new sibling helper besidegraftNormalizedOperators), plus its pin fileContainer & model: M,
mode:cloud,model: opusSerial constraints cleared: no in-flight PR in this lane (batch was empty at claim time); #4716 shares
saveMetaItem/promoteDraftForPublishwith this card and is therefore excluded from this batch — it remains queued behind its missingsys_metadatacorpus regardless. Sibling cards this round (#7408metadata-fstest, #7426deleteMetaItem) are region-disjoint from this one.Premise re-verified at the merged ref in the unlock comment above —⚠️ Every line number in the card body and the triage comment predates three landings on this file (
graftNormalizedOperatorslive atprotocol.ts:807, nogroupsfold anywhere underpackages/metadata-protocol/src/.f16e54e1d,30bed706d,89d7b35a7); anchor on function names.Generated by Claude Code
os-zhuang commentedon Aug 10, 2026
{ "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
fix(rest): pin the test double's write verbs and drop two test-layer …
test(rest): flip #7134's stored-row BOUNDARY pin — the lookup route n…
fix(metadata-protocol): the `groups` → `sections` fold reaches the st…
feat(spec): declare publicPicker on FormFieldSchema (objectstack-ai#7467