Skip to content

fix(lint)!: the remaining field-name indexes judge an object as served, so a read or write of a field an objectExtensions entry adds is not refused (#30471) - #30533

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-9jsz55
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-9jsz55

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

domain:spec#3 · os-support-ai · session_01WGufzft7TrxiryiTZm4o9p

Requested by Yibo Wang · project thread

Fixes #30471

Clause-②: yes (narrowing) — os validate / os build / os lint refuse three shapes they accepted before: a hook body's literal ctx.api.object(NAME).update(…) / .updateById(…) / .insert(…) writing a field an objectExtensions entry adds and declares readonly: true moves from a hook-body-write-unknown-field warning to a hook-api-update-readonly-field error; and a translation key naming a field or validation rule that only a replaced earlier entry of the same package adds, or only a top-level objectExtensions beside packages[] adds, is now translation-target-unknown at error, where it resolved, and with that top-level-beside shape no longer read, a translation subtree objects.X keyed to an object the stack does not define and only such an entry extends loses the extended-object skip (rung 2b, validate-translation-references.ts :1859) and is translation-target-unknown at error too; and a field an objectExtensions entry RE-DECLARES on its owner's object is judged on the extension's definition, which replaces the owner's whole in the fold as in the registry's mergeObjectDefinitions ({ ...base.fields, ...extension.fields }), so an owner amount: number re-declared as formula, readonly: true or text now gets that definition's sort-field-unsortable, readonly-write or expression type-mismatch verdict, each at error (ADR-0146 D10 already refuses that shape on the extension; no CLI door does). The widening half: a formula, validation, flow condition, hook condition, sharing-rule condition, flow-node, action-body or hook-body write, react page field prop, translation key, page sort or list-view sort naming a field an objectExtensions entry of the same artifact adds is no longer refused as an unknown field.
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/automation/hook-bodies.mdx (:220, the write rules' declarations include extension fields; :312, the same for hook-api-update-readonly-field). Measured at 7ca63171: git grep of the moved ids (the three unknown-field write ids, the five readonly ids, sort-field-unknown, sort-field-unsortable, page-field-unknown) over skills/ and content/docs/ (releases and references excluded) hits 11 lines in 3 files; content/docs/automation/hooks.mdx:43-47 and content/docs/deployment/validating-metadata.mdx:177 state the rules without a declared-only claim ("a field the bound object lacks") and read true; no skill names them.
Prior rulings read: objectextensions,buildfieldindex,indexobjectfields,buildreadonlyindex,indexobjectsortability → 2 hits; ADR-0029 D3, ADR-0146 D10; thread: not read (no --card); repo: objectstack-ai/objectstack

维护者速读

  • 改了什么:lint 里剩下的字段名索引(表达式检查、流程节点/动作体/钩子体三条写入规则、React 页面字段属性、翻译键、页面与列表视图排序、三条只读写入规则)都改读同一份“按注册表实际提供的对象”折叠:声明字段,加上同一制品里任何包通过 objectExtensions 加的字段。扩展字段不再被报成未知字段,它的定义(只读、能否排序、类型)按声明字段一样判断。
  • 为什么改:HotCRM 这类多包制品里,模块包给 app 包的对象加了字段,lint 却把读、写、排序这个字段一律报“未知字段”,而运行时明明提供它;扩展字段真正的问题(写只读字段会被运行时静默丢掉、按公式字段排序每次都 400)也被“未知字段”盖住了。
  • 风险与回滚:这是收窄,os validate / os build / os lint 会拒绝三类以前放行的制品:① 钩子体用 ctx.api 写一个扩展加的 readonly: true 字段,从警告(hook-body-write-unknown-field)升为错误(hook-api-update-readonly-field),因为运行时会静默剥掉这次写入;② 翻译键指向的字段或校验规则只出现在同一包被后一条替换掉的旧扩展条目里,或只出现在与 packages[] 并列的顶层 objectExtensions 里,现在报 translation-target-unknown 错误(注册表不提供它);同理,翻译里 objects.X 指向本制品没定义、只被这种顶层条目扩展的对象,不再按“被扩展的对象”跳过,也报这个错误;③ 扩展条目重新声明 owner 对象上已有的字段(如 owner 的 amount: number 被改成 formula / readonly: true / text)时,按扩展的定义判断,与注册表 mergeObjectDefinitions 整个替换字段一致,于是排序报 sort-field-unsortable、流程或钩子写入报只读错误、校验规则里乘法报类型不符,均为错误(ADR-0146 D10 在注册时就拒绝这种写法,CLI 此前没有任何一道门拦它)。其余变化只是换规则编号且同级或降级:流程节点写只读扩展字段 错误→错误(flow-update-readonly-field),页面和列表视图按公式扩展字段排序 错误→错误(sort-field-unsortable),三处 readonlyWhen 写入为警告。两个包在同一优先级写同一字段时不判它的定义;一个字段都没声明的对象照旧不判。回滚 = revert 本 PR。
  • 先看:packages/lint/src/validate-readonly-flow-writes.ts 的 buildServedReadonlyIndex(三条只读规则共用,含“对象一个字段都没声明就不判”);packages/lint/src/validate-sortable-fields.ts 的 indexServedSortFields;packages/lint/src/object-graph.test.ts 末尾 [#30471] 里 “each refusal this change adds” 一组。
  • 两分钟验证:运行 pnpm --filter @objectstack/lint exec vitest run src/object-graph.test.ts -t 30471,预期 69 例全绿;其中 “each refusal this change adds” 的 9 行各自断言新的规则编号与级别,并断言只看声明字段时(即 main 的视角)仍报原来的未知字段;另 2 行(重新声明 owner 字段:列表视图按 formula 排序、流程写 readonly)断言新的错误,只看声明字段时什么也不报。
  • 需要你做的:无,席位落地。

What changes

  • validate-expressions.ts: the field-name index (buildFieldIndex) is built over objectsAsServed(stack); the field-type and nullable indexes over objectsWithoutTiedFields. The walk stays on the declared objects, so every finding keeps its declared path and an extension's own formula or predicate is not walked. The read-attachment index is object-level and stays on the declared objects.
  • validate-hook-body-writes.ts indexObjectFields (read by the flow-node, action-body and hook-body write rules): the served field names; an object declaring no field keeps its empty set, so judgeableFieldsOf still leaves it unjudged when an extension adds to it. A tied name is kept (existence only).
  • validate-react-page-props.ts: indexes the served objects, as validatePageFieldBindings does since lint: twelve field-path rules and two private readers judge object fields without the objectExtensions fold, so a view, report, dataset or page naming an extension-added field is refused #30383.
  • validate-translation-references.ts: its private objectExtensionsByTarget is deleted; the universe reads the shared fold, which now carries each entry's validations (object-graph.ts, VisibleObjectExtension.validations).
  • The three readonly write rules share a new buildServedReadonlyIndex (validate-readonly-flow-writes.ts): buildReadonlyIndex over objectsWithoutTiedFields(objectsAsServed(stack)), an object that declares no field keeping its own empty map. Without that index, moving the existence question would have left a write to a readonly extension field silent; without the empty-map guard, an extension adding a readonly field to an object that declares none made the readonly rules refuse writes main left silent (measured at cc67f35a: flow and hook at error, the three readonlyWhen ids at warning).
  • validate-sortable-fields.ts (open to this card since fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 landed at c045c341): indexServedSortFields builds the existence index (indexObjectSearchTargets' shape) over the served objects, a tied name kept, and the sortability projection (indexObjectSortability) over objectsWithoutTiedFields; an object that declares no field map keeps its declared answer on both (skip 2). validate-searchable-fields.ts is not touched; the unprovisioned-anchor index is object-level and stays on the declared objects.
  • rule-explanations.ts and content/docs/automation/hook-bodies.mdx: "declared" includes the fields an objectExtensions entry of the same artifact adds (DECLARED_FIELD_DOOR, page-field-unknown, translation-target-unknown; hook-bodies.mdx:220, :312).
  • Tests: the [#30471] describe in object-graph.test.ts (the fold's harness), 69 cases; see the table and Verification.
  • Docblocks (no behaviour change): objectsAsServed states the one-to-one, in-order contract indexObjectFields joins on (objects[i] is the served form of recordsOf(stack.objects)[i], early return included); validate-react-page-props.ts at the served index and the write index's docblock say that handing pre-served objects bypasses the declares-nothing guard by construction, and why no verdict moves (the react rule judged that empty set on main too).
  • .changeset/30471-field-name-indexes-extension-fields.md: @objectstack/lint minor, BREAKING banner, ADR-0087 disposition not-required (no-migration-prescription).

Per site

One artifact: an app package declares xs_deal (name, amount) and xs_account; a module package extends xs_deal.

Site Moved or kept Why Pin
validate-expressions.ts buildFieldIndex (validations, formulas, flow and hook conditions, sharing-rule conditions) moved the runtime evaluates record against the served row an extension field read raises nothing; refused without the extension; nowhere byte-for-byte; per-package leg same
validate-expressions.ts field-type and nullable indexes moved, ties dropped type and nullability are the served definition's; a tie's winner is registration order tie (either order, both legs) raises nothing; the single-writer controls raise the type mismatch and the null guard
validate-expressions.ts read-attachment index, hop walk kept on declared object-level; the walk carries declared paths n/a
indexObjectFields (hook-body-writes) for flow-node-write-unknown-field, action-body-write-unknown-field, hook-body-write-unknown-field moved the write lands on the served object each: passes, refused without, nowhere byte-for-byte, per-package leg; an object declaring no field stays unjudged
validate-react-page-props.ts columns (page-field-bindings' indexObjectFields) moved the renderer binds the served object passes, refused without, byte-for-byte, per-package leg
validate-react-page-props.ts searchableFields (indexObjectSearchTargets) kept on declared its type question is the searchable-fields rule's, carried still searchable-field-unknown on an extension field
validate-page-field-bindings.ts indexObjectFields nothing to move both callers hand it served objects (:640-641 since #30383, react props :1144); #30270 added no declared-only reader covered by #30383's pins and the react row
validate-translation-references.ts private fold retired onto the shared fold (carries validations) one fold; the registry's one entry per package per target, packages[] alone when present fields.region, _validations.region_set resolve; refused without; byte-for-byte; per-package leg; the two refusals the fold adds pinned at error
readonly flow, hook and action rules (buildReadonlyIndex) moved, ties dropped, declares-nothing kept readonly is the served definition's; an object with no own field is an introspected schema the refusal table (9 rows × 3 legs) plus the per-rule describe
validate-sortable-fields.ts existence (indexObjectSearchTargets) moved, tie name kept, declares-nothing kept the list door judges the served schema; measured on the merged tree: a page sort naming an extension field was page-field-unknown error on both CLI legs page sort and list-view sort rows in the existence table (passes, refused without, byte-for-byte, per-package leg)
validate-sortable-fields.ts sortability (indexObjectSortability) moved, ties dropped, declares-nothing kept without it a sort by an extension formula field would turn silent where the door answers 400 INVALID_SORT refusal table rows (formula → sort-field-unsortable, both legs, tie, declares-nothing)

Every finding that moves, against main

Measured with runAuthoringRules('validate', …) over the built dist of origin/main at c23eafa8 and of this head, on the union artifact and the per-package leg (identical on both); the field is one an objectExtensions entry adds to an object that declares fields of its own. The last four rows were measured in round 2 over the built dist of origin/main at 5b748dfe and of this head; the no-extension control is identical on both trees, and the re-declared shapes not listed (a formula or text re-declaration written, a readonly one sorted or read, an action's ctx.api write) move nothing.

Write or read of the extension field main this head
hook ctx.api update / updateById / insert, readonly: true hook-body-write-unknown-field warning hook-api-update-readonly-field error
translation key naming only a replaced same-package entry's field or rule, or only a top-level objectExtensions beside packages[] resolves translation-target-unknown error
flow update_record / create_record, readonly: true flow-node-write-unknown-field error flow-update-readonly-field error
page sort (every slot) / list-view sort, formula page-field-unknown / sort-field-unknown error sort-field-unsortable error
flow update_record, readonlyWhen flow-node-write-unknown-field error flow-update-readonly-when-field warning
hook ctx.api update, readonlyWhen hook-body-write-unknown-field warning hook-api-update-readonly-when-field warning
action ctx.api update, readonlyWhen action-body-write-unknown-field warning action-api-update-readonly-when-field warning
expression with a type or null-guard problem unknown-field error the type mismatch / null guard: error in validations and hook conditions, warning in formulas and sharing-rule conditions
any other read, write or sort (a hook ctx.input stamp, an action ctx.api write of a readonly: true field, a create_record of a readonlyWhen field, a react columns prop, a plain sort) unknown-field error or warning nothing
the same on an object that declares no field of its own nothing nothing
an OWNER field an extension re-declares (owner amount: number), sorted on a page (interfaceConfig.sort) or list view, re-declared formula nothing sort-field-unsortable error
the same owner field re-declared readonly: true, written by a flow update_record or a hook ctx.api update nothing flow-update-readonly-field / hook-api-update-readonly-field error
the same owner field re-declared text, multiplied in a validation rule (record.amount * 2 > 10) nothing expression-invalid error (type mismatch string * int)
a translation subtree objects.X keyed to an object the stack does not define, extended only by a top-level objectExtensions beside packages[] skipped (rung 2b) translation-target-unknown error

Why

The runtime serves each object with every body's objectExtensions folded in. After #30383 the field-path rules read that served shape, but these field-name indexes still read declared objects only, so on a multi-package artifact (HotCRM, hotcrm#2315) a formula, validation, flow, hook, write, page prop, sort or translation key naming an extension-added field was refused as unknown, and the extension field's real problem (a readonly strip, an unsortable formula) went unjudged.

Decisions

  • Translation references. The shared fold carries each entry's validations and the private fold is retired. Carrying one more key changes nothing about which entries survive. The two measured differences from the retired reader are the registry's reading and are refusals at error (declared above and pinned): a key naming a field or rule only a replaced earlier entry of the same package adds, and a top-level objectExtensions beside packages[] (a shape no CLI leg produces; with no packages[] the top level is read).
  • The declares-nothing skip is kept on the readonly and sort rules as on the write rules: an object that declares no field is an introspected schema, and an extension adding to it does not make it judgeable. The contract review read the readonly rules' fieldMap.size === 0 guard as keeping this; on the served index it no longer fired, so buildServedReadonlyIndex restores it.
  • defineStack's collectRelatedListNameErrors stays additive. Aligning it with the registry's replacement measured as a further narrowing (two shapes accepted today would be refused STACK_CROSS_REFERENCE_INVALID), outside this card's declaration; agreed with the seat.

Acceptance notes

Verification

Round 2, at head 80c7ae3a (this branch with origin/main at 5b748dfe merged in fed33b86, clean; the merge brings packages/lint changes, so the dogfood battery was run again), after turbo run build --filter='!@objectstack/docs' --concurrency=2 under os-verify-lock (118/118 tasks, VERDICT command-exit 0), NODE_OPTIONS=--max-old-space-size=4096.

  • pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck under os-verify-lock: Test Files 195 passed (195), Tests 8172 passed (8172); check:test-typecheck: OK; VERDICT command-exit 0. The [#30471] describe: 69 passed.
  • Full dogfood battery, pnpm --filter @objectstack/dogfood test under the lock: VERDICT command-exit 0; Test Files 420 passed, 3 skipped (423); Tests 3496 passed, 36 skipped (3532).
  • npx eslint over the 12 changed .ts files: exit 0.

Round 1, at head 7ca63171 (origin/main at c23eafa8 merged in cc67f35a), 119/119 build tasks:

  • pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck under os-verify-lock: Test Files 195 passed (195), Tests 8158 passed (8158); check:test-typecheck: OK; VERDICT command-exit 0. The [#30471] describe: 67 passed.
  • Consumers of @objectstack/lint whose tests run the rule registry or the moved rules on fixtures, each under the lock, exit 0: @objectstack/cli (--project unit; the integration layer is CI's) 389 files / 5430 tests; @objectstack/metadata-protocol 317 passed, 4 skipped / 22442 passed, 26 skipped; @objectstack/mcp 56 / 678; @objectstack/platform-objects 112 / 1528; @objectstack/plugin-approvals 111 / 1469; @objectstack/cloud-connection 70 / 877; @objectstack/service-mailbox 10 / 152; @objectstack/example-showcase 46 / 544; @objectstack/objectql 552 / 11005; @objectstack/runtime 543 / 8655 passed, 19 skipped.
  • Full dogfood battery (the (narrowing) arm owes it), pnpm --filter @objectstack/dogfood test under the lock: VERDICT command-exit 1; Test Files 416 passed, 4 failed, 3 skipped (423); Tests 3482 passed, 3 failed, 47 skipped (3532). Every failure is a timeout, none an assertion: Hook timed out in 10000ms in package-first-authoring.dogfood.test.ts (and its three later suites refused with the already has a live bootStack kernel cascade a timed-out hook leaves) and in sys-file-metadata-write-refusal.dogfood.test.ts; Test timed out in 5000ms in activity-withheld-update.dogfood.test.ts (1) and analytics-engine-middleware-objects.dogfood.test.ts (2). The four files re-run alone under the lock at load average 7.8: Test Files 4 passed (4), Tests 23 passed (23), VERDICT command-exit 0. None of them reaches a moved rule's verdict.
  • npx eslint --no-inline-config over the 12 changed .ts files: exit 0.
Before/after against main (the table above)
  • Two throwaway probes (scratch, outside the repo) call runAuthoringRules('validate', …) from each tree's built dist (origin/main at c23eafa8 in a second worktree, this head) on the same stacks: an app body declaring xs_deal and xs_account, an object declaring no field (xs_empty), and a module body extending both with a readonly: true text field, a readonlyWhen number field, a plain text field and a formula field; each write surface (flow update_record / create_record, action ctx.api update / insert, hook ctx.api update / insert, hook ctx.input), each sort surface (interfaceConfig.sort, an element dataSource.sort, a defineView list), the expression passes (validation, formula, sharing rule, hook condition) with a text read, text arithmetic, an unguarded number compare and a date compare, a react columns prop and translation keys on the replaced-entry and top-level-beside shapes; on the union artifact, the per-package leg and a no-extension control.
  • At the merged head before this round's code (cc67f35a), the readonly rules refused writes to xs_empty that main left silent: flow-update-readonly-field and hook-api-update-readonly-field at error, the three readonlyWhen ids at warning, on both legs. At this head no xs_empty case differs from main.
  • Every remaining difference is a row of the table above; the no-extension control is identical on both trees for every case.
  • Round 2: a third probe, the same way over origin/main at 5b748dfe and this head, re-declares the owner's amount: number in the module body as formula, as readonly: true and as text, against a page and a list-view sort, a flow update_record, a hook and an action ctx.api update and a validation rule record.amount * 2 > 10, plus a translation subtree keyed to an undefined object extended by a top-level entry beside packages[] and by a package body. The differences are the four new rows of the table; the no-extension control and the package-body translation control are identical on both trees.
Ablations (each through scripts/ablation-replace.mjs: anchor hit once, the describe run, restored, blob equal to HEAD, git diff HEAD empty)

The lint tests import src (relative imports, no alias to dist), so no build sits between the mutation and the run.

  • indexServedSortFields: existence over stack instead of the served objects → 8 failed / 59 passed (the two sortable existence rows on both legs, the sort rows of the refusal table and their tie legs).
  • sortability over stack → 4 failed (the two sort refusal rows and their tie legs); sortability over the served objects with ties kept → 2 failed (the two sort tie legs); the declares-nothing loop skipped → 2 failed (the two sort declares-nothing legs).
  • buildServedReadonlyIndex: the empty-map guard removed → 7 failed (the seven readonly declares-nothing legs); ties kept → 10 failed (every readonly tie leg, old and new).
  • each readonly rule set back to the declared index: hook → 8 failed, flow → 8 failed, action → 4 failed (that rule's refusal rows, tie legs and the per-rule describe rows).
  • objectExtensionsByTarget reading the top level beside packages[] → 1 failed (the new translation pin); keeping every entry of a package → 2 failed (the replaced-entry pin and the new one).
  • Round 2, objectsAsServed no longer replacing an owner's field an extension re-declares (the fold keeps the owner's definition) → 2 failed / 67 passed (the two re-declared rows), restored, blob 6d6f1b21 equal to HEAD.
Gates
  • Round 2, at 80c7ae3a: dispatch-gates --commands derives the same 88 commands, each run as printed with its exit code recorded. First pass: 87 exit 0 (the teardown fixture fetched again first) and pnpm check:type-check-debt exit 3 (PREREQUISITE NOT MET: its closure build timed out queueing behind a sibling's locked build); re-run under os-verify-lock it exits 0 (0 raw tsc errors, none above its recorded number). dispatch-gates --ran: "88 derived, 88 run, 0 NOT-MEASURED", exit 0. check-changeset-no-major --base origin/main with this body as the event: exit 0, the same LEVEL AXIS line. check-adr-0087-registration --base origin/main: exit 0, one declared-breaking changeset [BREAKING+bang+clause-②-narrowing+metadata-protocol-narrowed], not-required (no-migration-prescription). check-empty-changeset --base origin/main: exit 0.
  • Round 1, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 7ca63171: 88 derived commands, each run as printed with its exit code recorded beside it. First pass: 87 exit 0 and check-plugin-teardown-shape.mjs --self-test exit 3 (PREREQUISITE NOT MET: its pinned fixture commit 621a4876 was outside the shallow clone); after git fetch origin 621a4876…:refs/issue-30471/teardown-fixture it exits 0 (48 cases).
  • dispatch-gates --ran: "88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN", exit 0.
  • node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT with this body as the pull_request payload: exit 0, "LEVEL AXIS: this PR declares clause-② yes (narrowing) and metadata-protocol narrowed, and no package … is graded patch".
  • node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0, one declared-breaking changeset, not-required (no-migration-prescription).
  • node scripts/check-empty-changeset.mjs --base origin/main: exit 0.
  • CI at 7ca63171 before this body: every check-run concluded, all success or skipped except Check Changeset, failure on the previous body's Metadata-protocol: none against the minor changeset (the reading this body replaces).

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

13 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 4 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 55d9dd448f00d1498ba3ae830368306aa0dd8d8a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 55d9dd448f00d1498ba3ae830368306aa0dd8d8a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head 482ad5c · 2026-11-05T14:27Z

Full review of the first head at CONTRACT_REVIEW_TIER (the lint field-name indexes outside indexObjectGraph move onto the shared served-object fold; a widening of what lint accepts).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 482ad5cbe58c28b19b1758ced4bff7d60f432fb5
Local-runs: none
Scope: the PR diff against merge-base f6caa1297f (8 files, 250 insertions, 76 deletions, 326 changed lines; the head is f4cc54af plus a merge of origin/main at f6caa129, so nothing of main's own is in the diff); packages/lint/src/object-graph.ts at head (VisibleObjectExtension :371-389, objectExtensionsByTarget :425-458, objectsAsServed :479-509, objectsWithoutTiedFields :821-830, indexServedObjectGraph :839-842); packages/lint/src/validate-expressions.ts (buildFieldIndex :205-233, buildAttachedOnReadIndex :267, buildFieldTypeIndex :416-437, buildNullableFieldIndex :548-560, the index build :2233-2250, consumers :2336-2338, :2442-2444, :2536-2545, :2896-2903, :2931, :3475-3480, :3494-3500, :3562-3563, :4111); packages/lint/src/validate-hook-body-writes.ts (indexObjectFields :499-538, judgeableFieldsOf :541-571, readers :1127-1138, :1208, :1263); packages/lint/src/validate-flow-node-writes.ts :91-100, :236-280; packages/lint/src/validate-action-body-writes.ts :88-100, :293, :382-396; packages/lint/src/validate-react-page-props.ts :57-80, :1139-1152; packages/lint/src/validate-page-field-bindings.ts :128, :458-461, :609-612 (head and PR #30270's head 647a1c1f); packages/lint/src/validate-sortable-fields.ts :505 (head) and :508 (#30270's head); packages/lint/src/validate-translation-references.ts (header :242-246, imports :262, strName :348, the note :1062-1123, artifactProvidedRecords :1131-1190, buildUniverse :1288-1335, packagesOf readers :1054, :1189, :1764); packages/lint/src/validate-translation-references.test.ts :1012-1050, :1077; the three readonly rules (validate-readonly-action-writes.ts :267-302, validate-readonly-hook-writes.ts :4, :407-408, :427-476, validate-readonly-flow-writes.ts :56, :140-152, :247, :309); the six other field-index sites the body names (validate-form-layout.ts :143-150, validate-notify-email-field-recipients.ts :85-92, validate-public-form-file-fields.ts :81-87, validate-flow-template-paths.ts :497-498, validate-page-visualization-bindings.ts :410, validate-searchable-fields.ts :333-338, :585-586) and every other recordsOf(stack.objects) hit of the census at head (36 sites, each read for what it indexes); packages/lint/src/non-record-object-entry.test.ts :180-200; packages/lint/src/validate-security-posture.test.ts :1656-1662, :1907-1916, :1946-1952; packages/lint/src/object-graph.test.ts :820-949; packages/lint/src/rule-explanations.ts :1088-1094, :1157, :1210, :1274, :1706, :3889-3903, :4326-4334; packages/spec/src/shared/suggestions.zod.ts :100-105; packages/spec/src/data/object.zod.ts :4979, :5107; content/docs/automation/hook-bodies.mdx :220; the changeset at head; scripts/pm/check-prior-rulings.mjs --terms objectExtensions,buildFieldIndex,indexObjectFields re-run locally; git merge-tree --write-tree pr-30533 pr-30270 and origin/main × pr-30533; the binding order (/mnt/project-files/seat3-orders/30471.md); card #30471 body and 4 comments (triage 6658720755, claims 6658863844 and 6658921231, os-dev-report 6660480237); PR #30533 body, 2 comments, 0 reviews; check-runs on the head via REST.

① Derived judgments

  1. Census, complete. The grep at head and at the merge base differ only in the five sites this PR moves: validate-expressions.ts :2247 (buildFieldIndex(servedObjects), main buildFieldIndex(objects)), validate-hook-body-writes.ts :529 (indexObjectFields now reads objectsAsServed), validate-react-page-props.ts :1144 (indexObjectFields({ objects: objectsAsServed(stack).objects })), and validate-translation-references.ts (the private objectExtensionsByTarget and its two add(...) reads gone, the shared import at :262). Every other hit is the same line at both ends. The card's census was wrong on one point and the dev's correction is right: validate-flow-node-writes.ts :91-96 and validate-action-body-writes.ts :88-100 import indexObjectFields and judgeableFieldsOf from ./validate-hook-body-writes.js, not from the page rule, so the hook rule's copy is the one function behind all three write rules and the page rule's own copy (validate-page-field-bindings.ts :458-461, still recordsOf(stack.objects) by body) has two callers at head, its own :611-612 and the react rule's :1144, both of which hand it served objects. No caller reads it declared any more, and the file is untouched, which is what the order's "move indexObjectFields last" and the fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 constraint together allow. validate-sortable-fields.ts :505 reads declared objects at head and :508 at fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270's head; untouched, as ordered. The body's nine extra sites are right by reading: validate-form-layout.ts :143-150 indexes the map-shape keys only (an array-shape object, which is what the fold emits for an extended object, would index nothing there, so moving it later needs recordsOf); validate-notify-email-field-recipients.ts :85-92 and validate-public-form-file-fields.ts :81-87 index field types; validate-flow-template-paths.ts :497-498 keeps objectsByName for types; validate-page-visualization-bindings.ts :410; validate-searchable-fields.ts :333-338 (indexObjectSearchTargets, also read by the react rule at :1152, so a searchableFields prop naming an extension field is still refused while a columns prop is admitted, stated in the body as carried); the three readonly indexes (validate-readonly-action-writes.ts :267, validate-readonly-hook-writes.ts :407-408, validate-readonly-flow-writes.ts :247). One more field-name reader the body's list omits: lint-autonumber-formats.ts :56-63 builds fieldMeta from the object's own fields to resolve {field} placeholders in an autonumber format, so an owner's format naming a field a sibling adds reads as unresolvable. Narrow, carried tier. The remaining 26 hits are object-level, action-level or walks over the object's own field list (validate-semantic-roles.ts :67, lint-liveness-properties.ts :613, validate-capability-references.ts :141, validate-rule-compilability.ts :423, validate-expressions.ts :4111 list views), none of which answers "is this name a field".
  2. Per site, existence versus type, and the tie rule. Expressions: the existence index (fieldIndex :2247) is built over servedObjects, the type and nullable indexes (:2249-2250) over objectsWithoutTiedFields, and the walk stays on the declared objects (:2233), so every finding keeps its declared path and an extension field's own formula or predicate is not walked (stated in the table). Each consumer read: validateExpression hints get fields served and fieldTypes without ties (:2536-2545, :3562-3563), so a tied field is a known name of type dyn, no type verdict; the null-guard arms read nullableIndex only (:2442-2444, :2896-2903), so a tied field is unguarded, no finding; the stored-metadata-body reference arm (:3475-3480) skips a field whose type the index does not hold, and its comment at :3466-3472 says so; the holders at :3494-3500 and the flow root scope (:2896, fieldIndex) and shadowed-read warning (:2931) are existence reads. The read-attachment index stays over declared objects (:2248); it is object-level and the fold spreads ...obj (:507), so the same answer either way. Write rules: existence only, never writability, as before; indexObjectFields :520-538 returns served names and keeps a tied name (a served name, the existence rule's own rule); the own.size > 0 ? namesOf(obj) : own guard at :535 preserves judgeableFieldsOf's second skip (:541-571, an object declaring no field stays unjudged) when an extension adds to such an object; the index alignment declared[i] with objectsAsServed(stack).objects[i] holds because both are recordsOf(stack.objects) mapped one to one (:479-481, :486-508, the early return at :483 included). A write to an object a sibling package owns stays unjudged on the per-package leg (first skip). React page props: existence through the page rule's index over served objects. Translation: existence of fields.FIELD and _validations.RULE (:1310-1333), the own declaration keeping the slot (:1325). Writability, measured: a body write to an extension-added field that declares readonly: true was refused on main under the unknown-field id (flow node at error, :276; hook and action at warning) and is now silent on all three surfaces, because the readonly rules that own that question index declared objects only (:267, :407-408, :247) and cannot see the field. The hook rule's own header (:4) calls such a write a silent no-op at run time, and the flow readonly rule gates it at error for a declared field (:309). That is a lost refusal in the widening direction, never a false refusal, so it is not a tie-rule breach; but the body's reason for carrying the readonly rules ("moving them would add findings, a narrowing") is true relative to this head and not relative to main for the literal readonly: true case, where main already refuses the write under the wrong id. Should-fix 1.
  3. The translation decision is sound and stated. The shared fold carries validations (:381-389, recordsOf(ext.validations) :441), a key ObjectExtensionSchema declares (object.zod.ts :5107), read through the same recordsOf as fields, so array and map shapes both fold; the filter that keeps one entry per package (:435), the packages[]-only source (:446-453) and the sort (:455) are byte-identical to e7602049, so which entries survive is unchanged and the only fold still decides the registry's rules. The security-posture key-read meta-test scans the fold (foldCode :1659-1662) and its ext row is widened to exactly the new key (:1913), with the schema check at :1949-1952 still holding every read key to a declared one. The two measured differences from the retired reader are the registry's reading and are pinned or shown harmless: the replaced-entry case (object-graph.test.ts :910-916, a field and a rule only the replaced entry adds are orphans, the later entry's field resolves); the top-level-beside-packages[] shape, which no CLI leg produces and no existing translation test uses (:1012-1032 is per-package with an extension-free owner body, :1043 is top-level only, :1077 has no extend and is skipped by both readers). The extended set at :1311 (rung 2b) has the same membership as before for every leg shape, since the retired reader also read every body. strName and isRec stay in use (:348, :1764), packagesOf too (:1054, :1189), so no dead import.
  4. indexObjectFields with fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 untouched: consistent with the order. The page rule's export is not moved; its two callers are served at the call, so the order's "last" step has nothing left to move except the file fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 holds. git merge-tree --write-tree pr-30533 pr-30270 writes a tree with no conflict (exit 0), the two PRs share no file (fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 edits validate-page-field-bindings.ts :52, :600-615, :643, :690 and validate-sortable-fields.ts; this PR edits neither), and origin/main × pr-30533 is clean too.
  5. Pins, real. The existence table (:854-877) has seven rows (two expression sites, three write rules, react props, translation); it.each runs each on the union artifact (:879-883), on the per-package shape (:885-889, the body at top level and under manifest, every body under packages[], matching packageBodyAsStack), and byte-equal (:891-898, severity, where, path, message of the one zq_absent refusal projected and toEqualed against the declared-only control). Four singles: _validations through an extension (:900-906), the replaced entry (:910-916), the declares-nothing object under the flow rule (:921-927), and the tie (:932-948). The tie pin asks a type question and is non-vacuous in both orders: tie(num, text) keeps text, which judged would give the type mismatch and the null guard (the control :946 shows both); tie(text, num) keeps number, which judged would give the null guard alone (control :947); both must be [] at head, and only objectsWithoutTiedFields makes them so. The byte-equal pin is sound by construction and should say so: findClosestMatches (suggestions.zod.ts :100-105) suggests within Levenshtein 3, and zq_absent is farther than that from every served name, so the did-you-mean half of each message is identical on both sides; a true unknown within distance 3 of an extension-added name now gets a hint naming that served field, which is the widening's own consequence and not a change to the refusal's words (should-fix 2). Ablations: the reported "14 failed, 11 passed" with the four rule files restored is what the describe's composition gives (seven rows × two legs = 14 existence cases, of which the two translation rows stay green because the retired fold already saw extensions, so 12 red; plus the replaced-entry and tie pins, 14 red; the seven byte-equal cases, the _validations pin and the declares-nothing pin stay green, 11); the two anchor ablations each redden exactly the pin that reads the anchor. Gap: no pin on the per-package leg for the tie (union only), and no pin that a searchableFields prop naming an extension field is still refused (the carried blind spot the react rule now has beside its admitted columns); neither blocks.
  6. Declarations and body. PR body and changeset both carry Clause-②: yes (widening: …) and Metadata-protocol: none (…), the parenthesis the reader tolerates; the changeset grades @objectstack/lint patch, the level none owes; Check Changeset, Check PR Size, PR-body guards, Lint · PM dispatch-gates and Governed Surface Queue Guard are success on the head; Test Core (8 shards), Build Core, Type Check and Lint contexts were in_progress at review time. The Prior rulings read: line reproduces locally (2 hits, ADR-0029 D3, ADR-0146 D10). Skills-docs: line present with its measurement. The per-site table matches the diff row for row (ten rows, two kept with the serial constraint as reason, one kept on declared with reason). The 维护者速读 block has its six lines and matches the code. 326 changed lines, 8 files, no governed surface. Out of scope respected: no HotCRM, no runtime, no change to the fold's entry selection (a carried key, which the card named as the first of its two options, is not a semantics change). One body claim is softer than it reads: content/docs/automation/hook-bodies.mdx :220 ("resolved against the target object's declarations") and the os explain texts for the five moved ids (DECLARED_FIELD_DOOR :1088-1094 behind :1157, :1210, :1274; page-field-unknown :4328-4331 "declared fields"; translation-target-unknown :3900-3901) read true only if an extension's fields count as declarations, which the membership-key explanation at :1706 states and these do not (should-fix 3).

② Semver level

@objectstack/lint patch. Refusals narrow (an extension-added field passes the expression passes, the three write rules, react page props and the translation rung on both CLI legs); rule id, severity, where, path and message of a true unknown are byte-equal to the declared-only control, pinned for all seven rows; the one authorable key the fold now reads (validations) is already in the schema. Clause-②: yes (widening) and Metadata-protocol: none are the right pair. The readonly consequence of item 2 is a widening too, so it does not move the level.

③ Boundary flags

④ Design judgment

The shape is right and matches the two prior rounds: one fold, every field-name reader resolving through it, the type questions on objectsWithoutTiedFields and the existence questions keeping a tied name, the walk on declared paths, the declares-nothing skip kept by one guard, and the translation reader retired by carrying one schema key rather than by a hand-aligned copy. No must-fix. Should-fix, in order: (1) the readonly rules: either move the three readonly indexes (validate-readonly-action-writes.ts :267, validate-readonly-hook-writes.ts :407-408, validate-readonly-flow-writes.ts :247) onto objectsWithoutTiedFields in this PR, with a pin that a flow node writing an extension field declared readonly: true is refused under the readonly id at error and a tied readonly field is unjudged, the fieldMap.size === 0 skips keeping the declares-nothing rule; or correct the acceptance note to the measured delta against main (refused under the unknown-field id, now silent, the readonly rules cannot see the field) and file the carry; (2) one sentence in the body and the changeset that a true unknown within edit distance 3 of an extension-added name now gets a hint naming the served field, and why that is the widening's own consequence; (3) one clause each in DECLARED_FIELD_DOOR (:1088-1094), page-field-unknown (:4328-4331), translation-target-unknown (:3900-3901) and hook-bodies.mdx :220 that "declared" includes the fields an objectExtensions entry of the same artifact adds, as :1706 already says (the Skills-docs line should then owe that one docs line); (4) add lint-autonumber-formats.ts :56-63 to the carried census, and a per-package tie case to the [#30471] describe. None blocks landing; landing waits on the running required contexts (Test Core, Build Core, Type Check, Lint) going green.

Implemented-by: claude/project-thread-9jsz55
Reviewed-by: session_01WGufzfgvGexKn9vLHsy89K

VERDICT: PASS

…ep the declares-nothing skip

validate-sortable-fields builds its existence index over objectsAsServed and
its sortability projection over objectsWithoutTiedFields, so a page or
list-view sort naming an extension-added field passes, and one naming an
extension formula field is refused as unsortable rather than unknown. An
object that declares no field map keeps its declared answer on both.

The three readonly write rules share buildServedReadonlyIndex, which keeps an
object that declares no field unjudged, extended or not, as the write rules
leave it: before this, an extension adding a readonly field to such an object
made the readonly rules refuse a write main left silent.

Pins: two sortable rows in the existence table (both legs, byte-equal), and a
table of every refusal this change adds (rule, severity, both legs, the
declared-only control), with tie and declares-nothing legs.

Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa
Co-authored-by: Claude <[email protected]>
… finding that moves

The changeset now carries Clause-② yes (narrowing) with the widening half,
Metadata-protocol narrowed, a BREAKING banner and the ADR-0087 disposition,
in the shape of the page-sort precedent; it lists every finding a name the
fold adds now moves to, measured against main on both CLI legs. Two refusals
of a stack main accepted: the hook ctx.api readonly write (warning to error)
and the translation keys the fold drops (resolved to error). hook-bodies.mdx
says the readonly rule's declarations include extension fields.

Pin: the two translation refusals at error, on the replaced-entry shape (both
legs) and on a top-level objectExtensions beside packages[].

Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/l and removed size/m labels Nov 5, 2026
@objectstack-fleet objectstack-fleet Bot changed the title fix(lint): the remaining field-name indexes judge an object as served, so a read or write of a field an objectExtensions entry adds is not refused (#30471) fix(lint)!: the remaining field-name indexes judge an object as served, so a read or write of a field an objectExtensions entry adds is not refused (#30471) Nov 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

Contract-review-claim: session_01L6bhgTqqeHSwZYJmApbDCa · head 7ca6317 · 2026-11-05T17:37Z

Full review at CONTRACT_REVIEW_TIER on the narrowing face (the changeset now declares Clause-②: yes (narrowing) + Metadata-protocol: narrowed; the patch round moved validate-sortable-fields.ts and the three readonly write rules onto the served fold). Run by an isolated at-tier subagent of this seat; the 482ad5c record 6660749982 is superseded by the new declaration.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7ca631717ba72e31a6e3b78aaf59707ae5b23544
Local-runs: none
Scope: packages/lint/src/object-graph.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/validate-hook-body-writes.ts, packages/lint/src/validate-react-page-props.ts, packages/lint/src/validate-readonly-action-writes.ts, packages/lint/src/validate-readonly-flow-writes.ts, packages/lint/src/validate-readonly-hook-writes.ts, packages/lint/src/validate-sortable-fields.ts, packages/lint/src/validate-translation-references.ts, .changeset/30471-field-name-indexes-extension-fields.md (the narrowing face: reviewFace returns no hits, so the floor is the narrowing's implementation) + cited, read in the live tree at this head: packages/lint/src/object-graph.ts (VisibleObjectExtension :372-389, objectExtensionsByTarget :425-460, objectsAsServed :479-509, objectsWithoutTiedFields :821-829); packages/lint/src/validate-readonly-flow-writes.ts (buildReadonlyIndex :140-162, buildServedReadonlyIndex :174-180, the reader :299-321); packages/lint/src/validate-readonly-hook-writes.ts :388, :430-436; packages/lint/src/validate-readonly-action-writes.ts :283-296; packages/lint/src/validate-hook-body-writes.ts (indexObjectFields :520-543, judgeableFieldsOf :564-571); packages/lint/src/validate-react-page-props.ts :629-631, :1144; packages/lint/src/validate-page-field-bindings.ts (checkFieldRefs :539-571, the served call :640-641); packages/lint/src/validate-sortable-fields.ts (check :405-412, indexObjectSortability :505-514, indexServedSortFields :530-544, unsortableFinding :555-562, the view path :654-657); packages/lint/src/validate-searchable-fields.ts (declaredFieldTarget :256-275, indexObjectSearchTargets :328-338); packages/lint/src/validate-translation-references.ts (the fold loop :1311-1332, the extended skip :1859); packages/lint/src/runtime-gate.ts (CONTEXT_STACK_KEY_ORDER :691-694, buildRuntimeWriteSnapshotSet :841-878); packages/lint/src/authoring-rules.ts :476, :1253-1259; packages/lint/src/reference-integrity-suite.ts :120; packages/lint/src/index.ts :526-532; packages/objectql/src/registry.ts (mergeObjectDefinitions :876-889, ownFieldsTarget note :566-574, registerObject :4515-4525, the extend-mode replacement :4727-4731, the priority sort :4751-4752); packages/objectql/src/engine-registration.ts :117-138; packages/objectql/src/engine-top-level.ts :2400, :4236; packages/objectql/src/validation/rule-validator.ts :2117-2147 (stripReadonlyFields); packages/metadata-protocol/src/protocol-query-validation.ts (assertSortFieldsExist :570-625); packages/metadata-protocol/src/protocol-extension-field-suffix.ts :35-50; packages/spec/src/api/sortability.zod.ts (fieldEntriesOf :208-222, resolveObjectSortability :255-275); docs/adr/0146-*.md D10; content/docs/automation/hooks.mdx :43-47; content/docs/deployment/validating-metadata.mdx :177; scripts/check-adr-0087-registration.mjs :147, :287. The rest of the diff (packages/lint/src/object-graph.test.ts, packages/lint/src/validate-security-posture.test.ts, content/docs/automation/hook-bodies.mdx) is the lane seat's ACCEPT at TIER_DEFAULT; the test file was read only for the pins the scope files cite. Inputs: card #30471 body and its 6 comments (6658720755, 6658863844, 6658921231, 6660480237, 6662477010, the dev report 6664779568); PR #30533 body r1 (written 17:37:48Z), its 14-file list, 4 comments (6660489221, 6660499840, 6660749982 at 482ad5cb, 6664876316), 0 reviews; the net diff against merge-base c23eafa88c (14 files, +543 −93); the check-runs on this head via REST (filter=all).

① Derived judgments

  1. The accept set widens on twelve sites, each right. The field-name index of the expression passes (buildFieldIndex(servedObjects) :2247), the shared write index behind flow-node-write-unknown-field / action-body-write-unknown-field / hook-body-write-unknown-field (indexObjectFields :520-543), the react rule's columns (:1144), the translation rung (fields.FIELD, _validations.RULE, :1311-1332), the page-sort and list-view-sort existence (indexServedSortFields :530-544) now admit a name an objectExtensions entry of the same artifact adds. Right against the runtime: engine-registration.ts :117-138 registers each package body's entries as extend contributors at ext.priority ?? 200, and mergeObjectDefinitions :888 folds { ...base.fields, ...extension.fields } onto the owner, so the served object carries the field. The fold the readers consume is byte-identical to main's except the carried validations key (:379-387, :441), so which entries survive is unchanged.
  2. Two refusals are added over main, both declared, both right. (a) A hook body's literal ctx.api.object(NAME).update / updateById / insert of a field an extension adds and declares readonly: true: hook-body-write-unknown-field warning → hook-api-update-readonly-field error. Right: the engine strips the key from a non-system hook's payload (stripReadonlyFields over the resolved object, engine-top-level.ts :2400, :4236; the resolved object is the merged one), the call returns success, and the rule already grades a declared readonly field error and skips runAs: 'system' (:388), which is the changeset's first prescription. (b) A translation key naming a field or rule that only a replaced earlier entry of the same package adds, or only a top-level objectExtensions beside packages[] adds: resolved → translation-target-unknown error. Right: registerObject in extend mode removes the same package's earlier extension (:4727-4731) and each package registers from its own manifest (:117-119), so the registry serves neither. One consequence the declaration half-names: with the top-level-beside shape no longer read, a target that only such an entry extends and the stack does not define leaves the extended set (:1311), so the objects.X subtree is now an orphan at error (:1859) where main skipped it wholly (rung 2b). Same shape, object level rather than field level; should-fix 1 says it in one clause.
  3. A third refusal class is added that the declaration does not name: an owner's own field an extension re-declares. The fold replaces the owner's field with the extension's whole (fields.set(key, f) :503, after the owner's :490-492; no tie, since lastWriter is empty for a declared name :500), and the registry does the same (:888). So an app package declaring amount: number (writable, sortable) with a module package re-declaring amount as text, readonly: true or formula is now judged on the extension's definition by the type and null-guard arms (:2249-2250), the readonly rules and the sortability projection: a validation record.amount * 2 (accepted on main) becomes a type-mismatch error; a flow update_record of amount becomes flow-update-readonly-field; a sort by amount becomes sort-field-unsortable. The verdicts are true to the runtime, the shape is one ADR-0146 D10 declares refused on the extension ("everything else on a base field — type, required, unique, reference, defaultValue, formulas, options membership — is refused on the extension"), and the field-path rules on main already judge it this way since lint: twelve field-path rules and two private readers judge object fields without the objectExtensions fold, so a view, report, dataset or page naming an extension-added field is refused #30383; but no CLI door refuses it (the only mechanical judge is the .custom pair's suffix rule, protocol-extension-field-suffix.ts :35-50, which does not reach a vendor module's plain-spelled field), so the shape reaches os validate and the changeset's "a name the fold adds is judged by its served definition" does not cover a name the fold replaces. Wrong in the declaration, right in the code: should-fix 1.
  4. Same-or-lower moves, right. Flow update_record / create_record of a readonly extension field error → error under the readonly id; readonlyWhen writes error/warning → warning under the three readonlyWhen ids; a page or list-view sort by an extension formula field or sortable: false field error → sort-field-unsortable error, which is the list door's own answer (assertSortFieldsExist :570-625 reads gate.known from the registry's served object and refuses the formula type after the unknown check); an expression's unknown-field error becomes the served type's verdict. The declares-nothing skip is kept on every moved rule: the write index keeps the declared empty set (:540-541 via judgeableFieldsOf :569), buildServedReadonlyIndex restores the declared empty map (:177-178) which the hook and action rules skip on size === 0 (:431, :284) and the flow rule skips per key (!meta :321), and indexServedSortFields restores null and the declared projection (:538-543) for skip 2 (:412, :657).
  5. The runtime write doors do not move. buildRuntimeWriteSnapshotSet assembles the gate's stack from CONTEXT_STACK_KEY_ORDER — objects, permissions, books, datasets (:691-694) — with no objectExtensions and no packages[], so objectsAsServed takes its early return (:483) there and every moved index equals the declared one. The body's framing of the narrowing as os validate / os build / os lint is exactly right, and the dev's page-door flag is answered by reading (③). Whether that door should see extensions is lint: twelve field-path rules and two private readers judge object fields without the objectExtensions fold, so a view, report, dataset or page naming an extension-added field is refused #30383's family question, not this diff's.
  6. No public surface moves. Of the touched symbols only indexObjectSortability is on the package index (:532), unchanged; indexObjectFields, buildReadonlyIndex, buildServedReadonlyIndex, VisibleObjectExtension and objectExtensionsByTarget are internal. minor is owed by the kind alone (②).
  7. defineStack's related-list check stays additive — the card's third option, taken with a measured reason (aligning it is a further narrowing). Right, and outside this changeset's declaration as it should be.
  8. The census correction holds (the flow-node and action-body rules import the hook rule's indexObjectFields; validate-page-field-bindings.ts' own copy has only served callers, :640-641 and :1144). validate-searchable-fields.ts stays on declared objects by decision, pinned (searchableFields still refused on an extension field).

② Semver level

@objectstack/lint minor with a BREAKING banner — right. The changeset carries both declaration lines: Clause-②: yes (narrowing) with the widening half written out, and Metadata-protocol: narrowed; narrowed on a package the diff moves owes at least minor, and the repository precedent for a lint finding moving from accepted to refused is PR #30270 (c045c341, the same pair). The ADR-0087 marker is present once, not-required (no-migration-prescription), a category the gate declares (check-adr-0087-registration.mjs :147, :287), with a reason that reads true: no schema key moves, the refused hook write was already dropped at run time, the refused translation key names nothing served. Check Changeset on this head re-ran on the r1 body and concluded success (run 127417258869, 17:40:43Z); the earlier failure (127367134210, 16:16:28Z) read the r0 body's Metadata-protocol: none and is superseded. The title carries !. Every refusal the head adds over main is declared except the re-declared-base-field class of ①.3 (and the object-level half of the top-level-beside shape, ①.2b); no refusal is declared that the head does not add — the "Every finding that moves" table matches the code row for row, and every row is in the [#30471] refusal table on both CLI legs. The gap does not move the arm, the kind or the level, so it is a should-fix on the declaration, not a FAIL.

③ Boundary flags

Dev report 6664779568, deviations:

  • PR body not written by the dev — the seat wrote r1 at 17:37:48Z; read here as the body of record. Answered.
  • Worktree created inside the shared checkout, moved before any edit — no trace in the diff or the shared tree. Answered.
  • First push failed and was retried; a second worktree at origin/main for the before/after probes, removed — none.
  • The claim named only the hook narrowing; the translation refusal was found by measurement — now in the changeset and body, pinned at error on both legs plus the top-level-beside shape (object-graph.test.ts :143-155), judged right in ①.2b. Answered; the object-level half goes with should-fix 1.
  • The declares-nothing refusals of the merged head were removed rather than listed — the right call: the three readonly rules' size === 0 guards and the write rules' judgeableFieldsOf say an object declaring no field is not judgeable, and the sort rule's skip 2 says the same; the served index had defeated the guard by construction, buildServedReadonlyIndex restores it, pinned on 7 readonly and 2 sort legs with ablations. Answered.
  • Title ! left to the seat — present at this read.
  • CLI integration layer not run locally — Test Core (8 shards) success on this head.

out_of_scope_findings:

  • list-view-field-unknown at error on main for an object that declares no field once an extension adds to it — an observation on main's lint: twelve field-path rules and two private readers judge object fields without the objectExtensions fold, so a view, report, dataset or page naming an extension-added field is refused #30383 graph, not this diff's; a reproducible contract gap, so Prime Directive 10 wants a card, not a note. Escalated: the seat files it on the family's closing card.
  • Seven field indexes still on recordsOf(stack.objects) (validate-form-layout.ts, validate-notify-email-field-recipients.ts, validate-public-form-file-fields.ts, validate-flow-template-paths.ts, validate-page-visualization-bindings.ts, validate-searchable-fields.ts, lint-autonumber-formats.ts) — the same family as this card; carried here with reasons (the searchable one by decision, pinned). Escalated: one follow-up card, filed by the seat, rather than "noted, not filed".
  • The page publish door measured on CLI legs only — answered by reading: the runtime gate's snapshot carries no objectExtensions and no packages[] (①.5), so validateSortableFields at PUT /api/v1/meta/page/NAME reads the identical index before and after; CLI legs are the whole measurement for this diff.

open_questions: none. Added by this review: ADR-0146 D10's refusal of a base-field re-declaration on the extension has no CLI door (only the .custom suffix judge), which is why ①.3's shape reaches these rules at all — a spec/cli finding outside this card's surface; escalated to the seat for a card. Lint still imports no runtime (the ten scope files import @objectstack/spec and siblings only); the fold's entry selection is unchanged from e7602049.

④ Design judgment

Judged against the scenario of record — an app package's object extended by a module package's objectExtensions, served merged by the registry — and against the code each hunk cites:

  • object-graph.ts — carrying validations on VisibleObjectExtension is one key read through the same recordsOf as fields; the registry merges validations by name (mergeValidationsByName, :892) so a rule's name is addressable whichever layer wins, which is all the one reader asks. Holds.
  • validate-expressions.ts — existence over the served objects, the two definition arms over objectsWithoutTiedFields, the walk on declared objects. Holds for an added field (a tied one is a known name with no type and no null verdict, pinned both legs). For a re-declared owner field the arms judge the extension's definition — the runtime's answer, undeclared (①.3).
  • validate-hook-body-writes.ts indexObjectFields — served names, the declares-nothing guard on the declared twin. The positional join declared[i] / objectsAsServed(stack).objects[i] rests on the fold mapping recordsOf(stack.objects) one to one in order (:481, :486), which it does, including the early return; the contract is unstated on the fold. Holds; should-fix 2.
  • validate-react-page-props.ts — hands indexObjectFields pre-served objects, so the declares-nothing guard is measured on the served object and cannot fire; no verdict moves because the react rule judges an empty set on main too (checkFieldRefs :571 and checkObjectChart :631 return only on a missing object, never on an empty set), so a declares-nothing object extended is a widening there as everywhere. Holds; the indexObjectFields docblock's "judgeability stays the declaration's" is not true for this caller — should-fix 3.
  • The three readonly rules, buildServedReadonlyIndex — buildReadonlyIndex reads both field shapes (:150-158), so the served array shape folds; ties dropped (a definition question); the declared empty map restored. The hook rule's prescription set (runAs: 'system', the own-hook stamp, drop the key) is the rule's own (:467-475) and the changeset repeats it. The index is built twice per call (served and declared) — cost only. Holds.
  • validate-sortable-fields.ts indexServedSortFields — existence keeps a tied name, sortability drops it, and an absent projection entry is undefined to unsortableFinding (:555), so a tied formula field is unjudged rather than judged on the fold's winner — the one place this could have leaked, and it does not. resolveObjectSortability reads the array shape (fieldEntriesOf :211-221). Skip 2 restored on both maps. Matches the door's verdicts (assertSortFieldsExist). Holds.
  • validate-translation-references.ts — the private fold retired onto the shared one; the registry's two readings (one entry per package, packages[] alone) now govern, with the owner's own slot kept (:1325). Holds; the rung-2b consequence of ①.2b is a declaration clause, not a code change.
  • rule-explanations.ts — the three texts say "declared includes the fields an objectExtensions entry of the same artifact adds", matching the membership-key explanation. Holds.
  • The changeset — see ② and should-fix 1.

Must-fix: none.

Should-fix: (1) declaration — one clause in the changeset's "Now" list and the PR body's Clause-② line that a field an extension re-declares on an owner's object is judged on the extension's definition too (the registry's { ...base.fields, ...extension.fields }), and that the top-level-beside shape's undefined target loses the rung-2b skip at the object level; plus one pinned row for the re-declared shape (owner amount: number, extension amount: formula, a sort → sort-field-unsortable; or the readonly twin) — this PR. (2) state the one-to-one, in-order contract on objectsAsServed's docblock (or key indexObjectFields' join by name) — this PR or the family's follow-up card. (3) one sentence at validate-react-page-props.ts :1144 that the declares-nothing guard is bypassed by construction and why that moves no verdict — this PR. (4) the two cards of ③ (the family's follow-up: seven carried indexes plus the list-view-field-unknown observation; and the spec/cli card: no CLI door for ADR-0146 D10's base-field refusal) — filed by the seat, not this PR.

Gate verdicts on this head, read at 2026-11-05T17:51Z: 69 check-runs (filter=all), 65 distinct names; newest run per name 55 success, 10 skipped, 0 failure, 0 pending. The ten required contexts: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI success; Lint UI skipped (path-filtered, no ui/ path in the diff). Check Changeset success on the r1 body (17:40:43Z). No governed surface in the file list; 636 changed lines.

Implemented-by: claude/project-thread-9jsz55
Reviewed-by: session_01L6bhgTqqeHSwZYJmApbDCa

VERDICT: PASS


Generated by Claude Code

…t skip, pin the re-declared row, state the fold's positional contract

The changeset now names the two refusals the contract review found
undeclared: an owner field an objectExtensions entry re-declares is judged
on the extension's definition, and a translation subtree keyed to an object
only a top-level-beside entry extends loses the rung-2b skip. The served
fold's refusal table in object-graph.test.ts pins the re-declared shape (a
list-view sort of a formula re-declaration, a flow update of a readonly
one) on both CLI legs with the declared-only control.

objectsAsServed states the one-to-one, in-order contract indexObjectFields
joins on; the react page rule and the write index say why handing
pre-served objects bypasses the declares-nothing guard and moves no
verdict. Docblocks only, no behaviour change.

Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa
Co-authored-by: Claude <[email protected]>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

Contract review — delta

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 80c7ae3a1ff6a02f99bc8faa16749b731857424e
Local-runs: none
Scope: the delta since the reviewed head 7ca63171 (record 6665186290): the patch commit 80c7ae3a (fed33b86..80c7ae3a, 5 files, +42 −6: .changeset/30471-field-name-indexes-extension-fields.md, packages/lint/src/object-graph.ts, packages/lint/src/validate-hook-body-writes.ts, packages/lint/src/validate-react-page-props.ts, packages/lint/src/object-graph.test.ts) and the merge fed33b86 of origin/main at 5b748dfe + cited: packages/lint/src/validate-react-page-props.ts :55-68, :1149 at this head; packages/lint/src/validate-page-field-bindings.ts indexObjectFields :473 and checkFieldRefs :571; packages/lint/src/validate-expressions.ts isNullableField :478-488; packages/lint/src/object-graph.ts objectsAsServed :486-509; packages/objectql/src/registry.ts :888; main's own merge-side changes to packages/lint/src/validate-expressions.ts, rule-explanations.ts and reference-integrity-suite.ts; the dev's round-2 report 6666676414; PR body r2 (19:05:02Z, 28295 bytes); the check-runs on this head via REST. Everything judged in 6665186290 and not touched by this delta stands as judged there.

The merge

fed33b86 has parents 7ca63171 and 5b748dfe. git diff-tree --cc fed33b86 prints no hunk: no conflict was resolved by hand. Its delta over the main parent (git diff fed33b86^2 fed33b86) is exactly the PR's 14 files, +543 −93, the stat of the reviewed head — the PR's content came through byte-whole. Main's own changes to the files this PR moves are three: validate-expressions.ts (+15 −2, #30585's advisory date verdict; no line of the field indexes), rule-explanations.ts (+20 −8, unrelated texts), reference-integrity-suite.ts (validateSortableFields now also runs on object writes at the runtime door, #30564). The third changes nothing for this PR: the runtime door's snapshot still carries no objectExtensions and no packages[] (6665186290 ①.5), so the served indexes there equal the declared ones. The merge owed the dogfood battery (main's lint changes) and it ran green (VERDICT 0, 420 files).

Should-fix 1 — answered

  • Code and declaration agree. The changeset's Clause-② line now names three refused shapes: ① the hook ctx.api readonly write (warning → error), ② the translation refusals — the replaced-entry / top-level-beside field or rule, and now also the objects.X subtree of an undefined object only a top-level-beside entry extends (rung 2b, :1859) — and ③ an owner's field an extension re-declares, judged on the extension's definition because the fold (fields.set(key, f) :503) and the registry ({ ...base.fields, ...extension.fields }, registry.ts :888) replace the field whole. The "Now" list carries the same two new bullets. The PR body r2's Clause-② line is the same text with the :1859 cite added; its 维护者速读 risk bullet says 三类 and enumerates ①, ② (with the 同理 object-subtree clause) and ③ (amount: number → formula / readonly: true / text); the "Every finding that moves" table gains four rows (the formula sort, the readonly flow/hook write, the text expression, the translation subtree), each measured against origin/main at 5b748dfe on both CLI legs with the no-extension control identical. The three carriers agree with each other and with the tree.
  • Completeness. Over main, the shapes that go from accepted to refused are exactly these three; the remaining moves are same-or-lower severity under another id (6665186290 ①.4), and the did-you-mean change is not a refusal. Within shape ③ the per-verdict examples (sort-unsortable, readonly write, type mismatch) are examples of the one declared rule "judged on the extension's definition"; one more instance of the same rule is the null-guard verdict (an owner amount declared required: true, re-declared by the extension without it, is nullable by isNullableField :479 and an unguarded compare in a validation rule then errs). It is carried by the generic clause and needs no new shape; naming it among the examples is optional (a note, not a finding).
  • No false positive on an added field. The replacement fires only on a key the owner already declares (fields is seeded from the owner's own records :490-492 before the extension loop :497-505); a field the extension adds has no owner definition to replace, is served as the extension's own definition, and that is the widening the first record judged. Pinned: the existence rows (region passes on both legs) are untouched, and the two new rows (object-graph.test.ts :1146-1163) assert the declared-only control raises nothing. Ablation reported 2 failed / 67 passed with the replacement removed — the two new rows and nothing else, as the composition gives.

Should-fix 2 — answered

objectsAsServed's docblock (:479-485) states the contract indexObjectFields joins on: one to one and in order with recordsOf(stack.objects), on the early return as on the fold, nothing dropped, added or reordered. True of the body (:486, objects.map; :483 returns the same array).

Should-fix 3 — answered, with a correction to my record

The react rule imports indexObjectFields from validate-page-field-bindings.ts (:64 of its import block), not from validate-hook-body-writes.ts; that index (:473) has no declares-nothing guard at all, so my ④ sentence attributing the bypass to the write rules' docblock was misattributed — the dev's reading is the right one. What stands from the first record: no verdict moves toward a refusal, because checkFieldRefs (:571) and checkObjectChart skip only a missing object. The delta says exactly that at validate-react-page-props.ts :1143-1148, and scopes the write index's docblock to "the stack this is handed" with the pre-served bypass named (validate-hook-body-writes.ts :515-521). Docblocks only; no behaviour change, confirmed by the diff.

Changeset semantics

Unchanged where it matters: Clause-②: yes (narrowing) — the value is the first thing after the colon, the arm the only one; Metadata-protocol: narrowed; @objectstack/lint minor; ! in the PR title and in the changeset summary; one ADR-0087 marker, not-required (no-migration-prescription), whose reason still reads true (no schema key moves; shape ③ is one ADR-0146 D10 already refuses on the extension). check-changeset-no-major reads the arm and the kind, not the prose that follows the dash, so the longer sentence changes no gate reading; the dev's local run and the head's Check Changeset (below) both say so.

Verdict and findings

Must-fix: none.
Should-fix: none owed. One optional note for the seat: add the null-guard verdict to shape ③'s examples in the changeset if a later round touches it; no round is owed for it.
Carried from 6665186290, unchanged: the two cards for the seat to file (the family's follow-up on the seven declared-only indexes plus the list-view-field-unknown observation; the spec/cli card on the missing CLI door for ADR-0146 D10's base-field refusal).

Gate verdicts on this head, read at 2026-11-05T19:11Z: 69 check-runs (filter=all), 65 distinct names; newest run per name 55 success, 10 skipped, 0 failure, 0 pending. The ten required contexts: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI success (18:03Z–18:29Z); Lint UI skipped (path-filtered). Check Changeset re-ran on the r2 body and concluded success at 19:07:00Z (run 127466834765). 672 changed lines, no governed surface.

mcp_calls: 0 — every GitHub read went by REST (curl), no MCP tool was called; the one write is this comment through scripts/pm/post-stamped.mjs.

Implemented-by: claude/project-thread-9jsz55
Reviewed-by: session_01L6bhgTqqeHSwZYJmApbDCa

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa

维护者速读

  • 改了什么:lint 里剩下的字段名索引(表达式检查、流程节点/动作体/钩子体三条写入规则、React 页面字段属性、翻译键、页面与列表视图排序、三条只读写入规则)都改读同一份“按注册表实际提供的对象”折叠:声明字段,加上同一制品里任何包通过 objectExtensions 加的字段。扩展字段不再被报成未知字段,它的定义(只读、能否排序、类型)按声明字段一样判断。
  • 为什么改:HotCRM 这类多包制品里,模块包给 app 包的对象加了字段,lint 却把读、写、排序这个字段一律报“未知字段”,而运行时明明提供它;扩展字段真正的问题(写只读字段会被运行时静默丢掉、按公式字段排序每次都 400)也被“未知字段”盖住了。
  • 风险与回滚:这是收窄,os validate / os build / os lint 会拒绝三类以前放行的制品:① 钩子体用 ctx.api 写一个扩展加的 readonly: true 字段,从警告(hook-body-write-unknown-field)升为错误(hook-api-update-readonly-field),因为运行时会静默剥掉这次写入;② 翻译键指向的字段或校验规则只出现在同一包被后一条替换掉的旧扩展条目里,或只出现在与 packages[] 并列的顶层 objectExtensions 里,现在报 translation-target-unknown 错误(注册表不提供它);同理,翻译里 objects.X 指向本制品没定义、只被这种顶层条目扩展的对象,不再按“被扩展的对象”跳过,也报这个错误;③ 扩展条目重新声明 owner 对象上已有的字段(如 owner 的 amount: number 被改成 formula / readonly: true / text)时,按扩展的定义判断,与注册表 mergeObjectDefinitions 整个替换字段一致,于是排序报 sort-field-unsortable、流程或钩子写入报只读错误、校验规则里乘法报类型不符,均为错误(ADR-0146 D10 在注册时就拒绝这种写法,CLI 此前没有任何一道门拦它)。其余变化只是换规则编号且同级或降级:流程节点写只读扩展字段 错误→错误(flow-update-readonly-field),页面和列表视图按公式扩展字段排序 错误→错误(sort-field-unsortable),三处 readonlyWhen 写入为警告。两个包在同一优先级写同一字段时不判它的定义;一个字段都没声明的对象照旧不判。回滚 = revert 本 PR。
  • 先看:packages/lint/src/validate-readonly-flow-writes.ts 的 buildServedReadonlyIndex(三条只读规则共用,含“对象一个字段都没声明就不判”);packages/lint/src/validate-sortable-fields.ts 的 indexServedSortFields;packages/lint/src/object-graph.test.ts 末尾 [#30471] 里 “each refusal this change adds” 一组。
  • 两分钟验证:运行 pnpm --filter @objectstack/lint exec vitest run src/object-graph.test.ts -t 30471,预期 69 例全绿;其中 “each refusal this change adds” 的 9 行各自断言新的规则编号与级别,并断言只看声明字段时(即 main 的视角)仍报原来的未知字段;另 2 行(重新声明 owner 字段:列表视图按 formula 排序、流程写 readonly)断言新的错误,只看声明字段时什么也不报。
  • 需要你做的:无,席位落地。

席位意见:契约复审两轮都 PASS。第一轮(6665186290,7ca63171)提了 3 条建议,第二轮修补全部落实;增量复审(6666860927,80c7ae3a)无必改、无建议。收窄是有意的:新拒绝的三类写法,运行时本来就不按作者以为的方式处理(只读写入被静默丢掉、翻译目标不被提供、重新声明的字段被整个替换),所以由 CLI 提前报错。按 #30270 的先例,@objectstack/lint 走 minor,并在标题和 changeset 里标 !。复审发现的两处同族遗留已另立 #30608、#30610。

席位核对 — ACCEPT · domain:spec#3 · head 80c7ae3a1ff6a02f99bc8faa16749b731857424e

  • Shape: 14 files (+579 −93), draft, base main, first content line Fixes #30471. Hand-written:

    • the served-fold readers in packages/lint/src: validate-expressions.ts, the three write rules (validate-hook-body-writes.ts, validate-readonly-{flow,action,hook}-writes.ts), validate-react-page-props.ts, validate-sortable-fields.ts, validate-translation-references.ts;
    • object-graph.ts and its [#30471] describe (69 cases);
    • the rule-explanations.ts texts that now say declared includes extension-added fields;
    • one key-list pin in validate-security-posture.test.ts, which gains validations;
    • content/docs/automation/hook-bodies.mdx :220 and :312;
    • the changeset.

    No generated file, no packages/spec file and no content/docs/releases/ edit.

  • Premise measured:

  • Gates:

    • CI: check-runs on this head: 69 runs over 65 names; newest per name 55 success, 10 skipped, 0 red. check-expected-skips --pr 30533: all 10 skips are in main's roster (exit 0). Lint & Repo Gates, TypeScript Type Check, Test Core and Check Changeset are all success; Check Changeset re-ran on body r2.
    • Dev report 6666676414:
      • dispatch-gates --commands derived 88 commands; 88 run, 0 NOT-MEASURED. check:type-check-debt exited 3 on a lock timeout and passed when re-run under os-verify-lock.
      • @objectstack/lint 195 files / 8172 tests, typecheck OK; eslint on the 12 changed .ts files exit 0.
      • check-changeset-no-major, check-adr-0087-registration and check-empty-changeset all exit 0.
    • Ablations: round 2 (the fold keeps the owner's field when an extension re-declares it): 2 failed / 67 passed, restored to blob 6d6f1b21 == HEAD. Round 1's ablations are in the body.
    • Dogfood battery: owed (! in the title and an (narrowing) arm), and owed again by the merge of main at 5b748dfe, which moved packages/lint. Green at this head: VERDICT 0, 420 files passed and 3 skipped, 3496 tests passed and 36 skipped.
    • Merge: git merge-tree against main 778aedf6 merges clean.
  • Contract review:

    • Round 1: 6665186290 on 7ca63171, PASS, Served-tier: CONTRACT_REVIEW_TIER, no must-fix, three should-fix.
    • Delta: 6666860927 on 80c7ae3a, PASS, no must-fix, no should-fix. It confirms all three should-fix are answered.
    • It also confirms that merge fed33b86 carries no hand-resolved hunk (git diff-tree --cc prints none), and that the re-declare replacement fires only on a key the owner already declares, so an added field has no false positive.
    • It confirms the page publish door (PUT /api/v1/meta/page/NAME) does not move: its snapshot carries no objectExtensions or packages[].
  • Declarations:

    • Clause-②: yes (narrowing) (three refused shapes, the same list in the changeset, the body and the 维护者速读) and Metadata-protocol: narrowed.
    • @objectstack/lint minor, with ! in the title and the changeset summary (the seat's call, precedent fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270). ADR-0087: one declared-breaking changeset, not-required (no-migration-prescription).
    • Skills-docs: updated here holds: hook-bodies.mdx :220 and :312 are in the diff. hooks.mdx :43-47 and validating-metadata.mdx :177 make no declared-only claim, and no skill names the moved ids.
  • Accepted deviations:

    • The branch claude/project-thread-9jsz55 is the card thread's own branch, inherited from the previous seat holder.
    • The PR body's first line keeps the opener's signature (os-support-ai, the seat that opened it); the seat sends every body edit.
    • Two re-declared pins instead of one, because sort and readonly read different indexes.
    • Should-fix 3 was answered on validate-page-field-bindings.ts' indexObjectFields, the one the react rule actually imports. The delta review corrects its own round-1 record to match.
    • An unpublished commit message was reworded with --amend after the pre-push hook refused Refs #30471; no force-push.
    • mcp_calls: 6, all reads, in the first report; 0 in each later round.
  • Carried:

  • Landing: not governed (check-governed-merges --pr 30533: 0 of 14 paths, 672 lines ≤ 3000). The hand-off is pr_ready plus automerge_enable, through the merge queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 19:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
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 size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant