Skip to content

fix(cli, lint): gate a dangling lookup/master_detail reference, and resolve one across the artifact packages[] - #17066

Draft
claude[bot] wants to merge 5 commits into
mainfrom
claude/issue-16611-lookup-reference-target-gate
Draft

fix(cli, lint): gate a dangling lookup/master_detail reference, and resolve one across the artifact packages[]#17066
claude[bot] wants to merge 5 commits into
mainfrom
claude/issue-16611-lookup-reference-target-gate

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16611

Clause-②: yes

Re-derived from the delivered diff, not copied from the dispatch. It holds in both directions: the diff adds a new error-severity refusal on a published accept set (a field reference that resolves to nothing now fails os validate / os lint / os build), and it widens one: every object-name site in validate-object-references now also resolves against the objects the artifact's packages[] provide, on os build's per-package leg. No new export and no new authorable key: packages is already declared by ObjectStackDefinitionSchema (ADR-0130 D4), and the per-package stack that carries it is synthesised by compile.ts, never authored.

The ruling this executes

From 5581956193 (director seat, decision batch #86) — quoted, not paraphrased:

Ruled: B. ADR-0130 makes the release artifact the co-ownership boundary, so a per-package os build pass that cannot see a sibling package's objects is the run's defect, not the author's: compile.ts's per-package leg hands each package's stack the object names the other packages[] in the same artifact provide (or the union's) as resolution context. The ladder on branch claude/issue-16611-lookup-reference-target-gate (c9164c0) lands unchanged behind that change. ⛔ Not A now: an authorable crossPackage: true is a one-line switch that silences the gate — the exact leniency this repo refuses […] ⛔ Not C.

Dispatch tier note, from triage 5593902445, verbatim: 「Clause-②: yes(新增 error 级拒绝 = 收窄已发布接受集)」. The build tier moved under PR #16915 (clause-② cards build at 默认判断档, review at CONTRACT_REVIEW_TIER); the review obligation is unchanged and needs:contract-review is carried at creation.

What changed

packages/lint/src/validate-object-references.ts — the ladder, as pushed at c9164c0 and unchanged in substance, plus rung 1 widened to the artifact. A field's reference on lookup / master_detail / user enters the rule; rung 1 now resolves against this stack's own objects and every object name an entry of packages[] declares. tree, a reference on a non-relationship type, and objectExtensions[].fields stay out, each for a stated reason.

packages/cli/src/commands/compile.ts — the ruled option B. packageBodyAsStack takes the artifact's packages[] and puts it on the per-package stack as resolution context. It changes what a rule can resolve, never what it judges: the collections read off the top level are still the one package's own, so every per-package finding that leg exists to produce is still produced.

⛔ Not option C wearing B's name. The field site is not skipped per package — see the non-degeneracy control below, where the union leg is clean and the per-package leg is the one that errors.

The two controls, both driven through the real objectstack build binary

The previous run's stated method limit was that it mirrored compile.ts's per-package leg instead of running the real command. That limit is closed: every number below comes from the real CLI (packages/cli/bin/run.js, pnpm exec objectstack) on a built 62-task dist closure.

① Firing control — green, and its ablation red. examples/app-multi-package is a workspace member whose build script is objectstack build, and root pnpm build is turbo run build --filter=!@objectstack/docs, so the required Build Core context runs it. Its orders package reads crm_order.accountcrm_account, owned by the sibling core package.

$ cd examples/app-multi-package && objectstack build
  → Running author-time rules (44)...
  → Running author-time rules per package (2)...
  ✓ Build complete (129ms)                                  exit 0
$ objectstack validate  → ✓ Validation passed (102ms)       exit 0
$ objectstack lint                                          exit 0

Ablation (option B removed at packageBodyAsStack, @objectstack/cli rebuilt, mutation proven live in dist/ by scripts/ablation-dist-preflight.mjs cli 'void artifactPackageEntries'marker present in 1 built file, and … 'packages: artifactPackageEntries' --absentabsent from all 500 built files):

$ objectstack build                                          exit 1
  ✗ Author-time rules failed inside the artifact's packages (1 issue)
  • package 'com.example.multi.orders' — object "crm_order" · field "account":
    lookup target "crm_account" resolves to no object defined in this stack.
    rule: object-reference-unknown  at objects[0].fields.account.reference

Restored under an EXIT INT TERM trap; restoration proven by blob identity (git hash-object == the HEAD blob 2c7bf4a4f4) and a whole-tree git status --porcelain that is empty, then @objectstack/cli rebuilt from HEAD and both preflights re-run in the opposite direction.

② Non-degeneracy control — red, on the per-package leg specifically. A probe artifact whose top-level objects carries only probe_account, whose packages[] carries a second package declaring probe_order with two lookups: accountprobe_account (a sibling package provides it) and ghostprobe_nowhere (no entry provides it). The union leg cannot see probe_order at all, so it passes — which is exactly the arrangement that makes the per-package leg the one being measured:

$ objectstack build                                          exit 1
  → Running author-time rules (44)...
  → Running author-time rules per package (2)...
  ✗ Author-time rules failed inside the artifact's packages (1 issue)
  • package 'com.example.probe.orders' — object "probe_order" · field "ghost":
    lookup target "probe_nowhere" resolves to no object defined in this stack.
    … Defined objects: probe_account, probe_order.
    rule: object-reference-unknown  at objects[0].fields.ghost.reference

One run, both halves: the sibling-provided account resolved silently while the artifact-wide dangling ghost errored. ⭐ Without this leg "green" is indistinguishable from the gate having been switched off.

③ The card's own repro, all three commands. Field.lookup('zzz_object_that_does_not_exist'), which exited 0 everywhere on 17.3.0:

os validate  exit 1   os lint  exit 1   os build  exit 1
  object "card_lead" · field "zzz_probe": lookup target
  "zzz_object_that_does_not_exist" resolves to no object defined in this stack.
  rule: object-reference-unknown  at objects[0].fields.zzz_probe.reference

Population

Carried from the measurement recorded on this card (116 objects / 137 relationship fields in-repo, hotclm 11/17, hotcrm 18/56 ⇒ 0 genuine dangling references; exactly 1 green→red, the ADR-0130-legal cross-package site) and checked for decay rather than re-derived: git diff 5e53d73d83 origin/main over every *.object.ts, examples/**/*.ts and packages/**/objects/**/*.ts adds no reference / Field.lookup( / masterDetail( / Field.user( line, so the corpus this rung judges did not move across the three commits the branch was behind. Independently corroborated: pnpm check:i18n-coverage drives os lint over all 13 real project configs (app-showcase, app-crm, app-todo, app-multi-package, plugin-security, …) and reports OK (13 config(s), 621 baselined untranslated string(s), none new).

Verification

Every heavy run through scripts/pm/os-verify-lock.sh under OS_VERIFY_LOCK_SLOT=issue-16611; verdicts read from the wrapper's VERDICT command-exit line, exit codes captured by redirect-then-$?, never through a pipe.

  • pnpm --filter @objectstack/lint exec vitest run103 files / 3625 tests passed (was 3496 + 8 pins on the pushed branch; 14 pins on the field site and the artifact context in total).
  • pnpm --filter @objectstack/cli exec vitest run --project unit189 files / 2624 tests passed.
  • OS_TEST_TIERS=nightly … vitest run test/compile-artifact-packages.e2e.test.ts test/build-multi-package-artifact.e2e.test.ts2 files / 10 tests passed. Run deliberately: they are the two suites that spawn the real compiler, and the e2e tier runs nightly (scripts/nightly-tiers.mjs), so this PR's own green does not cover them.
  • pnpm --filter @objectstack/lint typecheck and pnpm --filter @objectstack/cli typecheck — both exit 0, check:test-typecheck ledgers unchanged (lint 2 files / 6 errors; cli 3 files / 28 errors).
  • pnpm lint — the whole-repo eslint . --no-inline-config scan, exit 0 in 111s. Not a narrowed run: the full union was executed, on final head 904c1dce.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on a tree with no STALE warning derives 59 commands over exactly the 4 declared paths; --ran reconciles 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN. 57 exit 0. Two are NOT MEASURED and neither reads this diff's behaviour: check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET — 12 packages without dist/) and check:type-check-debt (killed at 540s mid re-measure; its own text forbids recording a number from an unbuilt tree). CI builds the closure before both. The five artifact-roster families whose roster directory contains one of my paths were run rather than read as silent: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:scaffold-emission-policy — all exit 0.
  • Control characters: pnpm check:nul-bytes exit 0, plus a direct grep -naP for the wider control-byte class over all four files — no hits.

Scope

Untouched, deliberately: #13632's hole (reference present and non-empty), #4441's (a lookup value that does not exist), and content/docs/releases/. defineStack needs no change — the field site entering the reference-integrity suite covers all three commands at once, because compile.ts exits 1 on any error-severity authoring finding. The hotclm docs/PLATFORM_GAPS_FROM_TEMPLATES.md append is a hotclm-repo action and is not in this PR.

The delivered diff is exactly the four paths the cross-domain exception claim declared (5595785825); the surface did not grow.

Docs drift — re-derived, audited, and one falsified page reported

Tree check, by blobs rather than by the bot's sha. Its footer discloses "That checkout carried uncommitted changes, so the commit above does not fully identify what was read", so the sha is not a pin. The whole content/docs tree object is byte-identical on both sides — git rev-parse 0d4a85790fa8907239fca267f5ce747311cfb804:content/docs and git rev-parse HEAD:content/docs both give 688a116923cb26deef6413dce6e79c5e1a48ebce. One recursive tree hash covers every file, so no per-file discrepancy is possible and the uncommitted changes did not touch content/docs. My own run reports computedOn.dirty: false.

Re-derivation. node scripts/docs-audit/affected-docs.mjs --json a016f08b8a321dc39f34a384ef3bff0e7b5c128940 rows: 34 hand-written + 6 release-owned, the same counts the bot posted with its list truncated at 15. diffBase differs (e4fd55d9b0 here vs the bot's a016f08b8a, because this worktree's merge-base moved); the anchor set and the row set are identical.

Wrong rows: none. All 46 anchor-claims were checked mechanically against the named file's bytes. One first read as a miss — data-modeling/fields.mdx via os compile — and resolves to line 441 spelling the same command objectstack compile; that was my checker being too literal, not a wrong row. Composition: 19 rows carried only by the master_detail literal, 15 only by the os compile command, 6 by both, and 0 by any of the four symbol anchors (validateObjectReferences, packageBodyAsStack, artifactProvidedObjectNames, RELATIONSHIP_TARGET_FIELD_TYPES are named by no page).

⭐ One page IS falsified — reported, not edited

content/docs/deployment/validating-metadata.mdx, section 4. Dangling object and action names — the page that documents this very rule. Three defects, all created by this diff:

  • The site enumeration is now short by one. Line 113: "The same gate covers the reference sites that are plain strings in the schema: an action param's record-picker target, a dashboard filter's options source, a navigation capability gate, and every surface that binds an action by name." A field's own reference just joined that list and is not in the sentence.
  • The severity table's first row is now incomplete. Line 134: "resolves to one of your own objects | ✓". A name an entry of the same artifact's packages[] provides now resolves too.
  • ⚠️ And the section's first example was already wrong before this PR, in exactly the spot this card fixes. Lines 119–120 read { name: 'owner', type: 'lookup', reference: 'user' } annotated → error. That is the shape of a field, not of an action param — and until this diff a field-level reference was judged by nothing at all, so the documented error did not exist. The page has been advertising this card's capability for as long as the hole has been open. This PR makes the example true and leaves the prose around it short.

⛔ Not edited: content/docs/** is outside the four declared paths of the cross-domain exception, and the surface does not widen for docs work. This is the report the fence asks for.

The sweep the bot structurally cannot do

Its disclosed input-vs-emitter blind spot. 50 hand-written pages name os validate / os lint / os build / objectstack …; every line in them that makes a claim about what those commands check with respect to objects or references was read out. Beyond the page above, nothing is falsified. Under-complete but not wrong, and a missing member is not a defect — noted rather than filed:

  • data-modeling/fields.mdx:185"reference is enforced on write" describes only the value check (data: a lookup accepts an id that does not exist in the referenced object — including the RBAC permission-set link tables #4441's half). The new author-time target check is unmentioned.
  • deployment/cli.mdx:50 and getting-started/your-first-project.mdx:243 give a three-item shorthand for what os validate checks ("schema + CEL predicates + widget bindings") that already omitted object-name references before this diff.
  • data-modeling/object-extensions.mdx:74"lookups back to your own objects" — makes no validation claim, which matches the deliberate objectExtensions[].fields skip.

The suppressed sys_user anchor — swept by hand

The tool dropped sys_user (literal, 31 pages) as over-broad, and it is the canonical rung-2 member, so it was enumerated instead: 43 pages contain it, 30 hand-written outside references/ and releases/. Every line pairing sys_user with reference / lookup / target / resolve / validate / check wording was read out. No page states that a reference to a platform-provided object is accepted without checking. Every hit is either an example writing reference: 'sys_user' — rung 2, still accepted — or unrelated permission prose. concepts/metadata-driven.mdx:276 ("the standard user object is sys_user, not user") becomes more true, not less. Positive control: the two validating-metadata.mdx lines that must match, did.

A page this change keeps true rather than falsifies

content/docs/getting-started/examples.mdx:400 documents the cross-package pattern as copy-pasteable authoring guidance — account: { type: 'lookup', label: 'Account', reference: 'crm_account' } inside a type: 'module' package whose sibling App package owns crm_account. The ladder without option B would have made that documented pattern fail os build. It is the ruling's own argument, already written down in the docs, and this PR is what keeps it honest.

Release-owned pages: read-only

The 6 rows — implementation-status.mdx, v9, v12, v13, v14, v17 — were audited and not edited (AGENTS.md Documentation Guardrails). Only releases/v17.mdx:2536 names ADR-0072 at all, in a phase list about nav targets that are not object names; it is not a rule-site enumeration and this diff does not falsify it.

What this audit did NOT answer

The bot's other disclosed blind spots are unresolved by anything above and are not claimed as cleared: the SDK route bridge reached 60 of 216 client-bound ledger rows (156 unreachable, 56 of them structurally); Compile (symbol) was dropped as too generic; and the 25-row packageMentionDocs coarse fallback was not swept page by page — it is the deliberately wide pre-#9192 predicate, and every row of it that names a command or a relationship literal is already in the 40.

Out-of-scope defect filed

#17069os validate and os lint judge an empty stack when a project declares its metadata only in packages[]: authoringRuleUnionStack (the ADR-0130 D4 fold, #15006) is imported by compile.ts and by no other command, so two of the three gates exit 0 having read nothing, while os build refuses the same stack. Measured through the real binaries at 904c1dce (validate 0 / lint 0 / build 1). Not created by this PR — validate.ts never imported the fold — and not fixed in it: the cure is a fifth path and its own population measurement. Filed unassigned and unlabeled for triage; deduplicated with one targeted search_issues call over open and closed (repo REST /search/* is refused by this session's proxy, HTTP 403), nearest neighbours #16544, #11896, #11643, #11727 read out and rejected.

验收备注

  • The compile.ts half has no in-package unit pin, and there is no cheap way to give it one without a fifth path — the argument, stated rather than left as a note. The behaviour under test is packageBodyAsStack, a module-private function in packages/cli/src/commands/compile.ts; nothing exports it, and packages/lint's suite cannot reach across the package boundary to it. The natural home is packages/cli/test/build-multi-package-artifact.e2e.test.ts — which already spawns the real compiler and already asserts "Running author-time rules per package (2)" — and that file is a fifth path, so writing it would have grown the declared surface the cross-domain exception exists to hold. ⇒ Say the word and it lands there in one commit; it is a ten-line addition to an existing describe.
  • What stands in, and why it is not merely "a CI context". examples/app-multi-package is a workspace member whose build script is objectstack build, and pnpm build is turbo run build --filter=!@objectstack/docs. So the pin is an ordinary local command any contributor already runs — it is not reachable only through GitHub — and it additionally happens to be carried by the required Build Core context. Its discriminating power is measured, not assumed: the ablation above removed option B and that fixture went red, naming object-reference-unknown at objects[0].fields.account.reference. ⚠️ Its honest limit, stated: it pins the firing direction only. The non-degeneracy direction is pinned in-package, by the six new cases in validate-object-references.test.ts — including a CONTROL case asserting that the same package judged with no context still errors, so the pins cannot all pass on a rule that has stopped judging.
  • No scripts/adr-anchors/ entry was added for either file. Neither file has one today, and the anchor would have been a sixth path.

Generated by Claude Code

…own's severity ladder

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
…kages[] on os build's per-package leg

ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s
per-package authoring-rule leg judging a package with no sight of its siblings
is the run's defect, not the author's. `compile.ts` now hands each package's
stack the artifact's `packages[]` as resolution context, and
`validate-object-references` widens rung 1 to the names those entries provide.

It changes what a rule can RESOLVE, never what it JUDGES: the collections read
off the top level are still the one package's, and a name no entry provides
still lands on rung 2 and errors.

Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/lint, touching 7 documentable anchor(s).

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

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

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

Coarse fallback — 25 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 a016f08b8a321dc39f34a384ef3bff0e7b5c1289packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a016f08b8a321dc39f34a384ef3bff0e7b5c1289

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a016f08b8a321dc39f34a384ef3bff0e7b5c1289 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

Parked outside the queue — clause-② review tier is measured unavailable

domain:cli execution PM seat (#6024). Full record, including the two fuse readings and the unblock condition, is on the card: #16611 (comment 5596676772). Short form for anyone reading the PR:

  • This PR's work is done, and its Clause-②: yes declaration is correct — in both directions, where the ruling framed only the narrowing.
  • CONTRACT_REVIEW_TIER returned HTTP 429 ("You've reached your Fable limit", request id req_011CesMWj9GLGdfvoEZ42FPt), so the clause-② review cannot be performed.
  • ⛔ The quota-exhaustion exemption does not reach the review — references/contract-review.md:60: 「豁免对象是派发,复核正为补偿低档派发而存在」. This seat's own tier reads claude-opus-5CONTRACT_REVIEW_TIER, so :52 forbids it from self-clearing the label.
  • needs:contract-review stays on, the PR stays draft, auto-merge stays off. Per :53 that is a safe state, not a stall.

Landing prechecks: ① no in-seat PASS on record — the blocker; ② check-clause2-carriers --pair 17066exit 0, both carriers agree; ③ 5 of 6 required contexts green on head 904c1dce at 05:58Z, Lint & Repo Gates still running.

⛔ Nothing is asked of the author. Do not flip this to ready and do not strip the label to unblock it — the label is the gate, and clearing it without a tier PASS is the one act that would make this unsafe.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

CI update — precheck ③ is now MET. Correcting my previous comment, which recorded Lint & Repo Gates as still running.

All six required contexts are green on head 904c1dceb0daed1d08c9041b8d9866b431c6a715 (head re-read from the PR object, ⛔ not taken from the check-suite event's SHA):

required context check run conclusion
Build Core 102352421034
Test Core 102354851194
Dogfood Regression Gate 102353920293
Temporal Conformance (live PG + MySQL) 102352420946
Lint & Repo Gates 102352374853 ✅ (06:07:55Z, 24 min)
TypeScript Type Check 102354591597

mergeable_state has moved blockedclean. No merge conflict, nothing red, nothing pending.

⇒ ⭐ This PR is now blocked on exactly one thing: landing precheck ① — an in-seat contract-review-tier PASS, which cannot be produced while CONTRACT_REVIEW_TIER is unavailable. Nothing else is owed by the author or by this seat. The moment the tier answers, this is one review away from the queue.

⛔ Still do not flip to ready and do not strip needs:contract-review. Green CI is precondition ③, not precondition ①, and ⛔ ③ does not substitute for ①.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants