Skip to content

fix(objectql,lint)!: a conditional rule's when reads one hop through a reference, hydrated like a condition - #24650

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24512-conditional-when-hydration
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24512-conditional-when-hydration

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24512

Clause-②: no (narrowing) (the save door newly refuses a when that reads a second hop or an undeclared related column, which today saves and then refuses every write it guards; a narrowing owes one CONTRACT_REVIEW_TIER review of record before the queue)

Dispatched by the domain:engine seat 2 PM (claim 6275835421, session_01AHeUBbe6S3JRawcpbLejXD). The developer ran as that session's subagent. The route is triage 6265543836's: hydrate when.

What changes

Engine (packages/objectql, validation/rule-validator.ts). No change in engine.ts: the engine's related read already takes its input from the collector below.

  • collectPredicateRelationships now collects a conditional rule's when, at every depth, beside the rules it wraps. So the engine preloads a when's hop through the same loader as a condition's. It uses the same system authority, the same projection, and PR fix(lint,objectql)!: a validation rule reading a field its lookup target does not declare is refused at save, and no longer leaves its sibling rules without a verdict #24508's per-rule handling of undeclared columns.
  • checkConditional evaluates when on the scope resolveTraversalScope decides, the function checkPredicate calls. The hydrated copy belongs to the when alone. The branch gets the untouched context and decides its own scope, so a branch comparing the stored id still sees the id.
  • The scope can refuse a when: a second hop, a reference read both through it and as a value, an undeclared related column, or an empty, unresolved or unreadable reference. That refusal goes through one new builder, traversalScopeRefusal, which checkPredicate now uses too. It answers code: 'rule_violation' with constraint: { rule, reason: 'unevaluable', fault }. checkPredicate's message and log line are byte-identical to before.
  • A when that faults on a delete's reference cleanup gets referentialClearRefusal's text, as a condition does.

Lint (packages/lint, validate-expressions.ts). This is the cross-domain rider the claim declares to domain:spec.

  • The top-level when and every nested predicate pass traversalHydration. The existing hop checks now judge a when: second hop, both-ways read, stored-metadata-body hop, and undeclared hop column. They run under the existing EXPRESSION_INVALID rule and are located at the when slot. No new rule, code or message.
  • RulePredicate.slot lost its only reader and is removed.

Measurement

Write time

The fixture is the real ObjectQL engine over an in-memory driver double. A conditional has when W, then and otherwise rules that both refuse an amount over 10, and the insert has amount: 50. So "then" or "otherwise" names the branch that was judged.

  • Before: rule-validator.ts restored from 1e8b6284c (this PR's base); engine.ts is unchanged by this PR.
  • After: this branch.
  • The probe was a one-off file. It is not committed.
W empty reference, before empty reference, after partner account, before partner account, after
record.account.type == 'partner' refused, No such key: type refused, "no single related record" (names the guard) refused, No such key: type then
record.account != null && record.account.type == 'partner' otherwise refused, both-ways read refused, No such key: type refused, both-ways read
record.account.?type.orValue('') == 'partner' otherwise refused, "no single related record" otherwise (wrong branch, silently) then
has(record.account.type) && record.account.type == 'partner' otherwise refused, "no single related record" otherwise (wrong branch, silently) then
record.account.owner.region == 'x' refused, No such key: owner refused, more than one hop refused, No such key: owner refused, more than one hop
record.account.region == 'x' (undeclared) refused, No such key: region refused, 'crm_account' declares no 'region' refused, No such key: region refused, 'crm_account' declares no 'region'
record.account != null (the guard) otherwise otherwise then then

The "before" refusal text for every row was "The when-predicate reads 'type', which this object does not declare — fix the rule's condition, or declare the field". That sentence points the author at the wrong object.

The runtime narrowing. Rows 2 to 4, empty-reference column: a write that otherwise judged before is refused now. The when is judged as a condition reading through an empty reference has been judged since hydration landed for conditions: the verdict does not depend on which CEL operator is written. The refusal names the guard, which is a separate when: record.account != null with this rule nested as its then. This was not in the claim's measurement assumptions; see the deviations.

Save door and build

These run validateStackExpressions, the pass os validate, os build and the object save door run. The fixtures pass ObjectSchema / ObjectStackSchema validation.

when before (1e8b6284c) after
record.account.region == 'north' (target declares no region) 0 errors 1 EXPRESSION_INVALID at validation 'gate' when, "unknown field region on hop_account"
record.account.owner.email == 'x' 0 errors 1, "read through more than one relationship hop"
record.account != null && record.account.status == 'active' 0 errors 1, "read BOTH through the relationship", naming the guard
record.account.status == 'active' (declared) 0 errors 0 errors
record.account != null (the guard) 0 errors 0 errors

The "before" column is the new pins run on the unfixed tree: three red, with expected [] to deeply equal [ "object 'hop_order' · validation 'gate' when" ]. The stored-metadata-body hop takes the same opt-in and is not separately pinned.

Exposure in the shipped tree. Measured at 1e8b6284c with git grep outside tests: zero when reads through a reference. The control is the same regex with condition in place of when, which finds 8 lines, among them examples/app-showcase/src/data/objects/invoice.object.ts:191. The only when in examples/ is record.status == 'churned'.

Pins

  • packages/objectql/src/validation/rule-relationship-traversal.test.ts, [#24512] block, on the evaluator:
    • the collector names a top-level and a nested when hop, and nothing for the guard spelling;
    • then and otherwise are each picked on the related row, at the top level and nested;
    • the branch keeps the stored id, and the payload is not mutated;
    • the guard control;
    • a second hop and an undeclared column are refused (code + constraint.rule / reason / fault) while a sibling keeps its own verdict;
    • an empty reference names the guard;
    • a delete's cleanup is named.
  • packages/objectql/src/engine-predicate-relationship.test.ts, [#24512] block, on the real engine:
    • both branches on real related rows, with the VALIDATION_FAILED envelope and per-field code;
    • one system read projecting ['id', 'type'];
    • a sibling when reading an undeclared column refuses alone and never enters the read.
  • packages/lint/src/validate-expressions.rule-one-hop.test.ts, [#24512] block, on the build: the three refusals at the top-level and nested when slots, the declared read admitted, and the guard control.
  • packages/lint/src/runtime-gate.object-validation-writes.test.ts:
    • The #22042 per-slot pin used to assert the opt-out ("OFF for a nested when"). It is rewritten to assert the opt-in at both slots, top level and nested.
    • A new #24512 block at the save door: a target from another package, a nested when, and the control, all asserting rule: EXPRESSION_INVALID.

Ablations

Each leg went through scripts/ablation-replace.mjs. The anchor hit once and the mutation landed (blob changed). The file was restored to the HEAD blob with git diff HEAD empty. The subjects are imported by relative path from src/, so no dist/ is involved.

  • A — addFrom(r.when) deleted from the collector: 5 red, the two collector pins and the three engine pins. Pin (a) on the real engine shows the card's defect: Validation rule 'partner_gate' could not be evaluated (runtime: No such key: type).
  • B — checkConditional evaluating ctx.merged instead of the hydrated copy: 6 red (both branch pins, nested, branch-isolation, engine branch pin, engine sibling pin).
  • C — the when scope refusal replaced by return null: 4 red (second hop, undeclared, empty reference, engine sibling).
  • D — the when cleanup text replaced by null: 1 red (the cleanup pin).
  • E1 / E2 — the lint opt-in reverted at the top-level when / the nested when: 5 red / 4 red.

Verification

Final head a8614e4116, a merge of origin/main 14ccb1da9. The incoming commits are service-automation and a spec/ui export, and they touch none of this diff's files.

  • pnpm install --frozen-lockfile: exit 0. pnpm turbo run build --concurrency=2: 77/77 tasks, exit 0.
  • pnpm --filter @objectstack/objectql exec vitest run --project local: 447 files, 8432 tests passed, exit 0.
  • pnpm --filter @objectstack/lint exec vitest run: 148 files, 6932 tests passed, exit 0.
  • typecheck: objectql exit 0 and lint exit 0. Both include their check:test-typecheck leg.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 70 commands. All 70 ran on a8614e4116 and exited 0. --ran reports 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN.
  • An earlier battery on e5a2eecb0 had three non-zero exits, each resolved before the final run:
    • check:error-code-casing (exit 1): the new test literals asserted code: 'rule_violation' without the field key that marks them field-addressed. Fixed in 9b80c99f6.
    • check:engine-split-ratio (exit 2): the shallow-clone refusal. Deepened with git fetch --shallow-since=2026-07-14.
    • check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET): missing dists. Ran after the full build.
  • test:repo was not run locally. It is CI-owned, and this diff touches no repo-project test.

Changeset

.changeset/24512-conditional-when-hydration.md:

  • @objectstack/objectql minor and @objectstack/lint minor;
  • fix(objectql,lint)!:, BREAKING (an accept-set narrowing at the save door and at write time), Clause-②: no (narrowing);
  • ADR-0087 marker not-required (no-migration-prescription), accepted by check-adr-0087-registration;
  • FROM / TO lines for each package and a "What to do".

Deviations from the dispatch

  • @objectstack/objectql is minor BREAKING, not patch. The write-time probe above measured a narrowing on that face. A write on an empty reference under a null-safe when (.?, has(), or an inline != null test of the same reference) was judged by otherwise before. Now it is refused. Precedent for an objectql write-time narrowing: the 8d8f965 entry, Clause-②: no (narrowing), minor.
  • The triage premise holds in part. "Hydration admits nothing new at the door, and it makes a write that is refused today evaluate" is true for the plain spelling. It is not the whole effect:
    • the null-safe spellings silently took the wrong branch on every set reference before (now corrected);
    • they took otherwise on an empty reference (now refused, as above).
  • Rulings kept. The route is unchanged: hydrate when as checkPredicate hydrates a condition, with the same helpers and no new resolver.
  • No dogfood pin. A booted pin is outside the claim's file surface (packages/qa/dogfood is domain:cli). The envelope is asserted at the engine (code: 'VALIDATION_FAILED' plus per-field code and constraint) and at the save-door pass (rule: 'expression-invalid'). The HTTP statuses (400 on the data door, 422 INVALID_METADATA on the save door) are the existing mappings and were not re-measured on a booted stack here.

Acceptance notes

  • A when now follows a condition's rules for an empty reference. No single-expression spelling skips a rule while the reference is empty. The guard is its own when, as the refusal says. This is the platform's existing stance for conditions, now applied to when.
  • packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts carries no when-hop pin. The save-door pass is pinned in lint's runtime-gate test, which is the body the door runs. Not added here.
  • pnpm --filter @objectstack/core test:repo was not run. packages/core's per-file census already has rows for both edited source files, and CI's Test Core runs it.

Generated by Claude Code

…a reference, hydrated like a condition

WIP: engine hydrates when (collector + checkConditional), lint opts when
into traversalHydration; pins in objectql and lint.

Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
…breaking narrowing)

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via rule_violation (literal, a string literal in checkPredicate; a string literal in traversalScopeRefusal))
  • content/docs/data-modeling/validation.mdx (via rule_violation (literal, a string literal in checkPredicate; a string literal in traversalScopeRefusal))
  • content/docs/protocol/kernel/error-handling.mdx (via rule_violation (literal, a string literal in checkPredicate; a string literal in traversalScopeRefusal))

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

  • content/docs/releases/v17/17-5.mdx (via rule_violation (literal, a string literal in checkPredicate; a string literal in traversalScopeRefusal))

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 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 21 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 f89d29e382e7d50cfe1b2a98742ac1c3d436cd26 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT (seat half) · domain:engine seat 2 · session_01AHeUBbe6S3JRawcpbLejXD · 2026-10-19T21:15Z

Reviewed against GitHub on head a8614e4116, not against the report. This is the seat's half. The code is the narrowing face, whose floor is every non-prose path of the diff, so it is judged by the CONTRACT_REVIEW_TIER review now running. needs:contract-review is set on this PR until that record is posted.

  • Form: draft into main. The body's first line is Fixes #24512, and Clause-②: no (narrowing) sits at a line start (the claim's line, verbatim). A closing-keyword scan of the whole body finds one hit, Fixes #24512.
  • Scope: 7 files, +498 / −55, all inside claim 6275835421's file surface. engine.ts is untouched. check-governed-merges --pr 24650: not governed. The packages/lint half is declared to domain:spec (6277435434).
  • Changeset (@objectstack/objectql minor, @objectstack/lint minor, both BREAKING), checked against the diff and the body's two measurement tables:
    • the objectql FROM → TO lines are the write-time table's rows: the plain spelling now judged, the null-safe spellings judged on the row, the empty-reference refusal naming the guard, and the second-hop / undeclared / both-ways sentences;
    • the lint FROM → TO lines are the save-door table's three refusals plus the stored-metadata-body hop;
    • "What to do" gives the nested-guard and required: true prescriptions the refusal texts name;
    • the ADR-0087 marker reads not-required (no-migration-prescription), which holds: no stored shape or authorable spelling moves.
  • The report's open question (objectql patch or minor BREAKING): A, keep minor BREAKING. The write-time narrowing is measured (rows 2–4, empty-reference column), and Clause-② exists to make exactly that visible. C (special-casing when) would leave when and condition with two verdicts for one read, against triage's route "as checkPredicate does".
  • Deviations accepted: the objectql grade moved from the claim's planned patch; the #22042 lint pin was rewritten because it pinned the removed opt-out; and RulePredicate.slot was removed with its only reader. Whether that member is reachable from @objectstack/lint's entry is one of the contract review's ① items.
  • Triage's premise is falsified by measurement. "Hydration admits nothing new at the door" does not hold: both halves narrow. Under the standing pull-forward rule (6261447528), the question is put on A conditional validation rule's when that reads through a lookup (record.account.status) saves at every door, but checkConditional evaluates it unhydrated, so every write the rule guards is refused as unevaluable #24512 (6277452230), and the card is in the decision box.
  • Landing conditions: a CONTRACT_REVIEW_TIER PASS record on the then-current head; the maintainer's answer recorded on A conditional validation rule's when that reads through a lookup (record.account.status) saves at every door, but checkConditional evaluates it unhydrated, so every write the rule guards is refused as unevaluable #24512 (A: land into 18.0; B: park); and every check green. At this reading 10 of the 38 check-runs are in progress, and none is red.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a8614e4116d9b89950a393023a7d44f1d4a96b79
Local-runs: none
Scope: packages/objectql/src/validation/rule-validator.ts, packages/objectql/src/validation/rule-relationship-traversal.test.ts, packages/objectql/src/engine-predicate-relationship.test.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/validate-expressions.rule-one-hop.test.ts, packages/lint/src/runtime-gate.object-validation-writes.test.ts + packages/objectql/src/engine.ts (resolvePredicateRelated, the collector's consumer), packages/formula/src/relationship-traversal.ts (the analyzer behind resolveTraversalScope), packages/lint/src/index.ts and packages/lint/package.json (export reachability), packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts (the protocol half of the rewritten pin), scripts/check-adr-0087-registration.mjs and docs/adr/0087-metadata-protocol-upgrade-contract.md (the disposition and the level), docs/adr/0137-predicate-fault-semantics-are-contract.md (D1); the changeset .changeset/24512-conditional-when-hydration.md is the prose face, judged under ② only

① Derived judgments

Face and inputs. Clause-②: no (narrowing) is on the PR body (line 2) and in the changeset body; the file list holds no published schema and no governed path, so this is the narrowing face and every non-prose path is the floor. The GitHub net diff is byte-identical to git diff 14ccb1da98..a8614e4116 (the merge base), 7 files, +498/-55. Nothing was built, run or re-run locally.

  1. Save-door narrowing (lint when opt-in): right. check(...) at the top-level when and at every nested predicate now passes traversalHydration (the sixth positional, verified at the signature), so the existing hop checks judge a when: a second hop, a both-ways read, an undeclared hop column and a stored-metadata-body hop. No new rule id, code or message: EXPRESSION_INVALID throughout, located at the when slot (top level validation 'gate' when, nested ... then → 'gate' when-predicate, both label styles pre-existing). This is the right accept set because the engine now evaluates when on resolveTraversalScope, which refuses exactly those shapes, so the door and the engine agree per predicate (ADR-0137 D1 is a statement about the engine; the lint mirrors it). The declared one-hop read and the guard spelling record.account != null stay admitted, pinned as controls at the build pass and at runRuntimeAuthoringRules.
  2. Write-time narrowing: right, and reached in code, not only in the probe. A null-safe when (.?, has(), [?]) over an EMPTY reference was judged by otherwise and is refused now. packages/formula/src/relationship-traversal.ts treats .?, has(...) and [?] as ordinary member reads by design (its docblock: the verdict cannot depend on which operator was written), so record.account.?type is a traversal of account naming type; with the engine's no-reference binding, resolveTraversalScope refuses before evaluation and the sentence names the guard. The inline record.account != null && record.account.type == ... form is the both-ways conflict, refused on a set and an empty reference alike, judged before any row. Committed pins cover the plain spelling's empty-reference refusal (unavailable('no-reference')), the second hop, the undeclared column and the both-ways read; the .? and has() rows of the PR table rest on the dev's uncommitted probe plus the analyzer's documented rule, and that reading holds. The guard spelling keeps reading the stored id: the collector names nothing for it (pinned), so no binding is sought and the record is left untouched.
  3. No public member moved. No export line is added or removed anywhere in the diff. RulePredicate is a non-exported interface in validate-expressions.ts, rulePredicates is module-private, neither is named in packages/lint/src/index.ts, in packages/lint/package.json or in any api-surface baseline at the head, and the only reader of .slot at the base was the p.slot === 'condition' branch this diff removes. The removal of RulePredicate.slot is therefore a private edit, not a public-surface change. traversalScopeRefusal is module-private. The error codes (VALIDATION_FAILED, rule_violation), the constraint shape (rule, reason: 'unevaluable', fault), the _record field for a when refusal and the rule id EXPRESSION_INVALID all pre-exist.
  4. checkPredicate byte-identical: right. The removed inline block and traversalScopeRefusal(rule.name, field, scope, 'predicate', logger) produce the same log line (Validation rule '...' predicate could not be evaluated (...) — write rejected) and the same { field, code, message, constraint }; field is still rule.fields?.[0] ?? '_record'. The one builder serves both slots so the two cannot drift apart.
  5. engine.ts untouched: right. resolvePredicateRelated (engine.ts:10269) consumes collectPredicateRelationships(schema) wholesale, so a when's hop takes the same loader, system authority, projection and the per-rule undeclared-column isolation. addFrom(r.when) is the same dialect-checked path a condition takes, and the depth cap is unchanged. Pinned on the real engine: one system read projecting ['id', 'type']; a sibling when naming an undeclared column refuses alone and never enters the read; both branches pick on real related rows with the VALIDATION_FAILED envelope.
  6. Hydration is per predicate: right. The branch is handed ctx untouched (evaluateRule(branch, ctx)), so a branch comparing the bare id keeps seeing it and the payload is not mutated. Pinned.
  7. Referential-clear parity: right. referentialClearBinding returns an empty binding carrying the cause, so resolveTraversalScope leaves the record alone, CEL faults on the bare null, and referentialClearRefusal names the delete, with the same argument order and the same post-scope record as checkPredicate. Text only; the verdict and the machine-readable half are the generic ones. Pinned.
  8. The rewritten #22042 pin is honest. The HYDRATION fixture's nested when at otherwise → 'mid' IS MULTI_HOP, so the second finding the pin now expects is a real refusal, not a masked spurious one; the branch it used to pin (OFF for a nested when) is exactly what the ruled route retires. No other test at the head pins the opt-out, and no docs prose at the head still describes when as unhydrated.
  9. Stale text repaired. Every comment that said when is evaluated WITHOUT hydration is rewritten at both lint sites and in the collector's docblock; the check() parameter docblock names checkConditional beside checkPredicate.

② Semver level

  • Both bumped packages publish (private unset on @objectstack/objectql and @objectstack/lint, both 18.0.0-next.5), so a changeset is owed and skip-changeset would be wrong. Right.
  • Clause-②: no (narrowing) is on the PR body and in the changeset body, the same arm on both. (narrowing) is BREAKING; under ADR-0087's amended level rule (pre-GA, a break ships minor, carrying the **BREAKING** banner and its disposition, and an npm major is never a side effect of one card) minor is the correct level for both packages. The title carries the bang (fix(objectql,lint)!:) and the body carries the banner. The 8d8f965 entry (an objectql write-time narrowing, minor, the same arm, the same disposition) is the matching precedent the dev cites, and it reads the same way.
  • Migration text: FROM → TO exists for @objectstack/objectql (four write-time rows plus the guard control) and for @objectstack/lint (four save-door rows), and a "What to do" paragraph prescribes the nesting guard, required: true, a declared field and one hop. Checked against the code and the pins: the objectql rows match resolveTraversalScope's no-reference and both-ways arms and the analyzer's optional-form rule; the lint rows match the four traversalHydration checks. 400 VALIDATION_FAILED and 422 INVALID_METADATA are the existing door mappings; they were not re-measured on a booted stack (the dev says so), while the pass itself is pinned at runRuntimeAuthoringRules and the engine envelope on the real engine. Accurate.
  • ADR-0087 disposition: exactly one marker, not-required (no-migration-prescription). Right: no spec key, export, stored shape or authorable spelling is added, removed or renamed; a rule keeps its authored shape; the "What to do" is an authoring prescription for a newly refused rule, not a migrate meta rewrite. The gate ran on this head inside Check Changeset (success).

③ Boundary flags

  • Open question (objectql minor BREAKING vs patch; options A, B, C): answered, A. The narrowing is measured and is reached in code (①.2). B under-declares a write-time narrowing the Clause-② arm exists to make visible. C would make a when's verdict depend on the CEL operator written, the exact property relationship-traversal.ts is built to deny, and it departs from the triage route "as checkPredicate does". No escalation is needed; this record adopts A.
  • Deviation "triage premise holds in part": accepted. The door admits nothing new (true as stated); what the triage did not anticipate is the write-time effect on null-safe spellings, which the claim's own changeset line foresaw ("BREAKING if ... measured") and the dev measured and declared.
  • Deviation "referentialClearRefusal extended to when": text-only parity, judged ①.7.
  • Deviation "RulePredicate.slot removed": private, judged ①.3.
  • Deviation "#22042 pin rewritten": judged ①.8.
  • Deviation "no booted dogfood pin; HTTP statuses not re-measured": accepted as a reservation, not a FAIL reason. packages/qa/dogfood is outside the claim's file surface, the statuses are the existing mappings, and the engine envelope and the save-door pass are pinned at their own layers.
  • Deviations "merged origin/main 14ccb1d before opening" and "shared clone deepened for check:engine-split-ratio": procedural and additive; the net diff is the 7 files above and nothing from the merge rides in it.
  • Cross-domain rider: the lint half was declared to domain:spec in the claim (6275835421), as the objectql(validation): one validation rule reading a field its lookup's target does not declare leaves every rule through that lookup without a verdict, so each write refuses with the other rule's message; the save door does not judge a rule's hop column #24468 precedent requires; nothing further is owed here.
  • out_of_scope_findings is empty; nothing to file.
  • Check-runs on this head: 44 check-runs on this head, every one completed: 35 success, 9 skipped (Build Docs, Console Pin Gate, the dogfood matrix placeholder row, Dogfood Verify CLI, two Packed-tarball smoke opt-ins, Temporal Conformance on live PG and MySQL, and the later re-run instances of Auto Label and Check PR Size whose first instances succeeded), 0 failure, 0 cancelled. Build Core, Test Core 1 to 6, Type Check (workspace, consumer gates, source gates, debt ledger), Check Changeset, Governed Surface Queue Guard, every Lint job, Dogfood Regression Gate and the card-claim and single-writer-path checks are green. Their conclusions are the gate verdicts this record adopts; nothing was re-run.
  • Landing is not this record's act. The PR is draft with mergeable_state: blocked, the correct resting state for a narrowing awaiting this record; the owning seat lands it through the queue once every check is green.

Implemented-by: claude/issue-24512-conditional-when-hydration
Reviewed-by: session_01AHeUBbe6S3JRawcpbLejXD

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 22:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 22:37
@objectstack-fleet
objectstack-fleet Bot disabled auto-merge October 19, 2026 22:38
@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft October 19, 2026 22:38
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 00:39
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 00:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 073b1b2 Oct 20, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24512-conditional-when-hydration branch October 20, 2026 01:41
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