Skip to content

fix(lint)!: os build and the object_extension publish door refuse a formula cycle an object extension adds - #33442

Queued
objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/project-thread-j2hlfl
Queued

objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/project-thread-j2hlfl

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #33153

Clause-②: no (narrowing) — os build, os validate, os lint and the runtime object_extension publish door (Studio, PUT /api/v1/meta/object_extension/TARGET, the MCP and AI authoring doors) now refuse one shape an object extension could carry before: a formula field an extension adds that sits on a cycle of formulas (it reads itself, two added formulas read each other, or the cycle closes through a formula the target declares). No exported type or signature changes. Measured first-party exposure: 0 objectExtensions entries add a formula field (examples/app-showcase one entry, a select, a url and a number field; packages/plugins/plugin-auth one, a secret field; HotCRM origin/main ac4dd3d has no objectExtensions in code), so nothing first-party is newly refused.
Metadata-protocol: narrowed
Skills-docs: none owed — this PR edits content/docs/deployment/validating-metadata.mdx (the object extension paragraph names the cycle check); skills/objectstack-formula and skills/objectstack-data teach formula fields and defineObjectExtension({ extend, fields }) without saying an extension is exempt from the cycle check, so the build and the door now give the verdict the object's own formulas already get.

Precedent followed: #33216/#33217 (PR #33398), which widened the same extension walk and declared the same no (narrowing) / narrowed pair.

维护者速读

  • 改了什么:对象扩展(objectExtensions / Studio 发布的 object_extension)新增的公式字段,如果引用自己,或两个互相引用,或经由对象自身声明的公式绕回来,现在在 os build 和运行时发布口都会被拒(规则 id expression-invalid,和对象自身公式循环同一句提示),位置为 object '对象' · objectExtensions[0] · field '字段' expression。
  • 为什么改:引擎按依赖顺序计算公式,循环里的公式永远不会被计算、每行都读到空值。此前循环检查只看对象自身声明的公式,扩展加的循环在两个入口都放行,要到读记录时才暴露。
  • 风险与回滚:收窄;第一方实测 0 处扩展新增公式字段,无既有元数据被新拒。对象自身公式循环的结论与位置不变;对象声明的公式与扩展公式构成的循环,声明那一侧仍报在对象上、只报一次;发布口不会把目标对象自身已有的循环算到扩展这次写入上。草稿保存不受影响。回滚即 revert。
  • 先看:packages/lint/src/validate-expressions.ts(扩展遍历末尾新增的一次 formulaCycleIssues 调用)、packages/lint/src/formula-cycle-issues.ts(extensionFormulaLoci 与 map 形式的 report)、packages/lint/src/runtime-gate.object-formula-cycle.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/runtime-gate.object-formula-cycle.test.ts,预期 16 条全过:扩展自引用、互相引用、经声明公式绕回的循环在 build 与发布口都被拒且位置一致,链式公式和读对象公式的扩展公式通过;发布口按写入条目自身的定义判定同名字段(含与其他包同优先级或被更高优先级覆盖的情形,build 此时放行,后续卡 object_extension door: the gate's served snapshot carries a sibling package's extension field with no priority, so the door's formula-cycle verdict differs from os build on ties and outranked entries #33494)。
  • 需要你做的:无,席位落地。

What changes

packages/lint/src/validate-expressions.ts (the extension walk only; the gate-setup region #33246 edits is untouched). After each target's entries are judged, the walk runs the formula-cycle pass (#33100's formulaCycleIssues) over the fieldDefs it already builds for that target: the target as served (its declared fields with every kept entry's fields folded in through objectsAsServed, a tie between two packages left out), its declared fields and injected columns behind it; for a target the stack does not declare, the entries' own fields. No second fold. Each cycle member an entry adds gets a finding located at that entry, and in the build a member the authored target declares is left to the object walk, so a cycle closing through a declared formula keeps that member's location at the object and names it once. On the door's one pass the object walk does not run and the written entry is folded over the target as served before the write, so every name the entry adds is served from the entry's own record and none is left out, a name the target already serves included (review round 1).

packages/lint/src/formula-cycle-issues.ts. formulaCycleIssues' report also takes a map from field name to location. The new extensionFormulaLoci(target, entries, left) builds it: each name an entry adds, located object 'T' · objectExtensions[i] · field 'F' expression (the location the field-formula pass gives the same field), a name two entries add located at the later entry in fold order (the definition the registry serves), the caller's left names left out (the authored target's declared names in the build, none at the door). Kept in this module so the walk gains no new receiver for the #5017 key scan.

Door. validateStackExpressions is already on the object_extension door (#33072), so the roster and its pins do not move. The door's one pass reads only the written entry, and only names the entry adds are reported members there, so a cycle the target or a sibling package already carries, and a formula that only reads a cycle, are not charged to the extension write.

packages/spec/src/migrations/entries/semantic/18.object-extension-formula-cycle-refused.ts: the ADR-0087 D3 entry, its one exception stated (in os build on a declared target, a field two packages add at the priority that decides it is not judged; the door judges the written entry's own definition, see #33494). No D2 conversion: which read breaks the cycle is a fact only the author holds.

content/docs/deployment/validating-metadata.mdx: the object extension paragraph names the cycle check, its location in the build, and the door's reading of a name the target already serves.

.changeset/33153-extension-formula-cycle.md: @objectstack/lint minor and @objectstack/spec minor, both declaration lines, a BREAKING banner (Before / Now / Unchanged / Fix) and adr-0087: registered object-extension-formula-cycle-refused.

Why

The engine computes an object's formulas, an extension's included, in dependency order; a formula on a cycle has no order, is never evaluated and reads null on every row. The cycle check named the object's declared formulas only, so the card's probe (an extension adding a__c = record.a__c, or a__c and b__c reading each other) got 0 findings at both the build and the door.

Tests

  1. packages/lint/src/runtime-gate.object-formula-cycle.test.ts, new top-level describe #33153 (11 cases) in the validate_expression and the object publish gate accept a formula that reads another formula field, and that formula reads null on every row #33100 harness file, at the real gate (runRuntimeAuthoringRules with type: 'object_extension') and the build (runAuthoringRules('build', …)):
    • LIT: an extension formula reading itself is refused at the door at the entry, in the engine's sentence (formulaCycleMessage); the build's finding equals the door's (where and message).
    • LIT: two extension formulas reading each other are refused on each, door and build equal.
    • LIT: a cycle closing through a declared formula: the door refuses the added member at the entry; the build names the declared member at the object (its validate_expression and the object publish gate accept a formula that reads another formula field, and that formula reads null on every row #33100 location) and the added member at the entry, once each.
    • A cycle across two packages' extensions is named at each entry (packages[j].manifest.objectExtensions[0]).
    • LIT (review round 1, shape a): an extension re-declaring a field the target declares as a formula reading itself is refused at the door at the entry; the build names it once, at the object.
    • LIT (review round 1, shape b): an entry adding, as a self-reading formula, a name a sibling package's published extension already serves is refused at the door at the entry; the build over both packages (sibling priority 100, written entry 300) refuses it at the later entry.
    • Review round 2: the door judges the written entry's own definition whatever a sibling package's priority. A tie (both 200) and an outranked entry (sibling 300, written 200) are each refused at the door at the entry, while the build lets both through (it drops the tied name on a declared target and serves the outranking sibling's definition). The pin names follow-up object_extension door: the gate's served snapshot carries a sibling package's extension field with no priority, so the door's formula-cycle verdict differs from os build on ties and outranked entries #33494, which makes the door read the build's winner.
    • A sibling package's stored cycle, and an extension formula that only reads it, are not charged to the extension write.
    • An extension of an object the door does not carry is judged on its own fields.
    • The target's own stored cycle is not the extension write's to answer for (the differential).
    • CONTROL: an extension formula over a declared formula, and a chain inside the extension, are clean at both.
  2. packages/runtime/src/domains/meta-object-extension-item.test.ts, new describe [#33153] (3 cases) through the real saveMetaItem + publishMetaItem: a self-reading formula and a two-formula cycle each get 422 INVALID_METADATA with the build's rule id, where and path on each member, the field is not served and the row stays a draft; a chain publishes and is served.

Red at base: the round-0 head tests over the base sources (git restore --source=fc283d6d of the two lint files): 5 of 12 red (the file then held 12 cases), every LIT plus the sibling and undeclared-target cases; controls and the differential green. Restored with git checkout HEAD --, git status --porcelain empty.

Ablations (one-shot, through node scripts/ablation-replace.mjs on the committed head; the tool's restore verdict each time: blob equals HEAD, git diff HEAD empty):

  • A1, the report scoping in formulaCycleIssues (!report.has(field) dropped, so every served member is reported): 5 of 12 red (round 0), including the differential case (the target's own cycle charged to the extension write) and the declared-member case (named twice). Lint tests read src/.
  • A3 (review round 1), the door guard (base && !extensionWrite back to base, the round-0 reading): 2 of 15 red, exactly shapes a and b; and the build-side exclusion (ownNames emptied in the build too, behind an opaque globalThis marker): 1 red, shape a's build assertion (the member named twice). Both restored by the tool's verdict.
  • A4 (review round 2), the same door-guard mutation at bfa139ba: 3 of 16 red, shapes a and b and the tie/outranked pin; restored by the tool's verdict.
  • A2, the extension loci (entries read as none, behind an opaque globalThis marker), for the runtime pins that read lint through dist/: pnpm --filter @objectstack/lint build, ablation-dist-preflight found the marker in 4 built files, runtime -t 33153: 2 red (both LITs), the control green. Restore leg: lint rebuilt, --absent found the marker in 0 of 20 built files, tree clean, 3/3 green.

Acceptance notes

Verification

Head bfa139ba (base fc283d6d; review round 1 adds 88b22a14 and 7c2eeb88 over 67ff848e, round 2 adds bfa139ba). origin/main has moved to 811b976a; git diff --stat fc283d6d 811b976a over every file this PR touches is empty, so no merge was needed. #33246 (PR #33434) had not merged.

Package tests
  • @objectstack/lint: 210/210 files, 8982/8982 tests; pnpm --filter @objectstack/lint typecheck (tsc + test typecheck) exit 0.
  • @objectstack/runtime src/domains/meta-object-extension-item.test.ts: 46/46; pnpm --filter @objectstack/runtime typecheck exit 0.
  • @objectstack/spec src/migrations: 5 files, 198/198.
Gates (dispatch-gates --commands, 109 lines)

The derivation at bfa139ba is 109 lines, the same list as at 67ff848e and 7c2eeb88. Every line ran after a whole-repo pnpm build of bfa139ba, its exit code captured before any pipe, and all 109 exited 0. dispatch-gates --ran: 109 derived, 109 run, 0 NOT-MEASURED. The record is pm-orders/spec/33153-gates-ran-r2.txt in the project share (earlier rounds: 33153-gates-ran.txt, 33153-gates-ran-r1.txt).

Dogfood (Clause-②: carries the (narrowing) arm and the title !): full battery, the CI command (pnpm --filter @objectstack/dogfood test under the verify lock), no shard, on head bfa139ba after a whole-repo build. Result: VERDICT command-exit 0, Test Files 465 passed and 3 skipped (468), Tests 3932 passed and 42 skipped (3974), no failures (the same counts as on 67ff848e and 7c2eeb88).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

… and the object_extension door

A formula field an objectExtensions entry adds that reads itself, or two
that read each other, now get the formula-cycle refusal the object's own
formulas get, located at the entry. A cycle closing through a declared
formula keeps the declared member's location at the object.

Claude-Session: https://claude.ai/code/session_01FEkQ5mAXtyZgMs9QwfQzXE
Co-Authored-By: Claude <noreply@anthropic.com>
…he check in the object extension paragraph

Claude-Session: https://claude.ai/code/session_01FEkQ5mAXtyZgMs9QwfQzXE
Co-Authored-By: Claude <noreply@anthropic.com>
…refusal through saveMetaItem and publishMetaItem

Claude-Session: https://claude.ai/code/session_01FEkQ5mAXtyZgMs9QwfQzXE
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 2 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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 172 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 8fdf25a43707b03f2120941acd6acefbdd0b034a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8fdf25a43707b03f2120941acd6acefbdd0b034a

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mswnWm181XgYcCUag

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 67ff848e4cd5f759c80c2921854d18c66c8cb690
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.object-extension-formula-cycle-refused.ts + packages/lint/src/formula-cycle-issues.ts, packages/lint/src/validate-expressions.ts (the extension walk's cycle call and its import only)

Inputs read: card #33153 (body, comments 6865769873, 6883476966, 6884342925), PR #33442 (body, 7 files, net diff against main, merge base fc283d6d), the check-runs on the head. Code outside the diff read on main, not reviewed: packages/lint/src/object-graph.ts, packages/formula/src/formula-read-order.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, runtime-authoring-gate.ts, protocol-object-extension-item.ts, protocol-extension-redeclared-owner-field.ts. None of these moved between the merge base and main.

Check-runs on the head when this record was written: 21 success, 10 skipped, 33 in progress, 0 failed. Concluded green among them: Check Changeset, Governed Surface Queue Guard, Spec property liveness, Lint · PM dispatch-gates, PR-body guards, Dogfood Regression Gate. Not concluded: Test Core 1-8, Test UI 1-16, Build Core, Type Check (workspace, debt ledger, consumer gates, UI), three Lint groups, Live E2E UI, Build & E2E UI. This record judges the contract and its implementing code; it does not answer for a check-run that had not concluded.

① Derived judgments

  • Accept-set: os build, os validate, os lint and the object_extension publish door refuse one more shape, a formula field an extension adds that sits on a cycle. A narrowing. Clause-②: no (narrowing) and Metadata-protocol: narrowed are right, on the PR body and the changeset alike.
  • Rule id and roster: no new rule id. The finding is expression-invalid, from validateStackExpressions, which already lists object_extension in its runtimeTypes on main (authoring-rules.ts:851, lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072). The door roster and its pins do not move: right.
  • Public surface: formulaCycleIssues gains a second accepted report type and extensionFormulaLoci is new. Neither is exported from the package entry; the only importer at the head is validate-expressions.ts. "No exported type or signature changes" is right.
  • ADR-0087: one semantic entry, id object-extension-formula-cycle-refused, file name 18. plus the id, the changeset's marker names the same id. No D2 conversion, for a reason that holds (which read to cut is the author's fact). Right.
  • First-party exposure: the two first-party extension files on main (examples/app-showcase/src/data/extensions/account.extension.ts, packages/plugins/plugin-auth/src/manifest.ts) carry no formula. Nothing first-party is newly refused: right.
  • Unchanged verdicts: the object walk's call is untouched (formulaCycleIssues(objectName, fieldDefs, declaredNames)), so a declared cycle keeps its finding and location. Right.

② Semver level

@objectstack/lint minor and @objectstack/spec minor. The kind is narrowed, which takes at least minor on each package the diff moves; the diff moves lint (source) and spec (the registry entry). The runtime change is a test file and publishes nothing. The BREAKING banner, the Before / Now / Unchanged / Fix body and the ADR-0087 marker are present. Level matches what the diff publishes.

Clause-②: no (narrowing) — matches the diff.

③ Boundary flags

  • open_questions: empty. Nothing to answer.
  • Deviation, no labels and no assignees: follows the order; no contract effect. Accepted.
  • Deviation, no merge of main: the files this change reads and edits did not move between fc283d6d and main (checked for the two lint sources, object-graph.ts, packages/formula/src and the three door files). Accepted.
  • Deviation, branch claude/project-thread-j2hlfl instead of claude/issue-33153-*: stated on the claim (6883476966). Accepted.
  • File surface: all 7 files sit inside the claim's surface.
  • out_of_scope_findings, the redeclared-name gap at the door, reported with carrier: none and not filed: not accepted as written. See finding 1; the mitigation the PR body gives for it does not cover every case, and the gap is wider than the one shape reported. Escalated to the seat as finding 1.

④ Design judgment

18.object-extension-formula-cycle-refused.ts against the code.

  • "reads its own value, itself or through the formulas it reads, the target's own formulas included": holds. The cycle pass runs over fieldDefs, the target as served (typedObjects at the target's position, declared fields and injected columns behind it), so a loop closing through a declared formula is found, and the added member is reported at the entry.
  • "refused with the verdict and the sentence the object's own cycle gets": holds. Same function, same formulaReadOrder message, same severity: 'error'.
  • "on each member the extension adds, located at the entry; a member the object declares keeps its location at the object": holds in the build. The object walk reports the names of its fieldList; the extension walk reports names NOT in fieldEntries(base), the same authored body. The two sets are disjoint, so no member is named twice, and each walk iterates a member once.
  • The example (fields as a name-keyed map) is read through recordsOf, which folds the key into name: it is refused as written.
  • "a stored row is not re-judged until it is published again": holds. The door strips the written package's own stored extension from the target (objectExtensionWriteFold, withoutExtensionsOf), and a write bound to no package is refused before the gate (objectExtensionItemRefusal, step 1), so a republish that turns an existing extension field into a cycle member IS refused: the old field is not in base, so its name is in the loci.

Attempts to break the implementing code.

  • Downstream readers: formulaReadOrder names strongly connected components only (Tarjan, plus a self-read). An extension formula that merely READS a member of the target's own cycle is not a member and is not refused. The door differential holds for it.
  • Target's own cycle at the door: its members are in base, so they are outside the loci. Not charged. A sibling package's stored cycle on the same target is likewise in base and not charged.
  • Two packages forming one cycle (build): objectExtensionsByTarget hands every kept entry for the target; each member is located at the entry that adds it, with the packages[j].manifest. prefix from entry.path. Holds.
  • Two packages forming one cycle (door): A republishes a__c to read B's stored b__c, which reads a__c. b__c is in base, a__c is the written entry's: a__c is refused at the entry, b__c is not charged. Holds.
  • Same name from two entries at different priorities: the list is sorted ascending by priority then carrier, the same order objectsAsServed folds in, so "located at the later one" is the definition served. Holds.
  • A lower-priority entry's shadowed cyclic formula is not served and not reported. Consistent with "as served".
  • Undeclared target: fieldDefs is the entries' own fields, ownNames is empty, every cyclic added formula is reported. Holds.
  • report instanceof Map with the fallback location: a Set caller keeps the old location, a Map caller always has an entry for a name it has. No path yields a wrong location.

Findings.

  1. should-fix — at the door, a cycle member whose NAME the door's target already serves is never refused, and the build disagrees. ownNames is fieldEntries(base); in the build base is the authored object, but at the door it is the target as the registry serves it without the written package's extension, which includes every OTHER package's published extension fields. Two shapes get through the door:

    • (a) a package extending an object it owns and re-declaring one of its declared fields as a formula on a cycle. The PR body says such a re-declaration "is refused at the save door anyway (ADR-0146 D10)". That is true only for another package's object: protocol-extension-redeclared-owner-field.ts states "Not judged: an extension of an object its own package owns". For that case nothing refuses it at the door.
    • (b) a field the written entry adds under a name a sibling package's published extension also adds, at a higher priority, so the written definition is the one served. The owner does not declare it, so the entry does add it, and the contract's sentence "refused ... on each member the extension adds" is false for it at the door. The build refuses the same stack at the entry. The dev report does not name this shape.
      Both are rare, and both sit beside the card's Expected ("at both the build and the object_extension door"). A fix that keeps the differential: on the extension write, leave a name out of the report only when the served definition (fieldDefs.get(name)) is not one of the written entry's own field records. If the seat rules this out of the card instead, it needs a carrier (a follow-up card), and the PR body's D10 sentence corrected; carrier: none is not an answer.
  2. nit — ties. A field two packages add at the same priority is dropped from the served body of a declared target (objectsWithoutTiedFields), so a cycle through it is not found, even when every tied definition is cyclic. The PR body states this and it follows the walk's existing treatment of a tie; the entry's surface and acceptanceCriteria state no exception, so "os build reports no finding" and "each formula reads a computed value" can part ways there. On an UNDECLARED target the tie is not dropped (fieldDefs takes the later entry's definition), so the call-site comment "(a tie left out)" is not true for that branch. No effect on any first-party or plausible stack. Accepted deviation, or a line in the changeset's Unchanged list.

  3. nit — wording. extensionFormulaLoci's jsdoc and the call-site comment call declared "a name the target declares itself"; at the door it is every name the target serves before the write. The words should say so once finding 1 is settled.

No must-fix. Finding 1 earns a delta review of its fix or of its carrier.

Implemented-by: claude/project-thread-j2hlfl
Reviewed-by: session_01FEkQ5mswnWm181XgYcCUag

VERDICT: PASS

…ber under a name the target already serves

On the door's one pass the written entry is folded over the target as served
before the write, so every name it adds is served from its own record: none
is left out of the cycle report there. The build keeps leaving the authored
target's declared names to the object walk.

Claude-Session: https://claude.ai/code/session_01FEkQ5mAXtyZgMs9QwfQzXE
Co-Authored-By: Claude <noreply@anthropic.com>
…eck and the door's reading of a served name

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

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mswnWm181XgYcCUag

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7c2eeb88abde827c8df6a806e51fcd9a93ad7f2d
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.object-extension-formula-cycle-refused.ts + packages/lint/src/formula-cycle-issues.ts, packages/lint/src/validate-expressions.ts (the extension walk's cycle call, its comment and its import only)

Round 2. The round-1 record is comment 6884435411 on PR #33442, at head 67ff848e. This record judges the new head on its own and then answers each round-1 finding.

Inputs read: card #33153 (body, comments 6865769873, 6883476966, 6884342925, 6885198948), PR #33442 (body, 7 files, comments, net diff against main with merge base fc283d6d, and the round's delta 67ff848e..7c2eeb88), the check-runs on the head. Code outside the diff read on main, not reviewed: packages/lint/src/object-graph.ts (objectExtensionsByTarget, objectsAsServed), packages/metadata-protocol/src/protocol-authoring-gate.ts (objectExtensionWriteFold). Between the merge base and main no file under packages/lint or packages/formula/src moved; protocol-authoring-gate.ts gained a locale option only.

Check-runs on the head: 72 runs, 60 success, 12 skipped, 0 failed, 0 pending. Concluded green: Test Core 1-8, Test UI 1-16, Build Core, TypeScript Type Check and the four Type Check groups, every Lint group, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Lint · PM dispatch-gates, PR-body guards, Dogfood Regression Gate, Build Docs, Live E2E UI, Build & E2E UI.

① Derived judgments

  • Accept-set: os build, os validate, os lint and the object_extension publish door refuse a formula field an extension adds that sits on a cycle. A narrowing. Clause-②: no (narrowing) and Metadata-protocol: narrowed are right, on the PR body and the changeset.
  • The round's delta narrows the door once more and leaves the build alone. The one code change is base && !extensionWrite in place of base when the left-out names are built. extensionWrite is options.runtimeWriteType === 'object_extension', never true in a build, so every build verdict and location is what it was at 67ff848e. Right.
  • Rule id and roster: no new rule id, the finding stays expression-invalid from validateStackExpressions, already on the object_extension door. The roster and its pins do not move. Right.
  • Public surface: extensionFormulaLoci's third parameter is renamed declared to left. The function is not exported from the package entry and its one importer is validate-expressions.ts. "No exported type or signature changes" stays right.
  • ADR-0087: one semantic entry, id object-extension-formula-cycle-refused, the changeset marker names the same id. Right.
  • First-party exposure: unchanged from round 1 (no first-party extension adds a formula). Right.

② Semver level

@objectstack/lint minor and @objectstack/spec minor, unchanged this round. The kind is narrowed; the diff moves lint (source) and spec (the registry entry); the runtime change is a test file. The BREAKING banner, the Before / Now / Unchanged / Fix body and the ADR-0087 marker are present. The level matches what the diff publishes.

Clause-②: no (narrowing) — matches the diff.

③ Boundary flags

  • open_questions (6885198948): empty. Nothing to answer.
  • out_of_scope_findings (6885198948): empty. The round-1 entry (carrier: none) is answered by the fix; see the round-1 status below.
  • Deviation, the PR body written by the seat from the dev's draft: the body on the PR carries the round-1 content (the corrected D10 sentence, the new cases, ablation A3). Accepted.
  • Deviation, no merge of main (now b392ff7b): the files this change edits and reads did not move in a way that touches it (checked above). Accepted.
  • File surface: the same 7 files, all inside the claim's surface (6883476966).

④ Design judgment

The key attack: no name is left out of the report on the door's write.

  • What base and entries are at the door: objects is the target as the registry serves it with the written package's own extension left out (objectExtensionWriteFold, withoutExtensionsOf), every other package's extension fields folded in as plain fields; objectExtensions is the written entry alone. objectsAsServed folds that entry over the target, "a later writer replacing the whole field", so fieldDefs.get(name) for a name the entry adds is the entry's own record. The report set is exactly the names of the written entry.
  • The target's own stored cycle: its members are fields of base that the entry does not name, so they are not in the loci. Not charged. Pinned ("the differential").
  • A sibling package's stored cycle: the same, its members sit in base. Not charged. Pinned this round.
  • A formula that only reads a cycle member: formulaReadOrder names members of a strongly connected component only. The reader is in the loci but is not a member, so it gets no finding. Pinned this round, in the same case.
  • An entry that re-declares a member of the target's own cycle: the whole field is replaced, so the member is a cycle member only if the entry's own expression puts it on one. A display-only re-declaration (no expression) takes the name off the cycle. The write is charged only for a cycle its own definition is on.
  • Duplicate findings: at the door the object walk reads an empty list (extensionWrite ? [] : objects), so the extension walk is the only reporter and formulaCycleIssues iterates each member once. In the build the object walk reports declaredNames (the authored field list) and the extension walk leaves out fieldEntries(base) of the same authored body: disjoint, as at 67ff848e. No member is named twice at either.
  • Build verdict: unchanged by construction (① above). Shape (a) in the build is named once, at the object, as main already names it; the docs sentence now says the build and the door differ in location for that shape.

18.object-extension-formula-cycle-refused.ts against the code. surface, replacement, and the body of reason hold as judged in round 1. The two sentences added this round are judged in finding 1.

Findings.

  1. should-fix — the door now reads the written entry's definition as the one served whatever a sibling's priority is, and three new sentences say otherwise. The door's base carries a sibling package's extension field as a plain field, with no priority, so the fold cannot see a tie or a higher sibling. Three consequences at this head:

    • (i) Tie. A sibling's published extension adds s__c at priority 200; the written entry adds s__c at priority 200 as a formula reading itself. The door refuses it (one entry, so objectsAsServed sees no tie). The build over both packages drops the tied name and reports nothing. The entry's acceptanceCriteria says such a cycle is one "which neither door judges", and its reason and the changeset's Unchanged list say the field "is not judged". That was true of the door at 67ff848e (the name was left out) and stopped being true in the commit that wrote the sentence.
    • (ii) Outranked. The sibling adds s__c at priority 300, the written entry at 200 as a cyclic formula. The registry serves the sibling's definition; the build reports nothing (the shadowed definition is not served). The door refuses the write at the entry. So the door refuses a stack os build accepts. The call-site comment's "every name the entry adds is served from the entry's own record", the changeset's "the written definition is the one served" and the docs' "the written entry's fields are served over the target" are true of the gate's fold and not of the registry here. The new shape (b) pin sets the written entry at 300 over a sibling at 100, the winning case only.
    • (iii) The tie exception is also wider than the code on an undeclared target in the build: there the walk takes the later entry's definition of a shared name and judges it (the PR body says so; the entry and the changeset do not).
      All three are in the refusing direction, need two packages adding one field name to one object plus a formula cycle, and give the author a clear 422. No must-fix. But the hits file states a behaviour the door contradicts, so this is not a nit. Smallest fix: say in the entry (reason, acceptanceCriteria), the changeset and the call-site comment that the tie exception is the build's on a declared target, and that the publish door judges the written entry's definition as served over the target whatever a sibling's priority; add a pin for (i) or (ii) at the door. Making the door agree with the build instead needs the sibling entries and their priorities in the door's snapshot, which is outside this diff and would be a follow-up card.
  2. nit — PR body. 维护者速读 still says the two-minute run expects 12 cases (the file has 15 at this head), and "What changes" still writes extensionFormulaLoci(target, entries, declared). Prose face; fixable without moving the head.

Observation, not charged to this PR: the same door snapshot gives a false negative when a higher-priority sibling's definition of a name the entry also adds closes a cycle through another field of the entry (the door folds the entry's definition over the sibling's and sees no cycle; the build reports the entry's member). That is a property of the #33072 snapshot, present at 67ff848e and for every as-served check on that door. It belongs on the follow-up card named in finding 1, if one is filed.

Round-1 findings (6884435411).

  • Finding 1 (should-fix, a cycle member under a name the target already serves never refused at the door): resolved. Shape (a), an own-package extension re-declaring a declared field as a cyclic formula, is refused at the door at the entry; shape (b) as written in round 1 (the written entry at the higher priority) is refused at the door at the entry and by the build at the later entry. Both are pinned at the real gate, and the dev's ablation of the guard turns exactly those two red. The door differential holds (above). The fix reaches further than shape (b) asked; that is finding 1 of this round.
  • Finding 2 (nit, ties): resolved as asked, with a residue. The tie exception is stated in the entry's reason and acceptanceCriteria and in the changeset's Unchanged list, and the call-site comment now describes the declared and the undeclared branch correctly. The stated exception is too wide for the door and for an undeclared target; carried in finding 1 of this round.
  • Finding 3 (nit, declared wording): resolved. The parameter is left, and the jsdoc says what the build passes and what the door passes. The PR body's one stale spelling is finding 2 of this round.

No must-fix. Finding 1 earns a delta review of its fix or of its carrier.

Implemented-by: claude/project-thread-j2hlfl
Reviewed-by: session_01FEkQ5mswnWm181XgYcCUag

VERDICT: PASS

…anked name, and state it beside the build's tie exception

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

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mswnWm181XgYcCUag

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bfa139bac599ca65a062b0486364ea25d7e57ce8
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.object-extension-formula-cycle-refused.ts + packages/lint/src/formula-cycle-issues.ts, packages/lint/src/validate-expressions.ts (the extension walk's cycle call, its comment and its import only)

Round 3. Round 1 is comment 6884435411 (head 67ff848e), round 2 is comment 6885259034 (head 7c2eeb88), both on PR #33442. This record judges the new head on its own and then answers each round-2 finding.

Inputs read: card #33153 (body, comments 6865769873, 6883476966, 6884342925, 6885198948, 6885969777), PR #33442 (body, 7 files, comments, net diff against main with merge base fc283d6d, and the round's delta 7c2eeb88..bfa139ba), card #33494, the check-runs on the head. Code outside the diff read on main, not reviewed: packages/lint/src/object-graph.ts (objectExtensionsByTarget, objectsAsServed, objectsWithoutTiedFields), packages/lint/src/runtime-gate.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts (objectExtensionWriteFold). Between the merge base and main (811b976a) nothing under packages/lint/src or packages/formula/src moved; protocol-authoring-gate.ts and runtime-authoring-gate.ts gained a locale option (#32656) that selects a language and no verdict.

Check-runs on the head: 72 runs, 60 success, 12 skipped, 0 failed, 0 pending. Concluded green: Test Core 1-8, Test UI 1-16, Build Core, TypeScript Type Check and the four Type Check groups, every Lint group, Check Changeset (both runs on this head, 132613268973 and the rerun 132628462605 after the PR body was rewritten), Governed Surface Queue Guard, Spec property liveness, Lint · PM dispatch-gates, PR-body guards (both runs), Dogfood Regression Gate, Build Docs, Live E2E UI, Build & E2E UI.

① Derived judgments

  • Accept-set: os build, os validate, os lint and the object_extension publish door refuse a formula field an extension adds that sits on a cycle. A narrowing. Clause-②: no (narrowing) and Metadata-protocol: narrowed are right, on the PR body and the changeset.
  • The round's delta changes no behaviour. 7c2eeb88..bfa139ba is 6 files: the changeset (one paragraph), the docs page (one sentence), formula-cycle-issues.ts (two jsdoc lines), validate-expressions.ts (comment lines only; the two code lines under the comment, ownNames and the formulaCycleIssues call, are byte-identical), the semantic entry (reason and acceptanceCriteria strings), and one new test case. No executable line of lint moved, so every build and door verdict and location is what round 2 judged at 7c2eeb88. Right.
  • Rule id and roster: no new rule id; the finding stays expression-invalid from validateStackExpressions, already on the object_extension door. Right.
  • Public surface: unchanged this round. extensionFormulaLoci(target, entries, left) is not exported from the package entry; "No exported type or signature changes" stays right.
  • ADR-0087: one semantic entry, id object-extension-formula-cycle-refused, unchanged id; the changeset marker names it. The entry's strings are registry text, so the spec package still moves. Right.
  • First-party exposure: unchanged (no first-party extension adds a formula). Right.

② Semver level

@objectstack/lint minor and @objectstack/spec minor, unchanged this round. The kind is narrowed; the diff moves lint (source) and spec (the registry entry); the runtime change is a test file. The BREAKING banner, the Before / Now / Unchanged / Fix body and the ADR-0087 marker are present. The level matches what the diff publishes.

Clause-②: no (narrowing) — matches the diff.

③ Boundary flags

④ Design judgment

Every sentence the round added, against the code, for the build and for the door.

What the code does, read at the head: the walk's fieldDefs for a declared target is typedObjects[position], that is objectsAsServed (entries sorted by ascending priority then package order, a later writer replacing the whole field, a name whose deciding writers share a priority put in tied) passed through objectsWithoutTiedFields (tied names removed), with the authored body's fields and injected columns filled in behind it. For an undeclared target it is the entries' own fields in fold order, later replacing earlier, with no tie handling. At the door the stack is the live target (other packages' extension fields already folded in as plain fields) plus the written entry as the only member of objectExtensions, the object walk reads an empty list, and ownNames is empty.

  • Entry reason, "In os build one exception holds on an object the stack declares: a field two packages' extensions add at the same priority is not judged ... so a cycle through such a field is not found there": true. The tied name is not in fieldDefs (unless the authored object also declares it, and then it is the object walk's), so it is no node of the read order.
  • Entry reason, "(on an object the stack does not declare the later entry's definition is judged)": true. The undeclared branch sets each name from the entries in fold order; on a tie the later package's record stays.
  • Entry reason, "The publish door judges the written entry's own definition of every field it adds, whatever priority another package's extension adds the same field at, so it can refuse a tie, or an entry another package outranks, that os build lets through": true. With one entry the fold sees no tie and no sibling priority; the entry's record replaces the plain field of the same name, and every name of the entry is in the report map. In the build the tie drops the name, and the outranking sibling's definition is the one in fieldDefs; neither leaves the written formula on a cycle.
  • Entry acceptanceCriteria, "save, in os build on a declared object, a cycle through a field two packages add at the same priority, which the build does not judge": true, and it no longer says anything of the door that the door contradicts. The round-2 phrase "which neither door judges" is gone.
  • extensionFormulaLoci jsdoc, the door "passes none, since it judges the written entry's own definition of every name the entry adds, over everything the target served before the write": true of the call site (base && !extensionWrite).
  • Call-site comment in validate-expressions.ts: "carried as plain fields with no priority", "the door judges the written entry's own definition of every name it adds, a name the target already serves included", "a tie, which the build drops on a declared target", "at a higher one (the build serves the sibling's)", "the door can refuse a cycle the build lets through; making the door read the build's winner is object_extension door: the gate's served snapshot carries a sibling package's extension field with no priority, so the door's formula-cycle verdict differs from os build on ties and outranked entries #33494": each true as read above. The round-2 phrase "served from the entry's own record" is gone. The last sentence (the target's own cycle, a sibling's, a formula that only reads a cycle) is unchanged and holds as judged in round 2.
  • Docs sentence (lane face, read for agreement): "The door judges the written entry's own definition of every field it adds ... whatever priority another package's extension adds it at; os build judges the definition the registry serves, and on a declared object leaves out a field two packages add at the same priority": true.
  • Changeset Unchanged paragraph (lane face, read for agreement): the same three statements as the entry, each true.

The new pin against the texts. runtime-gate.object-formula-cycle.test.ts, the case titled "the door judges the written entry's own definition whatever a sibling's priority ... (#33494)". For each of a tie (200/200) and an outranked entry (sibling 300, written 200) it runs the real gate (runRuntimeAuthoringRules, type object_extension) with the live target carrying the sibling's s__c as a plain number and the written entry adding s__c as a formula reading itself, and asserts exactly one cycle finding at object 'x_job' · objectExtensions[0] · field 's__c' expression; then it runs the build over the declared target and both packages and asserts no cycle finding. That is what the entry, the changeset and the comment say, in both directions, on a declared target. The pin's title and the call-site comment both name #33494. The dev's ablation of the door guard turns this pin red with shapes a and b (3 of 16), so it does hold the door's reading. The file has 16 cases, 11 in the #33153 describe, as the PR body now says.

Attempts to break the texts.

Findings.

  1. nit — one stale clause in the changeset. The Now paragraph still reads "(at the door, a name the target already serves included: the written definition is the one served)". Round 2 named this clause: it is true of the gate's fold and not of the registry when a sibling outranks the written entry. The Unchanged paragraph of the same file now states the door's reading correctly, so the changeset is not misleading as a whole, and the hits file and both implementing files carry no such clause. The changeset is the lane's face, not ④'s. Accepted deviation; the object_extension door: the gate's served snapshot carries a sibling package's extension field with no priority, so the door's formula-cycle verdict differs from os build on ties and outranked entries #33494 fix rewrites these texts and can take the clause with it.

  2. nit — "a field two packages add at the same priority" is short for "at the priority that decides it" (objectsAsServed's own words). With a third package adding the name at a higher priority there is no tie and the name is judged, in the refusing direction. In the entry, the changeset, the docs and the comment alike. Three packages on one field name; no effect on any verdict the texts promise. Accepted deviation, or folded into object_extension door: the gate's served snapshot carries a sibling package's extension field with no priority, so the door's formula-cycle verdict differs from os build on ties and outranked entries #33494's rewrite.

  3. nit — PR body, "What changes", the entry's line still says "its one exception stated (a field two packages add at the same priority is not judged)" with no "in os build on a declared target". The Acceptance notes of the same body state it in full. Prose face; fixable without moving the head.

No must-fix and no should-fix. Nits alone: the round ends here.

Round-2 findings (6885259034).

Implemented-by: claude/project-thread-j2hlfl
Reviewed-by: session_01FEkQ5mswnWm181XgYcCUag

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mswnWm181XgYcCUag

ACCEPT · PR #33442 (#33153) · head bfa139bac599ca65a062b0486364ea25d7e57ce8 · 2026-11-14T23:43Z

席位核对

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 23:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
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/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A formula field an object extension adds that reads itself, or two that read each other, is refused at neither the publish door nor os build

1 participant