Skip to content

feat(spec): an object declares the business order of its related lists, and 'primary' places a list - #27215

Merged
os-zhuang merged 17 commits into
mainfrom
claude/project-thread-vr8ygc
Oct 28, 2026
Merged

os-zhuang merged 17 commits into
mainfrom
claude/project-thread-vr8ygc

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #27069

Clause-②: yes (widening)
Metadata-protocol: semantic
Skills-docs: skills/objectstack-data/rules/relationships.md (the relatedList tri-state now reads as placement, plus one bullet teaching relatedLists) and the relatedList: 'primary' bullet in skills/objectstack-ui/SKILL.md. Both files are net smaller (token ratchet green). The field-group placement entry is not taught yet: its reader is #27055, and the layout skill text is #27059's.

维护者速读

  • 改了什么:对象新增 relatedLists(按业务重要性排列相关列表,写子对象名或 object.field),字段分组新增 relatedList(让一个分组代表一张正文子表);relatedList: 'primary' 改读为"放在正文"而非排序,带 inlineEdit 的 master_detail 子表默认即 primary。
  • 为什么改:按 2026-10-28 裁决「A」(在父对象上写有序列表),以及明细行子表放正文、可放在基本信息之下的两条裁决。
  • 风险与回滚:语义变更一处——带 inlineEdit 且未写 relatedList 的明细子表,在今天的标签页布局里会升为独立标签页;写 relatedList: true 可保持原样。回滚 = revert 本 PR。本仓库内会变化的三个记录页:crm_opportunity、showcase_invoice、showcase_expense_report,各自的明细子表(inlineEdit: 'grid',未写 relatedList)从共享 Related 标签页升为独立标签页。
  • 先看:ui/packages/app-shell/src/utils/deriveRelatedLists.ts(resolveRelatedListOrder、isMainContentList)、packages/spec/src/stack.zod.ts 的 collectRelatedListNameErrors、packages/spec/src/data/object.zod.ts 的两个新键与 refuseFieldsInRelatedListGroups。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/stack-related-lists-order.test.ts,预期 14 个用例全绿(含 relatedLists: ['nope'] 被拒并列出现有的入向关系)。
  • 需要你做的:无,席位落地。

What changes

  • spec, ObjectSchema.relatedLists?: string[] (packages/spec/src/data/object.zod.ts, beside highlightFields). Each entry is a child object name ('task', every list of that child) or object.field for one FK ('event.related_to'). The entry grammar is a regex on the element (snake_case, at most one dot), so the shape is judged at parse with no new refinement node. The .describe() says it orders the record page's related lists by business importance, that unlisted ones follow in the derived order, and that it orders only.
  • spec, ObjectFieldGroupSchema.relatedList?: string, same spelling. A group carrying it stands for that list's table in the details column. A field whose group names such a group is refused at parse at fields.NAME.group (refuseFieldsInRelatedListGroups, inside the object's existing superRefine, so no new check node for the JSON Schema projection to drop).
  • spec, strict names at defineStack (collectRelatedListNameErrors in stack.zod.ts, called from validateCrossReferences beside collectHydratedInlineColumnErrors). Every relatedLists entry and every group relatedList must match an inbound lookup / master_detail field of a child (the stack's objects, every packages[].manifest.objects, and the fields their objectExtensions add). A miss is a STACK_CROSS_REFERENCE_INVALID issue naming the entry and the inbound relationships that exist, spelled as an entry may name them. Not judged: a child only a sibling package declares (artifactObjects), and a platform object. Audit FKs (created_by, updated_by, owner_id) are not inbound relationships, matching the reader. It runs before the object-count early return, so an artifact whose objects live only in package bodies is judged too, once per object name.
  • lint, the object publish door (packages/lint/src/validate-related-list-refs.ts, validateRelatedListRefs, rule related-list-ref-unknown, error): a new reference-integrity member on flow and object writes. It writes the sentence defineStack writes (a test holds the two equal), resolves the children from the snapshot's objects (the registry plus the stored rows) and objectExtensions, and refuses a child the snapshot does not hold only when no package closure is in force; under a closure that child is not judged. On the CLI an absent child is left to defineStack's artifact scope. A Studio / REST /meta / MCP save of relatedLists: ['nope'] is now 422 INVALID_METADATA.
  • lint, validateSemanticRoles: rules (b) field-group-empty and (d) field-group-shadowed skip a group that carries relatedList (a body table holds no fields), so os lint no longer tells the author to assign a field the parse refuses.
  • app-shell, Studio field inspector (main's feat(app-shell): Studio authors relatedList, the related-list block's columns/sort/filter, ordered highlight fields, and slotted-by-default record pages #27163 control): readRelatedListMode reads the record page's own predicate (isMainContentList, now exported), so an inline-edited master_detail with no relatedList shows as Primary; picking the default clears the key, and Show there writes relatedList: true.
  • docs/adr: ADR-0035 amended status line plus a note on the two sentences this changes (the inline-edit default, 'primary' as placement); ADR-0085 §2 note on the keys that entered under its admission test.
  • spec, FieldSchema.relatedList TSDoc and .describe(): 'primary' means the record's main content (a body table; its own tab on the tabbed page) and no longer orders; absent on a master_detail FK that sets inlineEdit, the list is 'primary' by default, and true keeps it a side card.
  • spec, Studio object form (object.form.ts): a relatedLists string-tags row after highlightFields, and a relatedList sub-row in the fieldGroups repeater. Labels and help texts in en, zh-CN, ja-JP and es-ES (platform-objects metadata-form bundles).
  • app-shell, deriveRelatedLists.ts: the one resolution function resolveRelatedListOrder(lists, declared) replaces the plain owned-then-lookup tail. Listed lists first in the declared order (a list takes the position of the first entry naming it); unlisted ones after, in the derived order. An entry naming no list here places nothing. isMainContentList computes isPrimary with the inline-edit default. buildDefaultTabs is unchanged: it filters isPrimary into own tabs and the rest into the Related tab, both preserving this order, so "primary still promotes an unlisted list" holds on the tabbed page.
  • liveness: new object.relatedLists row, live (reader deriveRelatedLists → RecordDetailView, authoring judges collectRelatedListNameErrors and the door rule). object/fieldGroups is drilled (10 child rows, the container's baseline row removed); fieldGroups.relatedList is planned (reader console record page v3: frameless detail sections, one merged timeline (discussion + activity + field changes), compact related cards, header 1 primary + 2 secondary + ⋯ — the synthesized default page, drawn from the object's roles; the dead BuildPageOptions hints go #27055, no authorWarn, since nothing warns an author about it). field.relatedList / relatedListTitle cite RecordDetailView.tsx:1959, with a note on the placement reading. Object shard 76 live, 1 planned.
  • generated: authorable-surface data.json, content/docs/references/** (gen:docs, 4 files).
  • changeset .changeset/27069-related-lists-order.md: @objectstack/spec, @objectstack/platform-objects, @object-ui/app-shell, @objectstack/skills, @objectstack/lint, @objectstack/metadata-core, all minor.
  • metadata-core, the served-object FLS scrub (object-schema-fls-references.ts): relatedLists and a field group's relatedList are classified keep. They name child relationships, never a field of this object, so read as unclassified they would have over-masked an entry whose child shares a denied field's name. This is pinned against the denied-name fixture (897e8d89).

Why

The maintainer's ruling (2026-10-28), verbatim, on 「元数据的开发者,可以根据业务的重要性自己指定顺序吗」 with options A (an ordered list on the parent object) / B (a number on each child FK) / C (full page only): 「A」. On a quote's line items: 「明细行的子表放在右侧合适嘛?」 → the body. On placement among the field groups: 「他如果做到放在基本信息下面,而条款和备注在后面」「如果有多个子表呢」.

On the declaration: the card said Metadata-protocol: added. Two keys are added, and the diff also changes what an existing value means and what an absent value defaults to: relatedList: 'primary' becomes placement, and an absent relatedList on an inline-edited master_detail FK now reads as 'primary', which on today's tabbed record page moves such a child from the shared Related tab to its own tab. That is a changed meaning and a changed default, so semantic, which outranks added. The re-reading is semantic, not only documentation: the inline-edit default is a behaviour change a reader can see without editing any metadata.

Readers and producers (measured on origin/main 0f1045b)

  • Order before this PR: deriveRelatedLists.ts pushed master_detail lists, then lookup lists, each in registry order; buildDefaultTabs (ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts) put isPrimary lists into their own tabs ahead of the Related tab. Nothing else reads relatedList (git grep under ui/: the only branch on the value was deriveRelatedLists.ts).
  • Where the strict-name check sits: highlightFields' existence check is a lint rule (validate-object-field-refs.ts) at the publish door and os validate, but it is a same-object check. A related-list name is cross-object, so it sits in defineStack's validateCrossReferences, the cross-object refusal family with the ADR-0112 envelope.
  • Pins this moves: spec compose-stacks-merge-collection-refusal.test.ts and compose-stacks-collection-pipe-arm.test.ts (the derived object collection-key list gains relatedLists, plus a merge-refusal row), platform-objects object-lifecycle-panel-echo-decisions.test.ts (positive control +2, 690 → 692 on top of main) and object-collapsed-sections-echo-decisions.test.ts (open leaves 118 → 122).

Tests

  • packages/spec/src/stack-related-lists-order.test.ts (14 cases): shape accepts names and object.field, refuses a non-array, a non-string, a non-snake and a two-dot entry; a field group with relatedList and no fields parses, a field naming it as its group is refused at fields.name.group; defineStack accepts child names and one FK of a child with several, refuses nope with code, a numeric status, one issue naming the entry and 'rlo_task', 'rlo_event.who_id', 'rlo_event.related_to', refuses a declared child with no FK and an unknown field, says "it has none" when there is no inbound relationship, sees an FK an objectExtensions entry adds, leaves a sibling-package child unjudged (and refuses it without artifactObjects), and judges a group's relatedList the same way.
  • ui/packages/app-shell/src/utils/__tests__/deriveRelatedLists.relatedListsOrder-27069.test.ts (11 cases): the lead fixture's derived order; ['task', 'campaign_member'] renders tasks, campaign members, then the rest in derived order; object.field vs bare child name; first entry wins and an unknown entry places nothing; relatedList: false still hides a listed relationship; an unlisted primary list keeps its derived position (primary no longer orders); the inline-edit default on every arm, true opting out, no default without inlineEdit or on a lookup.

Acceptance notes

Verification

Final head 897e8d89a. It is 08cebee83, which merges origin/main d745bb5e5 with no conflict, plus one fix found in CI. Test Core 4/8 went red on 08cebee83: metadata-core's object-key classification pin (object-schema-fls-references.test.ts) found relatedLists unclassified. The fix is in 897e8d89a. The served-object FLS scrub now keeps relatedLists and a group's relatedList as relationship names, a new test pins that against the denied-name fixture, and the changeset now names @objectstack/metadata-core. Every run below was under os-verify-lock.

Suites
  • 912f48922 (round-2 code, after the first main merge):
    • @objectstack/lint: 173 files / 7549 passed.
    • @objectstack/spec: 823 files / 23588 passed, 1 todo.
    • @objectstack/metadata-protocol: 277 files / 21752 passed, 26 skipped.
    • @objectstack/platform-objects: 96 files / 1361 passed.
    • @object-ui/app-shell: utils/__tests__ plus the RecordDetailView and inspector related-list files, 32 files / 448 passed, 8 skipped.
  • 8a549c1e2 (second merge): check:generated --fix rebuilt spec and found nothing stale. Lint 173 files green. The three spec pin files (stack-related-lists-order, the two compose-stacks pins) are green, 101 tests.
  • 897e8d89a: @objectstack/metadata-core 21 files / 845 passed.
  • New tests:
    • validate-related-list-refs.test.ts: 8 cases, including that its sentence matches defineStack's word for word.
    • runtime-gate.object-writes.test.ts: 5 door cases with controls.
    • protocol.runtime-authoring-gate.test.ts: a 422 INVALID_METADATA pin with a control that saves.
    • validate-semantic-roles.test.ts: the relatedList group exemption, with a control.
    • stack-related-lists-order.test.ts: now 15 cases, adding an artifact with objects only in package bodies, judged once per name.
    • ObjectFieldInspector.relatedList-27054.test.tsx: the inline-edit default reads Primary, and Show writes true.
    • object-schema-fls-references.test.ts: relationship names are served.
Gates
  • dispatch-gates --commands on 08cebee83: 125 derived.
    • The first pass ran 125. 118 exited 0. Six exited 3, because the spec dist was older than the form-comment commit (declaration stamp mismatch). check:generated exited 1 for the same stale-dist reason.
    • After a locked pnpm --filter @objectstack/spec build and client-react build, those 7 were re-run and all exited 0.
    • --ran: 125 run, 0 NOT-MEASURED (a derived zero), verdict exit 0.
  • On 897e8d89a the derivation is unchanged (125). The gates that read the changeset were re-run and exited 0: check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main and check:pm-changeset-deadline-census.
  • Also exit 0 on the final tree: check-skills-token-ratchet, check-adr-links, check-adr-anchors, spec check:liveness.
  • Dogfood not owed: no ! in the title and no (narrowing) arm.

Skills readings (pricedTokens, against origin/main):

  • skills/objectstack-data/rules/relationships.md: 3873 → 3872 tokens, 418 → 418 lines.
  • skills/objectstack-ui/SKILL.md: 3843 → 3840 tokens, 308 → 308 lines.

Skills readings (pricedTokens, the ratchet's own count, round 2): skills/objectstack-data/rules/relationships.md 3873 → 3872 tokens (418 → 418 lines); skills/objectstack-ui/SKILL.md 3843 → 3838 tokens (308 → 307 lines). Both net smaller.

…elated lists

ObjectSchema.relatedLists orders the record page's related lists; a field
group's relatedList places one body table; relatedList: 'primary' places a
list instead of ordering it, and is the default for an inline-edited
master_detail child. defineStack refuses a name that matches no inbound
relationship. deriveRelatedLists resolves the order in one function.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMx1APzx1RoDRtUiC
…-vr8ygc

# Conflicts:
#	packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/authorable-surface/data.json, packages/spec/liveness/field.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: master_detail (literal, 36 pages)
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 148 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 295699d51d1484a735989450238ec2e14c0c1104 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 959ac8483df2eeb637151e79babcf1d71835dd7c — the merge of head 897e8d89aff69b18cc77197d807f4cd03cda4d73 into base 295699d51d1484a735989450238ec2e14c0c1104, 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 959ac8483df2eeb637151e79babcf1d71835dd7c && git checkout 959ac8483df2eeb637151e79babcf1d71835dd7c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 295699d51d1484a735989450238ec2e14c0c1104 897e8d89aff69b18cc77197d807f4cd03cda4d73 && git checkout -B drift-repro 295699d51d1484a735989450238ec2e14c0c1104 && git merge --no-ff 897e8d89aff69b18cc77197d807f4cd03cda4d73

node scripts/docs-audit/affected-docs.mjs --json 295699d51d1484a735989450238ec2e14c0c1104

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

@os-zhuang
os-zhuang marked this pull request as ready for review October 28, 2026 10:58
@os-zhuang
os-zhuang enabled auto-merge October 28, 2026 10:58
…object publish door

validateRelatedListRefs joins the reference-integrity suite on flow and
object writes (rule related-list-ref-unknown, error). It writes the
sentence defineStack writes, resolves children from the snapshot's
objects and objectExtensions, and refuses a child the snapshot does not
hold only on the whole collection (no package closure in force).

fieldGroups is drilled in the object ledger, so fieldGroups[].relatedList
has its own row, graded planned until the record page body layout draws
it. Form help text and the changeset follow the new door.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMx1APzx1RoDRtUiC
…-vr8ygc

# Conflicts:
#	packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts
…rimary default

Main's field inspector read an absent relatedList as shown. Under this
branch an inline-edited master_detail with no relatedList is primary, so
the control now reads the record page's own predicate (isMainContentList,
now exported), clears the key when the author picks the default, and
writes an explicit true for Show where the default is primary.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMx1APzx1RoDRtUiC
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d53bd33122bdf502502bb782de3a75ef46b8360b
Local-runs: none
Scope: packages/spec/src/data/field.zod.ts, packages/spec/src/data/object.form.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/stack.zod.ts, skills/objectstack-data/rules/relationships.md, skills/objectstack-ui/SKILL.md + ui/packages/app-shell/src/utils/deriveRelatedLists.ts, ui/packages/app-shell/src/utils/tests/deriveRelatedLists.relatedListsOrder-27069.test.ts, packages/spec/src/stack-related-lists-order.test.ts, ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts (buildDefaultTabs), ui/packages/app-shell/src/views/RecordDetailView.tsx (:1848, :3256-3299), packages/lint/src/validate-semantic-roles.ts (rules (a)/(b)/(d)), packages/lint/src/object-field-groups.ts, packages/lint/src/validate-object-field-refs.ts, docs/adr/0035-config-driven-master-detail.md (:84-96), docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md (§2), .changeset/27069-related-lists-order.md, packages/spec/liveness/object.json + field.json, packages/spec/src/compose-stacks-*.test.ts (the two pins), examples/app-crm/src/objects/opportunity-line-item.object.ts, examples/app-showcase/src/data/objects/invoice.object.ts, examples/app-showcase/src/data/objects/expense-report.object.ts, packages/metadata and packages/objectql hotcrm __fixtures__ (census only). The remainder of the diff (generated docs, authorable-surface baseline, platform-objects translation bundles and their two pins, the metadata-protocol count pin) is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs read: card #27069 body and its two comments (claim 6465054400, os-dev-report 6467128510); epic #26676 round-3 comments 6459899198 and 6460478339; PR #27215 body, file list (27 files) and the net diff against the merge base 4265176cf3 (origin/main at 9dacccf961); the 41 check-runs on the head.

Check-runs on d53bd3312 at read time (2026-10-28), 41 in all: 11 completed success (Spec property liveness, Check Changeset, Governed Surface Queue Guard, PR-body guards, Dogfood Regression Gate, Check PR Size, Check Documentation Links, Flag docs affected, Auto Label, Plan Test Core shards, filter), 7 skipped (opt-in / schedule gates), 23 in progress (Type Check x4, Test Core 1-8/8, Lint x8, Build Core / Build UI Deps / Build Docs). No red at read time; the in-progress families are reported as counts, not verdicts.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged against the tree:

  1. ObjectSchema.relatedLists?: string[], each entry ^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$ — RIGHT. The grammar is the object/field machine-name grammar with at most one dot, judged at parse with no new refinement node; relatedLists: 'task', [1], ['Task'], ['a.b.c'] are refused by the shape (pinned in stack-related-lists-order.test.ts). Sits beside highlightFields, as the card asked. objectExtensions cannot contribute it (ObjectExtensionSchema is strict with no such key), the same as highlightFields.
  2. ObjectFieldGroupSchema.relatedList?: string, same grammar — RIGHT in shape. The group schema carries no fields key (membership is Field.group → fieldGroups[].key), so "a group with relatedList carries no fields" is realised as refuseFieldsInRelatedListGroups: one located issue at ['fields', NAME, 'group'] per field naming such a group, inside the object's existing superRefine, so the JSON Schema projection drops nothing new. Judged against the lint gate it must co-exist with, it does NOT hold today — see ④ M2.
  3. defineStack refuses a relatedLists entry or a group relatedList that matches no inbound lookup/master_detail field, STACK_CROSS_REFERENCE_INVALID with code + numeric status, the message naming the entry and the inbound relationships spelled as an entry may name them ('rlo_task', 'rlo_event.who_id', 'rlo_event.related_to', or it has none) — RIGHT, and placed where highlightFields' sibling cross-object checks sit (validateCrossReferences, after collectHydratedInlineColumnErrors). The walk covers config.objects, every packages[].manifest.objects (the assembled ArtifactPackageSchema body does carry object definitions there; the same pattern applyControlledByParentExtensionGate uses) and the fields objectExtensions add. Audit FKs (created_by/updated_by/owner_id) are excluded by field name, matching the reader's AUDIT_FK_FIELDS. Artifact-scoped: a child named only by a sibling package (artifactObjects) or a platform-prefixed object is left unjudged ONLY when the child is nowhere in fieldsByObject; a declared child with no such FK is refused. Consistent with the spec: defineStack's per-stack cross-reference validation refuses permissions[].objects and data[].object that name an object owned by ANOTHER PACKAGE OF THE SAME ARTIFACT — so ADR-0130's co-owned artifact and the "permission sets stay whole in the app package" addendum cannot both hold once the app package owns objects #18202 ARTIFACT-SCOPED classes. Two limits, graded should-fix in ④ (S3): the check sits below if (objectNames.size === 0) return errors;, so an artifact-only definition (objects only in package bodies) never reaches it, contrary to the docblock; and seen keys by object identity, so a name declared both top-level and in a body is judged twice.
  4. relatedList: 'primary' re-read as placement, no longer ordering — RIGHT per the card's target 2 ("its meaning becomes PLACEMENT"). resolveRelatedListOrder is the one resolution: listed first in declared order (a list takes the position of the FIRST entry naming it; a bare child name takes every list of that child in their derived order; object.field takes one), unlisted after in today's derived order (owned then lookup, discovery order). relatedList: false still hides a listed relationship (the FK is skipped before the list exists, so the entry places nothing). An unknown entry places nothing. buildDefaultTabs is unchanged and filters isPrimary into own tabs and the rest into the Related tab, preserving order within each half, so the card's acceptance 2 holds on the tabbed page: ['task', 'campaign_member'] renders tasks, campaign members, then the rest; primary still promotes an unlisted list (own tab) without moving its order. All pinned in the 11-case app-shell test.
  5. An absent relatedList on a master_detail FK that sets inlineEdit (true / 'grid' / 'form', the three arms FieldSchema.inlineEdit declares) is isPrimary by default; explicit true opts out; lookup + inlineEdit does not default — RIGHT per the maintainer's line-items ruling quoted in the card (「明细行的子表放在右侧合适嘛?」 → the body). Only one branch on the value exists under ui/ (deriveRelatedLists.ts; RecordDetailView forwards isPrimary, buildDefaultTabs splits on it; no second derivation under ui/apps), and MetadataProvider passes object keys through, so objectDef.relatedLists reaches the reader.
  6. Census of what visibly moves on today's tabbed page from the default in 5 (whole head tree, non-test code): THREE in-repo parents — crm_opportunity (examples/app-crm, crm_opportunity_line_item.opportunity: inlineEdit: 'grid', no relatedList), showcase_invoice (showcase_invoice_line.invoice, same) and showcase_expense_report (showcase_expense_line.expense_report, same). Each gains its own "Products" / "Line Items" / "Expense Lines" tab and leaves the shared Related tab on merge. packages/platform-objects: no inlineEdit FK at all, nothing moves. The three hotcrm fixtures in repo (packages/metadata/src/__fixtures__/hotcrm-17.1-*, packages/objectql/src/__fixtures__/hotcrm-4.0.1-*) carry no inlineEdit and no relatedList, nothing moves. Field.masterDetail sets no relatedList default, so the builder path is the raw path. No test or e2e pins the Related tab for those three parents, so the move is silent in CI — which is exactly why the changeset should name them (④ S2).
  7. Compose: relatedLists joins the derived object collection-key list, so two declarations of one object both carrying it are refused at merge, as highlightFields is — RIGHT (the two compose pins move by one row each). A relationship contributed by another package is seen by the check only when both halves are in one defineStack call (composed stacks, or an artifact's package bodies); a package validated alone with a parent naming a sibling's child relies on artifactObjects, exactly as the other artifact-scoped classes do.
  8. Studio: object.form.ts gains the relatedLists string-tags row after highlightFields and a relatedList text sub-row in the fieldGroups repeater; labels and help in four locales (platform-objects, lane remainder) — RIGHT. The help text honestly states the publish-door gap ("os validate refuses it"), which is true: os validate imports the stack, so defineStack's throw is the refusal.
  9. FieldSchema.relatedList TSDoc/describe re-read — RIGHT against the reader; the tabbed-page parenthetical keeps today's behaviour visible.
  10. Liveness: object.relatedLists is live with the reader (resolveRelatedListOrder, deriveRelatedLists → RecordDetailView) and the authoring judge named; field.relatedList / relatedListTitle re-cited. The deriveRelatedLists citations (:367, :378-379) are exact on the head; the RecordDetailView.tsx:1827 citation is :1848 on the merge head (the pre-merge line; Spec property liveness is green, observation only). fieldGroups[].relatedList has no row and shelters under object/fieldGroups = live by a dogfood proof that never exercises relatedList — graded in ④ S4.

② Semver level

Clause-②: yes (widening) — RIGHT. Two new optional keys; the only new refusals (fields.X.group naming a relatedList group; a dangling relatedList name at defineStack) bite only input that could not exist before this head, so nothing previously accepted is refused.

Metadata-protocol: semantic — RIGHT, and the dev's step-3 upgrade from the card's added is the correct reading under AGENTS.md step 3 ("a changed meaning or default is semantic; several ⇒ narrowed over semantic over added"): 'primary' changes meaning (order → placement) and the absent value changes default on an inline-edited master_detail FK. Both declaration lines are line-initial in the PR body and the changeset body.

Changeset .changeset/27069-related-lists-order.md: @objectstack/spec minor (the kind takes at least minor), @object-ui/app-shell minor (the reader's behaviour moves without a metadata edit), @objectstack/platform-objects minor (the generated metadata-form bundles move), @objectstack/skills minor (a package 13 other changesets in the root .changeset already name) — all RIGHT. It is in the root .changeset, carries both declaration lines and a "What to do" migration note for the semantic default (relatedList: true keeps the old placement), which is what step 3 asks of a semantic change (the mandatory FROM → TO mapping is the breaking-only clause). Check Changeset is green on the head. One should-fix on the note's content, ④ S2: it does not census the movers.

③ Boundary flags

open_questions: [] — nothing to answer. The three out_of_scope_findings and the four PR acceptance notes:

④ Design judgment

packages/spec/src/data/object.zod.ts — against ruling A and its first consumer (deriveRelatedLists). relatedLists: holds. Grammar, describe, placement beside highlightFields, extension-proof, compose-key: all as the card asks, and the reader resolves exactly what the describe promises. ObjectFieldGroupSchema.relatedList + refuseFieldsInRelatedListGroups: the parse-time half holds (correct path, message names the group and the list), but the design does not hold against the lint gate the same object passes through:

  • M2 (must-fix): packages/lint/src/validate-semantic-roles.ts rule (b) FIELD_GROUP_EMPTY fires on every group that carries relatedList — "fieldGroups declares 'lines' but no field references it — the group never renders. Assign at least one field via group: 'lines', or remove the unused group declaration." — and that hint prescribes the one thing this head refuses at fields.X.group. The card's own example ({ name: 'lines', label: '报价明细', relatedList: 'crm_quote_line_item.crm_quote' }) run through os validate / os lint is told to undo itself. A rule and the gate it must co-exist with are one change: exempt a group carrying relatedList from rule (b) (and from (d), which already skips empty groups) in this PR, with a pin. Warning-tier and CLI-only, but a contract that contradicts its own gate on the authoring path the help text points to is not a cosmetic miss.

packages/spec/src/stack.zod.ts — collectRelatedListNameErrors against the ADR-0112 envelope and compose. Holds for the scenarios the card names (entry / object.field / unknown child / known child without FK / unknown field / no inbound at all / extension-added FK / sibling package / group relatedList), all pinned with code + numeric status + named subjects, never a bare toThrow().

  • S3 (should-fix): the call sits below if (objectNames.size === 0) return errors;, so the package-body walk the docblock promises ("the stack's objects, every packages[].manifest.objects") never runs for a definition whose objects live only in package bodies. Either state that limit in the docblock (it is shared with every sibling check below that line) or hoist the call above it, since this check needs no top-level object to resolve against. Also, seen is keyed by record identity and dedupes nothing: a name declared both top-level and in a body is judged twice with merged fields, two identical issues. Minor; one line each.
  • S3' (should-fix, the seat's ask (a)): the publish door. Add a relatedLists / fieldGroups[].relatedList dangling-name rule beside validate-object-field-refs at the tier that door dispatches, reading the registry the door has (the lint object-graph index, as object-field-groups.ts does), so a one-object save of relatedLists: ['nope'] is refused rather than silently placing nothing. Until it lands the form help text's "os validate refuses it" is the only door.

packages/spec/src/data/field.zod.ts — the relatedList TSDoc and describe against the reader: holds. Every sentence has a line in isMainContentList / buildDefaultTabs. The three inlineEdit arms the default keys on are the three the schema declares.

packages/spec/src/data/object.form.ts — against Studio's form renderer and the spec: holds. Both rows match the key shapes (string-tags for string[], text for the group's string), sit where the card asked, and the help texts say what the tree does, gap included.

skills/objectstack-data/rules/relationships.md — against the spec on this head and the card's acceptance scenario: the tri-state bullets and the relatedLists bullet are accurate and the file is net smaller (ratchet green per the dev; CI's ratchet job in progress at read time).

  • S1 (should-fix): the bullet "true / omitted — a side card (the shared Related tab)" is contradicted one bullet up for the inline-edit default; say "true, or omitted on anything but an inline-edited master_detail — a side card". Same file, the code comment "read side: a body table (default with inlineEdit)" is right.

skills/objectstack-ui/SKILL.md — the relatedList: 'primary' bullet is accurate. But the "Related-list layout" paragraph that follows it (:117-128), untouched by the diff, still closes with "For arrangements the relationship layer can't express — filtered splits, a chart/report tab, exact tab ordering — assign the object a custom record Page". After this head exact tab ordering IS expressible: relatedLists orders the own tabs among themselves and the Related tab's lists among themselves (buildDefaultTabs preserves the resolved order within each half).

  • S1' (should-fix): drop "exact tab ordering" from that list (or point it at relatedLists), in the same file so the skill does not teach a custom Page for what the object key now does. Paid for by the line it removes.

Cross-cutting

Implemented-by: claude/project-thread-vr8ygc
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: FAIL

Two must-fixes, both one-sitting: M1 the ADR-0035 (and ADR-0085 §2) amended status line for the default and the re-reading this head ships; M2 the FIELD_GROUP_EMPTY exemption for a relatedList group. The resolution function, the strict-name check, the primary re-reading and the inline-edit default are right against the card and the tree; the semver level and both declaration lines are right. The delta review closes M1, M2 and the seat's two asks (S3', S4) together.

…roups, artifact-only stacks

ADR-0035 gains an amended status line and a note on the two sentences
this card changes (the inline-edit primary default, and 'primary' as
placement); ADR-0085 section 2 notes the keys that entered under its
admission test. validateSemanticRoles rules (b) and (d) skip a field
group that carries relatedList, so os lint no longer tells an author to
assign a field the parse refuses. collectRelatedListNameErrors runs
before the object-count early return, so an artifact whose objects live
only in package bodies is judged, once per object name. The two skills
bullets, the changeset census of the three in-repo parents that move,
and the RecordDetailView liveness citation follow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMx1APzx1RoDRtUiC
…ts relatedLists line

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMx1APzx1RoDRtUiC
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8a549c1e26aeec4c0ebc42eed2566e37639b36fd
Local-runs: none
Scope: packages/spec/src/data/field.zod.ts, packages/spec/src/data/object.form.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/stack.zod.ts, skills/objectstack-data/rules/relationships.md, skills/objectstack-ui/SKILL.md + packages/lint/src/validate-related-list-refs.ts (the door rule, in scope by the round's order), packages/lint/src/reference-integrity-suite.ts (the validateRelatedListRefs member and its wholeCollection predicate), packages/lint/src/runtime-gate.ts (buildRuntimeWriteSnapshotSet, narrowObjectsToPackageClosure, runRuntimeAuthoringRules, fingerprint, RuntimeStackContext), packages/lint/src/validate-semantic-roles.ts (rules (b)/(d)), packages/lint/src/rule-explanations.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts + protocol-authoring-gate.ts (listCollection('object', 'objects'), resolveWritePackageScope, pending), packages/objectql/src/registry.ts (listItems → getAllObjects → resolveObject, the extender fold), packages/spec/src/stack.zod.ts validateCrossReferences / collectObjectNames / isPlatformObjectName, ui/packages/app-shell/src/utils/deriveRelatedLists.ts, ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx (readRelatedListMode), docs/adr/0035-config-driven-master-detail.md, docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md (§2, §5), .changeset/27069-related-lists-order.md, packages/spec/liveness/object.json + field.json, and the pins: packages/spec/src/stack-related-lists-order.test.ts, packages/lint/src/validate-related-list-refs.test.ts, packages/lint/src/runtime-gate.object-writes.test.ts, packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, packages/lint/src/validate-semantic-roles.test.ts, ui/packages/app-shell/src/utils/tests/deriveRelatedLists.relatedListsOrder-27069.test.ts, ObjectFieldInspector.relatedList-27054.test.tsx. The remainder of the diff (generated content/docs/references/**, the authorable-surface baseline, platform-objects translation bundles and their two pins, the metadata-protocol count pin, lint index.ts re-exports, the compose-stacks pins) is the lane seat's ACCEPT at TIER_DEFAULT.

Delta review of the round-1 record 6467460418 (FAIL at d53bd3312). Inputs read: card #27069 body and its two comments (claim 6465054400, os-dev-report 6467128510); PR #27215 body as updated for this round, file list (42 files) and the net diff against the merge base 835b2d1cc9; the 14 commits on the branch, the five after d53bd3312 being 2c1525067 (the door rule), 3c90b4f2f (merge of main), bd3f1079d (Studio inspector), 8375ff9b6 (ADR amendments, relatedList groups, artifact-only stacks), 912f48922 (SKILL.md), 8a549c1e2 (merge of main); the check-runs on the head. No new os-dev-report was posted for this round; the PR body carries the round-2 account.

Check-runs on 8a549c1e at read time (2026-10-28T11:59Z), 65 in all: 28 completed success (Spec property liveness, Check Changeset x2, Governed Surface Queue Guard, PR-body guards x2, Dogfood Regression Gate, Check PR Size, Check Documentation Links, Flag docs affected, Auto Label, filter, Plan Test Core shards, Build UI Deps, Build Docs, Build and E2E UI, Lint and Repo Gates, Lint x8 (ESLint, PM dispatch-gates, docs and code guards, repo and tooling gates, CI/release/engine gates, three scoped), Type Check source gates, Type Check debt ledger), 9 skipped (opt-in / schedule gates), 28 in progress (Test Core 1-8/8, Test UI 1-16/16, Build Core, Lint UI, Type Check UI, Type Check workspace). No red at read time; the in-progress families are reported as counts, not verdicts.

Round-1 findings, each judged on this head

  • M1 (ADR-0035 and ADR-0085 not amended) — CLOSED. docs/adr/0035-config-driven-master-detail.md carries an amended status line (Accepted · Amended (2026-10-28, #27069, …)) naming the ruling and the three changes (placement, the parent's relatedLists, the inline-edit default), plus a note under the read-side table that names the two sentences this head falsifies and states that "'primary' promotes it to its own tab" stays true on the tabbed page. docs/adr/0085-…md §2 carries the note on the two keys admitted under its test and on the prominence → placement re-reading at the relationship layer. Both say what the tree does.
  • M2 (rules (b) and (d) fire on relatedList groups) — CLOSED. validate-semantic-roles.ts builds relatedListGroups from fieldGroups[].relatedList !== undefined and continues in both loops; the pin ([#27069] a group carrying relatedList …) holds the clean case and a control that differs by the key alone (FIELD_GROUP_EMPTY still fires). Rule (a) (a field naming an undeclared group) is untouched and correct. object-field-groups.ts answers only "declared at all", so it has no empty-group verdict to exempt.
  • S1 / S1' (skill copy) — CLOSED. relationships.md: "true, or omitted (except inline-edited master_detail) — a side card (the shared Related tab)"; "exact tab ordering" is gone from SKILL.md's custom-Page list, and the layout paragraph says both halves keep the object's relatedLists order.
  • S2 (changeset census) — CLOSED. The changeset names crm_opportunity, showcase_invoice and showcase_expense_report with their FKs; the PR body's 风险与回滚 line carries the same three.
  • S3 (early return, seen) — CLOSED. collectRelatedListNameErrors is called before if (objectNames.size === 0) return errors; with the artifact scope computed inline; collectObjectNames reads config.objects only, so an artifact-only definition did need the hoist. seen keys by object name. Both pinned (judges an artifact whose objects live only in package bodies, once per object name).
  • S3' (publish door) — CLOSED, by the new rule; judged below.
  • S4 (planned liveness row) — CLOSED. object.fieldGroups is drilled (10 child rows, the undrilled-containers baseline entry removed); fieldGroups.relatedList is planned with the reader named (console record page v3: frameless detail sections, one merged timeline (discussion + activity + field changes), compact related cards, header 1 primary + 2 secondary + ⋯ — the synthesized default page, drawn from the object's roles; the dead BuildPageOptions hints go #27055). Spec property liveness is green on the head.

① Derived judgments

Judgments 1, 2 (parse half), 3 (now including artifact-only stacks), 4, 5, 6, 7, 8, 9, 10 of the round-1 record stand on this head unchanged; re-read, not restated. New or changed on this head:

  1. Lint rule validateRelatedListRefs, related-list-ref-unknown at error, member of REFERENCE_INTEGRITY_RULES with runtimeTypes: ['flow', 'object'] — RIGHT. It resolves children from stack.objects (array or map form) with stack.objectExtensions merged by name, excludes the three audit FKs by name (the reader's AUDIT_FK_FIELDS), and refuses an entry that matches no inbound lookup / master_detail field. A child the snapshot does not hold is refused only under wholeCollection, which the suite sets exactly as it does for validateListViewFieldRefs: runtimeWriteType set and no packageScope (lint: an overlay write (no package) of a list view bound to an object that exists nowhere answers 200 with no issue — validateListViewFieldRefs skips it and no other rule judges a view write's binding #24668). The sentence is defineStack's word for word, held by a pin that runs both. Path spelling is positional on the raw snapshot and name-keyed on the wire (objects.rl_quote.relatedLists[0]), the finding: a publish refusal's issues[].path carries an array index into the gate's private snapshot (objects[417].sharingModel) that no caller can resolve #10064 shape. Pinned at three layers: the pure rule (8 cases), runRuntimeAuthoringRules on an object write (5 cases, closure case included), and the protocol's saveMetaItem (422 INVALID_METADATA, the row not stored; the control stores active with a body-table group beside it).
  2. The door's universe, read in the cited code: protocol-authoring-gate.ts hands listCollection('object', 'objects') as objects, which is registry.listItems('object') → getAllObjects → resolveObject, the ADR-0029 D9 fold of extend contributors onto the base layer (plus the tenant's overlay rows and this batch's pending drafts). So at the door the child's fields are the FOLDED fields: an FK an objectExtension adds is in the snapshot. RuntimeStackContext carries no objectExtensions key, so the rule's own extension merge never runs at the door; it is the CLI shape's, and is harmless. packageScope comes from resolveWritePackageScope(evt.packageId); absent on a Studio/REST write that names no package, so such a write is judged on the whole collection, the lint: an overlay write (no package) of a list view bound to an object that exists nowhere answers 200 with no issue — validateListViewFieldRefs skips it and no other rule judges a view write's binding #24668 reading.
  3. Studio inspector (ObjectFieldInspector.tsx, main's feat(app-shell): Studio authors relatedList, the related-list block's columns/sort/filter, ordered highlight fields, and slotted-by-default record pages #27163 control) — RIGHT, lane remainder read as the schema's first Studio consumer: readRelatedListMode(value, field) reads an absent value through the record page's own predicate isMainContentList, so the control shows what the reader will draw; picking the default clears the key, and "Show" on an inline-edited master_detail writes an explicit true (the only spelling that keeps a side card on this head). Pinned.

② Semver level

Clause-②: yes (widening) — RIGHT, as in round 1, and still right with the door rule: the only refusal it adds bites relatedLists / fieldGroups[].relatedList, keys no stored object could carry before this head (the strict object parse refused them), so nothing previously accepted at the door is refused. Metadata-protocol: semantic — RIGHT (unchanged reasoning). Changeset .changeset/27069-related-lists-order.md now also names @objectstack/lint minor (a new exported rule and a new door verdict), beside spec, platform-objects, app-shell and skills, all minor — RIGHT; both declaration lines are line-initial in the PR body and the changeset; Check Changeset is green on the head.

③ Boundary flags

open_questions: []. The three round-1 out_of_scope_findings are now in the PR (the door rule, the planned row, the ADR amendment). The PR body's acceptance notes on this head:

④ Design judgment

packages/lint/src/validate-related-list-refs.ts + reference-integrity-suite.ts — the door rule, tried against legitimate saves. It holds:

  • A stored sibling parent carries a dangling entry (its child was uninstalled, or it was saved under a closure) and the author saves an unrelated object or flow. The finding is on another entry and identical in baseline and candidate (same rule, where, path, message; siblings keep their index across passes), so it cancels in the differential; the unrelated write is not refused.
  • The child's FK comes from an objectExtension (a sibling or downstream package extends the child, or extends a platform object such as a sys_* task with a lookup to the parent). At the door the child row is the registry's folded view, so the FK is present and the parent's entry resolves; under a closure the fold has already happened before narrowing (narrowing drops rows, never fields), and a platform child survives limb 1. On the CLI collectRelatedListNameErrors merges the stack's and the package bodies' objectExtensions, pinned. Not refused.
  • Package closure. A child in a downstream package that points in is outside the closure: unjudged, pinned. A child in the written package or a dependency is judged on its folded fields. A platform object with no FK to the parent is refused, which is what the reader does with it (places nothing).
  • The write is the child, and it drops the FK a stored parent names. Accepted: the parent's finding reads the same with the child absent (baseline) as with it present without the FK (candidate), so it cancels. The parent's entry then dangles at runtime and the reader places nothing. This is the gate's located-on-another-entry policy, shared with every cross-object member, not a refusal; observation only.
  • A polymorphic or array reference, a relatedList: false FK, a self-reference, an entry spelled object.field against a child with one FK. Each resolves exactly as deriveRelatedLists resolves it (string reference equal to the parent name; false is a relationship that exists and is hidden; a self FK is inbound; object.field matches that one FK).
  • fields in array form. Not reachable: the written item has passed the object parse (fields is a record there) and registry rows are ObjectSchema-shaped.
  • flow writes. The member runs, but objects is identical in both passes, so it can only cost time; no flow write can be refused by it.

No must-fix. The rule's docblock sentence "with the fields their objectExtensions add merged in" describes the CLI shape; at the door the merge is the registry's. Observation, not graded.

packages/spec/src/data/object.form.ts — the two rows and their help texts hold against the tree ("saving or validating the object refuses it" is now true at both doors). One contradiction inside the file:

  • S1 (should-fix): the code comment above the relatedLists row (:1268-1272) says the dangling-name check runs "defineStack, so os validate / os build — not at a one-object save". On this head a one-object save IS refused (related-list-ref-unknown, 422); the help text one line below says so. Drop "not at a one-object save" and name the door rule, so the next reader of the form file is not told the opposite of what the row's own help text and the tree do.

skills/objectstack-ui/SKILL.md — the relatedList: 'primary' bullet and the custom-Page list are right on this head. One sentence the head falsifies remains:

  • S2 (should-fix): "Related-list layout" (:117-119) still says "each relatedList: 'primary' child gets its own tab; every other related list stacks under a single shared Related tab". On this head an inline-edited master_detail with no relatedList also gets its own tab (isMainContentList), and nothing in this file teaches that default; the read-side intro ("every child relationship is shown as a related list by default (owned master_detail children first)") does not either. The same class as round 1's S1 on relationships.md, which now teaches it correctly and which this file points to. One parenthetical — "(an inline-edited master_detail counts as primary unless it writes relatedList: true)" — paid for inside the paragraph; the ratchet is green with 5 tokens of room on this file.

packages/spec/src/data/object.zod.ts — holds, as in round 1; the gate it must co-exist with (M2) now agrees with it. The relatedLists and group relatedList TSDoc say "refused at defineStack" and do not name the door; true, not complete; not graded.

packages/spec/src/stack.zod.ts — holds; S3 closed as above. The artifact scope handed to the hoisted call is objectNames ∪ artifactObjects when sibling packages are declared and objectNames otherwise, the same set the sibling checks below the early return compute; isPlatformObjectName is a module-level prefix test, so the exemption behaves the same above the line as below.

packages/spec/src/data/field.zod.ts — holds, as in round 1; ADR-0035 and ADR-0085 now record the re-reading (M1 closed).

skills/objectstack-data/rules/relationships.md — holds; S1 closed. The code sample's comment ("read side: a body table (default with inlineEdit)") and the four bullets match isMainContentList and resolveRelatedListOrder line for line.

Cross-cutting, observations not graded: the changeset's @objectstack/skills and @object-ui/app-shell entries describe the Studio control change; the liveness object.relatedLists evidence names both judges (collectRelatedListNameErrors, validateRelatedListRefs), which is the row's authoring half, right on this head. The buildDefaultPageSchema.ts:1003 comment ("Owned-before-lookup order is preserved by deriveRelatedLists upstream") still reads as before this head, non-hit, unchanged from round 1.

Implemented-by: claude/project-thread-vr8ygc
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

Every round-1 finding (M1, M2, S1-S4) is closed on this head in the tree, not only in the PR body. The door rule refuses nothing legitimate in the scenarios tried (package closure, extension-added FK, sibling dangling entries, child writes, flow writes); its universe is the registry's folded view, which is why the extension case holds. Two should-fixes remain, both copy inside hits files that contradicts the tree (the object.form.ts comment; the SKILL.md layout sentence), neither a must-fix.

… relatedList as relationship names

Test Core 4/8 failed on the object-key classification pin: relatedLists was
an unclassified top-level key, so the served-object scrub would read it as
a possible field reference and drop an entry whose child shares a denied
field's name. Both keys name child relationships, never a field of the
object, so they are kept; a test pins it against the denied-name fixture.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfMx1APzx1RoDRtUiC
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT (armed) · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-28T13:07Z

Head 897e8d89aff69b18cc77197d807f4cd03cda4d73. Card #27069.

  • Contract review:
    • Round 1, 6467460418 at d53bd331: FAIL. M1 was the ADR-0035/0085 amendment; M2 was semantic-roles rules (b)/(d) firing on relatedList groups.
    • Round 2, 6468417631 at 8a549c1e: PASS. Every round-1 finding was judged closed on the tree. The reviewer could not make the new door rule related-list-ref-unknown refuse a legitimate save; the cases tried were a package closure, an extension-added FK, a stored sibling's dangling entry, and flow writes.
  • Since round 2:
    • 7f2760da closes both round-2 should-fixes. The object.form.ts comment names the door rule, and the objectstack-ui SKILL.md layout paragraph names the inline-edit default. The ratchet stays green (3843 → 3840).
    • 08cebee8 merges main (no rebase).
    • 897e8d89 fixes what Test Core 4/8 caught on 08cebee8. Metadata-core's FLS scrub had relatedLists unclassified, and it would have over-masked an entry whose child shares a denied field's name. relatedLists and a group's relatedList are now classified keep, pinned against the denied-name fixture, and @objectstack/metadata-core is added at minor. The seat read the hunk. Both keys name child relationships (a child object, or object.field for that child's FK), never a field of this object, so nothing in them is this object's to deny.
    • The seat applied the round-2 body delta, the metadata-core bullet and the final Verification section.
  • Dev readings on this head: derived gates 125 run, 0 NOT-MEASURED, all exit 0. Metadata-core passes 845 tests.
  • Gates: check-runs on this head are 58 success, 9 skipped and 0 red. Check Changeset and the PR-body guards are re-running after the body edit; auto-merge waits on them.
  • Landing: check-governed-merges --pr 27215: GOVERNED (4 of 44 paths: docs/adr/** and skills/**), Tier H. os-zhuang's APPROVED review 5681527416 (at d53bd331, not dismissed) is a governed approval, which lifts Tier H. pr_ready + automerge_enable in this act.

Merged via the queue into main with commit c63ce2a Oct 28, 2026
69 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-vr8ygc branch October 28, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

3 participants