Skip to content

feat(spec,lint): object-level composite / repeater are untyped JSON — a typed list is a child object - #25755

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-25681-repeater-untyped-json
Oct 25, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-25681-repeater-untyped-json

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #25681

Clause-②: yes (widening)
Metadata-protocol: semantic
Ruling-ref: 6386744473

Ruling 6386744473 (batch #12 item 4, letter b), verbatim:

b = 不给对象字段的 repeater / composite 加子字段声明;明确写下「对象级 repeater / composite 是无类型 JSON,要带列的列表请建子对象(master_detail)」,并对期望列的 repeater 做 lint/拒收。协议不新增面。

What changed

  • FieldType (packages/spec/src/data/field.zod.ts). The comments on composite / repeater used to promise "declared sub-fields". They now say these are untyped JSON containers of CMS lineage (Strapi component, ACF group or repeater), that FieldSchema has no sub-field member, and that a typed list is a child object with a master_detail field (inlineEdit: 'grid' edits it inline on the parent's form). No schema change: the enum, FieldSchema and every generated artifact are byte-identical (check:generated: all 14 up to date).
  • The parse refusal names the route (packages/spec/src/data/authoring-key-lint.ts). Three FIELD_KEY_GUIDANCE rows, fields / subFields / columns, fed into the strict FieldSchema through its existing guidance channel. The parse already refused all three before this PR, but its message named only the key, so an author learned the key was wrong but not where the list goes. The columns row also names the three real column keys (lookupColumns, relatedListColumns, inlineColumns). This changes a message, not what parses.
  • New lint rule field/embedded-sub-fields (error) in lintDataModel (packages/lint). It fires when a composite / repeater object field carries fields, subFields or columns. It has an os explain entry and a one-line fix. Its docblock's intake table states which doors reach it (below).
  • Docs. data-modeling/field-types.mdx gets the same sentence plus a child-object example, the shape Salesforce, Dynamics, ServiceNow and Odoo use. data-modeling/validation-rules.mdx loses the false "Sub-field shapes are declared on the field". protocol/objectui/layout-dsl.mdx now says that a form field's fields list belongs to the form, not to the object field.
  • Not touched, by the ruling: a form view's fields sub-rows (view.zod.ts), field.form.ts, objectui. No member was added to FieldSchema.

The published skill skills/objectstack-data/rules/field-types.md repeated "declared sub-fields". skills/** is a governed surface (Tier H), so that fix is its own draft PR on claude/issue-25681-skills-repeater-untyped (Refs #25681). This PR carries no governed path.

Measured before building (on origin/main 47af3dde)

  • Premise holds. field.zod.ts:96–:97 promised "declared sub-fields", and so did field-types.mdx:515/:522 and the skill file. FieldSchema declares no sub-field member. The FieldType enum has no .describe(), so the generated references never carried the claim.
  • Mechanism (the PM's lead): the parse already refuses. FieldSchema.safeParse on a repeater / composite carrying fields, subFields, columns, schema, items, properties, itemFields, of or children answers unrecognized_keys on every one. Before this PR the message was Unrecognized key(s) on this field: `fields`. Until this shape was closed these were dropped silently — …: it names no route. defineStack(x) (strict) throws the same message. defineStack(x, { strict: false }) ACCEPTS the field and keeps fields verbatim.
  • So the lint arm reaches only the unparsed doors, the posture security-owd-alias documents. Its docblock and os explain both say so:
door reaches field/embedded-sub-fields?
defineStack(x) (strict), hence os validate / os build / os lint on such a config no — refused at load, now naming the route
saveMetaItem (Studio / REST /meta / MCP) no — refused at the parse
os lint on a raw object-literal config (lintConfig runs before any parse) yes
defineStack(x, { strict: false }) yes
metadata-generation rubric (scoreMetadata: lintConfig on the normalized input, beside its own schema verdict) yes
  • Producers. This repo: the only object fields of these types are examples/app-showcase/src/data/objects/field-zoo.object.ts:166–:167 (f_composite, f_repeater), and neither carries a sub-field key. Every other hit is a metadata FORM row (*.form.ts, excluded by the ruling). HotCRM at a4b3560: 0 object fields of type repeater / composite (control: type: 'lookup' hits in src/sales/actions/activity-actions.ts). objectui at a3c0da9: packages/fields/src/index.tsx:3625–:3626 already states that "the spec's FieldSchema has no sub-field member for either type and refuses fields / subFields". No producer is refused by this PR that was not already refused.

Ablations (commit first, mutate through scripts/ablation-replace.mjs, self-restore)

leg mutation predicted observed
A data-model-rules.ts: if (declared.length > 0) { → if (false && …) 11 red (9 in embedded-sub-fields.test.ts, 2 in fix-lines.test.ts), 13 green 11 red, 13 green
B authoring-key-lint.ts: delete the fields: guidance row 2 red (repeater + fields, composite + fields), 19 green 2 red, 19 green

Both restored blob-identical to HEAD (678d7d76, a2d3a01b), and git diff HEAD was empty after each. Both suites resolve the mutated module from src/, never through a dist/ export, so no rebuild leg applies.

Verification

Every reading below is on head 87ff1c79 (this branch with origin/main 5cf1b9d2 merged in), after pnpm install --frozen-lockfile and a rebuild of the @objectstack/cli... closure. Each exit code was captured to a file before any pipe.

command exit reading
pnpm --filter @objectstack/spec test 1 720/721 files, 21818/21842 tests. All 23 failures are in scripts/liveness/check-liveness.test.ts, the main-red below
pnpm --filter @objectstack/spec test:repo 0 79/79 files, 1269 tests
pnpm --filter @objectstack/spec typecheck 0
pnpm --filter @objectstack/spec check:generated 1 13/14 up to date; liveness/state-counts/ is the main-red below. On the pre-merge head 22414cf1: 14/14
pnpm --filter @objectstack/lint test 0 162/162 files, 7301 tests
pnpm --filter @objectstack/lint typecheck 0
pnpm --filter @objectstack/cli test (unit and integration) 0 501 passed + 1 skipped files, 6297 tests
pnpm --filter @objectstack/cli typecheck 0
node scripts/pm/dispatch-gates.mjs --ran … 0 ✓ 111 derived famil(ies) accounted for — 111 run, 0 NOT-MEASURED. 110 exit 0; check:liveness exit 1 is the main-red below
node scripts/check-dev-prereqs.mjs (local-only) 0

Pre-merge head 22414cf1, the same battery, all green: spec test 721/721 (21829 tests), spec test:repo 79/79, lint 162/162 (7296 tests), cli 500 + 1 skipped files (6291 tests), and 111/111 families (110 run green, check:dual-build-cjs-loads NOT MEASURED there and green on 87ff1c79).

Not this PR's: check:liveness is red on main 5cf1b9d2. The translation ledger row flows.screens cites packages/services/service-automation/src/engine.ts#renderFlowTextSlot. engine.ts no longer names screens in any spelling: the refactors f3fea0a0 and 0189524a moved renderFlowTextSlot into node-text-rendering.ts. Its one earlier hit was a coincidental comment ("states two screens down"), which moved to run-resume.ts. Neither file of this PR touches it. Proposed patch, proved but not applied here: delete the clause ; packages/services/service-automation/src/engine.ts#renderFlowTextSlot (…) from that row's evidence in packages/spec/liveness/translation.json. The screen-nodes.ts#registerScreenNodes pointer beside it already cites the caller and names the key. With that deletion, pnpm check:liveness answers ✓ (run through scripts/ablation-replace.mjs, then restored blob-identical). Repointing to node-text-rendering.ts alone would stay red: that file does not name screens either. No open PR carries a fix (searched 2026-10-25).

Acceptance notes

  • The guidance rows are an exact, case-sensitive channel (shared/suggestions.zod.ts): subfields or sub_fields still gets the bare refusal. The three spellings are the ones the ruling names.
  • record (ADR-0007) is also stored as untyped JSON on an object field, but the ruling names only composite / repeater, so the lint does not judge record (pinned silent).
  • docs/audits/2026-06-handwritten-docs-accuracy-followups.md:66 asked "whether sub-shapes are declared elsewhere". This PR answers it (they are not, by ruling); the audit record is left as written.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T2U7eGg8aRTpJ8kKXxDiog


Generated by Claude Code

…ld keys refused with the child-object route

Claude-Session: https://claude.ai/code/session_01T2U7eGg8aRTpJ8kKXxDiog
Co-authored-by: Claude <noreply@anthropic.com>
…g sub-fields is an error on the unparsed doors

Claude-Session: https://claude.ai/code/session_01T2U7eGg8aRTpJ8kKXxDiog
Co-authored-by: Claude <noreply@anthropic.com>
…ist to a child object with inlineEdit grid

Claude-Session: https://claude.ai/code/session_01T2U7eGg8aRTpJ8kKXxDiog
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/field-types.mdx (via FieldType (symbol, a top-level const object), subFields (literal, a string literal in SUB_FIELD_KEYS))
  • content/docs/data-modeling/validation-rules.mdx (via FieldType (symbol, a top-level const object), subFields (literal, a string literal in SUB_FIELD_KEYS))
  • content/docs/getting-started/quick-reference.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/protocol/objectql/types.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/protocol/objectui/layout-dsl.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/protocol/objectui/widget-contract.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/ui/actions.mdx (via FieldType (symbol, a top-level const object))

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

  • content/docs/releases/v17/17-1.mdx (via FieldType (symbol, a top-level const object))
  • content/docs/releases/v17/17-5.mdx (via FieldType (symbol, a top-level const object))

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
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 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 — 144 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 6679644a1a56b552d6864b125900221823ec78b3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6679644a1a56b552d6864b125900221823ec78b3

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #25681 · domain:spec seat 2 (seat post #18549) · sunhaolin · session session_01PC4NPXPgPmpDRG9DdDnyXT · 2026-10-25T05:47Z · head 87ff1c798a · report 6394053765 (on #25681; cloud session session_01T2U7eGg8aRTpJ8kKXxDiog) · seat review at TIER_DEFAULT. The path limb hits (packages/spec/src/data/**), so a same-shape ## Contract review PASS at CONTRACT_REVIEW_TIER on the landing head is owed. needs:contract-review is added in this stroke.

  1. Shape. Draft, base main, first line Fixes #25681, title feat(spec,lint):. Clause-②: no, Metadata-protocol: semantic and Ruling-ref: 6386744473 are line-initial in the body and in the changeset (@objectstack/spec minor + @objectstack/lint minor). 12 files, +369/−14. check-governed-merges --pr 25755: NOT governed, 383 lines. The governed half, skills/**, is docs(skills): object-level composite/repeater are untyped JSON — a typed list is a child object #25741 (Tier H). os-zhuang approved and enqueued it at 05:07–05:09Z. Its text is true on main today, so the order of the two merges does not matter.
  2. The ruling, measured on the diff. Ruling b (「不给对象字段的 repeater / composite 加子字段声明;明确写下「对象级 … 是无类型 JSON,要带列的列表请建子对象(master_detail)」,并对期望列的 repeater 做 lint/拒收。协议不新增面。」):
    • No member added. The FieldType comments (field.zod.ts:95–104) drop "declared sub-fields" and say untyped JSON, with the child-object route (master_detail, inlineEdit: 'grid'). FieldSchema, the enum and every generated artifact are byte-identical (check:generated 14/14 on the pre-merge head).
    • 拒收. The claim's mechanism lead was confirmed by measurement: the strict FieldSchema already refused fields / subFields / columns (and 6 other spellings), but the message named only the key. Three FIELD_KEY_GUIDANCE rows now make that refusal name the route. The columns row also names the live lookupColumns / relatedListColumns / inlineColumns. This changes the message, not what parses.
    • lint. A new rule, field/embedded-sub-fields (error), in lintDataModel, with an os explain entry and a one-line fix. Its docblock's intake table is honest: it reaches only the unparsed doors (os lint on a raw config, defineStack(x, { strict: false }), which was measured to keep the key, and the metadata-generation rubric). This is the security-owd-alias posture.
    • Docs. field-types.mdx gains the sentence and a child-object example. validation-rules.mdx loses the false sentence. layout-dsl.mdx now says a form field's fields list is the form's.
    • Untouched. A form view's fields sub-rows, field.form.ts, objectui.
  3. Clause-②: no holds. Every parse door already refused these keys, so nothing that published before is refused now. The only new refusal is a lint finding on doors that never parse, which is the "lint" half of the ruling's 「lint/拒收」. Metadata-protocol: semantic (the meaning of two types is now stated, the message changes) → minor, which is correct. The contract review judges this independently.
  4. Producers.
    • This repo: only field-zoo.object.ts:166–167, with no sub-field key.
    • HotCRM a4b3560: 0 object fields of either type.
    • objectui a3c0da9: packages/fields/src/index.tsx:3625 already says the spec has no sub-field member.
    • No producer is refused that was not already refused.
  5. Gates. On 87ff1c79 (with origin/main 5cf1b9d2 merged):
    • spec test: 720/721. The 23 failures are all check-liveness.test.ts, the main red that fix(spec/liveness): drop the comment-only engine.ts pointer from the translation/flows.screens row #25751 fixes.
    • spec test:repo: 79/79.
    • lint: 162/162 (7301 tests).
    • cli unit + integration: 501 files / 6297 tests.
    • typecheck: spec, lint and cli all exit 0.
    • Derived families: 111/111 run, 110 exit 0. The 1 exit 1 is check:liveness (main red).
    • On the pre-merge head 22414cf1: all green, 721/721.
    • Ablations, predicted then observed, each restored blob-identical:
      • A (the R14 guard off): 11 red / 13 green.
      • B (the fields guidance row deleted): 2 red / 19 green.
  6. Open questions.
    • (1) Guidance rows on the parse refusal. A: this is the ruling's 拒收 half at the doors AI authors actually use (defineStack), it changes a message only, and it feeds the existing channel.
    • (2) The lint arm lives in lintDataModel. A: every registry door parses first, so a registry entry would be a dead second voice.
    • (3) record (ADR-0007). A: left as ruled. The ruling names only composite / repeater, and the silence is pinned.
  7. Deviations accepted.
    • The file surface grew within the claimed container: authoring-key-lint.ts, lint index.ts / rule-explanations.ts, and two docs pages this ruling made false or ambiguous.
    • Model-free trailers.
    • The HotCRM read went through the anonymous git lane after the session classifier refused add_repo.
    • mcp_calls 4, all reads. Relay writes 3.
  8. Landing. Not governed. Once fix(spec/liveness): drop the comment-only engine.ts pointer from the translation/flows.screens row #25751 (the main-red fix) is in main, the seat brings main in through the relay (pr_update_branch). The CONTRACT_REVIEW_TIER review runs on that landing head. On PASS with every check green, the seat sends pr_ready + automerge_enable through the relay.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 87ff1c798a28e947a56d27e5c3f2d0e33bc34866
Local-runs: none
Scope: packages/spec/src/data/authoring-key-lint.ts, packages/spec/src/data/field.zod.ts + packages/spec/src/shared/strict-object.ts, packages/spec/src/shared/suggestions.zod.ts, packages/spec/src/kernel/metadata-authoring-lint.ts, packages/spec/src/stack.zod.ts, packages/spec/src/index.ts, packages/spec/src/data/authoring-key-lint.test.ts, packages/lint/src/data-model-rules.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/index.ts, packages/lint/src/data-model-rules.embedded-sub-fields.test.ts, packages/lint/src/data-model-rules.fix-lines.test.ts, packages/cli/src/commands/lint.ts, packages/cli/src/lint/score.ts, .changeset/25681-repeater-untyped-json.md, content/docs/data-modeling/field-types.mdx, skills/objectstack-data/rules/field-types.md (merged #25741, consistency only)

Rendered 2026-10-25T09:15Z · PR #25755 · card #25681 · ruling 6386744473 (letter b) · dev report 6394053765 · seat ACCEPT 6394076839 read as an input, not adopted. Read-only shape held: the net diff origin/main...87ff1c79 (12 files, +369/−14), the card thread, the head's check-runs via ci-failure.mjs, the cited code at origin/main. The two failing jobs' logs were not readable from this session (the blob host behind the redirect is refused), so where a reading below rests on the dev's measurement it says so.

① Derived judgments

Accept set, per door — each named right or wrong against the code:

  1. FieldSchema parse: accept set unchanged — right. The three new FIELD_KEY_GUIDANCE rows (fields, subFields, columns) carry only why, so fieldKeyGuidanceAsStrictOptions() (field.zod.ts) turns them into guidance, never aliases. strictObject's guidance is consulted on the unrecognized_keys path alone (strict-object.ts: "it is consulted for unrecognized_keys", and the alias/guidance tables are "a claim about the schema … that the key it is filed under is one the shape rejects"). None of the three is a live FieldSchema key (no fields: / subFields: / columns: declaration anywhere in field.zod.ts; the guard no guidance entry names a key the schema now declares itself in authoring-key-lint.test.ts pins that). So every input that parsed before parses now, every input refused before is refused now, and only the refusal text moved. Nothing that parsed before now refuses. The dev's reading that defineStack (strict), os validate, os build and the metadata save door (saveMetaItem, per-type Zod parse answering 422) refuse first is consistent with the code read.
  2. columns row's claims — right. lookupColumns, relatedListColumns and inlineColumns are declared on FieldSchema (field.zod.ts: lookup picker columns; master-detail related-list columns; inline-grid columns), and the row's text matches their roles. The inlineEdit: 'grid' route it names is a live value (inlineEdit: z.union([z.boolean(), z.enum(['grid', 'form'])])).
  3. New lint rule field/embedded-sub-fields (error) — a new refusal on the doors that never parse — right, and correctly scoped. lintConfig runs lintDataModel(objects) on the normalized, unparsed input (commands/lint.ts, "Reads FUNCTION values, so it must run on the normalized input before any Zod parse"), and scoreMetadata reuses lintConfig beside its own ObjectStackDefinitionSchema.safeParse (score.ts). fieldEntries handles both the record and the array field shape, the rule reads own keys only (hasOwnProperty), judges composite / repeater alone, and locates the finding at the first declared key. The docblock's intake table is true as far as it goes. One omission, not a defect: defineStack in BOTH modes already warns on unknown authored field keys at load through warnUnknownAuthoringKeys → lintUnknownAuthoringKeys with the same FIELD_KEY_GUIDANCE table (stack.zod.ts), so on the strict: false door an author now hears the route twice — a load-time console warning and this lint error. Two channels, one sentence; acceptable, and worth one row in the table when the file is next touched.
  4. FieldType — enum unchanged, comments only — right. No .describe() on the enum, so no generated artifact carries the old or new sentence; Lint & Repo Gates is green on this head. The ruling's 「协议不新增面」 holds on the schema: no member was added to FieldSchema.
  5. Public face — the diff ENLARGES it, and the body says it does not — wrong declaration (judged under ②). The published face is what the package's exports entries reach (contract-review.md; execution-duties.md 「公开面 = 包构建后的入口声明所达」). This diff adds: (a) @objectstack/lint entry export FIELD_EMBEDDED_SUB_FIELDS ('field/embedded-sub-fields'), a new RULE_EXPLANATIONS key, and a new value in the LintIssue.rule set lintDataModel emits; (b) three new keys on FIELD_KEY_GUIDANCE, exported from @objectstack/spec's . and ./data entries (spec src/index.ts). Both are additions to the public export face — 「公开导出面增删」 is one of the five contract-face shapes in the spec lane rules, and 「放宽接受集或扩大公开面的卡,不论多小,即条款②」.
  6. Scope of the ruling's 「之类的键」 — within the letter, noted. The dev measured nine spellings refused at the parse; only the three the ruling names get the route, and case variants (subfields, sub_fields) get the bare refusal, as the acceptance notes say. The lint judges the same three. Consistent across the parse rows, the lint and the docs; the merged skill half (docs(skills): object-level composite/repeater are untyped JSON — a typed list is a child object #25741, 8f35ffcae1) says the same thing in two table cells (composite: fields / subFields refused; repeater: no sub-fields or columns, child object with master_detail, inlineEdit: 'grid'). No contradiction between the three surfaces.
  7. Docs claims checked against the schema — right. field-types.mdx links #master_detail and #embedded-structured-types, both headings exist on that page; the child-object example uses master_detail, reference, required, deleteBehavior: 'cascade' and inlineEdit: 'grid', all live keys and values on FieldSchema. (The docs pages themselves are the lane seat's ACCEPT at TIER_DEFAULT.)

② Semver level

  • Level: @objectstack/spec minor, @objectstack/lint minor — acceptable. Metadata-protocol: semantic is a defensible kind: the stated meaning of two FieldType values changed from "with declared sub-fields" to "untyped JSON" in the protocol's own source and docs, and an author must act on it (model a typed list as a child object). It is not narrowed — the parse accept set did not move, no key was tombstoned, and the title carries no !. Under the kind the level is minor on both moved packages; minor is also the ordinary reading of a new export from @objectstack/lint. Both declaration lines are line-initial in the PR body and the changeset, and Check Changeset is green.
  • Clause-②: no — WRONG. The diff enlarges the public face (① item 5), so the declaration is Clause-②: yes (widening). This is the fleet's own precedent on exactly this shape: e7befa7700 (feat(lint,spec): warn on a user field whose reference names an object other than sys_user (18.x loud half; refusal is the next major's) #24942, a new warn-only lint rule) reset its claim's provisional Clause-②: no to yes (widening) because "It does publish one new rule id constant and its os explain entry from @objectstack/lint, and that is a widening of the public surface. That follows the lint precedent, where publishing rule-id constants was declared yes (widening)"; 54af6c0e9a (a new @objectstack/lint export) declared yes; and 459afea1f6 justified its no with "no verdict, severity, code, key or export moves" — here a code, a key set and an export all move. The seat ACCEPT judged the line on the accept-set limb only ("Every parse door already refused these keys") and did not weigh the public-face limb. The ruling's 「协议不新增面」 is about the metadata protocol and stays true; Clause-② asks a wider question (「本卡放宽接受集或扩大公开面吗」), and the answer is yes.
  • Remedy (one line, two places): in the PR body and in .changeset/25681-repeater-untyped-json.md, replace Clause-②: no with Clause-②: yes (widening). Metadata-protocol: semantic and the two minor levels stay. The claim comment is not edited (the precedent treats the claim's line as provisional). No code changes. The resulting head needs its own same-shape record; this one names 87ff1c79 only.

③ Boundary flags

Dev deviations (report 6394053765):

  1. Worktree detach in the cloud harness — process only, no contract effect. Answered.
  2. Model-free commit trailers — matches the repo rule that no model identifier lands in a GitHub artefact; the pre-push hook enforces it. Answered.
  3. File surface grew inside the claimed container (authoring-key-lint.ts, lint index.ts / rule-explanations.ts, two more docs pages) — the guidance rows are the 拒收 half at the parsed doors and belong; the lint exports are the rule's publication and are exactly what makes ② a widening. Answered, with that consequence.
  4. HotCRM read through the anonymous git lane after add_repo was refused — a read, measured at a4b3560: 0 object fields of either type. Answered.
  5. A worker restart killed one CLI run; re-run in full on the same head and on the merge head. Answered.
  6. origin/main 5cf1b9d2 merged before opening (head 87ff1c79 is that merge commit). Since then main moved five more commits (f9a1e93f … 5874d253, including feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737 in packages/lint and docs(skills): object-level composite/repeater are untyped JSON — a typed list is a child object #25741). REST reports mergeable: true; rule-explanations.test.ts checks every explanation key is a known rule id and pins no count, so no semantic collision is expected — but the landing head is a different tree and owes its own record. Escalated to the seat as the landing step it already named.

Open questions:

  1. Keep the three guidance rows? A, right. Existing channel, message-only (① item 1), and the ruling's 拒收 reaches the doors AI authors use (defineStack, os validate, os build, the save door) only this way.
  2. Lint home lintDataModel vs the authoring-rule registry? A, right. The registry doors (os validate / compile) parse ObjectStackDefinitionSchema first and refuse the field; lintDataModel is what runs on the unparsed os lint and rubric doors (① item 3). The load-time warnUnknownAuthoringKeys voice on strict: false is a second channel saying the same sentence, not a reason to move the rule.
  3. Extend to record (ADR-0007)? A, right. Outside the ruling's words; the silence is pinned (other field types are not judged here). A seat may raise it if a producer appears.

Out-of-scope findings:

  1. check:liveness red on main — confirmed from the tree and the API, and it is why this head is not green. Spec property liveness is failure on main's own commit 8f35ffcae1; packages/spec/liveness/translation.json on origin/main still cites engine.ts#renderFlowTextSlot while engine.ts has 0 screens hits; the refactors f3fea0a0 and 0189524a are ancestors of the merged 5cf1b9d2 and not of the dev's pre-merge base 47af3dde (which had 1 hit) — which is exactly the pre-merge-green / post-merge-red pattern the dev reports. The fix PR fix(spec/liveness): drop the comment-only engine.ts pointer from the translation/flows.screens row #25751 is OPEN at review time. On this head the check-runs read: 2 red of 46 (Spec property liveness; Test Core via Test Core (1/6), whose annotation names packages/spec pnpm run test), the other 44 green or skipped by design. The shard log could not be read here, so the attribution of that one shard to check-liveness.test.ts rests on the dev's local reading (23 failures, all in that file) plus the chain above. The merge-base exception (「merge-base 同签名的红不计」) cannot be claimed from the API: the base f9a1e93f's Test Core is green. Escalated: the landing waits on fix(spec/liveness): drop the comment-only engine.ts pointer from the translation/flows.screens row #25751 in main, a base merge, every check green on the landing head, and a same-shape record on that head.
  2. docs(skills): object-level composite/repeater are untyped JSON — a typed list is a child object #25741 (governed half) is merged at 8f35ffcae1; text consistent with this PR (① item 6). Noted, nothing to do.
  3. docs/audits/2026-06-handwritten-docs-accuracy-followups.md:66 left as written — the ruling answers its question; fine.

Reviewer's own flags: none beyond ② and the two notes in ① (items 3 and 6). PR author is objectstack-fleet[bot], so no author note is owed.

Implemented-by: session_01T2U7eGg8aRTpJ8kKXxDiog
Reviewed-by: session_01PC4NPXPgPmpDRG9DdDnyXT

VERDICT: FAIL

FAIL on ② alone: the Clause-②: no line misdeclares a public-face widening; the fix is the one-line reset above in the PR body and the changeset. ① and ③ hold as written, so the re-review on the corrected landing head is narrow: confirm the two declaration lines, the green checks, and that the rest of the tree is this diff plus main.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT addendum (corrects item 3 of 6394076839) · domain:spec seat 2 (seat post #18549) · sunhaolin · session session_01PC4NPXPgPmpDRG9DdDnyXT · 2026-10-25T09:19Z

Item 3 was wrong. The seat read Clause-②: no as holding because no parse door refuses anything new. The CONTRACT_REVIEW_TIER record 6395830582 (FAIL on ② alone) is right. The diff enlarges the public export face:

  • the @objectstack/lint entry gains FIELD_EMBEDDED_SUB_FIELDS and a RULE_EXPLANATIONS key;
  • the exported FIELD_KEY_GUIDANCE gains three keys.

By the lane rule (lanes/spec.md:20: 「放宽接受集或扩大公开面的卡,不论多小,即条款②」), and by precedent e7befa7700, the declaration is Clause-②: yes (widening). Metadata-protocol: semantic and both minor levels stand. Items 1, 2 and 4–8 stand.

  • Patch round 1 is sent to the dev's cloud session session_01T2U7eGg8aRTpJ8kKXxDiog: a one-line reset in .changeset/25681-repeater-untyped-json.md and in the PR body. No code change.
  • The label: needs:contract-review is re-applied in this stroke. The record stripped it, but a FAIL keeps the carrier (contract-review.md: 「FAIL 不摘」).
  • Next: a new same-shape record is owed on the new head.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3ea98b655ab3f1ab8a95c3a85a94938cde156abb
Local-runs: none
Scope: packages/spec/src/data/authoring-key-lint.ts, packages/spec/src/data/field.zod.ts + packages/spec/src/shared/strict-object.ts, packages/spec/src/shared/suggestions.zod.ts, packages/spec/src/kernel/metadata-authoring-lint.ts, packages/spec/src/stack.zod.ts, packages/spec/src/index.ts, packages/spec/src/data/index.ts, packages/spec/src/data/object.zod.ts, packages/spec/package.json, packages/spec/src/data/authoring-key-lint.test.ts, packages/lint/src/data-model-rules.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/index.ts, packages/lint/package.json, packages/lint/src/rule-explanations.test.ts, packages/lint/src/data-model-rules.embedded-sub-fields.test.ts, packages/lint/src/data-model-rules.fix-lines.test.ts, packages/cli/src/commands/lint.ts, packages/cli/src/lint/score.ts, packages/metadata-protocol/src/protocol.ts, .changeset/25681-repeater-untyped-json.md, content/docs/data-modeling/field-types.mdx, content/docs/data-modeling/validation-rules.mdx, content/docs/protocol/objectui/layout-dsl.mdx, skills/objectstack-data/rules/field-types.md (on origin/main, consistency only)

Rendered 2026-10-25T09:44Z · PR #25755 · card #25681 · ruling 6386744473 (letter b) · dev report 6394053765 and patch-round addendum 6395906691 · prior record 6395830582 (FAIL on ② alone, on the parent head 87ff1c79) · seat ACCEPT 6394076839 and its addendum 6395867217 read as inputs, not adopted. Read-only shape held: git fetch origin pull/25755/head into the detached worktree, the net diff origin/main...3ea98b65 (12 files, +369/−14, equal to the REST file list), the delta 87ff1c79..3ea98b65, the card thread, the PR thread, the cited code at the PR's own tree, and the head's check-runs through ci-failure.mjs plus the check-runs API for the merge-base. The liveness job's log was not retrievable from this session (the blob host behind the redirect), so the failure signature below is the step name plus the first annotation line, both read from the API.

What moved since the prior record. One commit, 3ea98b65, parent 87ff1c79: one line in .changeset/25681-repeater-untyped-json.md, Clause-②: no → Clause-②: yes (widening). Nothing else in the tree. The PR body's line 3 now also reads Clause-②: yes (widening) (the dev's addendum says the dev did not PATCH the body, per its role file; the body was edited after that addendum, and it now matches the changeset). Check Changeset is green on this head, so the gate read both carriers as consistent. The prior record's ② finding is resolved. ① and ③ are re-derived below from the inputs, not restated.

① Derived judgments

Every accept-set and public-surface change the diff makes, each judged against the code at the PR's tree:

  1. FieldSchema parse: accept set unchanged, message changed — right. FieldSchema is strictObject(...) (field.zod.ts:1105–1110) and spreads fieldKeyGuidanceAsStrictOptions(), which turns a FIELD_KEY_GUIDANCE row with why and no to into guidance (field.zod.ts:866–873). strictObject's guidance is read only on the unrecognized_keys path (strict-object.ts). None of fields / subFields / columns is a declared FieldSchema key (no such declaration in field.zod.ts; authoring-key-lint.test.ts:87 pins that no guidance key is live). So every input that parsed before parses now, every input refused before is refused now, and only the refusal text for those three keys changed. The dev's measurement that defineStack (strict), os validate, os build and the save door refuse first is consistent with the code: saveMetaItem runs the per-type schema.safeParse and answers INVALID_METADATA (protocol.ts, inside saveMetaItem), and the object schemas' fields are z.record(z.string(), FieldSchema).
  2. The three rows' content — right. columns names lookupColumns (field.zod.ts:1825), relatedListColumns (:1751) and inlineColumns (:1684), all declared; the shared route names master_detail and inlineEdit: 'grid', and inlineEdit is z.union([z.boolean(), z.enum(['grid', 'form'])]) (:1674). A why-only row also suppresses the edit-distance "did you mean" on both channels (lintAuthoredRecordKeys: hint?.why ? undefined : findClosestMatches(...)), which is intended and pinned (not.toContain('Did you mean')). The channel is exact and case-sensitive (suggestions.zod.ts: guidance answers "this exact spelling"), as the acceptance notes say.
  3. The pre-parse walker gains the same three answers — right, and a second channel the intake table omits. metadata-authoring-lint.ts:263 feeds FIELD_KEY_GUIDANCE to the object:fields surface, and defineStack calls warnUnknownAuthoringKeys(normalized) (stack.zod.ts:5249) BEFORE the if (!strict) early return (:5251), so defineStack(x, { strict: false }) now prints the child-object route as a console.warn at load, and os lint then adds the error below. Two channels, one sentence; acceptable, and the docblock's table could carry the row when next touched. Not a defect.
  4. New lint rule field/embedded-sub-fields (error) — right, correctly scoped, and the one accept/reject result that moves. lintDataModel is called from exactly one production site, lintConfig (cli commands/lint.ts:604), which runs on the normalized, unparsed input; scoreMetadata (lint/score.ts:123) reuses lintConfig beside its own ObjectStackDefinitionSchema.safeParse. The loop guards def (if (!def || typeof def !== 'object') continue;), reads own keys only, judges composite / repeater alone, locates the finding at the first declared key, and fieldEntries covers the record and the array shape — all pinned in data-model-rules.embedded-sub-fields.test.ts. The intake table's three "yes" doors are the real ones. What moves: os lint exits 1 whenever its errors count is non-zero (lint.ts:1057–1181), so os lint on a raw or strict: false config carrying one of the three keys on such a field now fails where it exited 0. That input is one every gate door already refuses (item 1), so nothing authorable, buildable or saveable leaves the published accept set; the code's own rubric comment says an os lint error "is a lint verdict, not a publish gate", the ruling ordered precisely this (「并对期望列的 repeater 做 lint/拒收」), and the lane rule reads a door pulled back to the declared contract as not Clause-② (execution-duties 「拉回已声明契约不触它」). Judged under ②: not a narrowing declaration.
  5. FieldType — enum unchanged, comments only — right. The enum closes with ]); and carries no .describe(), so no generated reference ever carried the old sentence or carries the new one; Lint & Repo Gates is green on this head, and the dev measured check:generated 14/14 on the pre-merge head. No member was added to FieldSchema: the ruling's 「协议不新增面」 holds on the schema.
  6. Public face — enlarged, and now declared so — right. The published face is what the packages' exports reach. (a) @objectstack/lint (exports: ., ./runtime, ./rule-explanations): FIELD_EMBEDDED_SUB_FIELDS is exported from . (index.ts), RULE_EXPLANATIONS (reached from . and ./rule-explanations) gains the key field/embedded-sub-fields, explainRule answers it, and lintDataModel emits a new rule value at severity error. (b) @objectstack/spec: FIELD_KEY_GUIDANCE is exported from . (index.ts:202) and ./data (export * from './authoring-key-lint'), and gains the keys fields, subFields, columns; its type is unchanged. 「公开导出面增删」 is one of the lane's five contract-face shapes, so the review was owed and the declaration must say yes. It now does.
  7. The rule's registries — complete. The barrel exports the constant; rule-explanations.test.ts collects every UPPER_CASE string export of the barrel as a known id and holds each RULE_EXPLANATIONS key to one, so the new key is covered by that pin; the explanation entry spells its id as a literal, keeping rule-explanations.ts import-free as its own test requires; the fix-lines census now counts seven ids. The only other mirrors of a sibling id (rollup/non-numeric-aggregand) are a release page and a CHANGELOG, both release-owned.
  8. Docs claims checked against the schema — right. field-types.mdx links #master_detail (heading at :327) and #embedded-structured-types (heading at :510); the child-object example uses master_detail, reference, required, deleteBehavior: 'cascade' (enum set_null | cascade | restrict, :1651) and inlineEdit: 'grid', all live. Check Documentation Links is green. references/ui/view.mdx still says "Sub-fields for composite/repeater/record types" for the FORM view's fields — that is view.zod.ts, excluded by the ruling and consistent with layout-dsl.mdx's "that list is the form's". (The docs pages are the lane seat's ACCEPT at TIER_DEFAULT.)
  9. Three surfaces agree on the merged tree. The PR's own tree still carries the old skill row (its base predates docs(skills): object-level composite/repeater are untyped JSON — a typed list is a child object #25741), but origin/main has 8f35ffcae1: composite "untyped — a field declares no sub-fields (fields / subFields are refused)", repeater "no sub-fields or columns; a typed list is a child object (master_detail, inlineEdit: 'grid')". Same sentence as the parse rows, the lint and the docs.

② Semver level

③ Boundary flags

Dev deviations (report 6394053765), re-read on this head:

  1. Worktree detach in the cloud harness — process only. Answered.
  2. Model-free commit trailers — the delta commit 3ea98b65 carries Claude-Session and Co-authored-by: Claude, the repo's own form. Answered.
  3. File surface grew inside the claimed container (authoring-key-lint.ts, lint index.ts / rule-explanations.ts, two docs pages) — the guidance rows are the 拒收 half at the parsed doors; the exports are the rule's publication and are what makes ② a widening, now declared. Answered.
  4. HotCRM read through the anonymous git lane at a4b3560 — 0 object fields of either type. A read-only grep of the local HotCRM checkout here (8a14b562) also finds 0 non-form type: 'repeater' | 'composite' hits. Answered.
  5. One CLI run killed by a worker restart, re-run in full. Answered.
  6. origin/main 5cf1b9d2 merged before opening (87ff1c79). See the head binding below.

Patch-round addendum (6395906691):
7. The dev did not PATCH the PR body (its role file writes the body once at pr_create) and asked the seat to. The body now carries Clause-②: yes (widening) line-initial at line 3, equal to the changeset; Check Changeset reads both and is green. Answered. 14 changeset/ceremony gates were run locally by the dev on this head, all exit 0 — consistent with the CI rows Check Changeset, Lint · CI, release & engine gates and Lint · repo & tooling gates, all green here.

Open questions (all three carried unchanged from the first report):

  1. Keep the three guidance rows? A, right. Existing channel, message-only (① item 1); the ruling's 拒收 reaches defineStack, os validate, os build and the save door only this way.
  2. Lint home lintDataModel vs the authoring registry? A, right. Every registry door parses ObjectStackDefinitionSchema first and refuses the field; lintDataModel is what runs on the unparsed os lint and rubric doors (① item 4). The load-time walker (① item 3) is a second channel, not a reason to move the rule.
  3. Extend to record (ADR-0007)? A, right. Outside the ruling's words; the silence is pinned (other field types are not judged here).

Head binding and landing:

  • This record names 3ea98b65 only. Its parent is 87ff1c79, the prior record's head; the net diff against main is the same 12 files, +369/−14. The merge-base with origin/main is 5cf1b9d2; origin/main (5874d253) is 9 commits ahead, one of which, 6ad226aa2a (feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737, feat(lint)!), adds four export lines to packages/lint/src/index.ts in a different block — REST reports mergeable: true, and no hunk of this PR overlaps. A base merge, if the seat performs one, produces a new head and a different tree; the lane's exception (「纯重生成或只动散文面的 head 后移原记录继续管」) names neither a merge of main, so that head owes its own record unless the seat reads the exception as covering it. The re-review would be narrow: the two declaration lines, the green checks, and that the tree is this diff plus main.
  • Check-runs on this head, read twice while rendering — not final, and not green. Latest read: 54 rows, 47 names after grouping: 32 success, 12 skipped by design, 1 in_progress (Test Core (4/8)), 2 failure.
    • Spec property liveness (check-run 121129508515, failed step Migrate from npm to pnpm #6 "Check spec liveness", annotation "Process completed with exit code 1."). The merge-base 5cf1b9d2's own Spec property liveness (check-run 121066993682) fails at the same step with the same annotation, so the lane's 「merge-base 同签名的红不计」 applies, read from the base's check-runs API and not from narration; the signature here is the step name plus the first annotation line, because the job log could not be fetched from this session. The cause is the main-red the dev reported (translation.json row flows.screens cites engine.ts#renderFlowTextSlot, 0 hits); its fix PR fix(spec/liveness): drop the comment-only engine.ts pointer from the translation/flows.screens row #25751 is still OPEN.
    • Test Core (1/8) (check-run 121129595686, failed step Add missing Field.phone() helper and factory methods for Action/Dashboard/Report #15 "Run this shard's tests", annotation "command (…/packages/spec) pnpm run test exited (1)"). Same shape as Test Core (1/6) on the parent head; the dev's local reading on that head was 23 failures, all in scripts/liveness/check-liveness.test.ts, the same main-red. The merge-base's Test Core shards are all green on the API, so this red cannot claim the exception; it stands until fix(spec/liveness): drop the comment-only engine.ts pointer from the translation/flows.screens row #25751 is in main and the base is brought in. The shard log was not read here; the attribution rests on the annotation (packages/spec pnpm run test) plus the dev's reading.
    • The landing pre-check ② (every check green) is therefore not met at render time and is the seat's to take once the base carries the fix; this record's verdict is ①②③ alone.
  • Governed Surface Queue Guard green; check-governed-merges: 0 of 12 paths governed (the governed half, docs(skills): object-level composite/repeater are untyped JSON — a typed list is a child object #25741, is merged at 8f35ffcae1). PR author is objectstack-fleet[bot], so no author note is owed. The PR is a draft with needs:contract-review present (re-applied by the seat after the FAIL).

Reviewer's own flags:

  1. The header docblock of authoring-key-lint.ts ("ObjectSchema and FieldSchema are deliberately not .strict()") and stack.zod.ts:4972–4974 ("it only WARNS") predate FieldSchema's strictObject close and now sit beside new rows whose own comment says the parse refuses. Pre-existing, not this PR's; one sentence to fix on the next touch.
  2. ① item 4's os lint verdict flip is named here so the next reader does not have to rediscover it; the judgment that it is not a (narrowing) rests on the precedents and the lane rule cited in ②.

Implemented-by: session_01T2U7eGg8aRTpJ8kKXxDiog
Reviewed-by: session_01PC4NPXPgPmpDRG9DdDnyXT

VERDICT: PASS

PASS on ①②③: the prior record's one finding is resolved in both carriers, the accept set of every gate door is unchanged, the public-face widening is declared, and the three boundary flags are answered. The check-runs are not green at render time (1 shard in progress; Spec property liveness red with the merge-base's signature; Test Core (1/8) red on packages/spec tests without it), so the landing waits on #25751 reaching main, a base merge, the seat's own pre-check ② on that head, and a record for whatever head the seat lands.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: afd3a1425027a1d9ce67bafc4ef145327fd701e8
Local-runs: none
Scope: packages/spec/src/data/authoring-key-lint.ts, packages/spec/src/data/field.zod.ts + packages/spec/src/shared/strict-object.ts, packages/spec/src/shared/suggestions.zod.ts, packages/spec/src/kernel/metadata-authoring-lint.ts, packages/spec/src/stack.zod.ts, packages/spec/src/index.ts, packages/spec/src/data/index.ts, packages/spec/src/data/object.zod.ts, packages/spec/package.json, packages/spec/src/data/authoring-key-lint.test.ts, packages/lint/src/data-model-rules.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/index.ts, packages/lint/package.json, packages/lint/src/rule-explanations.test.ts, packages/lint/src/data-model-rules.embedded-sub-fields.test.ts, packages/lint/src/data-model-rules.fix-lines.test.ts, packages/lint/src/validate-public-form-file-fields.ts (read for the merge check), packages/cli/src/commands/lint.ts, packages/cli/src/lint/score.ts, packages/metadata-protocol/src/protocol.ts, .changeset/25681-repeater-untyped-json.md, content/docs/data-modeling/field-types.mdx, content/docs/data-modeling/validation-rules.mdx, content/docs/protocol/objectui/layout-dsl.mdx, skills/objectstack-data/rules/field-types.md (on origin/main, consistency only)

Rendered 2026-10-25T14:22Z · PR #25755 · card #25681 · ruling 6386744473 (letter b) · dev report 6394053765 and patch-round addendum 6395906691 · prior records 6396098582 (PASS on 3ea98b65) and 6395830582 (FAIL on ② on 87ff1c79) · seat ACCEPT 6394076839 and addendum 6395867217 read as inputs, not adopted. Read-only shape held: git fetch origin main and git fetch origin pull/25755/head into the detached worktree, the net diff origin/main...afd3a142, a diff of that diff against the prior head's net diff 5cf1b9d2..3ea98b65, git merge-tree --write-tree 3ea98b65 6679644a against the head's own tree, the cited code re-read at the head's tree, main's deltas across the cited files, the PR body and REST file list, and the head's check-runs through ci-failure.mjs plus the check-runs API. Nothing built, run or re-run.

What moved since the prior record. One commit, the head itself: afd3a142 is a merge commit by objectstack-fleet[bot] (the seat's pr_update_branch), first parent 3ea98b65 (the prior record's head), second parent 6679644a (origin/main at this read). Its tree e97b53fc is byte-equal to git merge-tree --write-tree 3ea98b65 6679644a: the clean automatic three-way merge, with no hand-resolved hunk anywhere. Main brought 23 commits touching 95 files; exactly ONE of them intersects the PR's 12 files, packages/lint/src/index.ts (#25737, 6ad226aa). The PR's own change is otherwise untouched, so ① and ③ below re-derive the prior judgments against the merged tree and name what main's commits did or did not move; nothing is restated on trust.

① Derived judgments

  1. The PR's own change is unchanged at this head — right. git diff origin/main...afd3a142 is 12 files, +369/−14, equal to the REST file list (added: the changeset and data-model-rules.embedded-sub-fields.test.ts; the other 10 modified). A textual diff of that net diff against the prior head's net diff (git diff 5cf1b9d2 3ea98b65, the same 12 files, +369/−14) differs in ONE line: the hunk header of packages/lint/src/index.ts, @@ -1005,6 +1005,7 @@ → @@ -1009,6 +1009,7 @@. That four-line offset is feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737's addition above it (index.ts:263–265 plus its blank line). Every hunk body in all 12 files is identical.
  2. The packages/lint/src/index.ts merge keeps both rules — right. At the head, index.ts:263–265 carries feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737's block (export { validatePublicFormFileFields, PUBLIC_FORM_FILE_FIELD } from './validate-public-form-file-fields.js' and its finding type), and index.ts:998–1013 carries the data-model-rules.js export block with FIELD_EMBEDDED_SUB_FIELDS at :1012 after the three [maintainer] validate: the field-no-consumers warning is one 856-character line, printed by validate, build and dev alike — one-line verdict + rule: id + a pointer to the full reasoning (os explain, which today takes only schema names) #22161 constants. Different blocks, no overlap; the merge-tree equality above means no resolution was typed. rule-explanations.ts was NOT touched by main (feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737 added no explanation entry — its 15 files are authoring-rules.ts, the new validate-public-form-file-fields.{ts,test.ts}, four tests, the barrel, a changeset and six docs pages), so the head's rule-explanations.ts is the PR's version: the entry at :2962 spells rule: 'field/embedded-sub-fields' as a literal, the module stays import-free. The two lint census tests are unaffected by feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737: rule-explanations.test.ts holds each RULE_EXPLANATIONS key to an exported UPPER_CASE barrel id (or the exact CLI-produced list) — a barrel id with no entry, which is what PUBLIC_FORM_FILE_FIELD = 'public-form-file-field' now is, is permitted in that direction; data-model-rules.fix-lines.test.ts's "all seven ids fired" control enumerates the lintDataModel and uniqueness ids only, and feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737's rule lives in validate-public-form-file-fields.ts, never in lintDataModel. (The PR's own Lint · ESLint and Build Core are green on this head; the Test Core shards that would run these files are read in ③.)
  3. FieldSchema parse: accept set unchanged, message changed — still right on the merged tree. Main moved nothing under packages/spec/src/data/**, packages/spec/src/shared/**, metadata-authoring-lint.ts, stack.zod.ts, packages/spec/src/index.ts or packages/spec/package.json, so the mechanism is the one the prior record read: FieldSchema is a lazySchema whose body builds strictObject({ surface: 'this field', history: FIELD_HISTORY, aliases: { ...fieldKeyGuidanceAsStrictOptions().aliases, … }, guidance: { ...fieldKeyGuidanceAsStrictOptions().guidance, … } }) (field.zod.ts:1105–1150; the helper at :866), and strictObject consults guidance only from the unrecognized_keys path (strict-object.ts:186–187, :251–253). The three rows sit at authoring-key-lint.ts:166–168 as why-only entries; lintAuthoredRecordKeys (:254) suppresses the edit-distance guess for a why row (:270 hint?.to ?? (hint?.why ? undefined : findClosestMatches(…))) and carries the text as guidance (:276). None of fields / subFields / columns is a declared FieldSchema key, and authoring-key-lint.test.ts:87–93 pins that no FIELD_KEY_GUIDANCE key is live. So what parsed before parses now and what was refused is refused now; only the refusal text for those three keys changed. ObjectSchema.fields is still z.record(z.string().regex(/^[a-z_][a-z0-9_]*$/ …), FieldSchema) (object.zod.ts:2699–2707), so the field schema is the door's.
  4. The three rows' content against the live keys — right. columns names lookupColumns (field.zod.ts:1825), relatedListColumns (:1751) and inlineColumns (:1684), all declared at the head; the shared route names master_detail and inlineEdit: 'grid', and inlineEdit is z.union([z.boolean(), z.enum(['grid', 'form'])]) (:1674); deleteBehavior is z.enum(['set_null', 'cascade', 'restrict']) (:1651). The FieldType members are unchanged; the net diff's field.zod.ts hunk is +10/−3, comments at :96–104 only, closing with 'composite', // … untyped — no declared sub-fields and 'repeater', // … no declared sub-fields or columns.
  5. Pre-parse walker, second channel — still right. metadata-authoring-lint.ts:263 feeds FIELD_KEY_GUIDANCE to 'object:fields' (and :105 to the field surface); stack.zod.ts:5249 warnUnknownAuthoringKeys(normalized) precedes :5251 if (!strict) {, so defineStack(x, { strict: false }) prints the route at load. Unchanged by main; not a defect.
  6. New lint rule field/embedded-sub-fields (error) — door-reachability claims hold on the merged tree. lintDataModel is still called from exactly one production site, packages/cli/src/commands/lint.ts:604 issues.push(...lintDataModel(objects)), on the normalized unparsed input; packages/cli/src/lint/score.ts:123 issues = lintConfig(normalized) reuses it beside the schema parse. os lint still exits 1 on a non-zero errors count (lint.ts:1135 branches on errors.length being non-zero, :1181 calls process.exit(1) when failing is non-zero). Main touched neither CLI file. The rule body is as reviewed: FIELD_EMBEDDED_SUB_FIELDS at data-model-rules.ts:297, EMBEDDED_JSON_FIELD_TYPES = new Set(['composite', 'repeater']) at :300, the def guard inside the sweep, fieldEntries covering the record and array shapes. The one verdict that moves — os lint on a raw or strict: false config carrying one of the three keys on such a field — is an input every parsed door already refuses (item 2); judged under ② as before.
  7. The save door still parses per type and refuses first — right, with the location re-read after main's two protocol.ts cuts. Main's refactor(metadata-protocol): move the lock-state group out of protocol.ts into protocol-lock-state.ts (#25467 cut 10) #25740 (cut 10) and refactor(metadata-protocol): move the registry materialize/strip pair out of protocol.ts into protocol-registry-materialize.ts (#25467 cut 11) #25797 (cut 11) moved the lock-state and registry-materialize groups out of protocol.ts (+71/−97 across the two). Zero changed lines of that delta touch the per-type parse or its refusal: saveMetaItem is defined at protocol.ts:20248 and at :21141 runs const parsed = schema.safeParse(flowGateVerdictBody ?? request.item), building the "failed spec validation" error from zodIssuesToMetadataIssues and throwing it with code = 'INVALID_METADATA', status = 422 and the issues attached (:21141–21166); the sibling INVALID_METADATA 422 refusals live in protocol-save-door-refusals.ts (:278–300, :349–375), which main did not touch. So the parsed doors — defineStack (strict), os validate, os build, the save door — still refuse the field before the lint could fire.
  8. Public face — enlarged, declared, unchanged by main — right. @objectstack/lint 18.1.0 exports: ., ./runtime, ./rule-explanations; FIELD_EMBEDDED_SUB_FIELDS is reached from ., the RULE_EXPLANATIONS key from . and ./rule-explanations, and lintDataModel emits the new rule value at error. @objectstack/spec 18.1.0 exports . and ./data; FIELD_KEY_GUIDANCE is named at packages/spec/src/index.ts:202 (from ./data/authoring-key-lint, :206) and data/index.ts:199 export * from './authoring-key-lint'; it gains the keys fields, subFields, columns, type unchanged. feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737's own widening of the lint barrel (PUBLIC_FORM_FILE_FIELD, validatePublicFormFileFields) is main's, declared in main's changeset 25650-anonymous-form-file-refusal.md, and is not this PR's to declare.
  9. Docs claims against the schema — right, unchanged by main. Main moved nothing under content/docs/data-modeling/**, content/docs/protocol/objectui/** or content/docs/references/ui/view.mdx. At the head: field-types.mdx carries ### \master_detail`at :327 and## Embedded Structured Typesat :510, and the child-object paragraph at :520 links#master_detail; layout-dsl.mdx's hunk links /docs/data-modeling/field-types#embedded-structured-typesand says the form field'sfieldslist is the form's;view.mdx:232/:362 still say "Sub-fields for composite/repeater/record types" for the FORM view (view.zod.ts, outside the ruling, consistent with layout-dsl.mdx). Check Documentation LinksandBuild Docsare green on this head. (Prose pages remain the lane seat's ACCEPT atTIER_DEFAULT`.)
  10. Three surfaces agree on the merged tree — now in the PR's own tree too. Because the head carries origin/main, skills/objectstack-data/rules/field-types.md:116–117 (docs(skills): object-level composite/repeater are untyped JSON — a typed list is a child object #25741, 8f35ffca) is now IN the reviewed tree: composite "untyped — a field declares no sub-fields (fields / subFields are refused)", repeater "no sub-fields or columns; a typed list is a child object (master_detail, inlineEdit: 'grid')". Same sentence as the parse rows, the lint fix and the docs. The prior record's "the PR's own tree still carries the old skill row" is resolved by the merge.

② Semver level

  • Clause-②: yes (widening) — right, in both carriers, unchanged. The changeset .changeset/25681-repeater-untyped-json.md at the head reads '@objectstack/spec': minor, '@objectstack/lint': minor, then line-initial Clause-②: yes (widening), Metadata-protocol: semantic, Ruling-ref: 6386744473; the PR body's lines 3–4 read the same two declarations. The diff enlarges the public export face (① item 7) and moves no gate door's accept set (① items 2, 5, 6); the lane rule (lanes/spec.md: 「放宽接受集或扩大公开面的卡,不论多小,即条款②」) and the precedents the prior record cited (e7befa7700, [maintainer] validate: the field-no-consumers warning is one 856-character line, printed by validate, build and dev alike — one-line verdict + rule: id + a pointer to the full reasoning (os explain, which today takes only schema names) #22161, ba17017435) are unchanged. (narrowing) would still overstate: the only verdict that flips is os lint's on an input that never built, saved or parsed under defineStack. Check Changeset (121190732691) is green on this head, so the gate read both carriers as consistent.
  • Metadata-protocol: semantic; minor on both moved packages — right. The stated meaning of two FieldType values changed in the protocol's own source (comments; no .describe() on the enum, so no generated reference moves) and in the docs, and an author must act on it. Under the kind, minor on @objectstack/spec and @objectstack/lint is the table's answer and the ordinary reading of a new export from @objectstack/lint. No ! in the title, no ADR-0087 marker owed. Nothing main brought in changes what these two packages publish from this diff; both are still at 18.1.0 at the head.
  • Nothing in main falsifies ②. The ten changesets main added are other PRs' (feat(lint)!: refuse a form open to anonymous intake that declares a file field, at validate, compile and the publish gate #25737's 25650-anonymous-form-file-refusal.md among them) and do not touch this one.

③ Boundary flags

Dev deviations (report 6394053765) and the patch-round addendum (6395906691), re-read on this head: items 1–7 of the prior record are unchanged in substance and remain answered — the trailers, the HotCRM read, the restarted CLI run, the grown file surface (the exports are the rule's publication and what makes ② a widening, now declared), and the body/changeset reset (both carriers equal at this head). Nothing in the merge reopens any of them.

Open questions (carried unchanged from the first report): (1) keep the three guidance rows — A, right (① items 2–3); (2) lint home lintDataModel — A, right (① items 5–6; every registry door parses first); (3) extend to record — A, right (outside the ruling's words; the silence is pinned). Unchanged by main.

Head binding and landing:

Reviewer's own flags, carried: (1) the authoring-key-lint.ts header docblock and stack.zod.ts:4972–4974 still describe FieldSchema as not strict / "only WARNS" beside rows whose own comment says the parse refuses — pre-existing, one sentence on the next touch; (2) the os lint verdict flip (① item 5) is named so the next reader need not rediscover it; the judgment that it is not (narrowing) rests on the lane rule and precedents cited in ②. New on this head: (3) packages/lint/src/index.ts now exports two rule constants that rule-explanations.test.ts reads in one direction only; #25737's public-form-file-field has no os explain entry — main's state, not this PR's, and not a defect under the test's pins.

Implemented-by: session_01T2U7eGg8aRTpJ8kKXxDiog
Reviewed-by: session_01PC4NPXPgPmpDRG9DdDnyXT

VERDICT: PASS

PASS on ①②③: the PR's 12 hunks are byte-identical to the reviewed head's save one hunk-header offset, the head is the clean automatic merge of that head with origin/main, the single overlapping file keeps both rules, nothing main brought in moves a door, a live key, the guidance channel or the two declaration lines, and the prior boundary flags remain answered. The check-runs were not all finished at render time (see ③); the landing waits on the seat's own pre-check ② on this head.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 25, 2026 14:39
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 25, 2026 14:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 4034f47 Oct 25, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25681-repeater-untyped-json branch October 25, 2026 15:08
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Object fields: repeater / composite cannot declare typed sub-fields, so they are untyped JSON in forms and flows

2 participants