fix(cli, lint): gate a dangling lookup/master_detail reference, and resolve one across the artifact packages[] - #17066
Conversation
…own's severity ladder Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
…okup-reference-target-gate
…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>
…okup-reference-target-gate
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 34 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Parked outside the queue — clause-② review tier is measured unavailable
Landing prechecks: ① no in-seat PASS on record — the blocker; ② ⛔ 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 |
|
CI update — precheck ③ is now MET. Correcting my previous comment, which recorded All six required contexts are green on head
⇒ ⭐ This PR is now blocked on exactly one thing: landing precheck ① — an in-seat contract-review-tier PASS, which cannot be produced while ⛔ Still do not flip to ready and do not strip Generated by Claude Code |
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 fieldreferencethat resolves to nothing now failsos validate/os lint/os build), and it widens one: every object-name site invalidate-object-referencesnow also resolves against the objects the artifact'spackages[]provide, onos build's per-package leg. No new export and no new authorable key:packagesis already declared byObjectStackDefinitionSchema(ADR-0130 D4), and the per-package stack that carries it is synthesised bycompile.ts, never authored.The ruling this executes
From
5581956193(director seat, decision batch #86) — quoted, not paraphrased:Dispatch tier note, from triage
5593902445, verbatim: 「Clause-②: yes(新增error级拒绝 = 收窄已发布接受集)」. The build tier moved under PR #16915 (clause-② cards build at 默认判断档, review atCONTRACT_REVIEW_TIER); the review obligation is unchanged andneeds:contract-reviewis carried at creation.What changed
packages/lint/src/validate-object-references.ts— the ladder, as pushed atc9164c0and unchanged in substance, plus rung 1 widened to the artifact. A field'sreferenceonlookup/master_detail/userenters the rule; rung 1 now resolves against this stack's own objects and every object name an entry ofpackages[]declares.tree, areferenceon a non-relationship type, andobjectExtensions[].fieldsstay out, each for a stated reason.packages/cli/src/commands/compile.ts— the ruled option B.packageBodyAsStacktakes the artifact'spackages[]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 buildbinaryThe 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-packageis a workspace member whosebuildscript isobjectstack build, and rootpnpm buildisturbo run build --filter=!@objectstack/docs, so the requiredBuild Corecontext runs it. Itsorderspackage readscrm_order.account→crm_account, owned by the siblingcorepackage.Ablation (option B removed at
packageBodyAsStack,@objectstack/clirebuilt, mutation proven live indist/byscripts/ablation-dist-preflight.mjs cli 'void artifactPackageEntries'→ marker present in 1 built file, and… 'packages: artifactPackageEntries' --absent→ absent from all 500 built files):Restored under an
EXIT INT TERMtrap; restoration proven by blob identity (git hash-object== theHEADblob2c7bf4a4f4) and a whole-treegit status --porcelainthat is empty, then@objectstack/clirebuilt fromHEADand both preflights re-run in the opposite direction.② Non-degeneracy control — red, on the per-package leg specifically. A probe artifact whose top-level
objectscarries onlyprobe_account, whosepackages[]carries a second package declaringprobe_orderwith two lookups:account→probe_account(a sibling package provides it) andghost→probe_nowhere(no entry provides it). The union leg cannot seeprobe_orderat all, so it passes — which is exactly the arrangement that makes the per-package leg the one being measured:One run, both halves: the sibling-provided
accountresolved silently while the artifact-wide danglingghosterrored. ⭐ 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: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/mainover every*.object.ts,examples/**/*.tsandpackages/**/objects/**/*.tsadds noreference/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-coveragedrivesos lintover all 13 real project configs (app-showcase, app-crm, app-todo, app-multi-package, plugin-security, …) and reportsOK (13 config(s), 621 baselined untranslated string(s), none new).Verification
Every heavy run through
scripts/pm/os-verify-lock.shunderOS_VERIFY_LOCK_SLOT=issue-16611; verdicts read from the wrapper'sVERDICT command-exitline, exit codes captured by redirect-then-$?, never through a pipe.pnpm --filter @objectstack/lint exec vitest run— 103 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 unit— 189 files / 2624 tests passed.OS_TEST_TIERS=nightly … vitest run test/compile-artifact-packages.e2e.test.ts test/build-multi-package-artifact.e2e.test.ts— 2 files / 10 tests passed. Run deliberately: they are the two suites that spawn the real compiler, and thee2etier runs nightly (scripts/nightly-tiers.mjs), so this PR's own green does not cover them.pnpm --filter @objectstack/lint typecheckandpnpm --filter @objectstack/cli typecheck— both exit 0,check:test-typecheckledgers unchanged (lint 2 files / 6 errors; cli 3 files / 28 errors).pnpm lint— the whole-repoeslint . --no-inline-configscan, exit 0 in 111s. Not a narrowed run: the full union was executed, on final head904c1dce.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon a tree with no STALE warning derives 59 commands over exactly the 4 declared paths;--ranreconciles 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 withoutdist/) andcheck: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.pnpm check:nul-bytesexit 0, plus a directgrep -naPfor the wider control-byte class over all four files — no hits.Scope
Untouched, deliberately: #13632's hole (
referencepresent and non-empty), #4441's (a lookup value that does not exist), andcontent/docs/releases/.defineStackneeds no change — the field site entering the reference-integrity suite covers all three commands at once, becausecompile.tsexits 1 on any error-severity authoring finding. The hotclmdocs/PLATFORM_GAPS_FROM_TEMPLATES.mdappend 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/docstree object is byte-identical on both sides —git rev-parse 0d4a85790fa8907239fca267f5ce747311cfb804:content/docsandgit rev-parse HEAD:content/docsboth give688a116923cb26deef6413dce6e79c5e1a48ebce. One recursive tree hash covers every file, so no per-file discrepancy is possible and the uncommitted changes did not touchcontent/docs. My own run reportscomputedOn.dirty: false.Re-derivation.
node scripts/docs-audit/affected-docs.mjs --json a016f08b8a321dc39f34a384ef3bff0e7b5c1289→ 40 rows: 34 hand-written + 6 release-owned, the same counts the bot posted with its list truncated at 15.diffBasediffers (e4fd55d9b0here vs the bot'sa016f08b8a, 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.mdxviaos compile— and resolves to line 441 spelling the same commandobjectstack compile; that was my checker being too literal, not a wrong row. Composition: 19 rows carried only by themaster_detailliteral, 15 only by theos compilecommand, 6 by both, and 0 by any of the four symbol anchors (validateObjectReferences,packageBodyAsStack,artifactProvidedObjectNames,RELATIONSHIP_TARGET_FIELD_TYPESare 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:referencejust joined that list and is not in the sentence.packages[]provides now resolves too.{ 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-levelreferencewas 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— "referenceis 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:50andgetting-started/your-first-project.mdx:243give a three-item shorthand for whatos validatechecks ("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 deliberateobjectExtensions[].fieldsskip.The suppressed
sys_useranchor — swept by handThe 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 outsidereferences/andreleases/. Every line pairingsys_userwith 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 writingreference: 'sys_user'— rung 2, still accepted — or unrelated permission prose.concepts/metadata-driven.mdx:276("the standard user object issys_user, notuser") becomes more true, not less. Positive control: the twovalidating-metadata.mdxlines that must match, did.A page this change keeps true rather than falsifies
content/docs/getting-started/examples.mdx:400documents the cross-package pattern as copy-pasteable authoring guidance —account: { type: 'lookup', label: 'Account', reference: 'crm_account' }inside atype: 'module'package whose sibling App package ownscrm_account. The ladder without option B would have made that documented pattern failos 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). Onlyreleases/v17.mdx:2536names 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-rowpackageMentionDocscoarse 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
#17069 —
os validateandos lintjudge an empty stack when a project declares its metadata only inpackages[]:authoringRuleUnionStack(the ADR-0130 D4 fold, #15006) is imported bycompile.tsand by no other command, so two of the three gates exit 0 having read nothing, whileos buildrefuses the same stack. Measured through the real binaries at904c1dce(validate0 /lint0 /build1). Not created by this PR —validate.tsnever 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 targetedsearch_issuescall 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.验收备注
compile.tshalf 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 ispackageBodyAsStack, a module-private function inpackages/cli/src/commands/compile.ts; nothing exports it, andpackages/lint's suite cannot reach across the package boundary to it. The natural home ispackages/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 existingdescribe.examples/app-multi-packageis a workspace member whosebuildscript isobjectstack build, andpnpm buildisturbo 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 requiredBuild Corecontext. Its discriminating power is measured, not assumed: the ablation above removed option B and that fixture went red, namingobject-reference-unknownatobjects[0].fields.account.reference.validate-object-references.test.ts— including aCONTROLcase asserting that the same package judged with no context still errors, so the pins cannot all pass on a rule that has stopped judging.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