feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(spec,lint): a layout section can reference a declared field group instead of copying its members - #13897

Merged
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference
Aug 31, 2026
Merged

feat(spec,lint): a layout section can reference a declared field group instead of copying its members#13897
huangyiirene merged 6 commits into
mainfrom
claude/issue-13855-section-group-reference

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #13855

Clause-②: yes — path limb packages/spec/src/ui/** plus a content limb that widens the accept set on two authoring surfaces. Draft, with needs:contract-review attached in the same stroke as creation.

Maintainer ruling 2026-08-31 on #13855, option B, verbatim: 「直接处理b」. The renderer half is tracked separately; this PR is the spec half plus the reference diagnostics the ruling routes the existence check through.

The gap this closes

ADR-0085 makes fieldGroups + Field.group the canonical grouping, assembled in exactly one place — deriveFieldGroupLayout (ADR-0085 section 5). The two layout escape hatches had no way back to it: a custom record page's record:detailsproperties.sections and a view-level form.sections each enumerated their members by hand. An author who reached for either had to hand-copy the same membership fact a second and third time, with nothing linking the copies to the declaration — so every field added to the object afterwards made them quietly staler. Measured on a real app: three disagreeing groupings of one object, with the detail page missing two fields the form showed.

A section may now name the group instead, on both surfaces:

sections: [{group: 'contact_info'},// members + presentation derived{label: 'Notes',fields: ['note']},// enumerated, unchanged]

Members (every visible field whose Field.group points at the key, in field-declaration order) and the group's own presentation (label, icon, description, collapse, visibleWhen, and the drop when a group has no visible members) all come from deriveFieldGroupLayout. Nothing is re-implemented in section land — the section schema resolves nothing and assembles nothing.

Both surfaces, one design — the structural comparison

The ruling made the second surface conditional on the two section vocabularies being symmetric enough. Measured, they are:

record:details sectionFormSectionSchema
member keyfields (was required)fields (was required)
shared identity/presentationname, label, description, icon, columnsname, label, description, columns
collapse dialectcollapsible + defaultCollapsed (no defaults)collapsible + collapsed (both .default(false))
predicatenonevisibleWhen plus the deprecated visibleOn
surface-only keyshideEmpty, showBorder, headerColorpane

The vocabularies differ in their leaves, but the rule over them is identical, so it is declared once in packages/spec/src/shared/section-group-reference.ts and each surface passes in its own key names. No record-page-only landing, no named remainder on this axis.

The mixing rule — designed and pinned, offered for review

  1. A section declares its members exactly one way.group and fields are mutually exclusive. A section declaring NEITHER is refused — before this change that was unrepresentable, because fields was required, so the refusal is what keeps the old guarantee (no section reaches a renderer without a member source) after fields goes optional. Presence, never non-emptiness: fields: [] parsed before and still parses.
  2. A group-referencing section carries no key the group already declares.name, label, description, icon, the collapse pair, and visibleWhen/visibleOn are refused beside group, each refusal naming the fieldGroups entry that owns the key. The surface keys the group says nothing about ride alongside unchanged: columns, pane, hideEmpty, showBorder, headerColor.
  3. Across sections, both kinds coexist in declared array order. A group-referencing section occupies exactly one slot and expands in place. Ordering semantics are untouched.
  4. Not on a wizard step. A group carries visibleWhen and collapse, and deriveFieldGroupLayout passes both through to the section it derives — exactly the two things Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 refused on a step one day earlier. Accepting group there would deliver that behaviour through the object's declaration while the step-key refusals reported clean.

Two judgement calls in that list are the ones worth a reviewer's attention:

  • Rule 2 is the absence of a precedence rule, not a precedence rule. The alternative reading — section keys OVERRIDE the group's, so the author writes only the delta — is textually available in the card ("作者只写差量") and was considered. It was not taken, for two reasons: a section-level label beside group puts group presentation back in section land, which is the one thing ADR-0085 section 5 single-sources; and it re-creates a second writable spelling of the fact this whole form exists to stop copying. It is also the reversible direction — refusing now and allowing overrides later is additive, while shipping overrides and withdrawing them is breaking. If contract review prefers the override reading, this is the line to change.
  • collapsible / collapsed on the form surface are refused only when written true. They carry .default(false), so by the time an object-level refinement runs an authored false is indistinguishable from the default — the same asymmetry Wizard view v1 — declaration-and-refusal tightening of FormViewSchema type:'wizard' (Card S, ruled shape of #13622) #13704 records for the wizard step keys, and it costs nothing, since false declares exactly what a group with collapse: 'none' delivers.

Existence: reference diagnostics, not a new channel

The key names something on a DIFFERENT schema, so the spec door deliberately answers only the grammar — the UserFilterFieldSchema.field precedent ("must exist — checked by reference diagnostics"). Existence lands in @objectstack/lint, in the two rules that already own field-existence on these exact surfaces:

  • page-section-group-unknown in validate-page-field-bindings (the reference-integrity suite member for pages)
  • form-section-group-unknown in validate-form-layout, reading both the canonical sections and the legacy groups bucket

Both resolve against one shared index (indexObjectFieldGroups) so the two surfaces cannot grow two answers that happen to agree, and the page-side extraction reads COMPONENT_FIELD_SPECS[...].nestedSections — the same descriptor table the field check walks, so a component that grows sections is covered by both questions in one edit.

Severity: warning, matching the family. Both host rules declare warning for a dangling reference because the consumer degrades rather than fails, and the error limb in validate-page-field-bindings is reserved for a reference that reaches a QUERY, where the empty result is indistinguishable from "there is no data". A section that does not render is visible and touches no query, so it sits with its siblings. The hint lists the object's declared groups, and says what to do when the object declares none at all.

Type-surface note for consumers

fields becomes optional on both section shapes — that is what makes group the other way to declare the same fact — so z.infer now types it as possibly undefined. A consumer reading section.fields unconditionally must handle the reference form. Every in-repo reader already guards it (validate-form-layout, validate-translatable-sections, the CLI i18n extractor, the form-view refinement's own optional chain). No authored metadata changes shape and nothing that parsed before stops parsing.

Also single-sourced here: the group-key grammar is now FIELD_GROUP_KEY_PATTERN, declared beside the derivation and read by the declaring surface (ObjectFieldGroupSchema.key) and both referencing surfaces, so a key one accepts can never be a key another refuses.

Verification

Everything below at final commit 4cb9ddba6, exit codes captured before any pipe.

Suites

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 445 passed | 1 skipped (446), Tests 11962 passed | 1 skipped (11963)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 87 passed (87), Tests 2409 passed | 5 skipped (2414)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0 (spec's test layer compiles under tsconfig.test.json; the new test file is inside that program, confirmed by --listFiles)
  • pnpm lint (repo-wide eslint . --no-inline-config) — EXIT=0

New pins. 34 in packages/spec/src/ui/section-group-reference.test.ts (both doors: ComponentPropsMap['record:details'] and FormViewSchema), 11 across the two lint rule test files. Accept pins carry the weight the refusals cannot: the key must SURVIVE the parse on both surfaces (on the form surface, through a .transform, the view-level .superRefine, and the legacy groups fold), the enumerated form is pinned unchanged including fields: [], and every surface key that is NOT refused is pinned as still accepted — a refusal list written one key too wide is indistinguishable from a correct one until exactly that parses. Controls: an unknown sibling key is still refused on both surfaces, and a near-miss spelling of the new key is steered to group.

Reverse verification, from the committed state, both legs source-resolved (the pins import the mutated modules by relative path, not through dist/), each mutation proved on disk by anchor and injected-text grep counts plus a blob-hash change, each restore proved by blob-hash equality against the HEAD blob and an empty git diff HEAD:

  • neutralising the group/fields mutual-exclusivity refusal: Tests 2 failed | 32 passed (the two exclusivity pins, one per surface) — restored 16e68e091a7d to 16e68e091a7d
  • neutralising the dangling-group diagnostic: Tests 7 failed | 45 passed across both lint rule files — restored 6779ac6870c6 to 6779ac6870c6

Gate family, derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (44 families, 49 runnable commands) — all run:

  • 42 green.
  • 7 NOT MEASURED, each by the gate's own printed verdict, all needing a built workspace or an artifact this checkout cannot produce: check-dev-prereqs and check:dual-build-cjs-loads ("PREREQUISITE NOT MET ... nothing was measured"), check:type-check-debt (refuses --re-measure without the built closure, by design), @objectstack/lint check:doc-formula-expressions (prerequisite: @objectstack/formula built), check:react-declaration-parity (no objectui manifest in this repo), and check-test-completeness plus check-half-states, which both exit 3 saying nothing was read. Reported as unread instruments, never as green.
  • Three gates went red and were repaired, each by its own prescribed regeneration, then re-run green: check:docs (regenerated content/docs/references/{data/object,ui/component,ui/view}.mdx), the export-surface freshness pin (gen:export-origins + gen:api-surface for the new FIELD_GROUP_KEY_PATTERN export), and check-system-context-census (pure line rot from the docblock this PR adds to object.zod.ts, repaired with the gate's own --fix). Nothing generated was hand-edited.

Named remainders

  • The renderer half is not here. Until it lands a group-referencing section is declared, documented and diagnosed, but not yet assembled at render time.
  • A declared-but-EMPTY group (declared, but no visible field joins it) is dropped by deriveFieldGroupLayout, so a section referencing one renders nothing. That is inherited ADR-0085 behaviour, not introduced here, and it is deliberately NOT a finding: unlike the existence question it is data-dependent and has no closed oracle in a static pass.
  • A record:* component cannot be authored on a kind: 'react' page (recordContextFinding refuses it upstream), so there is no third surface needing this check.

Generated by Claude Code

@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 30 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/automation/flows.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/field-types.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/fields.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/formulas.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/schema-design.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/data-modeling/validation-rules.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/deployment/environment-variables.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/concept.mdx(via FormViewSchema (symbol, a top-level const), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/protocol/objectui/index.mdx(via FormViewSchema (symbol, a top-level const), contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx(via FormSectionSchema (symbol, a top-level const), FormViewSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/field-grouping-and-order.mdx(via contact_info (literal, a string literal in FormSectionSchema; a string literal in SectionGroupKeySchema; a string literal in sections; a string literal on a changed line))
  • content/docs/ui/pages.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/ui/views.mdx(via visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v12.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v15.mdx(via FormSectionSchema (symbol, a top-level const), visibleOn (literal, a string literal in FormSectionSchema), visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v16.mdx(via visibleWhen (literal, a string literal in FormSectionSchema))
  • content/docs/releases/v17.mdx(via validateFormLayout (symbol, a top-level function), visibleWhen (literal, a string literal in FormSectionSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 db9c460cf39211709e99a8db3d755d9d44188b4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d078180136b64c20ded84ff1f7e4cde8b199d12 — the merge of head a61844e50caa6b3431cc4194eab55bb97dcdfe67 into base db9c460cf39211709e99a8db3d755d9d44188b4f, 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 0d078180136b64c20ded84ff1f7e4cde8b199d12 && git checkout 0d078180136b64c20ded84ff1f7e4cde8b199d12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin db9c460cf39211709e99a8db3d755d9d44188b4f a61844e50caa6b3431cc4194eab55bb97dcdfe67 && git checkout -B drift-repro db9c460cf39211709e99a8db3d755d9d44188b4f && git merge --no-ff a61844e50caa6b3431cc4194eab55bb97dcdfe67
node scripts/docs-audit/affected-docs.mjs --json db9c460cf39211709e99a8db3d755d9d44188b4f

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

@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

Correction + re-verification at a61844e50

The CI red was mine, not a race with main. Correcting the record before anything else: the PR body's verification block says check:docs was green at 4cb9ddba6. It was green at eb23b9b34, one commit earlier. Between the two I edited a .describe() string in component.zod.ts to satisfy check:doc-authoring (stripping an issue id from customer-facing prose), re-ran only that one gate, and never re-ran gen:docs — so the committed content/docs/references/ui/component.mdx still carried the pre-edit sentence. The regenerated diff confirms it: the only line that moved is my own sentence, and nothing from main touches that file's inputs. This is precisely the "re-run the union AFTER the final commit, not after the repair you happened to notice" discipline, and I under-ran it.

What the merge did surface, separately and for real.git merge origin/main reported no conflicts, but the pre-commit regeneration hook caught that content/docs/permissions/system-context.mdx had been merged WITHOUT a text merge: git took my side of the file wholesale and silently dropped 73c846687's content — the declarations count bump from 21 to 22 and an 18-line paragraph explaining what that row counts. Repaired by taking main's version of the page and re-applying only the mechanical anchor shift with the gate's own --fix, so the page is now main's content plus exactly one changed line number. Verified: git diff against 55519d503 for that path is that single line. The other three generated artifacts I carry merged correctly, each checked as main's content plus exactly my one added line (api-surface/data.json, export-origins/data.json, authorable-surface/ui.json).

Re-verified at a61844e50 (merge commit 1f1e3d8f4 plus the regeneration commit), exit codes captured before any pipe:

  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 — EXIT=0, Test Files 446 passed (446), Tests 11964 passed (11964)
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 — EXIT=0, Test Files 88 passed (88), Tests 2446 passed (2446)
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck — EXIT=0
  • check:docs — EXIT=0, 230 generated files in sync with packages/spec; check:generated — EXIT=0, All 14 generated artifacts are up to date; check-system-context-census — EXIT=0
  • check-nul-bytes EXIT=0 plus a control-byte self-scan over all 20 changed paths, no matches

Gate family re-derived at the merged head and it GREW by 17content/docs/** had entered the change set after my first derivation, so a documentation family was owed and unrun. All 17 executed: 15 green, and the two that first refused on prerequisites (check:doc-security-posture, check:skill-examples) went green once @objectstack/lint, @objectstack/formula and @objectstack/client-react were built. The original 49 were re-run at this head too: green except six unchanged prerequisite refusals reported as NOT MEASURED, each by its own printed verdict — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt (all need the full built workspace closure), check:react-declaration-parity (no objectui manifest exists in this repo), check-test-completeness and check-half-states (both exit 3 saying nothing was read). check:doc-formula-expressions, previously in that list, is now green.

No change to the PR's ready or auto-merge state.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 39404f3Aug 31, 2026
34 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13855-section-group-reference branch August 31, 2026 18:57
os-steve pushed a commit that referenced this pull request Sep 1, 2026
…g set (#13804)
Contract-review increment on PR #14196 (director-seat conditional PASS,
comment 5494985273). Two changes, exactly as ruled:
1. The changeset for @objectstack/service-datasource moves patch -> minor.
The diff adds public API — the `datasourceConnectivityChanged` and
`ConnectivityBearingFields` exports, the public
`DatasourceConnectionService.reconnect` method, and the published
`DatasourceAdminServiceConfig.reregisterPool` member — which is additive
widening = minor by repo convention (#13897 is the same shape). `patch`
under-reported the surface movement.
2. `schemaMode` joins the connectivity-bearing field set. It was found during
this card's premise verification and reported as a fork rather than added
unilaterally; the review ruled it IN in the same stroke. It is really read
at three sites on the connect path — the `canConnect` policy gate, `toSpec`
-> `factory.create` (driver construction), and `registerDatasourceDef` (the
write gate's def) — and it is patchable by `updateDatasource`, so omitting
it left a schemaMode-only save persisting the new record while all three
kept the OLD value until restart: a narrower instance of the stale-pool
defect this card fixes. One comparator line, plus the exported field slice,
plus one comparator pin. The module docblock now states the resolution
instead of carrying the fork as an open question.
The label-only reverse control (same driver instance, zero factory calls, zero
evictions) stays green, which is what shows the set widened by exactly one
member rather than into "rebuild on everything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:dataprotocol:uisize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@huangyiirene@claude