Skip to content

fix(lint)!: a conditional validation rule's nested then / otherwise predicate meets the build's expression verdict, at os build and the object save door (#22042) - #22127

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22042-nested-validation-predicate-verdict
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22042-nested-validation-predicate-verdict

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22042
Clause-②: no (narrowing)

A conditional validation rule's nested then / otherwise predicates now meet the same validateExpression verdict as the rule's own condition / when. That holds in os build (validateStackExpressions), and so at the object save door, which has given the build's verdict for validation predicates since #22032 pass 1 (PR #22041).

What changes

  • packages/lint/src/validate-expressions.ts, the validation-rule loop.
    • rulePredicates now tags each predicate with its slot (condition or when) and its depth (0 is the rule itself). Its labels are unchanged.
    • The two top-level check() calls stay as they were, with the same location and flags. The loop adds one check() for each nested yield (depth 1 and below) and skips depth 0, so no predicate is judged twice.
    • A nested finding is located at the label rulePredicates already builds, which is the location the null-guard gate already gives that predicate: object 'OBJECT' · validation rule 'OUTER' then → 'INNER'. A nested when appends when-predicate. The top-level findings keep object 'OBJECT' · validation 'NAME'.
    • traversalHydration follows the evaluator per slot, as at the top level. It is on for a nested condition and off for a nested when (H2 below).
    • The new findings are emitted after the two top-level calls and before the null-guard loop. So the order of every existing finding is unchanged.
  • Tests: 7 lint-level pins (build and door) and 6 pins through the real saveMetaItem / publishMetaItem.
  • Changeset: .changeset/22042-nested-validation-predicate-verdict.md sets @objectstack/lint and @objectstack/metadata-protocol to minor. It carries fix(lint)!, Clause-②: no (narrowing), a BREAKING section, the remedy, and the ADR-0087 disposition not-required (no-migration-prescription).

Nothing else moves. There is no registry change in authoring-rules.ts and no change in runtime-gate.ts.

Measured before the change (the dispatch's H1–H6), at base 54ace18c6

  • H1, confirmed with positions re-read on this base.
    • rulePredicates is at :489 and recurses into then / otherwise at :507.
    • The top-level calls are check(where, rule.condition, …, true) at :1891 and check(`${where} when`, …) at :1896.
    • The rulePredicates loop is at :1899 and fed checkNullGuards alone.
    • At head 36ea1e8f8 these are :499, :1904, :1909, and :1924 (the new nested check() loop) / :1930 (the null-guard loop).
  • H2, hydration per nested slot: confirmed by code read plus an existing pin.
    • ObjectQL's checkConditional (rule-validator.ts:4226) evaluates its when against ctx.merged, with no resolveTraversalScope. It then hands the chosen branch to evaluateRule(branch, ctx) (:3469).
    • evaluateRule sends a script / cross_field branch to checkPredicate(rule, ctx.merged, …, ctx.related, ctx.fields). That is the same call, with the same related binding, as a top-level rule.
    • collectPredicateRelationships (:530) is what preloads related, and it recurses into a conditional's then / otherwise (its visit). The objectql pin reaches a predicate nested inside a conditional (rule-relationship-traversal.test.ts:86) covers that.
    • So a nested condition is hydrated, and the traversal checks are ON there. A nested when is evaluated by checkConditional without hydration, so they are OFF, as at the top-level when site.
  • H3, location. The rulePredicates label is used (triage: "with the label it already builds"). The door's 422 and runAuthoringRules('build', …) give it identically: rule, where, path, message and hint are compared key by key in the protocol (d) pin.
  • H4, no double report. Pinned: a top-level condition gives exactly one finding, at validation 'NAME' only. A faulting top-level when beside a faulting nested then gives exactly two findings, one at each location.
  • H5, no registry or runtime-gate.ts change was needed. runtimeAuthoringRulesFor('object') already lists validateStackExpressions (pinned), and the loop is not fenced on an object write. The protocol pins below reach the door through the built @objectstack/lint dist/ with no other edit.
  • H6, corpus first. The stop condition was not met.
    • The corpus is every *.object.ts under packages/** and examples/**, plus the two app-multi-package sub-stacks: 111 files, 18 groups, 118 objects.
    • It carries 21 validation rules, of which 1 is conditional. One rule carries nested predicates: examples/app-showcase showcase_account.churn_reason_consistency, with 2 nested conditions.
    • The new check was run on the base build by lifting each nested predicate to the top level: 0 errors, 0 warnings.
    • At head, the nested locations gave 0 errors and 0 warnings at the build (raw and ObjectSchema.parsed shapes, and through runAuthoringRules('build')). The object door gave 0 errors and 0 advisories.
    • Positive control, the card's body: 0 build and 0 door errors at base, 2 build and 2 door errors at head.
    • A repo-wide git grep for a conditional rule outside tests finds only that showcase rule and spec / skill doc examples. Those carry no object, so they are not a stored corpus. The sibling objectui checkout at 9990f9e has none.

Tests (all at head 36ea1e8f8)

  • @objectstack/lint pnpm test: Test Files 123 passed (123), Tests 5685 passed (5685).
    • typecheck exit 0, including check:test-typecheck (2 file(s) / 6 error(s) … held in test-typecheck-debt.json, unchanged).
  • @objectstack/metadata-protocol pnpm test: Test Files 221 passed | 3 skipped (224), Tests 28245 passed | 19 skipped (28264). typecheck exit 0.
    • tsc --listFilesOnly puts each touched test file inside its program: lint's tsconfig.test.json and metadata-protocol's tsconfig.json.
  • Consumers, against a rebuilt @objectstack/cli... and @objectstack/objectql... closure (59 tasks, 11 cached):
    • @objectstack/objectql save-meta-response-conformance, publish-meta-response-conformance, publish-package-drafts-response-conformance and engine-predicate-relationship: 4 files, 80 tests passed.
    • @objectstack/cli authoring-rule-command-parity, validate-field-predicate-traversal and verify-author-time-stage: 3 files, 16 passed.
    • The three cli *.e2e.test.ts files that read expression-invalid ran under OS_TEST_TIERS=nightly: 3 files, 34 passed.
  • New pins.
    • In packages/lint/src/runtime-gate.object-validation-writes.test.ts:
      • the fixtures are spec-valid (ObjectSchema.safeParse green);
      • LIT: build, then sqrt(record.amount) > 1 and otherwise amont > 1;
      • LIT: the door equals the build;
      • LIT: two levels, a nested conditional's when plus a rule below it;
      • CONTROL: valid nested predicates;
      • judged ONCE;
      • the traversal checks per slot;
      • the differential.
    • In packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts:
      • (a) three active-save refusals, each a 422 INVALID_METADATA whose code, status, path and where name the nested rule;
      • (a) the draft promotion;
      • (b) a valid nested rule saves active;
      • (d) door equals build, key by key.

Ablation (one-off, from committed head b658c1a52, with trap restore)

  • The fix. scripts/ablation-replace.mjs turned if (p.depth === 0) continue; into const ablation22042 = true; if (ablation22042 || p.depth === 0) continue;.
    • The anchor went from 1 to 0, and the blob went from c9e828b47cee to 63a185602ce9.
    • Lint was rebuilt, and ablation-dist-preflight found the marker in 4 built files.
    • Lint door file: 5 failed and 11 passed. Red were the 3 LIT pins, "judged ONCE" and "traversal per slot". Green were the fixtures, CONTROL, the differential and the 9 earlier pins.
    • Protocol file (dist-mediated): 5 failed and 87 passed. Red were the 3 (a) saves, the promotion and (d). Green was (b).
    • Restore: the blob equals HEAD c9e828b47cee, and git diff HEAD is empty. After a rebuild, preflight --absent found the marker absent from all 14 built files, with a clean tree. Back to green: 16/16 and 92/92.
  • The hydration flag, both directions, on lint's source-run suite. p.slot === 'condition' was set to true and then to false. Each time exactly 1 test failed and 15 passed: "the traversal checks follow the evaluator per slot". Each restore was proven by blob equality.

Gates (at head 36ea1e8f8)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands. Each ran with its exit code captured before any pipe, and all 63 ended at exit 0.
    • One needed a re-run: check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: some packages had no dist/). After turbo run build (72 tasks, 71 cached) it exited 0.
  • --ran reconciliation: "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN", exit 0.
  • Changeset gates: check-adr-0087-registration --base origin/main exit 0 (not-required (no-migration-prescription) accepted), check-changeset-no-major --base origin/main exit 0, and check-empty-changeset --base origin/main exit 0.
  • ESLint, narrowed and measured. eslint --no-inline-config --format json over the 3 touched TS files gave 3 files, 0 errors and 0 warnings, with none ignored. --print-config matches each file, and the config enables no type-aware linting (parserOptions.project and projectService unset). So this diff cannot move an untouched file's verdict.
  • CI runs the full farm and is not awaited here.

Acceptance notes


Generated by Claude Code

claude added 3 commits October 7, 2026 23:15
…dicates meet the build's expression verdict

The validation-rule pass called check() on a rule's own condition and when
only; the rulePredicates recursion into then / otherwise fed the null-guard
gate alone, so an unregistered function or a bare field one level down passed
os build and, since the object save door gives the build's verdict, saved.

Every nested predicate rulePredicates yields (depth >= 1) now meets check(),
located at the label it already builds, with the traversal checks on a nested
condition (ObjectQL hydrates it) and off on a nested when (checkConditional
does not). The rule's own condition / when keep their calls and locations.

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…rdict at the object save door

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

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 4 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 — 4 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 51290bca2cde5ed8befa3e8ee487ad56b3b88e58 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 51290bca2cde5ed8befa3e8ee487ad56b3b88e58

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 36ea1e8f877f59744c7d2f4f9dbdf16e8150840a
Local-runs: none

Reviewed read-only against card #22042 (body and all 5 comments, triage grade 6027686616 included), PR #22127 (body, 4-file list, its one comment, the net diff against main at the head; 3 commits over merge-base 54ace18c6, +392/−9) and the head's check-runs. The PR head resolved to the sha above at both reads and had not moved. Source read at the head for the judgments below: packages/lint/src/validate-expressions.ts, packages/lint/src/authoring-rules.ts, packages/objectql/src/validation/rule-validator.ts and its rule-relationship-traversal.test.ts, packages/cli/test/authoring-rule-command-parity.test.ts, content/docs/data-modeling/formulas.mdx, both package manifests and .changeset/config.json.

Check-runs on the head. First read 2026-10-08T00:28:34Z: 35 runs, 16 success, 13 in_progress, 6 skipped, 0 failed. Re-read 2026-10-08T00:34:35Z: 36 runs, 23 success, 7 in_progress (Test Core 1/6, 2/6, 3/6, 4/6, 6/6; Lint & Repo Gates; Type Check · workspace), 6 skipped, 0 failed. Green at the re-read include Build Core, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (rollup and 3/3), Dogfood Verify CLI, Governed Surface Queue Guard, Check Changeset (both runs), Check PR Size, Type Check · source gates / debt ledger / consumer gates, and the four claim / closing-target guards. No gate verdict is red; the verdict below is on content, and landing pre-check ② (every check green) is the seat's to re-read before pr_ready.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. validateStackExpressions narrows — nested condition predicates. The validation-rule loop now calls check() on every rulePredicates yield at depth 1 and below, so a conditional rule's then / otherwise script / cross_field condition meets validateExpression: errors (unknown function, undeclared field, bare reference, syntax) and warnings. The registry entry is commands: ALL (AUTHORING_COMMANDS = ['validate', 'build', 'lint']), so the changeset's "os build, os validate and os lint" is the registry's own fact. RIGHT — triage's direction verbatim ("run check() on every predicate rulePredicates yields").
  2. Nested when predicates judged, traversal hydration OFF. A conditional nested inside a branch has its when checked with traversalHydration unset. Verified at the head: checkConditional (rule-validator.ts) evaluates rule.when with ExpressionEngine.evaluate over { record: ctx.merged, previous } — no related, no resolveTraversalScope — so nothing hydrates a when at any depth, exactly as at the top-level when site. RIGHT, and the direction's "opts out as the when site does if they do not" is met.
  3. Nested condition predicates judged with the relationship-traversal checks ON. Verified: checkConditional hands the chosen branch to evaluateRule, which sends script / cross_field to checkPredicate(…, ctx.related, ctx.fields) — the top-level call — and collectPredicateRelationships.visit recurses into a conditional's then / otherwise, so the hop is preloaded (objectql pin reaches a predicate nested inside a conditional). RIGHT within the collector's depth cap; the cap itself is ③-1.
  4. The object save door narrows by derivation. No authoring-rules.ts or runtime-gate.ts change (H5 holds on the file list): the entry already carries object in runtimeTypes, and the validation-rule loop is admitted on an object write since finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032 pass 1, so publish-mode saveMetaItem and publishMetaItem (REST PUT /api/v1/meta/object/:name, POST …/publish) now answer 422 INVALID_METADATA with an expression-invalid issue at the nested rule. Pinned through the real protocol: three (a) saves asserting status, code, path, where, severity, message subject and that no row landed; the draft promotion; (b) a valid nested rule lands active; (d) door = build key by key on rule, where, path, message, hint. RIGHT.
  5. Draft saves stay ungated. mode: 'draft' is not judged (pinned in the promotion test's first half); only the promotion is. Nothing in the diff touches the draft door. RIGHT.
  6. Location dialect of the new findings. Nested findings carry where = path = object 'O' · validation rule 'OUTER' then → 'INNER' (… when-predicate for a nested when) — the label rulePredicates already builds for the null-guard gate; top-level findings keep object 'O' · validation 'NAME' / … when. The when vs when-predicate suffix split pre-exists this PR. RIGHT per triage ("with the label it already builds"); no new spelling enters the repo.
  7. Each predicate judged once; existing finding order preserved. Depth 0 is skipped (the rule's own condition / when met the two unchanged calls), and the nested loop sits after those calls and before the null-guard loop, so every pre-existing finding keeps its relative order while the new ones interleave per rule. Pinned (one finding for a top-level condition; two, one per location, for a faulting top-level when beside a faulting nested then). RIGHT.
  8. No public surface moves. rulePredicates and the new RulePredicate interface are module-private; validateStackExpressions(stack) keeps its signature; no barrel, no packages/spec change, so no generated artefact is owed; @objectstack/metadata-protocol changes a test file only. RIGHT.
  9. Stored rows. Not read-refused and not migrated; judged at the next publish-mode save. Nothing in the diff touches a read path. RIGHT.
  10. Test non-vacuity. Lint side: 8 new its (the PR body's "7 pins" leaves out the spec-valid fixtures test — a count nit; the file's 16 = 8 existing + 8 new holds). LIT pins assert exact where arrays and message subjects (sqrt not callable; bare reference amont / amount); CONTROL asserts both errors and advisories empty at the door and [] at the build; the per-slot pin asserts the one-hop refusal at then → 'inner' and silence on the multi-hop when; the differential holds. The dev's ablation (fix and hydration flag, both directions) is reported, not re-run here. RIGHT.
  11. Corpus (H6). Dev-measured: 118 objects, 21 validation rules, one conditional with nested predicates (showcase_account.churn_reason_consistency), 0 refusals; stop condition not met. Not re-measured here (read-only); corroborated indirectly by green Dogfood Verify CLI and Dogfood Regression Gate, which build the example apps through the narrowed rule. Consistent.
  12. OS_ALLOW_UNLINTED_METADATA_WRITES=1. Stated unchanged; the diff does not touch it. Consistent.

② Semver level

  • @objectstack/lint: minor — RIGHT. The act narrows an accept set (BREAKING). Under the launch-window convention (check-changeset-no-major, .changeset/pre.json absent at the head — confirmed 404), breaking ships as minor, with breaking-ness carried by the BREAKING banner and the ADR-0087 disposition; the changeset carries fix(lint)!, a BREAKING section, a Remedy and an Unchanged section.
  • Clause-②: no (narrowing) — RIGHT, in the PR body at a line start and in the changeset. The diff adds no key to a published payload (no); the accept set of os build / os validate / os lint and the object door shrinks (narrowing). Matches the Claim's Clause-②: no and triage's grade.
  • ADR-0087 not-required (no-migration-prescription) — RIGHT. No authorable key, spelling, export or stored shape moves and no stored row is converted, so there is nothing for objectstack migrate meta to rewrite; the Remedy is the author's own edit of a predicate the validator already names, not a FROM → TO rewrite. Check Changeset ran the gate green on both runs. The marker's exclusion of the other categories is argued on facts (packages publish; no ledger id; a verdict, not a declaration).
  • @objectstack/metadata-protocol: minor — the seat left this to the review: ACCEPTED. No non-test source in that package moves, so strictly the WHICH-LEVEL act for that package alone is below minor; but every publishable package versions in lockstep (fixed group), so the level is immaterial, and the entry's only effect is the CHANGELOG line landing in the package whose doors (saveMetaItem, publishMetaItem) exhibit the 422 — the text an upgrading agent greps after the refusal. The two finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032 changesets at the head (22032-object-save-door-validation-predicates.md, 22032-object-save-door-field-rule-slots.md) grade it identically. Do not churn the head to drop it: a changeset edit is not a pure regeneration and would re-owe this record.

③ Boundary flags

open_questions: none. Every flag the dev raised (deviations, out_of_scope_findings, PR Acceptance notes) and the seat's accepted deviation, answered:

  1. Depth cap (out-of-scope finding, "carrier: none") — ESCALATED to the seat as a filing decision. Verified at the head: collectPredicateRelationships.visit returns at depth above 8, while checkConditional → evaluateRule recurses without a cap, and resolveTraversalScope with no binding hands the bare id through ("invents no verdict for it"), so a valid one-hop read in a condition nested nine or more deep passes lint (hydration ON at every depth) and faults closed on every write it judges — the card's own class, one cap further down. Reach today is nil (no stored metadata near that depth) and the inconsistency is objectql's and pre-existing (collector cap vs uncapped evaluator), so it carries no FAIL weight here, and the fix is not lint mirroring the cap (with hydration off the same read would pass silently): it is objectql lifting the cap or refusing nesting above it at the door. Under Prime Directive chore: version packages #10 a reproducible metadata-authoring trap is a filing, and "carrier: none" is the seat's call, not the dev's — recommend a low-priority domain:spec card against packages/objectql.
  2. formulas.mdx "Build-time validation" (out-of-scope finding) — answered, no action on this PR. Read at the head: the section names os build and registerFlow, says nothing about the object door or nested rules, so no line is falsified by this diff; the addition is optional prose, and content/docs/** is outside the contract-review face (the dispatch seat's ACCEPT covers it).
  3. @objectstack/metadata-protocol graded minor (deviation, also the seat's accepted deviation) — ruled in ②: accepted.
  4. Commit trailers in AGENTS.md's model-free form (deviation) — conforms. All three commits carry Claude-Session: plus Co-authored-by: Claude and nothing else; the harness reminder's trailer is an exemption, not a requirement. A sweep of PR title, body, changeset, diff and commit messages for model identifiers is clean.
  5. origin/main moved 3 commits past base during the run (deviation) — no action. cdeabec84, 15ec50e52, 51290bca2: 77 files, none of the PR's four; the only packages/lint touch is lint-flow-patterns.test.ts, disjoint. GitHub reads the PR mergeable; the queue rebuilds the merged generation and re-runs the required set.
  6. Early preflight quoting (deviation) — no bearing; the dev used that reading for nothing and the dist-mediated protocol pins confirm the built rule.
  7. Contract review not attached (deviation / Acceptance note) — this record is it.
  8. Cleanup (deviation) — noted.
  9. needs:contract-review — the label's removal is the record-writing seat's act in the same stroke as reading this PASS; this reviewer makes no label write.

Implemented-by: claude/issue-22042-nested-validation-predicate-verdict
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 00:56
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 00:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 8fc50b7 Oct 8, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22042-nested-validation-predicate-verdict branch October 8, 2026 01:25
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…its it can prove, you apply the rest (objectstack-ai#22142)

Fixes objectstack-ai#9591
Clause-②: no (prescription text only; no input's accept or reject result
changes)

This is the spec-lane half of the card: the shared retirement sentence
names `--write`, and the class-wide pin moves in the same PR. The
codemod itself landed in PR objectstack-ai#22108 (`a959493cdf`).

## The sentence

Before (the objectstack-ai#9529 wording):

```text
Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand.
```

After:

```text
Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.
```

The one allowed two-clause variant (a conversion that covers only part
of a value) carries the same clause: `… to list the mechanical edits for
the X case; --write applies the ones it can prove, and
WHAT-HAPPENS-TO-THE-REST.` Its two members are dashboard
`compareTo.offset` and the script node's `config.actionType`.

**Checked against the tool on `main` (`51290bca`).**
`packages/cli/src/commands/migrate/meta.ts` declares `write:
Flags.boolean({ … default: false, exclusive: ['stored'] })`. Its help
text says it rewrites the authored sources in place "for each mechanical
change traced to one literal in one project file; every other change is
listed with the reason it was not written". Without the flag the run
writes only the `--out` snapshot. The wording satisfies every ruling
that binds it:

- **Triage `6045697201`.** The sentence never says "rewrite existing
sources automatically" unqualified ("the ones it can prove"), and it
names `--write` because the default run still only lists.
- **"It must be TRUE of the tool."** Every clause is a property of the
command, read from `meta.ts`.
- **"One antecedent."** "existing sources" still names one thing. "The
ones" can only be edits, because an edit is what gets applied. The key's
fate stays in the body prose.
- **Vocabulary.** The wording matches PR objectstack-ai#22122's skill text ("lists the
mechanical edits"; `--write` "rewrites in place each edit it can trace
to one literal in one project file, lists every other with the reason it
was not written").

## Where it moved (counted at `017761f0`; the merge of `main` added no
site)

- **161 sentences the pin judges.** `packages/spec/src`: 157 (155 house
form, 2 two-clause). `packages/lint/src`: 1.
`packages/drivers/driver-turso/src`: 3. Every one passes the new
anchors; `apply them by hand` survives only in the pin's own RED
fixtures.
- **Not judged by the pin, moved anyway:**
- `migrations/registry.ts`: 3 sentences, regenerated from the moved
`entries/semantic/18.*.ts` by `gen:migration-registry`.
- The lint `chartConfig.xAxis.field` hint in
`validate-widget-bindings.ts`: a template literal with interpolation
after the sentence, so the pin cannot see it (Acceptance notes).
  - The `retiredKey()` docblock example.
- **Mechanical replacement.** A script replaced the tail `apply them by
hand` in 63 files (188 occurrences; old tail left: 0) and printed
per-file before/after counts. Two seams split mid-phrase
(`translation.zod.ts`) and the two two-clause sites were rewritten by
anchored edits that had to hit exactly once.
- **Pins in other test files.** 23 test files asserted the old sentence
verbatim, as string or regex. Each now asserts the new sentence
verbatim, so a revert reds them. The ones that assert only the unchanged
prefix (`form-layout-inline-grid-retired.test.ts`, the turso /
driver-memory `toContain('os migrate meta --from 17')`) are untouched,
because they stay true.
- **Changeset.** `.changeset/9591-retirement-sentence-write.md`: `patch`
for `@objectstack/spec`, `@objectstack/lint` and
`@objectstack/driver-turso`, the three packages whose shipped text
moves.
- **Generated.** `content/docs/references/**`: 32 files (+268/−268) from
`pnpm --filter @objectstack/spec check:generated --fix`; `check:docs`
was the only stale artifact. `check:generated` then exited 0.

## The class pin (`retired-key-migrate-sentence.test.ts`)

- **Anchors.** `HOUSE_AT_MARKER` and `MIXED_AT_MARKER`, and their
markdown twins, require the new clause. The objectstack-ai#9529 sentence, which does
not name `--write`, is now RED. Two further spellings are RED: one that
names `--write` without the qualification, and one that qualifies it but
leaves the rest unowned.
- **Withdrawn claim.** `WITHDRAWN_CLAIM` is unchanged: the unqualified
automatic-rewrite claim stays a hard RED everywhere. A new non-vacuity
case proves neither legal shape trips it.
- **Truth anchor (new).** The pin reads `os migrate meta`'s own flag
table. A `write` boolean flag must exist and must have `default: false`,
the two facts the sentence rests on. The read is covered by
`@objectstack/spec`'s existing `packages/**/*.ts` cross-package
declaration.
- **Corpus widened by one root.** `packages/drivers/driver-turso/src`
joins, on objectstack-ai#7030's terms. Its three `turso` config tombstones carry the
house sentence, and their docblock defers to `retired-key.ts`, but the
pin never walked them. Without this, a rewording leaves them behind with
every assertion green. The lint-only anti-vacuity case now covers each
widened corpus.
- **Header and docblock.** The pin header and the `retired-key.ts`
module docblock record the new sentence and why. The "the claim may be
restored" note is gone, replaced by what was restored and how far.

**Reverse verification**, run from committed HEAD `017761f0` through
`scripts/ablation-replace.mjs` (each anchor hit as declared and was
restored to a blob equal to HEAD with `git diff HEAD` empty). Expected
direction: red.

| Mutation | Result |
|:--|:--|
| A. the three `turso.zod.ts` sentences back to the objectstack-ai#9529 wording
(anchor ×3→0, blob `e25cca4d5508`→`a05fe3f09733`) | 3 failed / 12
passed, naming `driver-turso:spec/turso.zod.ts:63`, `:75`, `:82` |
| B. `meta.ts` `write` flag `default: false` → `true` (blob
`c036012c63c9`→`71acff21ef8c`) | 1 failed / 14 passed: "the sentence is
TRUE of the command it names" |
| C. `meta.ts` flag renamed `write` → `inPlace` (blob
`c036012c63c9`→`29a8a9402284`) | 1 failed / 14 passed: "os migrate meta
declares no `write` boolean flag" |

Under the old corpora, mutation A would have stayed green, because
driver-turso was in no corpus.

## One bounded fix on the same sentences: `CHATTER_POSITION_RETIRED`

The three `record:chatter` / `record:discussion` `position` value
prescriptions (`'sidebar'`, `'inline'`, `'drawer'`, in
`ui/component.zod.ts`) told the author to run a bare `os migrate meta`.
The command refuses that with `Missing required flag --from` (`meta.ts`
`run()`, the `flags.from === undefined` branch). The conversion is
`record-chatter-position-vocabulary`, `toMajor: 18`, so they now name
`--from 17`. They therefore join the pin's judged set in house form, and
the "(registered under protocol major 18)" aside goes. The fix qualifies
as bounded: the same sentence class, a mechanical change to an
already-pinned form, a file inside this claim's surface, and the same
gate family. No test pinned the old text.

## Governed surface: `.claude/skills/spec-property-retirement/SKILL.md`
(Tier S)

The pin requires the retirement playbook to teach both shapes
(`SKILL_HOUSE_TEMPLATE` and `SKILL_MIXED_TEMPLATE` must match its
convention 5). So changing the sentence forces the playbook edit, and
this PR lands as Tier S. Convention 5 now carries the two new templates.
Its note that the command "never writes a source file" was made false by
PR objectstack-ai#22108, so it is deleted. Line count 337 → 337 (ceiling 337), with
every line within the 120-byte budget: `node
scripts/pm/check-skill-line-ratchet.mjs` exits 0. ⛔ No published
`skills/**` file changes: PR objectstack-ai#22122 owns
`skills/objectstack-upgrade/SKILL.md`, and no published skill carries
the sentence (`git grep` count 0).

## 维护者速读(草稿)

**改了什么**:所有退役键报错末尾那句统一提示,从「运行 `os migrate meta --from N`
列出机械修改,然后手工改」改为「……列出机械修改;`--write` 会写入它能证明的那些,其余你手工改」。共 161 处被 pin
判定的报错文案(含 2 处两从句变体),外加生成的 registry 3 处、lint 模板字符串 1 处;其中 3 处原先写成不带
`--from` 的命令(该命令会直接拒绝),一并改正。守这句话的 pin 同步更新,并新增一条断言:CLI 必须真有 `--write`
且默认不写。

**为什么改**:`--write` 已随 PR objectstack-ai#22108 落地,旧句只说「手工改」,低估了工具;但 `--write`
只写能证明的站点,所以不能说「自动重写源文件」。新句两头都如实。

**风险与代价(含回滚)**:纯文案,不改任何 schema、键、类型、导出或错误码;解析结果不变。依赖旧整句原文匹配的调用方会失配(仓内 23
个测试已同步);前缀「…for existing sources;」不变。回滚即 revert 本 PR。在途的兄弟 PR
若新增处方仍用旧句,会被 pin 打红,后落地者改用新句。

**席位意见**:

**你要做的**:无需操作;本 PR 触 `.claude/**`(Tier S),由席位按合同审查记录落地。

## Verification (branch base `51290bca`; final head `f9ca14d548`)

- `pnpm --filter @objectstack/spec build`: VERDICT command-exit 0.
`check:generated --fix` regenerated the one stale artifact;
`check:generated` then exited 0 (15 of 15 current), and again in the
gate run at `f9ca14d548`.
- spec `vitest run --project local`: Test Files 623 passed (623), Tests
18613 passed, 1 todo, at `017761f0`.
- spec `vitest run --project repo` (53 files incl. the class pin): 53
passed (53), Tests 903 passed (903), at `017761f0`.
- `@objectstack/driver-turso` `vitest run`: Test Files 88 passed (88),
Tests 2373 passed, 33 skipped, at `017761f0` (after `pnpm
--workspace-concurrency=2 --filter '@objectstack/lint...' --filter
'@objectstack/driver-turso...' build`; the first run, before that build,
could not resolve unbuilt dependencies and is NOT MEASURED, not red).
- `typecheck` for spec (`tsc --noEmit` + `check:scripts-typecheck` +
`check:test-typecheck`), lint and driver-turso: exit 0 at `017761f0`.
- **After merging `main`** (`033e5c536d`: objectstack-ai#22122, objectstack-ai#22127, objectstack-ai#22106) as
`f9ca14d548`, with no conflict (objectstack-ai#22127 also edits
`validate-expressions.ts`): the class pin 15 passed (15);
`@objectstack/lint` `vitest run`: Test Files 123 passed (123), Tests
5688 passed (5688); lint `typecheck` exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `f9ca14d548` derives 124 commands (the
claim-time 79 plus 45). All 124 exit 0 at `f9ca14d548`. `--ran`
reconciliation: 124 derived, 124 run, 0 NOT-MEASURED, a zero derived
from the recorded exit codes. (At `017761f0`, five gates first answered
exit 3, PREREQUISITE NOT MET: one shallow-clone fixture and four that
need unbuilt dists. The clone was deepened as the gate asked, and all
five are green in the `f9ca14d548` run.)
- `node scripts/pm/check-skill-line-ratchet.mjs`: exit 0; the playbook
is 337 lines (ceiling 337), and no line is over 120 bytes.
- eslint, narrowed: `pnpm exec eslint --no-inline-config --format json`
over the 66 changed `.ts` files reports 66 files, 0 errors and 0
warnings. The population is `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob, which excludes the changed
`.md` / `.mdx` files. The config never enables type-aware linting (its
own comment at `:326`–`:328`), so this diff cannot move any untouched
file's verdict. The repo-wide `pnpm lint` is CI's.

## Siblings in flight

objectstack-ai#21982, PR objectstack-ai#22094 (objectstack-ai#13458) and PR objectstack-ai#22103 (objectstack-ai#5082) each add prescriptions
with today's sentence. Whichever lands after this one carries the new
sentence; the class pin reds it at that merge otherwise. Whichever of
those lands first, this branch merges `main` before landing.

## Acceptance notes

- **Hand-written docs still use the old sentence**
(`content/docs/automation/flows.mdx` ×2,
`protocol/objectql/query-syntax.mdx`, `data-modeling/queries.mdx`,
`protocol/objectui/actions.mdx`, `ui/apps.mdx` ×2). Each is the page's
own advice, not a quoted error, and still true of the default run; each
undersells `--write`. They are `domain:devx` pages outside this claim,
so they are not touched here.
- **QA checklist item `cli.migrate-meta-codemod`**
(`docs/qa/platform-checklist/areas/cli.json`). Its RESTART CHECK fired
when PR objectstack-ai#22108 added `--write`, and the item still asserts a print-only
command. Its step 1 greps for the objectstack-ai#9529 sentence verbatim and now finds
none. Re-authoring the item belongs to the checklist author, not this
PR.
- **Comments that say the default run "lists the mechanical edits"**
stay as they are, because they are still true: the
`migrations/registry.ts` migration notes (outside the pin's scope by
design) and the `conversions/registry.ts` comments.
- **The lint `chartConfig.xAxis.field` hint**
(`validate-widget-bindings.ts`) moved, but it remains invisible to the
class pin: a template literal, with `suggestName(…)` and the suppress
hint interpolated after the sentence.
- **A published skill still claims an automatic strip.**
`skills/objectstack-data/rules/indexing.md:31` says "run `os migrate
meta --from 16` to strip them automatically", and
`skills/objectstack-data/SKILL.md:377` says the command "strips them".
The default run strips nothing from sources, and `--write` strips only
what it can prove. `WITHDRAWN_CLAIM` has no strip spelling, so the pin
cannot see this. Widening it here would red `main` on a Tier H file this
PR may not touch, so it is reported to the PM for the skills lane.
- **The `config.actionType` two-clause tail** ("the stub and marker
values are removed") is unchanged in substance; only the `--write`
clause was inserted before it.


## Patch round 1 (written by the PM seat from the dev's report
`6052088494`)

- **Merge:** `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103) was merged through
`os-regen-merge.sh` as `feca6b5ace`. The three reference pages both
sides had changed (`api/metadata`, `data/object`, `system/migration`)
were regenerated from the merged tree as `98e2f6e373`. That brings back
objectstack-ai#22103's `unique?: false | 'global' | 'organization'` rows, which the
driver had dropped.
- **objectstack-ai#22103's sites:** the merge brought two non-test sites with the old
tail and one test that asserts it verbatim. All three carry the new
sentence at `b9d6e82619`:
- `packages/spec/src/data/object.zod.ts`
(`DECLARED_INDEX_BARE_TRUE_RETIRED`);
- `packages/lint/src/data-model-rules.ts` (the
`unique-unscoped-declared-index` fix text);
  - `unique-scope-message.test.ts`.

The pin now judges 163 sentences: `spec` 158 (156 house + 2 two-clause),
`lint` 2, `driver-turso` 3.
- **The pin's blind spot:** the `data-model-rules.ts` sentence sat in a
template literal, which the judge cannot read (escaped backticks), so it
was never judged. It is now plain-quoted, as in
`validate-expressions.ts`, and the pin's Mechanism paragraph records
that template literals are invisible to the scan. Ablation D (that
sentence back to the old tail) gives 3 failed / 12 passed, naming
`lint:data-model-rules.ts:463`. `validate-widget-bindings.ts` stays the
one template-literal site the pin cannot judge (an Acceptance note).
- **Verification at `b9d6e82619`:**
  - spec `--project local`: 623 files / 18,619 tests;
  - spec `--project repo`: 53 / 903;
  - lint: 123 / 5,689;
  - driver-turso: 88 / 2,373;
  - typecheck: exit 0 for all three packages;
  - `dispatch-gates --ran`: 124 derived / 124 run / 0 NOT-MEASURED;
  - CI: 33 success, 2 expected skips.


## Patch round 2 (written by the PM seat from the dev's report
`6053397952`; claim revised `6052335087`)

- **Why:** PR objectstack-ai#22094 (objectstack-ai#13458, `fec87e7e07`) landed first with a
two-clause prescription lacking the `--write` clause, which this PR's
class pin refuses. The sibling rule here ("whichever lands later carries
the new sentence") puts the edit in this PR.
- **Merge:** `origin/main` `959c209d56` was merged through
`os-regen-merge.sh` as `3b6335b9be`, with no hand-written conflict.
`content/docs/references/api/protocol.mdx` was regenerated as
`c40b3babd7`.
- **The edit** (`fe3af5642c`, 4 files beyond the merge):
- `packages/spec/src/kernel/manifest.zod.ts`
`PLUGIN_PERMISSIONS_LIST_FORM` now closes with "Run `os migrate meta
--from 17` to list the mechanical edits for the package manifest case;
`--write` applies the ones it can prove, and a granted-permission record
is not a source it reads." It keeps objectstack-ai#13458's own second clause and adds
the house `--write` clause, the seat's wording.
- Its verbatim pin `manifest-permissions-string-list.test.ts` moved with
it.
- The changeset's two-clause bullet now names three members and says
"before their second clause".
- **The two-clause variant now has three members:** dashboard
`compareTo.offset`, the script node's `config.actionType`, and the
package manifest `permissions` case. The pin judges 164 sentences (spec
159 = 156 house + 3 two-clause; lint 2; driver-turso 3) with 0 bad
sites.
- **Verification at `fe3af5642c`:**
  - spec `--project local`: 625 files / 18,661 tests;
  - spec `--project repo`: 53 / 903;
  - class pin: 15 / 15, and the manifest pin: 17 / 17;
  - `dispatch-gates --ran`: 124 / 124 / 0 NOT-MEASURED;
  - CI: 33 success, 2 expected skips.

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

---------

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants