Skip to content

fix(spec): resolve permission-set and seed object references against the release artifact, not the single stack - #18212

Merged
os-elon-musk merged 8 commits into
mainfrom
claude/issue-18202-crossref-dependency-aware
Sep 15, 2026
Merged

os-elon-musk merged 8 commits into
mainfrom
claude/issue-18202-crossref-dependency-aware

Conversation

@os-elon-musk

@os-elon-musk os-elon-musk commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18202
Part of #14122 — the epic tracking the one-artifact/N-packages family. That tracker stays open.

Rework after a contract-review FAIL

The isolated contract review on head b0072379 returned FAIL and the bolded compatibility
claim this body used to carry was false. Three corrections landed on this branch; nothing else
moved, and the changeset level stays minor (the review judged the level correct and the body
wrong).

review finding correction
the two new collectors carried 0 Array.isArray guards and 0 malformed warnings, while step 3 of the same composeStacks body carries 3 and 1 — so a malformed permissions / data on an unparsed input raised a bare TypeError (no code, no status, outside the ADR-0112 envelope) where main composes with a warning both collectors now guard their own shapes: a non-array collection is announced through warnMalformedCollectionKey and skipped, and a non-object entry — or a non-string object — carries no reference for the rule to resolve and is skipped
the artifact pass runs for (const stack of stacks) over every input, while defineStack returns at if (!strict) before cross-reference validation ever runs — so strict: false and hand-built inputs are newly refused the claim is replaced by the invariant that holds, in the docstring, the changeset and this body; the narrowing is pinned in the fixture as declared behaviour
if (stacks.length === 1) return stacks[0]; is unchanged, so a one-input composition never reaches the pass, and the docs bullet and option docstring overstated the guarantee both now say "in a composition of two or more packages", and a fixture block fences the qualifier

Round 2 of the rework — one clause, two surfaces

The second contract review re-measured all three findings above and recorded them resolved
(36 probes; the guards hold with no TypeError, the strict: false refusals are pinned by five
positive fixtures, the one-input qualifier is fenced, and it confirmed the false-green fixture
correction). It failed on one sentence. The changeset and this body described the newly-refused input's prior
fate with the phrase previously composed with only a warning — quoted here as the text that was
REMOVED, and appearing nowhere else in this PR.

That was false. On main this class of input produces no diagnostic at allcomposeStacks
calls validateCrossReferences zero times and console.warn zero times, and its one
warnMalformedCollectionKey site fires on declared.length !== arrays.length, i.e. a collection
key that is not an array. The newly-refused inputs carry well-formed arrays holding a dangling
reference, so that condition never holds. The clause conflated two populations and handed an
upgrading reader a false self-test — "we never saw a warning, so this is not us" — in text that
ships verbatim into CHANGELOG.md.

Corrected in the changeset and here. Taken in the same stroke, the precision the review recommended:
the no-op half of the invariant holds for an input that passed the strict parse and did not opt
in
, which is now said in the changeset, in the collectArtifactCrossReferenceErrors docstring and
in the fixture file's header, which carried the same sentence. Text only — no schema, no rule, no
fixture and no docs page moved, and the level stays minor.

What changed

defineStack validated cross-references per stack, so "defined in objects" and "defined in the artifact" were the same question. In an ADR-0130 artifact they are two questions, and the two accepted records could not both hold once the type: app package owns objects of its own:

So the app package's sets necessarily grant on its modules' objects, and every such grant was refused. Measured downstream on objectstack-ai/hotcrm claude/issue-1907-sales-app-service-module (be11c07) as 18 grants across 7 sets; data[].object was refused the same way, which is why that branch had to push its seed rows into the module.

This PR gives the two reference classes that legitimately cross a resolution scope of their own, and moves their refusal to the pass that owns that scope.

DefineStackOptions.artifactObjects

const service = defineStack(serviceConfig);                // owns crm_case
const app = defineStack(appConfig, {                       // owns crm_account, grants on crm_case
  artifactObjects: service.objects?.map((o) => o.name),
});
export default composeStacks([service, app], { manifest: 'preserve' });

permissions[].objects and data[].object resolve against the stack's own objects plus these names. Nothing else in the function moves.

The measurement that chose the mechanism

The dispatch proposed gating the widening on the stack declaring non-empty manifest.dependencies. Measured against the reporter's own repro, that gate does nothing for this defect, and the finding is worth recording because the card's title carries the same framing:

package id declares refuses what
src/sales app.objectstack.hotcrm type: app, no dependencies its sets grant on crm_casethis is the failing stack
src/service app.objectstack.hotcrm.service type: module, dependencies: { 'app.objectstack.hotcrm': '^3.1.0' } nothing — it grants on its own objects

The dependency edge runs module to app, which is the only direction ADR-0116's topological order admits: the module registers after the package it extends. The app therefore cannot name its modules as dependencies without inverting that order. Resolving against the referencing package's declared dependency closure would refuse exactly the shape the addendum makes mandatory.

⇒ The resolution scope is the artifact, which is what ADR-0130 D1 says it is — not the dependency closure, and not "anything, checked later".

The two classes that cross, and the ones that must not

Every object-name reference class the check covers, with the per-class ruling:

class crosses? why
permissions[].objects yes the 2026-09-02 addendum forces it
data[].object (seed) yes rows placed into a co-owned object, not a claim on its definition; the same shape, and the card measures it
hooks[].object no ADR-0130 §1.5 measured this refusal and recorded it as the seam: "the split must follow hook ownership"
app navigation objectName no same paragraph: "navigation crosses only through contributions" (requiresObject remains the declared per-item escape)
view data.object, mappings[].targetObject, actions[].objectName (both positions) not widened here no record rules on them and the repro does not need them; leaving them refused is the conservative direction under Prime Directive #12. Named under Follow-ups below.

The first two blocks of the new fixture assert that artifactObjects does not widen the hook and navigation refusals even when the name is listed.

The refusal MOVED — composeStacks is the named pass

composeStacks did not re-run any cross-reference check at all (it only checked composed action-key collisions), so deferring without adding one would have opened a hole. It now re-checks the two artifact-scoped rules over the composed object set:

  • same codeSTACK_CROSS_REFERENCE_INVALID — and the same status: 422;
  • the same per-finding message, byte-identical, so one grep serves both passes;
  • a distinct headercomposeStacks artifact cross-reference validation failed (N issues): — because only the pass differs.

A name artifactObjects claims and no package in the artifact defines is therefore still refused, loudly, at a named pass — in a composition of two or more packages. That fixture is an acceptance criterion of the card, and it is ArtifactPass in the new test file.

What it can and cannot newly refuse

An earlier revision of this body claimed the pass "cannot newly refuse a composition that passes today". That is false, and the contract review measured it twice. The invariant that actually holds is two statements, not one:

  • An input that passed the strict defineStack parse AND did not opt in cannot newly fail here. It does not read the option (recording the option on the returned stack would put a composition-time concern on ObjectStackDefinitionSchema, i.e. on every artifact already built). It re-runs the two rules for every input, which is a no-op for such an input — its references already resolved against its own objects, and those are a subset of the composed set. The qualifier is load-bearing: an input that did opt in also passed the strict parse, but it resolved against its own objects plus the names it listed, and checking a listed name against the real artifact is precisely what this pass is for — so an opted-in input can fail here by design. That is the ArtifactPass fixture, not a gap.
  • An input that BYPASSED the strict parse is checked for these two rules here for the first time. defineStack(config, { strict: false }) returns before validateCrossReferences runs at all, and a hand-built stack object never enters it. Such an input carrying a dangling permissions[].objects key or data[].object is now refused at composition where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference.

The second is a real narrowing of what composeStacks accepts. It is the Prime Directive #12 direction — reject off-spec input loudly — and it is now declared: the changeset states it, collectArtifactCrossReferenceErrors's docstring states it, and the fixture pins it rather than leaving it to be rediscovered as a regression.

What the narrowing is not is a licence to crash. Both collectors guard their own shapes, so an unparsed input carrying a non-array permissions / data, a null entry, or a non-string object is warned about and skipped the way step 3's concat pass already treats it — never a bare TypeError outside the ADR-0112 envelope.

Inputs declaring no objects are still skipped, inheriting validateCrossReferences's own objectNames.size === 0 leniency verbatim; that leniency is why hotcrm#1449's measurement never saw the defect, and it is pinned as untouched in both directions.

Known boundary — this pass runs only for two or more packages. composeStacks returns stacks[0] untouched for a single input, so a one-package composition never reaches the pass, and neither does a stack that is never composed. That is the population whose artifactObjects claim stands unverified — the same shape as strict: false, for the same reason. Every reader-facing statement of the guarantee now carries the qualifier. os build composes.

Compatibility — #14122 §6

存量单 manifest 产物经新装载路径后,注册结果逐位相同

Nothing in this PR touches the load path, the artifact schema or any authored key. defineStack is unchanged for a stack that does not pass artifactObjects: the widened scope is literally the same Set object as the narrow one when the option is absent. The three existing cross-reference pin suites pass unchanged, and check:authorable-surface / check:api-surface are both green without regeneration.

composeStacks is where the acceptance set does move, for exactly one population — inputs that bypassed the strict parse — and that is stated above, in the changeset and in the docstring rather than claimed away. §6's own subject (a stored single-manifest artifact replayed through the load path) is untouched by it: the artifact pass runs at composition time, on the inputs, and never at load.

Verification

Re-run in a worktree off origin/main d4554d4, at 0c21631c — the rework head. Every exit code
below was redirected to a file and captured before any pipe.

what command result
package suite pnpm --filter @objectstack/spec test exit 0 — 478 files / 13632 tests passed
package typecheck pnpm --filter @objectstack/spec typecheck exit 0 (incl. check:test-typecheck)
the reworked fixture file vitest run src/stack-artifact-crossref.test.ts exit 0 — 30 tests passed (16 before the rework, 14 added)
generated artifacts pnpm --filter @objectstack/spec check:generated exit 0 — all 15 up to date, nothing to regenerate, tree clean after the build
gate families node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 106; each run singly with its exit code captured before any pipe, then --ran reconciled 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0
closure build pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 72/72 successful
repo-wide lint pnpm lint (eslint . --no-inline-config) — the whole union, not a narrowing exit 0, at 0c21631c

Seven of the 106 first returned a PREREQUISITE reading — six exit 3 (PREREQUISITE NOT MET) and
check:skill-examples exit 1 whose body says "packages/client-react/dist holds no .d.ts
declarations — build first, then re-run"
. Those are NOT-MEASURED, not reds: all seven were re-run
after the closure build and all seven exit 0. They are check:dual-build-cjs-loads,
check:lean-entry-closure, check:type-check-debt, check:docs-transcript-drift,
@objectstack/lint check:doc-formula-expressions, @objectstack/lint check:doc-security-posture
and @objectstack/spec check:skill-examples.

Carried from the pre-rework head b0072379, and why it is still valid: the consumer sweep
(turbo run typecheck --filter='...@objectstack/spec', the DOWNSTREAM direction, 139/139) was not
re-run. The rework changes two internal function bodies, docstrings, a fixture, a changeset and a
docs page — no exported type, no published exports entry. check:api-surface is green with no
regeneration owed and check:generated reports all 15 artifacts up to date, so the published
declaration surface is byte-unchanged and no import site can have moved.

Known staleness, declared: this branch is 12 commits behind origin/main and
dispatch-gates.mjs says so in its own words — six of the files its derivation reads changed across
that range. The 106 is therefore this tree's derivation, not origin/main's. The branch is
deliberately not merged forward: it is a targeted rework of an open PR that a reviewer is about to
re-read against its recorded FAIL, and CI runs the full farm on the merge result anyway.

Reverse verification (one-off, not committed tests)

Two legs, each committed first, then mutated, then restored under a trap whose restore path is
absolute and spelled git checkout HEAD --. Both restores are proven by blob identity and an empty
git diff HEAD, never by an exit code. No build step is involved: these tests import ./stack.zod
by relative path, not through the package exports.

Leg 1 — the shape guards. Prediction, written before the run: removing the two guards turns the
malformed-input block red with bare TypeErrors. Mutation proven on disk — Array.isArray inside
the two collectors 2 to 0, warnMalformedCollectionKey call sites 3 to 1, blob 8debc710 vs HEAD
690c80dd. Observed: 5 failed / 25 passed, every failure a bare TypeError
(permissions is not iterable, datasets is not iterable, Cannot read properties of null,
name.startsWith is not a function). The sixth case in that block stays green under the mutation
and is honestly a control, not a guard pin: objects: null on a permission entry was already
covered by the pre-existing !grants || typeof grants !== 'object' line.

One correction worth recording, because the first attempt of this leg measured a false green: the
non-array permissions fixture originally used a string, and a string is iterable, so removing
the guard changed nothing for it. It now uses map format — not iterable — and fails without the
guard. The fixture was fixed and the leg re-run from the committed state.

Leg 2 — the one-input boundary. Prediction: deleting if (stacks.length === 1) return stacks[0];
turns the two "accepts a one-input composition" fixtures red and leaves the rest green, proving those
fixtures actually observe the early return rather than merely asserting an accept. Mutation proven on
disk (occurrences 1 to 0, blob e7c7b384 vs HEAD 690c80dd). Observed: exactly 2 failed / 28
passed
, both in the one-input block. Restored, git status --porcelain empty.

docs-drift-check advisory — decided, not deferred

The advisory flagged five pages. Decisions, so a reviewer does not re-derive them:

  • content/docs/getting-started/glossary.mdx — names composeStacks() once, in passing ("the authoring form … or composes one with composeStacks()"). Nothing it states is falsified. No edit.
  • content/docs/getting-started/examples.mdx — its "rules worth knowing before you split a product this way" list is the page a reader lands on for exactly this shape, and nothing already there becomes untrue. But the list would have been silently missing the rule this PR creates, so it gains one bullet naming the two artifact-scoped classes, the artifactObjects spelling, and the hook exception. That is the documentation half of the option, not scope creep.
  • content/docs/releases/v17/* (three pages) — release-owned under AGENTS.md's Documentation Guardrails, and read-only in a code PR. Not edited here, and none of them was found to be wrong: they describe shipped behaviour this PR does not change.

Note the advisory was computed on the merge commit 318def6e, not on this PR's head, so a local re-derivation can legitimately differ.

Acceptance notes

  • check-plugin-teardown-shape.mjs --self-test exits 1 on a shallow checkout — it pins its positive control to a commit the clone cannot reach and says so rather than passing. Fetching that one commit clears it (47 cases pass). Not a finding, and not this PR's to change.
  • Four gates exit 3 (PREREQUISITE NOT MET) until the package closure is built: check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt, @objectstack/lint check:doc-formula-expressions. All four are green after the closure build. Working as designed; recorded so the exit-3 readings in this PR's history are not read as reds.
  • The docs bullet first said the ADR-0090 D3 reserved word for a position; pnpm check:role-word caught it in the same local run and it now says position. Recorded because the word is ratcheted repo-wide and the gate is easy to meet only if you know it exists.
  • The StackCrossReferenceError docstring said "there is exactly ONE raise site". With the artifact pass there are two, so the paragraph now names both and says why they share one code: the code names the rule family (what a machine matches), the header names the pass (what a reader needs).
  • composeStacks step 2 raises a bare TypeError for a non-array objects on an unparsed input, and always has. mergeObjects iterates stack.objects with no guard; probed against the built dist, objects as a map and objects as a number both give TypeError: stack.objects is not iterable, code undefined, status undefined. mergeObjects is not touched by this diff (git diff d4554d4f5..HEAD -- packages/spec/src/stack.zod.ts has no hit on it), so this is a pre-existing main defect of the same family as the one this rework fixes, not a regression. It is reported for filing rather than fixed here: the rework dispatch is explicitly scoped to the three review findings, and widening it would be the second time this PR's scope moved. Dedupe words: composeStacks, mergeObjects, non-array objects, bare TypeError, ADR-0112 envelope.
  • isPlatformObjectName calls name.startsWith with no string guard. No live call site can reach it with a non-string now that the seed-data collector filters on typeof === 'string' (the other caller passes Object.keys output), so this is noted, not filed.
  • The warnMalformedCollectionKey call added inside each collector can never be the FIRST to warn for its key in today's code: step 3 of composeStacks runs before step 3b and warns for the same key, and the reporter deduplicates per key for the module's lifetime. It is kept anyway so the rule cannot become the silent one if a future caller reaches it first, and the fixture asserts the observable consequence — exactly ONE warning per key, not two.

Follow-ups this PR does not take


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 7 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/examples.mdx (via artifactObjects (symbol, a field of interface DefineStackOptions), composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx (via composeStacks (symbol, a top-level function))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-3.mdx (via composeStacks (symbol, a top-level function))
  • content/docs/releases/v17/17-4.mdx (via composeStacks (symbol, a top-level function))
  • content/docs/releases/v17/index.mdx (via composeStacks (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: defineStack (symbol, 62 pages)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 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 — 136 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 75237a9a8950de387530e1fdcd8695ab3e92807epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 35ff00c5ba22da41c002493ad3e4cd7a13178975 — the merge of head 0424329a899a2be3d21cccb720883560350fd51f into base 75237a9a8950de387530e1fdcd8695ab3e92807e, 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 35ff00c5ba22da41c002493ad3e4cd7a13178975 && git checkout 35ff00c5ba22da41c002493ad3e4cd7a13178975
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 75237a9a8950de387530e1fdcd8695ab3e92807e 0424329a899a2be3d21cccb720883560350fd51f && git checkout -B drift-repro 75237a9a8950de387530e1fdcd8695ab3e92807e && git merge --no-ff 0424329a899a2be3d21cccb720883560350fd51f

node scripts/docs-audit/affected-docs.mjs --json 75237a9a8950de387530e1fdcd8695ab3e92807e

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

Copy link
Copy Markdown
Collaborator Author

pm-note (⛔ not a contract-review record — no Served-tier: line, no verdict)

Closing the promise made in the ACCEPT verdict: CI is green

The verdict on #18202 said the ruling was about the work, not about the lights, and that this seat would watch to green. It is green.

Reading b0072379
check-runs 38 success, 4 skipped, 0 pending, 0 failing
mergeability mergeable: true, mergeable_state: clean
state draft, needs:contract-review on both carriers

Pre-landing check ② — measured here so the reviewer does not re-derive it

$ node scripts/pm/check-clause2-carriers.mjs --pair 18212   # exit captured before any pipe
18212 :: exit 4

⚠️ That 4 is the epic residual, and it is ruled NOT blocking. The target pair is clean in the script's own words — "PR #18212 / card #18202 — the clause-② declaration is readable in the fixed spelling and both carriers agree". The 4 comes solely from a second pair the script derives against the epic tracker #14122, because this PR's body carries Part of #14122.

PM ruling, already recorded on #18204 and on the finding card:

A card named only by Part of and identifiable as a tracker is not a clause-② carrier.

⛔ So #14122 gets no review label, and this row is not a reason to hold the PR. Mechanism card: #18214 — which now has four independent measurements behind it: both devs hit it on their own PRs, and this seat reproduced it on #18212 and #18213 with the exit code captured before any pipe.

⚠️ One thing a reviewer must still check by hand, because the script says so rather than because it is suspected here: the C2-SIBLING row asserts only where the declaration lives, never that the sibling's reading covers this card's half of the increment.

What this seat is NOT doing

⛔ No carrier stripped, no ready flip, no queue action, no merge. Clause ② puts the review of a contract increment at the domain:spec seat, and the minor changeset on @objectstack/spec makes this a real increment — so the record is theirs to render, not this seat's. Staffing, since that is what the wait actually depends on: seat card #6017 reads off-shift since ~15:3xZ.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b00723791c1e290f2f46e9c7f4ec6b206ae799eb

① Derived judgments

Basis: the diff read from the REST endpoint (4 files, +579/−48 against merge-base d4554d4f; packages/spec/src/stack.zod.ts is byte-identical between that merge-base and origin/main 1bdbf82c), the head checked out in a detached worktree, origin/main's stack.zod.ts staged beside it, and the same inputs run through both. 42 check-runs on the head: 38 success, 4 skipped, 0 failing.

  1. Public-surface change: one optional member, DefineStackOptions.artifactObjects?: readonly string[]. DefineStackOptions is an exported interface (packages/spec/api-surface/root.json lists it at name level only; api-surface-signatures.json has no entry for it), so the ratchet records no member-level change and no regeneration is owed — consistent with the PR's "check:generated all up to date". Correct, and purely additive.

  2. The unopted path is the same path. Verified in code, not from the body: defineStack passes undefined when the option is absent or an empty array (line 2942), and validateCrossReferences binds artifactScope to the same Set object as objectNames in that case (line 2174). Probe P7: defineStack(cfg), defineStack(cfg, { artifactObjects: [] }) and origin/main's defineStack(cfg) produce a byte-identical message and code on a dangling grant. Correct.

  3. The widening is exactly two classes. Every other objectNames.has(...) site is unchanged: hooks (2185), view data.object (2201), mappings targetObject (2228), app navigation (2291), standalone actions (2399), object-bound actions (2457). Verified by probe with the name listed in artifactObjects: hook and navigation are pinned by the PR fixture; view, mapping, standalone action and object-bound action are not pinned by the fixture but I measured all four still refused with STACK_CROSS_REFERENCE_INVALID. Correct.

  4. composeStacks on origin/main runs no cross-reference check. Read the whole function body (lines 3674–3797): steps 1–7 are manifest pick, mergeObjects, concat, preserve, functions, remaining keys, action-key collisions, mergeActionsIntoObjects. No call to validateCrossReferences, no call to defineStack, no StackCrossReferenceError. The "must add, not defer" premise holds.

  5. The refusal moved for the opted-in case. Probe P6: a stack opting in with a nowhere name, composed with a sibling, is refused by composeStacks with name: StackCrossReferenceError, code: STACK_CROSS_REFERENCE_INVALID, status: 422, the same issues set as the per-stack refusal of the same config without the option, and a message whose lines after the header are byte-identical; only the header differs. StackRefusalError.status = 422 is inherited by both raise sites. Correct.

  6. "It cannot newly refuse a composition that passes today" — FALSE, measured twice. The artifact pass re-runs the two rules over every input with a non-empty objects, and its docstring's premise ("that input's references already resolved against its own objects") holds only for inputs that went through the strict parse. defineStack(..., { strict: false }) returns before validateCrossReferences (line 2784 on main), and a hand-built object never enters it. Probe P1 (strict: false app owning crm_account, granting on crm_nowhere, composed with the service stack): origin/main composes; head refuses with STACK_CROSS_REFERENCE_INVALID. Probe P2 (the same config hand-built, never through defineStack): main composes; head refuses. These are exactly the inputs composeStacks's own step-3 and step-5 comments name as reachable ("only reachable via strict: false or a hand-built stack object") and handle with a warning rather than a throw. The acceptance set of composeStacks therefore narrows for unparsed inputs, and neither the PR body, the docstring on collectArtifactCrossReferenceErrors, nor the changeset says so. The narrowing itself is in the direction Prime Directive Add comprehensive test suite for Zod schema validation #12 wants (reject off-spec input loudly); the defect is that it is claimed away instead of declared.

  7. New bare-TypeError crash path in the added code. Step 3 of composeStacks deliberately guards a non-array collection with Array.isArray and warnMalformedCollectionKey ("dropping it without a word is the exact defect composeStacks silently drops every non-array top-level key — api: today, server: as of #4910 #5005 closes"). Step 3b carries no such guard: collectSeedDataObjectErrors iterates config.data and collectPermissionGrantObjectErrors iterates config.permissions unconditionally, and both dereference each entry. Probe P4/P4b, all strict: false inputs with own objects, composed with a sibling — origin/main composes every one (with the step-3 warning); head throws TypeError: config.data is not iterable, TypeError: config.permissions is not iterable, Cannot read properties of null (reading 'object') for data: [null], Cannot read properties of null (reading 'objects') for permissions: [null]. No code, no status, no envelope — a crash, not a refusal, in the file whose refusal discipline is the ADR-0112 envelope. Narrow population, but it is a regression introduced by this diff on inputs main handles gracefully.

  8. One-input composition never reaches the artifact pass. composeStacks returns stacks[0] untouched when stacks.length === 1 (unchanged). Probe P3: defineStack(app, { artifactObjects: ['crm_nowhere'] }) then composeStacks([app], { manifest: 'preserve' }) and composeStacks([app]) are both accepted. The docs bullet ("composeStacks then refuses any name no package in the artifact defines, so a typo is still caught") and the option's docstring ("A name listed here that no package in the artifact actually defines is refused by composeStacks") overstate; the guarantee holds for compositions of two or more inputs. Sub-case of flag 2, but the reader-facing text does not carry it.

  9. The object-less leniency is inherited verbatim, both directions. Probe P8 confirms an object-less app granting on crm_nowhere composes on both main and head. Pinned by the PR fixture. Correct, and the PR is right that closing it would newly refuse plugin-served references.

  10. mergeObjects preserves names under override and merge (probe P9: an opted-in app composed with two siblings both defining crm_case is accepted under both strategies), so the composed object set is the union of input objects[].name — the artifact pass reads the right set.

  11. Envelope docstring correction ("exactly ONE raise site" → two, one code) is accurate and the right call: the code names the rule family, the header names the pass.

② Semver level

minor on @objectstack/spec is the correct level: an additive optional member on a published interface, plus a check whose only newly-refused population is inputs that bypassed the strict parse (strict: false / hand-built). Under this repo's own vocabulary "breaking" is a removed or renamed authorable key or a narrowed published type/export surface (AGENTS.md Post-Task step 3; ADR-0087 disposition categories), and none of that happens here; strict: false is documented on defineStack as "skip validation (advanced use cases only)" and was never a promise about what composeStacks accepts. So the level stands.

The changeset body does not stand as written. It ships verbatim into CHANGELOG.md — the text an upgrading agent greps — and it states "A stack that does not pass artifactObjects … validates exactly as before" while saying nothing about the narrowing in ① item 6. Required before landing: one sentence stating that composeStacks now applies the two artifact-scoped rules to every input with objects, so an input that bypassed strict defineStack and carries a dangling permissions[].objects / data[].object is refused at composition where it previously composed with a warning. With that sentence the level is minor; without it the changeset under-declares the acceptance-set change, and the PR body's bolded compatibility claim is false on the record.

③ Boundary flags

Flag 1 — should view data.object, mappings[].targetObject, actions[].objectName also resolve against the artifact? No, and not merely as the conservative default. ADR-0130's 2026-09-02 addendum states the grouping the boundary creates in one sentence: "Modules group Data, Automation and Interface; they do not group Access." A view is Interface, a mapping is Data, an action is Automation — each belongs to the package that owns the object, which is the same seam §1.5 records for hooks and navigation. Option B's "data-facing like a permission set" argument does not survive that sentence; option C would erase the seam. Widening any of them needs a record, and no consumer has measured the need. One observation the dev's own criterion ("no record rules on it → not widened") did not apply symmetrically: data[].object (seed rows) is also Data by that sentence, and the addendum does not rule on it. The widening is opt-in and the card asked for it, so this does not block — but the reasoning that seed rows are "the same shape" as permission sets should be recorded on the card, because the next author will read the addendum's sentence and ask why seed data crossed and views did not.

Flag 2 — an opt-in on a never-composed stack cannot be falsified. Acceptable, and the analogy to strict: false is right: both are an author's assertion that a later step confirms. Two corrections to how the boundary is stated. First, the boundary is wider than "never composed": a one-input composition also never runs the pass (① item 8), so the text should say "a composition of two or more packages". Second, C stays rejected for the reason given — ObjectStackDefinitionSchema is the artifact contract ADR-0130 D4 leans on and must not grow a build-time field; B (a packages/cli assertion that a stack declaring artifactObjects reached a composition) is worth a card only when a mis-declaration is measured, not before.

Flag 3 — --pair residual exit 4 on epic tracker #14122. Reproduced here with the exit captured before any pipe: node scripts/pm/check-clause2-carriers.mjs --pair 18212exit 4; the target pair #18212 / #18202 is clean in the script's own words, and the 4 is carried entirely by the #14122 row (C1: label on PR, absent on #14122; C2-SIBLING: no Claim: comment on #14122). #14122 is labelled tracking, is never dispatched, and a clause-② declaration is made per claimed card — so the only act that could satisfy the row on #14122 is writing a declaration on the claiming seat's behalf, which the script itself forbids. Not labelling #14122 was correct; the residual is a predicate defect and #18214 is open for it. The C2-SIBLING caveat ("verify the sibling's reading covers this card's half") is discharged here trivially: this PR delivers exactly one card, #18202; #14122 is named only by Part of and closes nothing. Not blocking.

Remediation that turns this into a PASS, all inside the PR's own surface: (a) in collectArtifactCrossReferenceErrors, skip a non-array permissions / data and a non-object entry the way step 3 does (reuse warnMalformedCollectionKey, or simply Array.isArray guards in the two collectors), with a fixture for the strict: false malformed input; (b) replace the compatibility claim in the collectArtifactCrossReferenceErrors docstring, the PR body and the changeset with the true invariant — "an input that passed strict defineStack cannot newly fail here; an input that bypassed the strict parse is now checked for these two rules at composition" — and pin the strict: false case in the fixture as the declared behaviour; (c) qualify the docs bullet and the option docstring with "in a composition of two or more packages".

Implemented-by: claude/issue-18202-crossref-dependency-aware
Reviewed-by: session_01T3YsvpK1PvYf9n1YUhYP6W

VERDICT: FAIL


Generated by Claude Code

…t narrows

The artifact pass added for #18202 ran the two artifact-scoped collectors over
every composed input, including inputs the strict parse never saw. Three
corrections, all inside this PR's own surface:

- `collectSeedDataObjectErrors` and `collectPermissionGrantObjectErrors` now
  guard their own shapes the way `composeStacks`'s step-3 concat pass does. A
  non-array `permissions` / `data` is announced through
  `warnMalformedCollectionKey` (deduplicated per key, so the two passes speak
  once) and skipped; a non-object entry, and a non-string `object`, carry no
  reference for the rule to resolve and are skipped. Before this, an unparsed
  input that `origin/main` composes with a warning raised a bare `TypeError`
  with no `code` and no `status` — a crash outside the ADR-0112 envelope, in
  the file whose refusal discipline is that envelope.

- The compatibility claim is replaced by the invariant that actually holds. An
  input that passed the strict `defineStack` parse cannot newly fail at
  composition; an input that BYPASSED it (`strict: false`, a hand-built stack
  object) is checked for these two rules at composition for the first time and
  a dangling reference in it is now refused. That narrowing is declared in the
  docstring, the changeset and the fixtures instead of being claimed away.

- `composeStacks` returns `stacks[0]` untouched, so the artifact pass never
  runs for a single input. The option docstring and the docs bullet now say
  "in a composition of two or more packages" rather than stating the guarantee
  unqualified.

Fixtures pin all three: the refusals on unparsed inputs, the malformed-shape
guards, and the one-input boundary with its two-package contrast.

Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
Co-authored-by: Claude <noreply@anthropic.com>
…not iterable

The first ablation leg measured the previous fixture as GREEN under the
mutation: a string value iterates its own characters, so removing the
`Array.isArray` guard changed nothing for it. Map format — the shape a
hand-built stack most plausibly carries for a collection key — is not
iterable, so the fixture now fails without the guard and the ablation reads
red for all six malformed cases.

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0c21631cd36bc79ba5f82a44ce46eb198b24962c

① Derived judgments

Basis: the diff read from the REST endpoint (4 files, +857/−48 against merge-base d4554d4f); the head fetched into a ref I own (refs/review/18212-r2/head = 0c21631c) and checked out in a detached worktree; origin/main b3b43b6e's stack.zod.ts (blob be30488c, byte-identical to the merge-base's) placed beside the head's (blob 690c80dd) as a sibling module so one probe file (36 cases, probe-results.json) drives both through the same inputs; a second worktree on the merge of main and head (git merge-tree clean → tree 7ce4d05a, probe commit 1f8f82e6, never pushed). Two ablations in my own worktree, each restored to blob 690c80dd and proven by git status --porcelain. Check-runs on the head at review time: 32 success / 2 skipped / 1 in_progress (Lint & Repo Gates), 0 failing. This is a re-run against the FAIL recorded in PR comment 5670836149 on head b0072379; its three charges were re-measured, not reread.

  1. Charge 1 (bare TypeError from the two collectors) — resolved. Probes M1–M12 on the head, each a hand-built stack with its own objects composed with a sibling: map-valued permissions, data: 42, string-valued permissions, [null] in each collection, a scalar entry plus { object: 7 }, objects: null on a set, data: null, a set with no objects key, object: '', array-valued objects. None raises a TypeError; every malformed shape is skipped. Where main accepted, head accepts (M1–M6, M8–M10, M12 identical on both sides, including the warning lines). Ablation A1 — both Array.isArray guards and the non-object-entry skips stripped (disk blob e1d8ba8c): the fixture reads 5 failed / 25 passed, every failure a bare TypeError (permissions is not iterable, datasets is not iterable, Cannot read properties of null ×2, name.startsWith is not a function). The FALSE GREEN correction is real: under the same mutation my string-valued permissions case (M3) still ACCEPTS — a string iterates its characters — while the map-valued case (M1) throws; the committed fixture uses the map, so it pins the guard. The one case in that block that stays green under mutation (objects: null on a set) is the control the PR declares it to be — the pre-existing typeof grants === 'object' line covers it. No other false pin in the block.

  2. Charge 2 (strict: false / hand-built inputs newly refused while the body claimed otherwise) — resolved as behaviour and as declaration, with one false clause left standing. U1 (strict: false, dangling grant), U2 (dangling seed), U3 (hand-built, both): main ACCEPTS, head refuses with StackCrossReferenceError, code STACK_CROSS_REFERENCE_INVALID, status 422, the per-finding lines byte-identical to the per-stack pass's and only the header different. U4 (same construction, names the artifact defines) accepts on both — the control. U6 (dangling hook on a strict: false input) accepts on both — only the two rules apply. U8 (grant on a third input's object) accepts on both — the scope is the union. The five positive fixtures pin exactly this, and the bolded compatibility claim is gone from the body, the docstring and the changeset. What is wrong: the changeset and the PR body both now say the refused input "previously composed with only a warning". Measured on main with console.warn/error/info/log/debug and both process streams captured: U1, U2, U3 produce zero lines. The only warning main has on this path is warnMalformedCollectionKey, which fires for a non-array collection key (control: data: 42 → one line) — a population that is still not refused on the head. The clause conflates the two populations and hands an upgrading reader a false test for whether they are affected ("we never saw a warning, so this is not us"). The collectArtifactCrossReferenceErrors docstring has it right ("refused at composition where it previously composed"); the two shipped texts do not.

  3. Charge 3 (one-input composition never reaches the pass; guarantee overstated) — resolved. if (stacks.length === 1) return stacks[0]; is unchanged (line 3931). O5/O6: a one-input composition of a stack claiming crm_nowhere is accepted with and without options; O4: the identical claim with a sibling present is refused at the artifact pass. The option docstring, the docs bullet and the changeset all carry "in a composition of two or more packages", and the docs bullet says why the qualifier is real. Ablation A2 — the early return deleted (disk blob e7c7b384): exactly 2 failed / 28 passed, both in the one-input block, so those fixtures observe the early return rather than merely asserting an accept.

  4. No acceptance-set change beyond the declared one was brought in by the rework, and the guards are not too wide. Strict path: after ObjectStackDefinitionSchema parses, data is z.array(SeedSchema) with object a non-empty regex string (seed.zod.ts:61) and permissions is z.array(PermissionSetSchema) — exactly the shapes the guards admit, so validateCrossReferences' two rules are unchanged for parsed input: S1 (dangling seed), S2 (dangling grant), S3 (map-format permissions authored, normalised before parse) give byte-identical envelopes on head and main, and O9 (artifactObjects: []) is the unopted path. Unparsed path: everything the guards skip is something main also composed (M8 data: null, M9 no objects key, M10 object: '', M12 object: 7 — identical both sides), so nothing that was refused is now let through. The refusals that are new are dangling references on unparsed inputs — the declared population. One of them worth naming: M11, objects given as an array on a hand-built set, is refused as grants on object '0' — an envelope, not a crash, but the message names the array index because the rule admits arrays through typeof grants === 'object'; that admission is main's per-stack code verbatim, not this diff's. Noted, not blocking.

  5. The population the narrowing hits is one the strict docstring itself advertises. DefineStackOptions.strict (line 1437) says false is for "when views reference objects provided by other plugins". A strict: false stack that declares objects and whose permissions[].objects / data[].object name a plugin-provided object outside the composition is exactly what the head newly refuses, with no opt-out other than declaring no objects. The narrowing is the Prime Directive Add comprehensive test suite for Zod schema validation #12 direction and is declared; the strict docstring does not point at it. Recommended, not required.

  6. warnMalformedCollectionKey reuse is consistent with step 3 and no key warns twice. data and permissions are concat keys (COMPOSE_KEY_DISPOSITIONS, lines 980 and 992), so step 3 runs first for every malformed collection and the reporter's module-lifetime set makes the collector's call a no-op today: M1 one line on head and one on main, M1b (same key again) zero on both. The fixture's toHaveLength(1) is therefore satisfied by step 3 alone and the collector's call is a latent guard, as the PR says. The count assertion depends on the key not having been warned earlier in the same module instance: nothing earlier in the fixture file triggers it, the spec vitest config sets no isolate: false or shared pool, and the fixture passed inside the four-suite run on both trees. It also held inside the whole @objectstack/spec local vitest project run on the merged tree: 478 files / 13666 tests passed, 1 skipped, exit 0 (captured to a file).

  7. Public surface unchanged from the pre-rework head. One optional member, DefineStackOptions.artifactObjects?: readonly string[]; api-surface/root.json names the interface at name level only; CI Type Check · source gates is green on this head, so no regeneration is owed. Every other objectNames.has( site is untouched (hooks 2227, datasets 2243, mappings 2270, navigation 2333, standalone actions 2441, object-bound actions 2499); artifactScope reaches exactly the two collectors (2261, 2289). O7: a hook naming a listed sibling object is still refused.

  8. Envelope docstring ("two raise sites since spec: defineStack's per-stack cross-reference validation refuses permissions[].objects and data[].object that name an object owned by ANOTHER PACKAGE OF THE SAME ARTIFACT — so ADR-0130's co-owned artifact and the "permission sets stay whole in the app package" addendum cannot both hold once the app package owns objects #18202, one code") is accurate: the probe shows the same code/status/issues from both passes and only the header differing.

  9. Behind origin/main by 12, not merged forward — acceptable, and this review read the tree that lands. stack.zod.ts is byte-identical at the merge-base and at main (blob be30488c); the two sides touch no common file; git merge-tree is clean (tree 7ce4d05a); the four cross-reference suites pass 183/183 and the whole spec local project passes 478 files / 13666 tests on the merged worktree; CI computes on the merge ref 4cf0c914 (head into b3b43b6e). The cost the staleness does carry is the one the PR names: its local 106-gate derivation is this tree's, not main's, so the Lint & Repo Gates run on the merge ref is the authoritative reading — still in progress at review time.

  10. Re-run on the head: eslint over the two changed TypeScript files exit 0; scripts/check-role-word.mjs exit 0 (the docs bullet says "position").

② Semver level

minor on @objectstack/spec stands: an additive optional member on a published interface, plus a check whose only newly-refused population is inputs that bypassed the strict parse — under this repo's vocabulary (AGENTS.md Post-Task step 3; the ADR-0087 categories) not a removed or renamed authorable key and not a narrowed published type, and strict: false is documented as "validation is skipped … advanced use cases", never as a promise about composeStacks. Clause-②: yes sets minor as the floor; the changeset frontmatter is '@objectstack/spec': minor. Level correct.

The body, sentence by sentence. FROM paragraph — accurate (S2's message is the quoted one). TO paragraph and example — accurate (O1/O2). "Nothing else widens … hooks[].object and an app's own navigation objectName stay refused" — accurate (O7; navigation pinned by the fixture). "in a composition of two or more packages, composeStacks now re-checks those two classes … same code, same 422, same per-finding message. Only the header differs" — accurate (O4). "composeStacks returns a single input untouched, so a one-package composition does not re-check the claim" — accurate (O5/O6). "defineStack itself is unchanged for a stack that does not pass artifactObjects" — accurate (O9, S1–S3). "it applies the two artifact-scoped rules to every input carrying objects, not only the ones that opted in" — accurate (U1–U3, U7 object-less skipped). "For an input that bypassed the strict parse it is not: defineStack(config, { strict: false }) returns before cross-reference validation runs, and a hand-built stack object never enters it" — accurate (line 2948). "Such an input carrying a dangling permissions[].objects key or data[].object is now refused at composition" — accurate. "where it previously composed with only a warning" — false: main composes it with no diagnostic at all (① item 2). "a malformed permissions / data on such an input is still skipped with the existing non-array warning rather than raising" — accurate (M1, M2). The changeset ships verbatim into CHANGELOG.md, the text an upgrading agent greps, and the false clause is the one that tells a reader whether they are in the affected population.

Precision, recommended rather than required: "For an input that passed the strict defineStack parse that is a no-op, so such an input cannot newly fail" is true relative to main only for an input that did not opt in — an opted-in input also passed the strict parse and IS re-checked here (the changeset's own fourth paragraph, the ArtifactPass fixture, O4). Add "and did not opt in", or state it as "a reference the per-stack pass already resolved against the stack's own objects cannot fail here", in the changeset and in the first bullet of the collectArtifactCrossReferenceErrors docstring.

③ Boundary flags

Flag 1 — the third REST write (PR body PATCH) against a two-write budget. Correct disposition. The same dispatch (card comment 5673292255) names "PR 正文" in remediation (b) and in its file surface, so the budget line omitted a write the deliverable required; leaving the false bolded claim in the body would have left (b) half-delivered on the one record a reviewer reads. The write was declared with its read-back, and the read-back holds: the body carries exactly one footer block, draft is still true, the labels are unchanged (needs:contract-review on both carriers). The fourth write — the PATCH correcting the report comment after it was posted while the remote was still at b63b526e — is a declared process wobble, not a boundary breach; the thing not to repeat is filing the report before the final push is confirmed on the remote.

Flag 2 — node scripts/pm/check-clause2-carriers.mjs --pair 18212: exit 4, exit code written to a file before any read. The target pair is clean in the script's own words ("PR #18212 / card #18202 — the clause-② declaration is readable in the fixed spelling and both carriers agree"); the 4 is carried entirely by the second pair the script derives from Part of #14122 (C1: label on the PR, absent on #14122; C2-SIBLING: no Claim: comment on #14122, declaration readable on sibling #18202). #14122 is a tracking epic named only by Part of; the recorded ruling is that such a card is not a clause-② carrier, and the predicate defect is filed as #18214. The C2-SIBLING caveat discharges trivially: this PR delivers one card, #18202. Not a reason to hold this PR.

Flag 3 — deliberately not merged forward. Judged in ① item 9: acceptable for a targeted rework, on the evidence that the reviewed stack.zod.ts is the one that lands and the merged tree is green on the relevant suites; the PM's merge-forward before landing (AGENTS.md Multi-agent §9/§10) is still owed, and the merge-ref CI reading, not the branch-local 106, is the gate reading.

Remediation that turns this into a PASS — one clause, two surfaces, nothing else moves: in .changeset/artifact-scoped-cross-reference.md and in the PR body, replace "where it previously composed with only a warning" with a true statement of the prior behaviour, e.g. "where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference". Recommended in the same stroke: the "did not opt in" qualifier from ② on the changeset paragraph and the collectArtifactCrossReferenceErrors docstring. The level stays minor; no fixture, no code and no docs page is owed.

Implemented-by: claude/issue-18202-crossref-dependency-aware
Reviewed-by: session_01T3YsvpK1PvYf9n1YUhYP6W

VERDICT: FAIL


Generated by Claude Code

… a warning

The changeset said an input the artifact pass newly refuses "previously
composed with only a warning". Measured on main, that class of input produces
no diagnostic at all: `composeStacks` calls `validateCrossReferences` zero
times and `console.warn` zero times, and its one `warnMalformedCollectionKey`
site fires on `declared.length !== arrays.length` — a collection key that is
not an array. The newly-refused inputs carry well-formed arrays with a
dangling reference, so that condition never holds.

The clause conflated two populations and handed an upgrading reader a false
self-test ("we never saw a warning, so this is not us") in text that ships
verbatim into CHANGELOG.md. It now reads "where it previously composed with no
diagnostic at all — the existing non-array warning covers a malformed
collection key, not a dangling reference".

Same stroke, the precision the review asked for: the no-op half of the
invariant holds for an input that passed the strict parse AND did not opt in.
An opted-in input also passed that parse but resolved against its own objects
plus the names it listed, and checking a listed name against the real artifact
is what this pass is for — so it can fail here by design. The qualifier is
added in the changeset, in the `collectArtifactCrossReferenceErrors` docstring
and in the fixture file's header, which carried the same sentence.

Text only: no schema, no rule, no fixture and no docs page changes, and the
changeset level stays `minor`.

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 19db79b67b29d2d5468acb00e907cfb3cfa491a5

① Derived judgments

Basis: the diff read from the REST endpoint (4 files, +865/−48 against merge-base d4554d4f) and this round's own diff 0c21631c..19db79b6 (3 files, +17/−9); the head fetched into refs/review/18212-r3/head and checked out in a detached worktree; origin/main 68fea8bc's stack.zod.ts (blob be30488c, byte-identical to the merge-base's) placed beside the head's (blob 6a3a622f) as a sibling module so one probe file (18 cases, probe-results.json) drives both through the same inputs; the same probe and the cross-reference suites re-run on a never-pushed merge of head onto current main (git merge-tree clean → tree d8108a9b, commit 4e3e5491). Check-runs on the head: 42 = 38 success / 4 skipped / 0 failing (Lint & Repo Gates was in progress when the review started and completed green during it). This is a re-run against the FAIL records 5670836149 (head b0072379) and 5673898021 (head 0c21631c); their charges were re-measured, not reread. Every exit code below was written to a file before it was read.

  1. The false clause is gone from both shipped surfaces, and the sentence that replaced it is true on main. Changeset at head: 0 hits for "only a warning", 1 for the new sentence; the four PR files at head and the full REST diff: 0 hits. The PR body's invariant statement (line 105) carries the new sentence. The replacement was measured, not accepted: U1 (strict: false, dangling grant), U2 (strict: false, dangling seed), U3 (hand-built, both) composed with the service stack on main — ACCEPT, with zero lines captured across console.warn/error/info/log/debug and process.stdout/stderr.write; the same three on the head — refused, StackCrossReferenceError, STACK_CROSS_REFERENCE_INVALID, status 422, still zero lines. The second half of the sentence: MW1 (data: 42) and MW2 (permissions as a map) produce exactly one warnMalformedCollectionKey line each on both trees and are accepted on both — the malformed-key population is still not refused on the head. Statically, main's composeStacks body (lines 3674–3798) contains 0 validateCrossReferences, 0 direct console.warn, 1 warnMalformedCollectionKey gated on declared.length !== arrays.length (line 3714), and one other warn helper, warnUncomposedStackKey, which fires only for a key with no declared disposition — permissions and data are concat keys, so it cannot fire for them. (The PR body's "console.warn zero times" is literally true of the function body; the helper route exists and cannot reach this population.) Correct.

  2. The "did not opt in" qualifier is correct and necessary. O4: defineStack(app, { artifactObjects: ['crm_nowhere'] }) passes the strict parse (accepted) and is refused at composeStacks with a sibling present — an input that "passed the strict parse" and does newly fail, so the unqualified sentence was false for exactly that population. P: a strict-parsed input that did not opt in composes on both trees with zero lines. O4's issues are byte-identical to the per-stack refusal of the same config (O4-perstack); only the header differs. Carriers at head, by grep: changeset line 23, collectArtifactCrossReferenceErrors docstring line 3807, test-file header line 47 — and no other place states the invariant (the docs bullet does not), so no carrier is missing. Correct.

  3. The third carrier — the test-file header — is acceptable. It carried the unqualified sentence verbatim under "The compatibility statement that holds is … two statements"; leaving it would have left the file's own header contradicting the block at line 223 it points to. The change is comment-only, proven rather than eyeballed: TypeScript transpileModule with removeComments emits byte-identical JS for the file at 0c21631c and at 19db79b6 (14595 bytes both), and likewise for stack.zod.ts (66015 bytes both). Test files do not ship in the package. The rework dispatch's (b) named the claim's carriers by kind, the dev declared the deviation, and it changes no fixture, assertion or behaviour. Not a scope breach.

  4. The PR-body occurrence is a citation, not a residual. One hit (line 23), inside the "Round 2" banner, followed in the same sentence by "quoted here as the text that was REMOVED, and appearing nowhere else in this PR" — and the "nowhere else" claim is true (item 1). The body already uses the identical pattern for the round-1 removed claim ("cannot newly refuse a composition that passes today", line 102), which round 2 read and did not flag. A count-only grep would misread the hit; a grep that reads the hit line would not, and the shipped surface (changeset → CHANGELOG.md) is at zero. Not blocking. Paraphrasing the banner so the body carries no verbatim copy is optional, not owed.

  5. No behaviour change was brought in this round. git diff 0c21631c..19db79b6: 3 files, +17/−9; every changed line is inside a /** … */ block or the changeset body, and item 3 proves it at the JS level. Changeset frontmatter unchanged ('@objectstack/spec': minor). The head's collectors carry the Array.isArray + warnMalformedCollectionKey guards, the non-object-entry and non-string-object skips (lines 2112–2185), and the artifact pass keeps the collectObjectNames(stack).size === 0 continue (3854–3869) — read, not inferred from the summary.

  6. Regression spot-check of the prior findings — none regressed. Round-1 charge 1 (bare TypeError): N1 permissions: [null], N2 data: [null], N3 data: [{ object: 7 }] + permissions: [42, { objects: null }], N4 string-valued permissions, plus MW1/MW2 — all accepted on the head, nothing thrown; the committed fixture's non-array permissions case uses map format (not iterable), so the round-2 false-green correction holds on disk. Round-1 charge 2 (strict: false narrowing hidden): U1–U3 refuse on the head with the envelope and are declared in the changeset, the docstring and the fixture block at line 336. Round-1 charge 3 (one-input guarantee): if (stacks.length === 1) return stacks[0]; is present on main (3679) and the head; O5/O6 accept a one-input composition claiming crm_nowhere with and without options; O4 refuses the same claim once a sibling joins. Round-2's finding: item 1. Unopted strict path: S1/S2 messages byte-identical head vs main; O9 (artifactObjects: []) identical to S1. Suites at head: stack-artifact-crossref + stack-cross-reference-envelope + stack-inline-action-crossref 97/97; stack-refusal-envelopes + stack.test + api/error-code-ledger 134/134; on the merged tree the four cross-reference suites + probe 143/143; every run exit 0. Not re-run here: the whole @objectstack/spec project and the 106-gate derivation — CI on the head is 38/38 green and the round is JS-identical to the tree round 2 measured.

  7. Two imprecisions, neither false, neither blocking. (a) ArtifactPass is cited as "the fixture" in the docstring (3815, new this round), the test header (39 pre-existing, 52 new) and the PR body, but no identifier or describe title carries that name — the block is titled "the ARTIFACT pass: the refusal MOVED, it did not disappear" (line 223). The pointer resolves by reading, not by grep. (b) The PR body's Verification table still says "at 0c21631c — the rework head"; the re-run at 19db79b6 (478 files / 13632 tests, 106/106 gates) is recorded only in the card report 5674158691. Valid by JS identity; stale as a record.

② Semver level

minor on @objectstack/spec stands, unchanged from the two prior readings: AGENTS.md Post-Task step 3 makes minor the floor for Clause-②: yes, and "breaking" there is a removed or renamed authorable key, export or config field — none is removed or renamed here (one additive optional member, DefineStackOptions.artifactObjects?: readonly string[]; every other objectNames.has( site unchanged). The newly-refused population is inputs that bypassed the strict parse, which strict's own docstring calls "validation is skipped … advanced use cases". Level correct.

The changeset body at head, sentence by sentence: headline (a sibling-owned object can be granted on and seeded into) — O1. FROM: both classes had to name a same-stack object, and the quoted message — S2 on main refuses with exactly Permission 'sales_rep' grants on object 'crm_case' which is not defined in objects. among its issues; "the only escapes were strict: false … or splitting the sets" — U1–U3 on main accept. TO and the example — O1 (artifactObjects: ['crm_case'] accepted at defineStack and at composeStacks; composed objects crm_account, crm_case). "Nothing else widens … hooks[].object and … navigation … stay refused" — fixture blocks at 182/199 pass; U6 (dangling hook on an unparsed input) accepted on both trees, so only the two rules apply. "in a composition of two or more packages … same code, the same 422, and the same per-finding message. Only the header differs" — O4 vs O4-perstack. "composeStacks returns a single input untouched" — O5/O6. "defineStack itself is unchanged for a stack that does not pass artifactObjects" — S1/S2 byte-identical head vs main, O9. "applies the two artifact-scoped rules to every input carrying objects, not only the ones that opted in" — U1–U3 never opted in and are refused; object-less inputs skipped by the size === 0 continue. "passed the strict defineStack parse and did not opt in … a no-op … a subset of the composed set" — P; the union in O1. The parenthetical on opted-in inputs — O4-define accepts, O4 refuses. "strict: false returns before cross-reference validation runs, and a hand-built stack object never enters it" — main 2784 if (!strict) precedes 2818 validateCrossReferences(data); U3. "refused at composition where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference" — U1–U3 zero lines on main; MW1/MW2 one line each on both trees; the static gate at 3714. "the one behavioural change to expect, and there is no earlier warning to have noticed it by; a malformed permissions / data … still skipped with that non-array warning rather than raising" — N1–N4 and MW1/MW2 identical on both trees, so nothing else moves for unparsed inputs, and MW1/MW2 on the head accept with one line. Every sentence holds. Level correct, body true.

③ Boundary flags

Flag 1 — the third "did not opt in" carrier (test-file header). Judged in ① item 3: comment-only, JS-identical, declared, and the header would otherwise contradict the block it cites. Acceptable.

Flag 2 — the removed phrase quoted once in the PR body. Judged in ① item 4: a citation under an explicit REMOVED banner whose "nowhere else" claim is verified; zero in the changeset. Not a residual, not blocking.

Flag 3 — DefineStackOptions.strict docstring does not point at the new composition-time refusal. Head lines 1431–1442 are main's text unchanged: false is advertised for "views reference objects provided by other plugins". A strict: false stack that declares objects and whose permissions[].objects / data[].object name a plugin-provided object outside the composition is now refused at composition with no opt-out other than declaring no objects. Round 2 rated the pointer recommended, not required; this round was scoped to one clause on two surfaces, so deferring was correct, and the declared successor (the next editor of that docstring, or a PM-filed card) is adequate. Not blocking.

Flag 4 — M11, an array-form objects grant map refused as grants on object '0'. main line 2114 if (grants && typeof grants === 'object') admits arrays exactly as the head's inverted form at 2171 does; per-stack behaviour verbatim, not this diff's, and an envelope rather than a crash. Noted; #18239's neighbourhood is the right home. Not blocking.

Flag 5 — #18239 (mergeObjects bare TypeError, pre-existing on main). Untouched here, as the dispatch instructed. Correct.

Flag 6 — node scripts/pm/check-clause2-carriers.mjs --pair 18212: exit 4, written to clause2.exit before being read. The target pair is clean in the script's own words ("PR #18212 / card #18202 — the clause-② declaration is readable in the fixed spelling and both carriers agree"). The 4 is carried entirely by the pair derived from Part of #14122: C1 (needs:contract-review on the PR, absent on #14122) and C2-SIBLING (no Claim: on #14122; the declaration readable on sibling #18202). Ruling applied: a tracker named only by Part of is not a clause-② carrier (mechanism card #18214). The C2-SIBLING caveat discharges: this PR Fixes exactly one card and closes nothing else. The residual does not block.

Flag 7 — 13 commits behind origin/main (68fea8bc), not merged forward. git merge-tree is clean (tree d8108a9b); none of the 13 touches the four PR files; four touch packages/spec/src elsewhere and the merged-tree run is 143/143; CI computed on the merge ref is 38 success / 4 skipped. Acceptable for a comment-only rework; the PM's merge-forward before landing is still owed.

Implemented-by: claude/issue-18202-crossref-dependency-aware
Reviewed-by: session_01T3YsvpK1PvYf9n1YUhYP6W

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

pm-note — 本席位的落地次序错了,已重挂闸门;第三轮 PASS 记录仍有效,但不再覆盖将要落地的树

发生了什么

时刻 动作
03:38:14Z 第三轮契约复核 PASS(记录 5674361474,所判 head 19db79b67),本席位按细则清双载体
03:38:33Z 本席位做 merge-forwardPUT .../update-branch),head 移到 0424329a8

19 秒。 我清完标就把 head 移走,等于自己作废了自己的清标。

机读判据当场抓到,原话:

C3 — the gate WAS bound and cleared (last removal 2026-09-15T03:38:14Z), but the PR's head has MOVED since: its head commit is dated 2026-09-15T03:38:33Z. The review that cleared this gate judged a different tree, so the clear no longer covers what would land. This is the 重挂-owed state the recovery rule already names — 「head 后移或无结论才重挂」

needs:contract-review 双载体已重挂(03:40:20Z / 03:40:22Z,经 label-write.mjs,回读匹配)。

正确次序,记下来免得再犯

⛔ 错的:复核 → 清标 → merge-forward → 落地
✅ 对的:merge-forward → CI 绿 → 复核 → 清标 → 落地

复核自己在 Flag 7 里就写明了 merge-forward 是「PM 落地前仍欠的一步」。我把它排在了复核之后,而不是之前。

这次 merge 本身是干净的,已核

  • 合并提交 0424329a899a,message Merge branch 'main' into claude/issue-18202-crossref-dependency-aware双父 19db79b67b + 75237a9a89 ⇒ 真合并提交,⛔ 未重写历史。
  • 带进 139 个文件,全部来自 main 侧。
  • 本 PR 的四个文件在合并前后字节未变git diff 19db79b67..0424329a8 -- <四个文件> 为空。
  • 第三轮复核已在一棵 merge-tree 干净的合并树上跑过(143/143),并核过那 13 笔无一触及本 PR 的四个文件。

⚠️ 但以上都不能替代在真实新 head 上的复核 —— 我合进来的父2 是 75237a9a89,而复核验的合并树基于 68fea8bc,是两棵不同的树。⛔ 不拿「差不多」当读数。

下一步

  1. 0424329a8 上的 CI 跑完(此刻 10 success / 2 skipped / 20 in_progress,0 红)。
  2. 绿了起第四轮达档隔离复核,判这棵真正要落地的树,⛔ 不沿用第三轮记录。
  3. PASS 才清标、转 ready、入队。这次 ⛔ 不在清标之后再动 head。

第三轮的 PASS 记录 5674361474 本身不作废 —— 它对 19db79b67 的判断仍然成立,只是那不再是将要落地的 head。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0424329a899a2be3d21cccb720883560350fd51f

① Derived judgments

Basis: the head fetched from origin and checked out in a detached worktree; the PR diff read from the REST endpoint (4 files) and recomputed locally as git diff 75237a9a..0424329a; the main-side parent's stack.zod.ts (blob be30488c) staged beside the head's (blob 6a3a622f) as a sibling module so one 27-case probe drives both through the same inputs, with console.warn/error/info/log/debug and both process.stdout/stderr.write captured; the PR fixture and five neighbouring suites run on the head worktree; every exit code written to a file before it was read. The three prior records (5670836149, 5673898021, 5674361474) were read for their charges only; each charge was re-measured on this tree.

  1. The merge commit is what the dispatch seat says it is — verified, not transcribed. git rev-list --parents -n 1 0424329a → parents 19db79b67b (the round-3 head) and 75237a9a89 (main side); committer GitHub <noreply@github.com>, signature valid, message Merge branch 'main' into claude/issue-18202-crossref-dependency-aware — the shape PUT …/update-branch produces, and no history rewrite. git merge-tree --write-tree 19db79b67b 75237a9a89 recomputes tree 5a094576e1 = 0424329a^{tree}, so the merge is the automatic one with no hand resolution. git diff --name-only 19db79b67b 0424329a is 139 paths, set-identical to git diff --name-only d4554d4f5 75237a9a89 (the merge-base to main side); for all 139 the blob at the head equals the blob at 75237a9a (0 mismatches). The PR's four files are blob-identical at 19db79b67b and at the head (c00cf9f3, 2ddf882a, b1322f46, 6a3a622f), and the PR patch against the merge-base before the merge and against 75237a9a after it are byte-identical (1011 lines each, cmp clean). No path is in both sets. Both claims correct.

  2. Nothing the merge brought in conflicts with, or invalidates, this PR — how it was sampled. (a) Name-set intersection of the 139 with the PR's four files: empty. (b) packages/spec/src/stack.zod.ts is untouched between the merge-base and the main side (same blob be30488c), so the only change to that file on this tree is this PR's. (c) The import closure of the head's stack.zod.ts intersected with the 139: two hits — data/analytics.zod.ts (only CubeSchema is imported; the change is dateRange arity and refusal wording, AnalyticsDateRangeSchema's array arm admits shapes every consumer now refuses, and the one shared refusal sentence is false for them #17598) and ui/view.zod.ts (normalizeFilterOperator own-property guard, spec: three more Object.prototype fall-through lookups (normalizeFilterOperator, resolveDiscoveryEnvironment, pluralToSingular), found by an index-expression-keyed survey and measured #17818); neither is on the defineStack/composeStacks cross-reference path. shared/metadata-collection.zod, kernel/metadata-authoring-lint, system/constants/platform-object-names, shared/error-map.zod — the modules the two passes actually lean on — are not among the 139. (d) The whole main-side diff grepped for composeStacks|validateCrossReferences|artifactObjects|collectArtifactCrossReferenceErrors|StackCrossReferenceError|warnMalformedCollectionKey: one hit, a doc comment in the new packages/cli/src/utils/artifact-packages.test.ts (test(cli): hold field-level and action-param object references in agreement on the per-package leg #18213). (e) test(cli): hold field-level and action-param object references in agreement on the per-package leg #18213 itself — the sibling card on the CLI per-package leg — adds two test files only (+202/−11) and no line mentioning permissions or seed data, so it neither re-refuses nor re-routes the two classes this PR widens; packages/lint's one main-side change is a test file. (f) The 17 main-side content/docs pages: examples.mdx is not among them, and no added line names artifactObjects, composeStacks, permission sets or seed rows crossing a package. (g) The six main-side changesets have distinct file names; Check Changeset is green on the head. Measured on the merged tree: stack-artifact-crossref.test.ts 30/30; stack.test + stack-cross-reference-envelope + stack-inline-action-crossref + stack-refusal-envelopes + api/error-code-ledger 201/201; both exit 0. Correct: the merge changes nothing this PR depends on.

  3. Round-1 charge 1 (bare TypeError from the two collectors) — still resolved. N1 permissions: [null], N2 data: [null], N3 data: ['crm_case', { object: 7 }] + permissions: [42, { objects: null }], N4 string-valued permissions, MW1 data: 42, MW2 map-valued permissions: every one composes on the head with nothing thrown, identical to the main-side module. MW1 and MW2 produce exactly one warnMalformedCollectionKey line on each side (… non-array value …), the others zero. The committed fixture's non-array permissions case is map-shaped (line 436), so the round-2 false-green correction is on disk.

  4. Round-1 charge 2 (strict: false / hand-built inputs newly refused while claimed away) — still resolved, as behaviour and as declaration. U1 (strict: false, dangling grant), U2 (strict: false, dangling seed), U3 (hand-built, both): main-side ACCEPTS with zero lines on every channel; head REFUSES with StackCrossReferenceError, STACK_CROSS_REFERENCE_INVALID, status 422, header composeStacks artifact cross-reference validation failed (N issue(s)):, also with zero lines. U4 (same construction naming crm_case) and U6 (dangling hook only) accept on both — only the two rules apply. The narrowing is declared in the changeset (line 23), the collectArtifactCrossReferenceErrors docstring (3807–3831) and the fixture block at line 336.

  5. Round-1 charge 3 (one-input composition; guarantee overstated) — still resolved. if (stacks.length === 1) return stacks[0]; at head line 3936 and main line 3679. O5/O6: a one-input composition claiming crm_nowhere accepts with and without options; O4: the identical claim with a sibling present is refused. The qualifier "in a composition of two or more packages" is at the option docstring (lines 1482–1490), the docs bullet (examples.mdx 441–444) and the changeset (line 21).

  6. Round-2's charge (the "only a warning" clause) and round-3's qualifier — both hold on this tree. Changeset at head: 0 hits for "only a warning", 1 for "no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference"; U1–U3 zero lines on the main-side module is the measurement behind that sentence, and MW1/MW2 (one line each, both sides) is the measurement behind its second half. "passed the strict defineStack parse and did not opt in" is at the changeset (line 23), the docstring (line 3807) and the test header (line 47); O4-define (opted in, accepted at defineStack) then O4 (refused at composeStacks) is the population the qualifier exists for, with issues equal to O4-perstack's and only the header differing.

  7. Public surface: one optional member, unchanged by the merge. DefineStackOptions.artifactObjects?: readonly string[]; api-surface/root.json names the interface at name level only; the main side's generated-artifact changes (api-surface/shared.json, declaration-map/shared.json, export-origins/shared.json, llms.txt) are spec: closed duration types DurationMs / DurationSeconds beside EpochMs (step 1 of ruling A on #18115) #18238's duration types, disjoint. Type Check · source gates, TypeScript Type Check and Lint & Repo Gates are green on this head, so no regeneration is owed. Every other objectNames.has( site is unchanged (2227 hooks, 2243 view data, 2270 mappings, 2333 navigation, 2441/2499 actions) and artifactScope reaches exactly the two collectors (2261, 2289): O7 hook, NAV navigation, MAP targetObject, VIEW list.data.object, ACT standalone objectName — each still refused with STACK_CROSS_REFERENCE_INVALID when the name is listed in artifactObjects.

  8. Acceptance set on this tree, class by class. Widened: permissions[].objects and data[].object at defineStack for an opted-in stack (O1 accepted; composed artifact 2 packages, objects crm_account, crm_case; U8 grant on a third input's object accepted — the scope is the union). Narrowed: composeStacks for unparsed inputs carrying a dangling reference in those two classes (U1–U3), declared. Unchanged: the unopted strict path — S1 (dangling seed) and S2 (grant on a sibling's object without the option, the reported defect) give messages byte-equal head vs main-side, S2's issues carry exactly Permission 'sales_rep' grants on object 'crm_case' which is not defined in objects.; O9 (artifactObjects: []) equals S2 on the head; the object-less leniency P8 accepts on both. The merge adds no acceptance-set change and no public-surface change of its own: the only diff between this head and 75237a9a is the PR patch, which is byte-identical to the one judged at 19db79b67.

  9. Check runs on the head: 39, all completed, 33 success / 6 skipped / 0 failing. The six required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL)) are all success; Check Changeset, Governed Surface Queue Guard, and the three carrier checks (The card this PR closes must claim this branch, No other open PR may claim the same issue, Part-of PR must not also close its card) are success. Console Pin Gate is skipped by its paths filter.

  10. PR body: every contract statement true on this tree; three process-narrative lines are stale. The what-changed, the two-class table, the moved-refusal section, the two-statement invariant, the compatibility section and the acceptance notes all hold against items 3–8. Stale: the Verification header "at 0c21631c — the rework head" (lines 125/136 — carried from round 3, JS-identical then, still a stale record); "Known staleness, declared: this branch is 12 commits behind origin/main … The branch is deliberately not merged forward" (lines 153–156) — false on this head, which was merged forward by the dispatch seat at 03:38:33Z; "computed on the merge commit 318def6e" (line 194) — the docs-drift run on this head reads 35ff00c5. None ships, none is a contract statement, and PR comment 5674382840 corrects the record; a one-line PATCH is recommended, not required.

② Semver level

minor on @objectstack/spec stands. AGENTS.md Post-Task step 3 makes minor the floor for Clause-②: yes (declared on card #18202 in the fixed spelling); "breaking" in this repo's vocabulary is a removed or renamed authorable key, export or config field, and nothing is removed or renamed — one additive optional member on a published interface, no authorable key. The only newly-refused population is inputs that bypassed the strict parse, which DefineStackOptions.strict's own docstring calls "validation is skipped … advanced use cases" and was never a promise about composeStacks. The frontmatter at head is '@objectstack/spec': minor; Check Changeset is green.

The body, which ships verbatim into CHANGELOG.md, sentence by sentence against this tree: headline — O1. FROM: the same-stack requirement and the quoted message — S2 on the main-side module refuses with exactly that string; "the only escapes were strict: false … or splitting the sets" — U1–U3 accept on main. TO and the example — O1. "Nothing else widens … hooks[].object and … navigation … stay refused" — O7, NAV (and MAP/VIEW/ACT beyond what the sentence claims). "in a composition of two or more packages … same code, the same 422, and the same per-finding message. Only the header differs" — O4 vs O4-perstack: equal issues, headers composeStacks artifact … vs defineStack …. "returns a single input untouched" — O5/O6. "defineStack itself is unchanged for a stack that does not pass artifactObjects" — S1/S2 byte-equal, O9. "applies the two artifact-scoped rules to every input carrying objects" — U1–U3 never opted in and are refused; P8 object-less skipped. "passed the strict parse and did not opt in … cannot newly fail" — U4's strict sibling and every non-opted parsed input in the suites; the parenthetical on opted-in inputs — O4-define then O4. "strict: false returns before cross-reference validation runs, and a hand-built stack object never enters it" — main-side if (!strict) at 2784 precedes validateCrossReferences(data) at 2818; U3. "refused at composition where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference" — U1–U3 zero lines on main; MW1/MW2 one line each on both sides. "still skipped with that non-array warning rather than raising" — MW1/MW2, N1–N4 on the head. Every sentence holds. Level correct, body true.

③ Boundary flags

Flag 1 — the merge-forward and its disposition. The merge itself is clean and honest (① item 1). The ordering error is real and is the dispatch seat's own: the gate was cleared at 03:38:14Z (PR) / 03:38:16Z (card) and the head moved at 03:38:33Z, so for 19 seconds a cleared gate stood on a tree no record had judged. The correction is adequate: the seat did not proceed to ready/enqueue, re-hung both carriers at 03:40:20Z / 03:40:22Z, recorded the error on the PR (5674382840) and requested this round rather than reusing 5674361474. check-clause2-carriers no longer raises its C3 row on the target pair. Two things a reader should carry forward: origin/main has since moved two commits past 75237a9a (ecf91cb44 #18243, c81e7ffcd #18241) — neither touches packages/spec, the four PR files or .changeset/artifact-scoped-cross-reference.md, and git merge-tree of this head onto current origin/main is clean (tree 5a08e738) — so no second merge-forward is owed before landing, the queue computes on the merge ref; and if one is done anyway it precedes the review, never follows the clear.

Flag 2 — node scripts/pm/check-clause2-carriers.mjs --pair 18212: exit 4, written to carriers-prhead.exit before it was read, and reproduced at exit 4 with origin/main's script (carriers-main.exit). The target pair is clean in the script's own words: "PR #18212 / card #18202 — the clause-② declaration is readable in the fixed spelling and both carriers agree". The 4 is carried entirely by the pair the script derives from Part of #14122 (C1: label on the PR, absent on #14122; C2-SIBLING: no Claim: line on #14122, declaration readable on sibling #18202). #14122 is labelled tracking, never dispatched; the recorded ruling — a tracker named only by Part of is not a clause-② carrier — applies, and mechanism card #18214 is open. Note for whoever runs the pre-landing check next: #18243 (now on origin/main) rebinds only H31's population in check-half-states.mjs; this script's own derivePairs still derives the Part of pair, so the residual persists at the current main and is still not a reason to hold. The C2-SIBLING caveat discharges: the PR Fixes exactly one card and Part-of PR must not also close its card is green.

Flag 3 — is the gate correctly re-hung on both carriers? Read from the label events, not the note: PR #18212 unlabeled needs:contract-review 03:38:14Z, labeled 03:40:20Z; card #18202 unlabeled 03:38:16Z, labeled 03:40:22Z; both by os-elon-musk, and both carriers carry needs:contract-review now. The two removals two seconds apart are the script's own signature of a clear rather than a strip, and the two re-adds two seconds apart are a dual-carrier hang in one stroke. Correctly corrected. 5674361474 remains a true record about 19db79b67 and is not a record about this head; this record is.

Flag 4 — DefineStackOptions.strict docstring still does not point at the composition-time refusal. Head lines 1431–1442 are the main-side text unchanged. Rated recommended in rounds 2 and 3; unchanged here, carried, not blocking.

Flag 5 — #18239 (mergeObjects bare TypeError, pre-existing). Untouched by this PR (0 hits on mergeObjects in the diff, 0 Array.isArray inside it on the head) and untouched by the merge (stack.zod.ts identical merge-base ↔ main side). Correct.

Flag 6 — the PR body's stale process lines (① item 10). Recommended: one PATCH replacing "this branch is 12 commits behind origin/main … deliberately not merged forward" with the fact (merged forward at 0424329a, parent 2 75237a9a) and dating the Verification table to the head it was run on. Not owed for PASS; the shipped surface is clean.

Flag 7 — docs-drift on this head. The bot's run on the merge 35ff00c5 lists the same two hand-written pages and three release-owned pages as the first run; the decisions recorded in the body stand on this tree (examples.mdx carries the bullet, glossary.mdx and content/docs/releases/v17/* are untouched on both sides).

Implemented-by: claude/issue-18202-crossref-dependency-aware
Reviewed-by: session_01T3YsvpK1PvYf9n1YUhYP6W

VERDICT: PASS


Generated by Claude Code

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

Projects

None yet

2 participants