Skip to content

fix(cli)!: os validate refuses a field naming a sibling package's picklist without a declared dependency - #31864

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-xj3m25
Nov 9, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-xj3m25

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #31832

Clause-②: no (narrowing)
Metadata-protocol: none
Narrowing: yes. Measured: on base cc16008a the door accepts a field naming a sibling package's list with no declared dependency (the new refusal pin is RED there, see Verification); on head it refuses it as picklist-reference-unknown. The runtime already refuses the same reference since E6 (#31731): the boot warns and serves no options, the post-seal install judge refuses. Narrowing face: os validate / os build exit 1 for a packages[] artifact whose field picklist is held only by packages outside the declaring package's declared dependency closure (plus sys), when that package declares no dependency outside the artifact. Two further refusals, both already refused by the boot (INVALID_METADATA, quoting the same sentence) and refused here whatever the outside dependencies: (a) a field whose package declares dependencies on two packages of the artifact that both declare the list (ambiguous-in-closure); (b) a picklistExtensions target two packages of the artifact declare (ambiguous-in-environment). Both were accepted on base. First-party count: 0 (no examples/** app and no platform package declares a picklist; E6 measured the same).
Skills-docs: none. No published skill teaches a field naming another package's picklist; skills/objectstack-data/SKILL.md mentions picklist only for fieldPatches.

维护者速读

  • 改了什么:os validate / os build 现在按运行时的规则解析字段的 picklist:先找声明该字段的包自己的列表,再找它声明的依赖闭包(加 sys)。同一发布包里兄弟包的列表,如果没有声明依赖,会被拒绝,提示句与运行时一字不差,并给出要加的那一行 dependencies。
  • 为什么改:E6 之后,运行时对这种引用会告警、字段没有选项、安装被拒;可作者期的门仍然放行,作者直到安装才发现。现在 os validate 当场说清楚。
  • 风险与回滚:收窄。多包制品里依赖兄弟包列表却没声明依赖的字段,从“校验通过”变成“校验失败”;运行时早已拒绝它,所以只是提前报错。第一方计数 0。回滚即 revert 本 PR,无数据迁移。
  • 先看:packages/cli/src/utils/picklist-references.ts 的 referenceResolver(直接调用 resolvePackageScopedHolders + packageResolutionClosure,没有第二套闭包遍历)和 refusedReferenceHint;picklist-references.boot-parity.test.ts 末尾新 describe(门的句子等于启动告警的句子)。
  • 两分钟验证:pnpm --filter @objectstack/cli exec vitest run src/utils/picklist-references.test.ts src/utils/picklist-references.boot-parity.test.ts,预期 53 passed;其中 “a field naming a sibling package's list (ADR-0150 D3)” 一组在 main 上是红的。
  • 需要你做的:无,席位落地(落地前席位做 CONTRACT_REVIEW_TIER 合同审查并告知 epic 席位)。

What changes

  1. The door resolves a reference with the runtime's rule, not a copy of it. judgePicklistReferences builds the holders of each list from the bodies that declare it (packageScopedHolderSource), and asks resolvePackageScopedHolders (@objectstack/core) with the closure from packageResolutionClosure (@objectstack/metadata-core), over a registry view of the artifact (each body answers getPackage for its own id). Those are the calls the registry's picklistReferenceVerdict makes, so no second closure walk exists. Both packages were already direct dependencies of @objectstack/cli.
  2. A field's picklist resolves from the package that declares the field: own list, then the unique holder in its declared dependency closure plus sys, else R's refusal. A refusal is picklist-reference-unknown with R's message verbatim (it names every candidate package and says to declare the dependency), and a hint carrying the exact entry, for example 'com.example.core': '^1.0.0' added to the dependencies of the declaring package, plus the consequence (no options, values refused). No new rule id, error code or export.
  3. The outside-dependency arm softens only what an outside package could fix. When the declaring package also depends on a package the artifact does not carry, a name nothing here holds, or one held only outside the closure (outside-closure), is reported as picklist-reference-unverified (info, never gating), because that dependency could supply the list. Two holders one reference reaches (ambiguous-in-closure, ambiguous-in-environment) are refused even then: an outside package can add a holder, never remove one (contract review F1).
  4. A picklistExtensions entry's extend resolves with no caller (epic-seat ruling X on the E6 card; the registry does the same): any single holder in the stack, so an extension of a sibling's list still needs no dependency (pinned as a CONTROL), and a target two packages hold is refused, naming both.
  5. A stack with no packages[] is unchanged: it is one package, every list it declares is its own.
  6. os explain picklist-reference-unverified follows the rule (packages/lint/src/rule-explanations.ts, added to the surface by the seat's claim amendment 6735317750): its first paragraph now says how a field's picklist resolves (declaring package, declared dependencies transitively, plus sys; a sibling counts only when declared; an extension against the one package that holds the list; two holders refused), and its fix paragraph adds declaring the dependency.
  7. The closure notice is one short verdict line. When the declaring package also has an outside dependency, the notice names the holders (FIELD: 'LIST' is only in 'PKG', undeclared — not judged: 'DEP' may hold it) instead of quoting R's whole sentence; pinned at most 200 characters with realistic package and field names, and its hint names the dependency to declare. The refusal keeps R's sentence verbatim.
  8. The module header's ADR-0130 paragraph is rewritten to the ruled rule, citing ADR-0150 D3 and ruling 6729820123, worded after child A's ADR-0130 note in PR docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841. The text-face heading over the reference refusals now names the closure case too.

Tests

  • picklist-references.test.ts, new describe "a field naming a sibling package's list (ADR-0150 D3)", 12 cases: refused with R's sentence (packageScopedRefusalMessage) and the dependency hint; two undeclared sibling holders both named; reported, not refused, when the declaring package has an outside dependency (hint names the dependency); that notice at most 200 characters with realistic names; two declared sibling holders refused ambiguous-in-closure, with and without an outside dependency; an objectExtensions field judged from the extending package; CONTROL declared dependency; CONTROL transitive dependency; CONTROL own list; CONTROL sys list with no dependency; CONTROL sibling extension with no dependency; an extension target two packages hold refused ambiguous-in-environment, with and without an outside dependency.
  • picklist-references.boot-parity.test.ts, new describe, 4 cases on a real kernel: the door's refusal message equals the boot's findOutsideClosurePicklistReferences() refusal for the same field; CONTROL with the dependency declared, the door accepts and the boot reports nothing; a two-holder field and a two-holder extension target, each refused by the door and by the boot (INVALID_METADATA, the boot's message quoting the door's sentence).
  • Forced test changes: picklistRefusalHeading CONTROL pin (heading text now names the closure case; renamed from "keep the heading they always had"); the boot-parity file's metadata save door: a select field bound to a picklist no package declares saves and publishes, then is served with no options and every write to it is refused #29898 describe docblock (it described the sibling allowance under ADR-0130). the first boot-parity describe's CONTROL stack now declares the core body's dependency on the app body, and asserts the door accepts the whole stack (before, it was a stack this door now refuses, passing only because it filtered to picklist-residual-unreferenced). No assertion of an existing case changed otherwise.

Acceptance notes

Verification

RED on main, then green on head (e32fe7b)

Head implementation swapped for base cc16008a's blob (931bc8f2), tests from head, under the verify lock: 11 failed / 42 passed. Failing: every new refusal and notice pin (sibling refusal, two undeclared holders, outside-dependency notice, realistic-length notice, two declared holders, objectExtensions field, two-holder extension), the three refusal boot-parity pins, and the forced heading pin. Every new CONTROL passed on main. Restored with git checkout HEAD, git diff HEAD empty.

Head: Test Files 2 passed, Tests 53 passed.

Ablations (each through scripts/ablation-replace.mjs; each restored: blob equals HEAD 0e54d2bc, git diff HEAD empty)
  • Closure back to whole-stack resolution (closureOf returns every artifact package): 5 failed / 48 passed — sibling refusal, outside-dependency notice, realistic-length notice, objectExtensions field, boot-parity sentence. The two-holder pins stay green in the expected direction: with every package reachable they are still refused as ambiguous.
  • F1: an outside dependency softens every refusal again (the refuse branch tests only judged.outside.length === 0): 2 failed / 51 passed — two declared holders with an outside dependency, two-holder extension with an outside dependency.
  • F2: a two-holder verdict passes, as on base (a non-outside-closure refusal skipped like a resolution): 4 failed / 49 passed — both two-holder unit pins and both two-holder boot-parity pins.
Gates on head e32fe7b, rerun on 661fbd9
  • After merging main (ac8fede8) and deleting the note, on 661fbd90: closure build, the two picklist suites (53 passed), cli typecheck and cli unit (406 files, 5807 tests) exit 0; the same 69 derived families all exit 0, --ran 69/69, 0 NOT-MEASURED, 0 UNRUN.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 69 families derived for the 5 changed paths. All 69 ran on e32fe7b2, every one exit 0; --ran reconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. (Earlier: check-adr-0087-registration was red on fcf3a4b9 until 8f820082 added the changeset's not-required (no-migration-prescription) marker; 63/63 green on 8f820082, 69/69 on 688a9602.)

  • pnpm --filter @objectstack/lint build, test (197 files, 8365 tests) and typecheck: exit 0.

  • pnpm --filter @objectstack/cli build and typecheck (tsc plus check:test-typecheck): exit 0.

  • pnpm --filter @objectstack/cli exec vitest run --project unit: 406 files, 5807 tests passed. The integration tier is declared to CI (no spawn entry or integration file touched).

  • eslint --no-inline-config over the changed source files: exit 0 (neither package has a lint script).

  • Full turbo build of packages/** (on fcf3a4b9): exit 0.

  • Full dogfood battery on e32fe7b2: exit 0; 441 files passed, 3 skipped; 3662 tests passed, 42 skipped; no failures.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmQYiGGwc3V7yGZ4iTo


Generated by Claude Code

…declared closure

A field naming a sibling package's list with no declared dependency on it
is refused at the author-time door, in the runtime's sentence (ADR-0150 D3,
maintainer ruling N1). The door calls resolvePackageScopedHolders over
packageResolutionClosure, the rule and walk the registry's picklist verdict
uses. Extensions still resolve with no caller.

Claude-Session: https://claude.ai/code/session_01ADHkmQYiGGwc3V7yGZ4iTo
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

17 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 ac8fede8f938439d2228f5b1404042d113a3812e.

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

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

Which tree this was computed on

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

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

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

…xt follows the closure rule

The picklist-reference-unverified explanation now says how a field's
picklist resolves (declaring package, declared dependency closure plus sys,
ADR-0150 D3). The notice for a reference held only outside the declared
dependencies names the holders in one line instead of quoting the
resolver's whole sentence.

Claude-Session: https://claude.ai/code/session_01ADHkmQYiGGwc3V7yGZ4iTo
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX

Contract-review-claim: session_01ADHkmP8JJo4zF2sjTBRwnX · head 688a960 · 2026-11-09T08:47Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 688a9602fe089ac72a25f7cf6acb7d3d43b08182
Local-runs: none
Scope: packages/cli/src/utils/picklist-references.ts, packages/cli/src/utils/picklist-references.test.ts, packages/cli/src/utils/picklist-references.boot-parity.test.ts + packages/core/src/package-scoped-resolution.ts (resolvePackageScopedHolders, packageScopedHolderSource, packageScopedRefusalMessage), packages/metadata-core/src/package-closure.ts (packageResolutionClosure, packageDependencyClosure, widenPairClosureToAppMembers), packages/objectql/src/package-scoped-resolver.ts (resolvePackageScopedName), packages/objectql/src/registry.ts (picklistReferenceVerdict, withPendingManifests, findUnresolvedPicklistReferences, findOrphanPicklistExtensions, findOutsideClosurePicklistReferences), packages/objectql/src/plugin.ts (post-seal install judge, kernel:ready boot warning), packages/objectql/src/bare-name-one-holder.ts, packages/metadata/src/picklist-references.ts, packages/cli/src/utils/artifact-packages.ts, packages/core/src/artifact-packages.ts, packages/cli/test/explain-rule-id.test.ts, docs/adr/0150-behavioural-metadata-addressed-by-package-objects-keep-the-literal-prefix.md (D3), docs/adr/0130-release-artifact-as-co-ownership-boundary.md, maintainer ruling 6729820123 (#31678), PR #31841's module-header note, E6 changeset .changeset/31678-picklist-by-referrer-package.md; widened to the prose faces: .changeset/31832-picklist-sibling-dependency.md, packages/lint/src/rule-explanations.ts (picklist-reference-unverified entry)

Inputs read: card #31832 body and all four comments (claim 6734213007, triage 6734232029, dev report 6735269712 on 8f820082, claim amendment 6735317750; no later dev report exists, so 688a9602 is covered by the PR body's Verification only); PR #31864 body and its 5-file list; the net diff cc16008a..688a9602 (merge base cc16008a1221cc0f676240c84a6b1a8285a38076, which is E6's own merge commit; main has since moved only registry.ts by #31847, which touches no picklist path); the 69 check-runs on the head.

Gates: 69 check-runs on 688a9602, all completed: 56 success, 13 skipped (opt-in / path-filtered lanes: Dogfood Verify CLI, Dogfood Regression Gate shards, Lint UI, Build Docs, Temporal Conformance, UI Published Artifacts, upgrade harness, packed-tarball smoke, queue-verdict lanes, schedule gate, one Auto Label and one Check PR Size duplicate), 0 failure, 0 running. Green include Test Core 1-8/8, TypeScript Type Check, Type Check source and consumer gates, Lint and Repo Gates, Lint · PM dispatch-gates, Check Changeset (both), Governed Surface Queue Guard, PR-body guards and the Dogfood Regression Gate aggregate.

① Derived judgments

Accept-set changes (author-time door, os validate / os build, packages[] artifacts only):

  1. A field's picklist held only by a sibling the declaring package does not reach through its declared closure, the declaring package having no dependency outside the artifact: was accepted, now refused as picklist-reference-unknown (exit 1), the message being R's outside-closure sentence verbatim. Right. It is the runtime's verdict since E6: the boot warns and serves no options (ruling N2), and the post-seal install judge refuses (plugin.ts, picklistReferenceVerdict(ref.picklist, caller, ordered)). ADR-0150 D3 plus ruling N1 (6729820123: "a sibling package in the same release artifact is NOT in a package's resolution closure unless its manifest declares the dependency") is the rule.
  2. The same reference when the declaring package also depends on a package the artifact does not carry: was silent, now an info notice (picklist-reference-unverified, never gating). Right: an outside dependency X can hold the name (then X is the unique holder in the closure) or depend on the sibling, so the door cannot prove the refusal.
  3. A field whose declaring package's closure reaches two holders (it declares both siblings), no outside dependency: was accepted, now refused (ambiguous-in-closure, R's sentence; hint "Keep 'X' in one of ..."). Right in substance: D3 says verbatim "A reference that would resolve to two holders in P's closure is refused at defineStack, os validate and the save door", and the boot refuses it (findUnresolvedPicklistReferences: "one the closure holds more than once, still refuses the boot"). Undeclared and unpinned (④ F2).
  4. A picklistExtensions[].extend whose target two packages of the stack hold: was accepted, now refused (ambiguous-in-environment, caller-less). Right in substance: the boot's findOrphanPicklistExtensions judges the target caller-less and refuses an ambiguity. Undeclared and unpinned (④ F2). The changeset's "A picklistExtensions entry's extend still resolves against any package of the stack" is true only for a target one package holds.
  5. The ambiguity arms (3 and 4) when the declaring package has an outside dependency: were silent, now an info notice ("is held by each of ... — not judged", hint "If one of those packages declares 'X', nothing is wrong"). Wrong: an outside package can only add a holder, never remove one, so the runtime refuses this reference whatever the outside dependency holds (and the boot refuses it outright). The notice says the reference may be fine when it cannot be (④ F1).
  6. Unchanged: a single-package stack (one body, every holder is the caller, step 1); a declared sibling (step 2); a transitive sibling through a declared sibling; a sys-namespace sibling (kernelPackages); the declaring package's own list (step 1 wins over any sibling); a name nothing holds (not-found keeps the old wording and the old refused-or-reported split); an extension of a list one sibling holds, with no dependency (ruling X, caller-less unique holder).

Is the door now exactly the runtime's resolver, with no second closure walk? Yes for the rule and the closure. referenceResolver calls resolvePackageScopedHolders with packageResolutionClosure(registry, caller), the same two calls resolvePackageScopedName makes (objectql/src/package-scoped-resolver.ts:144-153). artifactRegistry is a registry view (getPackage / getAllPackages over the bodies), the same shape as the install judge's withPendingManifests; it walks nothing itself. The App-pair widening (widenPairClosureToAppMembers) and the sys add-in come along for free. Two residual differences, both outside the rule. (a) The holder set is the door's own picklistNamesOf(body), not the runtime's collectManifestPicklistNames, which also reads nested plugins[]. That predates this PR and is unchanged (N7). (b) The view omits residualOwnerDependenciesOf, so a body that declares the stack's own id as a dependency is walked no further there. Such a body has that id in outside anyway, so the result is a notice, never a false refusal.

Do the refusal sentence and the boot refusal agree? Yes, for one holder, pinned on a real kernel. The door's message is verdict.message, and the boot warning carries ${ref.refusal}, the same packageScopedRefusalMessage over the same query. The new boot-parity describe asserts [['status', door[0].message]] equals findOutsideClosurePicklistReferences(). For two holders the candidate order is body order at the door and registration (hydrate) order at the boot, so the two sentences can name the same holders in a different order (N4). On severity, the door refuses where the boot warns. That matches the install judge, ruling N2 ("the post-seal install judge refuses loudly") and the card.

Extensions (ruling X): picklistExtensions[].extend is judged with callerPackageId: null, which the runtime does too (findOrphanPicklistExtensions, picklistContributions, install judge judge(ext, own, null)). Right. It is pinned as a CONTROL.

Outside-dependency info notice: kept in kind for not-found. For outside-closure the new short wording ("is held only by 'S', not a declared dependency — not judged: it may come from 'X', ...") is right. For the ambiguity reasons it is wrong (row 5).

The new ≤200-char closure notice: the fixture's message is 199 characters, one under the pin. The bound is not held by construction. The message carries where, the list name, up to two holder ids and up to two outside ids. With HotCRM-length ids and two holders, for example field "crm_opportunity.lead_source" held by 'com.hotcrm.crm.core', 'com.hotcrm.crm.marketing', it is about 240 characters. The #22841 convention is measured on the findings the suite fires (explain-rule-id.test.ts cliFindings() fires only the not-found notice), and the old notice had the same unbounded shape, so this is a nit (N1).

Public surface: no export added, removed or re-typed. PICKLIST_REFERENCE_UNKNOWN, PICKLIST_REFERENCE_UNVERIFIED, PICKLIST_RESIDUAL_UNREFERENCED, judgePicklistReferences, printPicklistReferenceNotices and picklistRefusalHeading keep their signatures. No rule id, severity or covers moves. New imports come from @objectstack/core and @objectstack/metadata-core, both already workspace:* dependencies of @objectstack/cli (package.json:65,74) and both re-exported from their barrels. Text faces that move: the refusal heading (forced pin, declared), the refusal message and hint for the new arms, and the os explain picklist-reference-unverified paragraphs. Right, and declared.

② Semver level

patch for @objectstack/cli is right. Read with scripts/pm/clause2-line.mjs (readClause2Line / readMetadataProtocolLine) on both carriers:

  • changeset: {"kind":"declared","value":"no","arm":"narrowing"} · {"kind":"declared","value":"none"}
  • PR body: {"kind":"declared","value":"no","arm":"narrowing"} · {"kind":"declared","value":"none"}

Clause-②: no (narrowing) holds: the diff widens no accept set and adds no public name. Every accept-set move in ① narrows, or adds a non-gating notice where there was silence. Metadata-protocol: none holds: no key of any schema is added, removed or re-shaped. The door now refuses references that the runtime rule shipped in E6 already refuses, and E6's own changeset declared that runtime narrowing Clause-②: no (narrowing) / Metadata-protocol: none / patch. The ADR-0087 marker not-required (no-migration-prescription) is present and green (Check Changeset). @objectstack/lint changes (one explanation text) and is not named in the front matter. It is in the fixed group with @objectstack/cli, so it releases at the same version anyway (N6). The declaration of what narrows is incomplete; that is ④ F2, not a level question.

③ Boundary flags

  • Open question 1 (rule id for the closure refusal). Seat: reuse picklist-reference-unknown. Concur. The rule id names one fact (the name reaches no list from where it is written), R's sentence carries the reason, and a new id would be a new public name that the card's delta does not admit.
  • Open question 2 (os explain text). Seat: widen to rule-explanations.ts for that entry. Concur. The edit stays inside the picklist-reference-unverified entry and moves no id, severity or covers. It edits two paragraphs (the first, and the fix sentence of the third) where the amendment says "(one paragraph)". Both are that entry's text, and the third-paragraph edit is the fix the first one implies, so the amendment's binding scope ("only the entry's text") holds (N6). No pinned string moved (rule-explanations.test.ts and explain-rule-id.test.ts read the id and covers only).
  • Open question 3 (docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841 ordering). Seat: whichever lands second drops the "not yet wired" note. Concur. docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841 (open, head 0a18e768) inserts its dated note right after the paragraph this PR rewrites. If docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841 lands second, its hunk conflicts on that paragraph and must drop the note. If this PR lands second, it must merge main and drop it. Either way the header must not keep both. Until docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841 lands, this header cites "ADR-0130's dated note under D1", which is not on main yet (N5). Ruling 6729820123 gave the dated note at "picklist-references.ts line 42" to child A, and the card (filed after the ruling, as the code carrier the ruling did not name) asks for the rewrite. The rewrite replaces a sentence that would otherwise be false, and "⛔ No rewrite of the ADR-0130 text" binds the ADR, not this comment. No conflict.
  • Dev deviations: no assignee, no PR subscription, and the ADR-0087 marker category. None of them is a contract concern. The marker is the one E6 used, and its gate is green.
  • Dev out-of-scope finding: the first boot-parity describe's CONTROL stack is now one this door refuses. Noted, nit (N3). The CONTROL asserts only the residual rule, so its meaning holds, but the label reads as a clean stack.
  • Unflagged, raised here: F1 and F2 below (the ambiguity arms). Neither is escalated. Both are should-fix inside this PR's own file surface.

④ Design judgment

The narrowing against the published spec on main. It takes away no declared capability at the consumer. The sibling allowance was ADR-0130-era prose in this module's header and in the os explain text. Ruling N1 overruled it, and since E6 (cc16008a) the runtime no longer honours it: the field is served no options, its values are refused, and the install is refused. ADR-0150 D3 names os validate among the doors that refuse a reference R refuses. This PR makes that refusal real at the door, in R's own words, through R's own two calls. The narrowing is real and right.

Cases tried:

  • A sibling reached transitively through a declared dependency: resolves. packageDependencyClosure walks dependencies transitively through the view's getPackage. Pinned (CONTROL, base → core).
  • A sys list: resolves with no dependency (kernelPackages reads namespace === 'sys' from getAllPackages). Pinned.
  • The declaring package's own list: step 1, whatever a sibling holds. Pinned.
  • A stack with no packages[]: one body, and packageId is the manifest id or 'this stack'. Every holder is the caller, so step 1 applies and nothing changes. The id !== '' tightening only stops '' from being read as the stack's own id. No new pin; the existing one-package suite is green.
  • A list two packages hold: neither declared → outside-closure naming both. Pinned. One declared → unique in the closure, so it resolves (correct, unpinned). Both declared → ambiguous-in-closure, a refusal: new, correct by D3, unpinned and undeclared (F2). With an outside dependency it becomes the info notice (F1).
  • An extension (picklistExtensions): caller-less, so one sibling holder resolves with no dependency (ruling X, pinned) and two holders refuse (F2).
  • A field on an object the package does not own (an objectExtensions field): the caller is the extending package (judged.packageId). That is the runtime's attribution: auditPicklistReferences reads contributor.packageId for the extend contributor, and collectManifestPicklistReferences attributes objectExtensions fields to the manifest that brings them. Correct, unpinned (N2).
  • Tried to break it further. (a) A transitive outside dependency: P declares sibling D, D declares outside X, and the list sits only on sibling S. P's outside lists direct dependencies only, so the door refuses where the runtime would resolve if X itself depended on S. That needs a cross-artifact dependency cycle; nit (N8). (b) Duplicate body ids: the first body answers getPackage and the holders are de-duplicated, so packageScopedHolderSource cannot throw. (c) A .custom pair: widened by the same call the runtime makes. No must-fix found.

Findings:

  • F1 · should-fix. judgePicklistReferences routes every refused verdict to the info notice once judged.outside is non-empty, including ambiguous-in-closure (field) and ambiguous-in-environment (extension). An outside dependency can only add a holder, so those references are certain refusals: the boot refuses them (findUnresolvedPicklistReferences / findOrphanPicklistExtensions), and D3 names os validate as a door that refuses them. Yet the notice tells the author "If one of those packages declares 'X', nothing is wrong". Send any refused verdict whose reason is not outside-closure to refusals whatever outside holds. refusedReferenceNotice's "held by each of" branch then goes away. This is not a loosening against base (base was silent), so it is not a FAIL.
  • F2 · should-fix. The narrowing is declared narrower than it is. The PR body's Narrowing: line and the changeset name only the outside-closure refusal. The head also newly refuses a field whose declaring package's declared closure reaches two holders, and a picklistExtensions target that two packages of the stack hold. Base accepted both because the name was "known". Both refusals are correct (D3, the boot), and the one-holder rule (bare-name-one-holder.ts: second holder refused post-seal, warned or refused at cold boot) makes them rare. Still, no test pins either arm (the PR's own ablation note shows the ambiguity refusal arising), and the changeset's sentence that an extension "still resolves against any package of the stack" is true only for a target one package holds. Name both in the Narrowing: line and the changeset, and pin each with a refusal case (first-party count stays 0).
  • N1 · nit. The "≤200 characters" closure-notice pin passes at 199 on its fixture and is not a property of the code: realistic ids give about 240. Either fire the closure notice in explain-rule-id.test.ts's cliFindings() so the cli: the 9 packages/cli-owned rule ids and the action-governance boot-log lines print one verdict sentence each (≤ 200 chars), with the reasoning behind os explain rule <id> (#22161, the domain:cli half) #22841 convention measures it, or soften the test comment from a property to a fixture measurement.
  • N2 · nit. No new case pins an objectExtensions field (a field on an object another package owns) naming a sibling's list. The attribution is right; one refusal case would lock it.
  • N3 · nit. The first boot-parity describe's "CONTROL: a body-declared list a body field names" stack is now refused by this door as picklist-reference-unknown. Rename it, or declare the dependency there, so a CONTROL stays a clean stack.
  • N4 · nit. For two or more holders, the door names candidates in body order and the boot in registration order, so the sentences can differ in order. The parity pin covers one holder only.
  • N5 · nit. The header cites "ADR-0130's dated note under D1", which lands with docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841. That is fine under the seat's ordering ruling, but if docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841 is reworked the citation must follow it.
  • N6 · nit. @objectstack/lint is not named in the changeset front matter (released anyway through the fixed group), and the os explain first paragraph omits the ambiguity refusal (F2's case).
  • N7 · nit, predates this PR. The door's holder set (picklistNamesOf) ignores nested plugins[] lists, which the runtime's collectManifestPicklistNames counts. It is unchanged here, but it is the one remaining place where "the door is the runtime's resolver" is not exact.
  • N8 · nit. outside is direct dependencies only (see the break attempt (a)). It is exotic, since it needs a cross-artifact dependency cycle.

No must-fix. The narrowing makes the spec's own refusal real and takes nothing declared away. F1 and F2 should land on this PR (both stay inside its file surface) or on a named follow-up before the epic seat closes E6's door work.

Implemented-by: claude/project-thread-xj3m25
Reviewed-by: session_01ADHkmP8JJo4zF2sjTBRwnX

VERDICT: PASS

…side dependencies

Only a reference held outside the declaring package's closure softens to
the info notice when that package depends on a package the stack does not
carry; two holders one reference reaches are refused, as the boot refuses
them. Pins the two-holder field and extension cases with boot parity, an
objectExtensions field, the notice's length at realistic names, and makes
the residual-parity CONTROL a stack the door accepts.

Claude-Session: https://claude.ai/code/session_01ADHkmQYiGGwc3V7yGZ4iTo
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Nov 9, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX

Contract-review-claim: session_01ADHkmP8JJo4zF2sjTBRwnX · head e32fe7b · 2026-11-09T09:58Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e32fe7b2e1ef230084e6150a64779a9f3d1ed28b
Local-runs: none
Scope: packages/cli/src/utils/picklist-references.ts, packages/cli/src/utils/picklist-references.test.ts, packages/cli/src/utils/picklist-references.boot-parity.test.ts + packages/core/src/package-scoped-resolution.ts (resolvePackageScopedHolders, packageScopedHolderSource, packageScopedRefusalMessage), packages/metadata-core/src/package-closure.ts (packageResolutionClosure, packageDependencyClosure, widenPairClosureToAppMembers), packages/objectql/src/package-scoped-resolver.ts (resolvePackageScopedName), packages/objectql/src/registry.ts (picklistReferenceVerdict, withPendingManifests, findUnresolvedPicklistReferences, findOrphanPicklistExtensions, findOutsideClosurePicklistReferences), packages/objectql/src/plugin.ts (post-seal install judge, kernel:ready audit and warning), packages/objectql/src/bare-name-one-holder.ts, packages/metadata/src/picklist-references.ts, packages/cli/src/utils/artifact-packages.ts, packages/core/src/artifact-packages.ts, packages/cli/test/explain-rule-id.test.ts, docs/adr/0150-behavioural-metadata-addressed-by-package-objects-keep-the-literal-prefix.md (D3), docs/adr/0130-release-artifact-as-co-ownership-boundary.md, maintainer ruling 6729820123 (#31678), PR #31841 (merged as 8079fa9) and its module-header note, E6 changeset .changeset/31678-picklist-by-referrer-package.md; widened to the prose faces: .changeset/31832-picklist-sibling-dependency.md, packages/lint/src/rule-explanations.ts (picklist-reference-unverified entry)

Round 2, a delta review. The r1 record (issuecomment-6736073411, head 688a9602, PASS, should-fix F1 and F2) is superseded by this one. Inputs:

  • the seat's asks file (F1, F2, three nits folded in, five nits accepted);
  • the seat's claim line 6737216105 on this head;
  • the refreshed PR body;
  • the delta 688a9602..e32fe7b2 (one commit, parent 688a9602) and the net diff against the merge base cc16008a1221cc0f676240c84a6b1a8285a38076 (unchanged; the branch has not merged main);
  • the 69 check-runs on this head.

The card has no new dev report. The dev's claims arrived through the seat and the PR body's Verification section, and are checked here against the tree.

Gates: 69 check-runs on e32fe7b2, all completed: 56 success, 13 skipped (the same opt-in and path-filtered lanes as r1), 0 failure, 0 running. A second Check Changeset run, started by the PR-body edit at 09:58Z, was in progress at first read and completed success at 10:00:39Z. Green include Test Core 1-8/8, TypeScript Type Check, the Type Check source and consumer gates, Lint and Repo Gates, Lint · PM dispatch-gates, both Check Changeset runs, Governed Surface Queue Guard, both PR-body guard runs, and the Dogfood Regression Gate aggregate.

① Derived judgments

Delta against the r1 findings:

  • F1, closed. In judgePicklistReferences, outsideCouldSupply = verdict.status === 'not-found' || verdict.reason === 'outside-closure', and the refusal branch now reads judged.outside.length === 0 || !outsideCouldSupply. So ambiguous-in-closure and ambiguous-in-environment refuse whatever the outside dependencies. Only not-found and outside-closure soften to the info notice. The old "held by each of" notice branch is gone: refusedReferenceNotice became outsideClosureNotice, which is reached only for outside-closure. The module header and the jsdoc give the reason (an outside package can add a holder, never remove one). Pinned: the declared-two-holders field and the two-holder extension are each run with and without com.acme.erp, and assert notices: [] plus one refusal.
  • F2, closed.
    • The changeset now names both new refusals: a field whose package declares two sibling holders, and an extension target two packages declare, "whatever the outside dependencies", with the fix "keep the list in one package".
    • The extension sentence is corrected to "any single holder in the stack".
    • The PR body's Narrowing: line names (a) ambiguous-in-closure and (b) ambiguous-in-environment as "accepted on base", and keeps first-party count 0.
    • Pinned by unit tests that assert R's exact sentence through packageScopedRefusalMessage, with candidates in order. Also pinned by a real-kernel it.each over both stacks: the door refuses, the boot throws INVALID_METADATA, and the boot message contains the door's message in parentheses.
    • The os explain first paragraph now says an extension resolves "against the one package of the stack that holds the list", and that "two holders" are refused.
  • Nit, ≤200 notice: closed. The notice is reworded to FIELD: 'LIST' is only in 'PKG', undeclared — not judged: 'DEP' may hold it, and pinned at realistic lengths. The pinned fixture is 192 characters by my count. With two 30-character holders and two outside ids it reaches about 256, so the bound is still not held by construction. The pin now measures a realistic single-holder case, which is what the seat asked for. Accepted as asked.
  • Nit, boot-parity CONTROL: closed. The first describe's CONTROL stack now declares core's dependency on app, and additionally asserts judgePicklistReferences(stack) returns no refusals and no notices. So it is again a clean stack for both doors. The comment names it a forced edit.
  • Nit, objectExtensions field: closed. The new case has P extend core's pk_account with a field naming segment, which only com.example.lists holds. P declares core but not lists. The case asserts one refusal at packages[2].manifest.objectExtensions[0].fields.segment.picklist, naming caller com.example.orders and holder com.example.lists. That is the extending package as the caller, which is the registry's extend contributor attribution.

Nothing else moved. The delta touches the same 5 paths as r1: the changeset, picklist-references.ts, its two test files, and rule-explanations.ts (still only the picklist-reference-unverified entry's first paragraph). In picklist-references.ts the changes are confined to:

  • the header paragraph;
  • outsideClosureNotice, which replaces refusedReferenceNotice;
  • the judgePicklistReferences routing and notice assembly.

The not-found notice sentence is byte-identical to r1 and to base, rebuilt inline, so explain-rule-id.test.ts's fired notice is unchanged. referenceResolver, artifactRegistry, bodiesOf, refusedReferenceHint, picklistRefusalHeading and every export are untouched. The net diff against the merge base still spans those 5 files only (+510 / -38).

Accept-set on the final tree (packages[] artifacts; a stack with no packages[] unchanged):

  1. A field held only outside the declaring package's closure, with no outside dependency: refused, R's outside-closure sentence. Right. This is the runtime's verdict since E6 (N1 / N2; install judge refuses).
  2. The same, with an outside dependency: info notice. Right. An outside package could supply the list.
  3. A field whose closure reaches two holders: refused, with or without an outside dependency. Right (ADR-0150 D3: "refused at defineStack, os validate and the save door"). The boot refuses it.
  4. An extension target two packages hold: refused, with or without an outside dependency. Right. It is the boot's findOrphanPicklistExtensions, judged caller-less.
  5. Unchanged and right:
    • an own list;
    • a declared sibling, directly or transitively;
    • a sys list;
    • an extension of a single-holder list with no dependency (ruling X);
    • a not-found name (old wording, old refused-or-reported split).

Is the door still exactly the runtime's resolver, with no second closure walk? Yes. Same two calls as resolvePackageScopedName; the delta does not touch them. On main since the merge base, #31876 only adds PackageScopedNameRefusedError to package-scoped-resolution.ts. The rule and packageScopedRefusalMessage are unchanged, so the pinned sentences hold after a merge.

Public surface: no export, rule id, severity or covers moves in the delta. Right.

② Semver level

patch for @objectstack/cli is right, unchanged from r1. Read with scripts/pm/clause2-line.mjs on the r2 carriers:

  • changeset: {"kind":"declared","value":"no","arm":"narrowing"} · {"kind":"declared","value":"none"}
  • PR body: {"kind":"declared","value":"no","arm":"narrowing"} · {"kind":"declared","value":"none"}

The added refusals narrow and add no public name. Metadata-protocol: none holds: no schema key moves, and every refused reference is one the runtime rule shipped in E6 already refuses, which E6 declared the same way. The ADR-0087 marker is unchanged and green. The declaration of what narrows is now complete (F2).

③ Boundary flags

④ Design judgment

The narrowing on the final tree, against the published spec on main: it still takes no declared capability away. Every refusal it adds is one ADR-0150 D3 names for os validate, or one the boot and the install judge already make. Round 2 removes r1's one under-enforcement, the ambiguity softened to a notice. It neither loosens nor over-reaches.

Cases re-tried on the final tree:

  • A transitive sibling: resolves.
  • A sys list: resolves.
  • The declaring package's own list: resolves.
  • A single-package stack: unchanged.
  • Two holders, neither declared: outside-closure naming both. With an outside dependency it is a notice. That is correct: an outside dependency could depend on one of them, or hold the list itself.
  • Two holders, both declared: refused, with or without an outside dependency.
  • Two holders, one declared: resolves to the declared one.
  • Extension, single holder: resolves with no dependency.
  • Extension, two holders: refused, with or without an outside dependency.
  • An objectExtensions field: judged from the extending package.

Tried to break the new routing:

  • A refused verdict from a package caller can only be outside-closure or ambiguous-in-closure. A caller-less one can only be ambiguous-in-environment. The door supplies no environment or platform item, so no other reason can reach the predicate. verdict.reason is read only after status === 'not-found' short-circuits.
  • An outside-closure verdict always has a package caller, so outsideClosureNotice's refusal.callerPackageId is never null.

Findings:

  • F3 · should-fix, a landing condition. As ③ says, docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841 is on main, and a clean merge of this head keeps its now-false "The code below still resolves against every body's picklists until the CLI reader is wired" note next to the rewritten paragraph. The seat's own ruling ("if docs(adr): ADR-0150 records its children's rulings; ADR-0048 §5, ADR-0129 and ADR-0130 point at it (ADR-0150 child A) #31841 lands first, this branch merges main and drops it") must be carried out before landing: merge main, delete that note, and confirm the merged header carries only the rewritten paragraph. The head as reviewed is correct, and the defect exists only in the merge result, so this is not a FAIL. The merge commit is a new head, and its delta should be exactly that removal.
  • N1 · nit. The 维护者速读 section of the PR body still says "预期 47 passed" for the two-minute check. Head runs 53.
  • N2 · nit (accepted, carried). The ≤200 notice bound is measured, not constructed. About 256 characters with two long holders and two long outside ids.
  • Accepted r1 nits (holder order, lint front matter, nested plugins[], direct-only outside dependencies): unchanged and not re-graded.

No must-fix. F1 and F2 are closed and pinned, the three folded nits are closed, and nothing outside the claimed surface moved.

Implemented-by: claude/project-thread-xj3m25
Reviewed-by: session_01ADHkmP8JJo4zF2sjTBRwnX

VERDICT: PASS

…header

The door now resolves through ADR-0150 D3's rule (this PR), so the dated
note saying the code still resolves against every body's picklists
contradicts the code below it.

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

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf

Epic red-line review of #31864 (#31832, E6 follow-up) at head 661fbd9: PASS. It lands when CI is green.

  • One rule. The door asks the runtime's own pair of calls, resolvePackageScopedHolders over packageResolutionClosure (@objectstack/core / @objectstack/metadata-core), across a registry built from the artifact's bodies. There is no second walk, and boot parity is pinned on a real kernel.
  • N1 holds (maintainer ruling 6729820123). A sibling in the same artifact is in the closure only when the referring package declares it. The old allowance, that any sibling resolves, is gone together with its "not yet wired" note.
  • Red line 2. Both two-holder verdicts are refused and every candidate is named. An outside dependency can add a holder but never remove one, so refusing these whatever the outside dependencies is sound.
  • No false refusal. When the declaring package names a dependency the artifact does not carry, a name that resolves nowhere, or only outside the closure, is reported at info and never gates. The CLI cannot read that dependency, so a refusal could reject a correct stack. This is the right way round for red line 1 at a door that cannot see the whole environment.
  • The remedy names the one dependencies entry to add, with a caret range.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX

ACCEPT · PR #31864 · #31832 · 2026-11-09T11:03Z

Head-sha: 661fbd90d018703958917f7fea8484e6a5e1ba25

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 11:04
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 9, 2026 11:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42609535314 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (8/8) — 失败步骤: Run this shard's tests

    @objectstack/plugin-security:test:  FAIL  src/delegated-admin-own-name-set.posture.test.ts > [ADR-0090 D12] a delegated answer about a user the caller cannot read does not depend on that user's existe
      ↳ 失败原因: @objectstack/plugin-security:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/delegated-admin-own-name-set.posture.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 13 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX

Re-queue · PR #31864 · 2026-11-09T11:58Z

Head-sha: 661fbd90d018703958917f7fea8484e6a5e1ba25 (unchanged; ACCEPT 6738444696 stands)

  • Ejection judged by its assertion: merge-group CI run 42609535314, job Test Core (8/8). One test failed: @objectstack/plugin-security src/delegated-admin-own-name-set.posture.test.ts:869, "posture 'single' > ground truth…", with Error: Test timed out in 5000ms. That it boots one harness per subject state inside a single test, so it is bounded by wall time. It is not this PR's failure. This PR touches only packages/cli/src/utils/picklist-references* and the packages/lint explain text, and plugin-security doesn't import either. The same suite passed in this PR's own CI on this head (56 success) and in the first merge group (run 42606094001).
  • Landing: automerge_enable happens in this stroke, with no push and no empty commit.

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 80c4c9a Nov 9, 2026
70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-xj3m25 branch November 9, 2026 12:31
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…gated-answer ground-truth posture pin (objectstack-ai#31935)

<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9WRooEf1dKxwnV3qGKCubny)_

Fixes objectstack-ai#31919

The card's fix: the ground-truth case that booted one rig per subject
state inside a single `it` is split into one `it` per state, the shape
commit 7200f54 already gave the sibling block in the same file. Claim
6739551496 (`domain:server#1`, card-thread
session_01ADHkmSzW7smpTAzJNeCnD2).

Clause-②: yes (test only, no contract change) — measured: the diff is
one test file; no source file, request shape or answer changes, so
nothing accepted on base is refused on head and nothing refused is
accepted.
Metadata-protocol: none

`skip-changeset`: the diff is one test file under `src/` that the
package does not ship (`plugin-security`'s `files[]` publishes `dist`,
`README.md`, `CHANGELOG.md`; after a full build the `dist/` tree holds 0
files with this case's title text, against a positive control,
`DelegationScopeViolationError`, in 2 built files), so nothing any
released package publishes changes.

## 维护者速读

- 改了什么:plugin-security 的一个权限测试里,"真值"用例原来在同一个用例里为 5
种用户状态各启动一套环境,现在每种状态是自己的用例;同文件里另外 3 个同样"循环里每轮启动一套环境"的对照用例也按同样方式拆开。
- 为什么改:这个用例在合并队列负载高时超过默认 5 秒超时,把同批不相关的 PR(objectstack-ai#31864)一起踢出队列;拆开后每种状态有自己的 5
秒预算和自己的失败行。
- 风险与回滚:只改一个测试文件,产品代码零改动;断言一条没删没改,用例数 202 → 226。回滚就是 revert 这一个提交。
-
先看:`packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts`
的 ground truth 那段(约 869 行),再看 913、1130、1256 行附近的三处对照拆分。
- 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run
src/delegated-admin-own-name-set.posture.test.ts`,预期 226 个用例全绿。
- 需要你做的:无,席位落地。

## What changes

One test file, four splits, each the same mechanical shape (a loop that
boots a rig per iteration inside one `it` becomes a `describe` keeping
the old title, with one `it.each` case per iteration):

| Case (per posture: single, group, isolated) | Before | After |
|---|---|---|
| ground truth: the delegate reads the subject exactly in the states
where it is a user the delegate may read | 1 `it`, 5 boots | 5 `it`
(`[...STATES, CONTROL]`), 1 boot each |
| CONTROL: a tenant-level administrator and the organization
administrator still remove the stored grant … | 1 `it`, 2 boots | 2
`it`, 1 boot each |
| CONTROL: a tenant-level administrator still moves the grant FROM / TO
a holder outside the scope … (one case per end) | 1 `it` per end, 2
boots | 2 `it` per end, 1 boot each |
| CONTROL: a tenant-level administrator re-names a grant freely … | 1
`it`, 2 boots | 2 `it`, 1 boot each |

Case count: 202 → 226 (+12 ground truth, +3 removal, +6 moves, +3
re-naming).

**Assertion targets, before and after: the same set.** Each `expect`
keeps its subject, matcher, expected value and message label
byte-identical; only its enclosing `it` changes.

- ground truth: `expect(reads, state).toBe(state !== 'an id naming no
user' && state !== UNREADABLE_INSIDE)`, once per state, 5 states × 3
postures = 15, before and after.
- removal: `expect(removed, who).toEqual({ status: 200 })` and
`expect(left, who).toEqual([])`, per remover, 2 × 2 × 3 = 12, before and
after.
- moves: `expect(throughWriteDoor(…), state).toEqual({ status: 200 })`
and `expect(holderOf(…), state).toBe(ENDS[end].user_id)`, per state per
end, 2 × 2 × 2 × 3 = 24, before and after.
- re-naming: `expect(renameThroughWriteDoor(…), label).toEqual({ status:
200 })` and `expect(setOf(…), label).toBe(to)`, per re-naming, 2 × 2 × 3
= 12, before and after.

⛔ No timeout raised: the slowest split case measured 360 ms locally, far
from the 5 s budget. No forced test edit elsewhere.

**Scan of the other plugin-security posture files**
(`by-id-write-row-reread`, `delegable-scope-no-identity-positions`,
`org-admin-assignment-boundary`): every `it` there boots one rig and
loops only over calls against it, so none has the shape; nothing changed
there. In this file, the other loops inside an `it` (delegable-scope
report, explain per unit, writes per unit) also run against one rig.

## Tests

Local runs on head 2a121ac (the commit this body describes), 3 workers
as CI's `VITEST_MAX_WORKERS`, `--sequence.concurrent=false`, JSON
reporter, under the verify lock:

- this file alone 5× on base 6a8f415: 5/5 green, 202 cases each. Old
single cases, max of 5 runs: ground truth 797 / 706 / 558 ms (group /
isolated / single); moves-FROM 767 ms (group); re-naming 528 ms
(single); removal 340 ms (group).
- this file alone 5× on head: 5/5 green, 226 cases each, 0 failures. The
slowest of the 24 split cases: 360 ms (moves FROM, single,
unreadable-inside); ground-truth states 104–237 ms.
- the CI timeout itself does not reproduce locally (old case max 0.8 s
against 5 s); the cause is shard load in the merge group, which the
split answers by giving each boot its own budget.
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: Test Files 252 passed (252), Tests 6092 passed | 45
skipped (6137).
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0
(`check:test-typecheck` OK, 0 errors).
- `eslint packages/plugins/plugin-security --no-inline-config` (the
package's `lint` script spelling, from the root): exit 0.

No ablation: no tested behaviour changes, and no negative pin is added
or moved.

## Acceptance notes

- Each split case still boots its own rig, as the old loop did per
iteration; the work per posture is the same, only the budget is per
case.
- The removal and re-naming `it.each` tables carry contexts built with
`delegateCtx` at collection time; the old loop built the same objects
inside the `it`. `delegateCtx` is a pure object literal.

## Verification

Derived gates on head 2a121ac: `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` derived 53 commands (1
changed path); all 53 ran, all exit 0; `--ran` reconciliation: "53
derived famil(ies) accounted for — 53 run, 0 NOT-MEASURED". Every other
family is CI's, on this PR's check-runs.

<details><summary>53 derived commands and exit codes</summary>

```text
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
node scripts/check-tenant-audit-census.mjs :: exit 0
node scripts/check-tenant-audit-census.mjs --self-test :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:i18n :: exit 0
pnpm check:i18n-stale-fill :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:vouch-for-reference-callers :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
```

</details>

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmSzW7smpTAzJNeCnD2)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant