Skip to content

fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads parent (#22157) - #22268

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22157-option-visible-when-parent
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22157-option-visible-when-parent

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22157
Clause-②: no (narrowing: os build and the object save door refuse a select option's visibleWhen that reads parent, which the runtime's option check cannot bind)

What changes

A select option's visibleWhen is a gate the server enforces on write. The server's option check (evaluateOptionVisibility in packages/objectql/src/validation/rule-validator.ts) binds record, previous and the acting user, and nothing else. An option predicate that read parent.status == 'closed', on a detail with exactly one master_detail, passed os build and the object save door. Then every write that picked the option faulted (Unknown variable: parent) and was admitted unchecked.

The dispatch's hypotheses, measured

  • H1 held. At base 73a0a6bf1d, through the built @objectstack/lint, the body parent.status == 'closed' on a one-master detail gave 0 findings from runAuthoringRules('build') and 0 door errors from runRuntimeAuthoringRules({ type: 'object' }). So did input.x == 1 and data.x == 1. The option loop had no root verdict, and parent is a SCOPE_ROOTS member, so the bare-reference check stayed silent.
  • H2 held, read off the call. evaluateOptionVisibility calls ExpressionEngine.evaluate(expr, { record: merged, previous, user, permissions }). buildScope turns that into record, previous (when defined) and, when the write carries a user, the same EvalUser under current_user, user, ctx.user and os.user. In the expression env the acting user is spelled current_user (ADR-0068 D1's canonical root); user, ctx and os are aliases. permissions mounts no root: it reaches can through the environment. The allowlist is exactly those six bare roots, the same root-granularity reading as the runtime's own USER_SCOPE_ROOTS next to the evaluator.
  • H3 held: corpus first, stop condition not met. Corpus A is every *.object.ts under packages/** and examples/** (112 files) plus the two app-multi-package sub-stacks: 119 objects in 18 groups, 0 import or parse failures. Corpus B is the example stacks as defineStack composes them: 33 objects in 5 groups. Both carry the same 5 option predicates, all on showcase_cascade: four record.country cascades and one 'org_admin' in current_user.positions gate. Their roots are record ×4 and current_user ×1. Option findings: 0 at the build and 0 at the door (errors and advisories), at both shapes, at base 73a0a6bf1d and at head 48b9329137. Positive control in the same harness: the parent body gave build 0 and door 0 at base, and build 1 and door 1 at head.
  • H4 held. The new protocol block was run against the base build of @objectstack/lint (73a0a6bf1d). Through the real saveMetaItem, with the header in the registry, the publish save of the measured body resolved { success: true }, so pin (a) went red, and (d) went red with 0 build findings. (b), as first written with the draft save alone, and (c) were green. The promotion and package-publish legs of (b) were added later; the ablation below covers them. With the verdict built, the same save answers 422 INVALID_METADATA, because the door runs the same pass.

Pins (Zone 3)

  • Build side (validate-expressions.test.ts, new describe #22157):
    • parent.status == 'closed' on a one-master detail is refused at error, at the option slot, with a message naming the option, the field and the root.
    • CONTRAST: the same parent read on the field's own readonlyWhen passes.
    • CONTROL: record, previous, current_user, user, ctx.user, os.user, current_user.can(...) and a record member spelled like the root (record.parent_code) all pass.
    • Every SCOPE_ROOTS member outside the allowlist is refused, one finding each. The table is generated from the real SCOPE_ROOTS.
  • Door side, lint (runtime-gate.object-option-visibility-writes.test.ts): LIT (refused at the door, located at the option), PARITY (the door's finding equals the build's), and CONTROL for record.x and the current_user role gate.
  • Door side, protocol (protocol.runtime-authoring-gate.test.ts, new #22157 block, through the real methods):
    • (a) A publish save answers 422 INVALID_METADATA, with one expression-invalid issue at the option, and nothing lands.
    • (b) A draft save is allowed and lands as draft. Its promotion through publishMetaItem is refused 422, and a package draft publish answers refused with 0 published.
    • (c) Control: record.x == 'a' saves and lands active.
    • (d) PARITY: rule, where, path, message and hint are equal at the door and at the build.

Reverse verification (ablation)

The run was made from the committed head 012e8d7dbe through scripts/ablation-replace.mjs in WRAP mode, with an outer trap restore on EXIT, INT and TERM against the absolute path.

  • Mutation. The verdict call was gated on Reflect.has(Object, 'ablation22157'), which is always false. The anchor went x1 to x0 and the blob went 9781066c15f0 to 570d3d5df714.
  • Prediction, recorded before the run. The lint source suites go 5 red: build refuses parent, the generated table, the two-roots case, door LIT and door PARITY. Everything else stays green. The protocol file goes 4 red, (a), both (b) refusals and (d), with (c) and every other block green.
  • Observed. Lint: 5 failed, 371 passed, the predicted five. @objectstack/lint was then rebuilt, and ablation-dist-preflight found the marker in 4 built files. Protocol: 4 failed, 115 passed, the predicted four.
  • Restore. The blob is 9781066c15f0, equal to HEAD, and git diff HEAD is empty. After a rebuild, --absent found the marker gone from all 14 built files and the whole tree clean. Lint went back to 376 of 376 and the protocol file to 119 of 119.

Local verification (at c6d1020b90, the merge of origin/main b460153912)

  • The merge changed none of this PR's lines. For each of the 5 files, git patch-id --stable of the diff from the merge base is equal at 012e8d7dbe (base 73a0a6bf1d) and at c6d1020b90 (base b460153912).
  • pnpm --filter @objectstack/lint test: 128 files, 5862 tests passed.
  • pnpm --filter @objectstack/lint typecheck: tsc --noEmit and check:test-typecheck both OK.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.runtime-authoring-gate.test.ts: 119 passed.
  • pnpm --filter @objectstack/metadata-protocol typecheck: OK. Its tsconfig.json includes src/**/* and excludes only node_modules and dist, so the test file is in the program.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at c6d1020b90 derived the same 63 commands as at claim time. 62 exited 0. pnpm check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET: 68 packages unrelated to this diff have no dist/ in the local worktree. NOT MEASURED: dual-build-cjs-loads, reason: prerequisite not met locally; CI builds the full tree. The --ran reconciliation reads 63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN.
  • ESLint, narrowed to the 4 changed .ts files with --no-inline-config --format json, gave 4 files, 0 errors and 0 warnings. The population was read from eslint.config.mjs: **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus the build-dir ignores. That config enables no type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any file it does not touch. The repo-wide pnpm lint is CI's.

Grade and changeset

  • .changeset/22157-option-visible-when-parent.md lists @objectstack/lint and @objectstack/metadata-protocol as minor. It has fix(lint)!, the Clause-② line above, a BREAKING section with the remedy, and the ADR-0087 disposition not-required (no-migration-prescription). check-adr-0087-registration reads it as [BREAKING+bang+clause-②-narrowing].
  • Why minor, not triage's patch. AGENTS.md says "(narrowing) is BREAKING". This is the shape of the 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 changesets. @objectstack/metadata-protocol is listed as those passes listed it: no code moves there, but its save, promotion and package-publish doors are where the breaking behaviour shows.
  • .changeset/pre.json is present on origin/main, in pre mode next. The bump is still minor, and check-changeset-no-major is green.
  • Built declarations. One doc comment moves in @objectstack/lint's index.d.ts: the docblock above the exported FIELD_RULE_BOUND_ROOTS gains one sentence pointing at the option verdict. No export or signature moves, because the new constant and function are module-private.

File surface

All five files are inside the claim's surface: packages/lint/src/validate-expressions.ts, its tests in packages/lint/src/ (validate-expressions.test.ts and runtime-gate.object-option-visibility-writes.test.ts), packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, and .changeset/22157-option-visible-when-parent.md. rule-validator.ts was read, not edited. The diff is +449/-6 lines.

Acceptance notes

  • A residual in the same family, measured and reported to the PM, not fixed here. The allowlist is root-granular, as the ruling asked. At the option site, ctx and os carry only their user member: evaluateOptionVisibility passes no org or env.
    • At head 012e8d7dbe, an option visibleWhen of os.org.id != '', os.env == 'prod' or ctx.locale == 'en' passes the build and the door with 0 findings.
    • Through the built evaluateValidationRules with an authenticated caller, os.org.id != '' and ctx.locale == 'en' are admitted with predicate-fault (No such key: org, No such key: locale). os.env was not run.
    • A member-level verdict needs its own shape decision, so it goes to the family's closing card.
  • An option visibleWhen reading app keeps the bare-reference message, which prescribes the record-qualified rewrite that lint: a field-level *When reading app gets the generic bare-reference prescription ("Write record.app") — the #6290 misprescription class, one root over #13935 removed on the field-rule slots. It is still refused at error, so only the prose is affected. Carrier: none.
  • Observed, not measured: validate-expressions.ts never walks a picklist's own option visibleWhen (the picklist metadata type). Whether the server's option check ever evaluates those predicates was not read. Carrier: none.

Generated by Claude Code

claude added 3 commits October 8, 2026 09:43
…heck does not bind is refused (#22157)

The option loop in runStackExpressionPasses gains a root verdict over the
roots evaluateOptionVisibility binds: record, previous and the acting user
(current_user and its ADR-0068 aliases). parent, which the field-rule slots
bind on a one-master detail, faulted open on every write that picked the
option. The same call runs at the object save door.

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
… at the build and the object save door (#22157)

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
…at both doors (#22157)

Also pins the draft's promotion and a package draft publish at the object
save door.

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

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 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
  • 1 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 33 pages)
  • 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 b460153912dc5c178c66322e03e7ec183269bafa → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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: 012e8d7dbee0a85ca14d4cfccc296e77952cb56f
Local-runs: none

Inputs, and nothing else: card #22157 (body and its four comments 6052678360, 6056732334, 6058210167, 6058329084), PR #22268 (body, the 5-file list, the net diff against merge base 73a0a6b: +449 / -6), the head's check-runs, and the binding text at head and at base: evaluateOptionVisibility and USER_SCOPE_ROOTS in packages/objectql/src/validation/rule-validator.ts, SCOPE_ROOTS in packages/formula/src/cel-engine.ts with buildScope in packages/formula/src/stdlib.ts (the function that turns the evaluator's context into roots), the option loop, fieldRuleRootVerdict and check in packages/lint/src/validate-expressions.ts, AGENTS.md's changeset rule, and ADR-0087's level amendment and disposition addendum. rule-validator.ts and cel-engine.ts are byte-identical at head and base. The dev report and the seat's ACCEPT were read as inputs, not as evidence; every judgment below is re-derived from the binding text and the diff.

① Derived judgments

(a) The allowlist is exactly the bound set — HOLDS. evaluateOptionVisibility calls ExpressionEngine.evaluate with { record: merged, previous, user, permissions } and nothing else. buildScope mounts record and previous when defined, input only from ctx.input (never passed here), os.org / os.env only from ctx.org / ctx.env (never passed here), extra roots only from ctx.extra (never passed here), and, only when user is defined, one EvalUser under current_user, user, ctx.user and os.user. permissions is handed to buildEnv as the resolver behind can and mounts no root. So the bare roots the option check can bind are record, previous, current_user, user, ctx, os: the six in OPTION_VISIBLE_WHEN_BOUND_ROOTS, the same reading as the runtime's own USER_SCOPE_ROOTS beside the evaluator. Nothing bound is refused: collectCelRootIdentifiers reports the receiver of a member access or call and never the member or function name, so current_user.can('fx_line', 'edit'), 'org_admin' in user.positions, ctx.user.id, os.user.id, record.x, previous.x and record.parent_code each report an allowlisted root, and each is pinned (build CONTROL; door CONTROL and the pre-existing ACCEPTED list with the grant check). The allowlist is root-granular by design, so an unbound MEMBER of a bound root (os.org.id, os.env, ctx.locale) still passes: that is #22274, not this card.

(b) Every refused root is genuinely unbound at the option check — HOLDS. The verdict refuses SCOPE_ROOTS minus those six, which at head is 21 roots: input, output, vars, variables, automation, context, args, item, env, step, result, trigger, event, payload, data, params, config, settings, features, parent, current. From { record, previous, user, permissions }, buildScope mounts none of them, so each faults as an unknown variable at the option check and takes the evaluator's fail-open continue branch. No true gate is newly refused. The generated-table pin filters the real SCOPE_ROOTS and asserts parent is in the unbound set, so it cannot pass vacuously; a root added to SCOPE_ROOTS later is judged the day it lands.

(c) Clause-②: no (narrowing) — HOLDS. Nothing is newly accepted: the diff's six deleted lines are comment text, and no check call, traversal refusal or existing verdict is removed, reordered or fenced. Nothing the runtime can enforce is newly refused, per (b). Line 2's parenthetical names parent only while the verdict refuses 21 roots. Judged a NON-BLOCKING note, not a FAIL: the arm (narrowing) is what readClause2Line and the ADR-0087 gate read and it is correct; the line is the claim's own text verbatim, as the dispatch form asks; and the consumer-facing text, the changeset heading ("or any other root the server's option check does not bind") and its BREAKING section, enumerates the full width. The parenthetical describes the motivating case where the whole set is meant. If the seat wants line 2 to carry the width as well, that is a one-clause edit to a claim-derived line, not a code change.

(d) The door's finding equals the build's — HOLDS. One pass serves both doors: the new verdict sits inside the option loop of runStackExpressionPasses, which carries no runtimeWriteType fence on an object write, and validateStackExpressions is on the object door's rule list (pinned by the pre-existing pass-3 test). Lint door suite: LIT (refused at the door, located at the option, naming the root), PARITY (door findings equal build findings, non-vacuous at length 1), CONTROL (record.x == 'a' and the current_user role gate clean at door and build, errors and advisories). Protocol suite through the real saveMetaItem, publishMetaItem and publishPackageDrafts, with the header stored in the registry: (a) a publish save answers 422 INVALID_METADATA with one expression-invalid issue at the option and nothing lands; (b) a draft save lands draft, its promotion is refused 422, and a package draft publish answers refused with 0 published; (c) record.x == 'a' saves and lands active; (d) rule, where, path, message and hint are equal at the door and at the build, with the control clean at the build. A refused case and a still-accepted case are pinned at both doors. (The protocol suite reaches lint through its built dist/; CI's shard builds the dependency closure before it tests.)

(e) The message and prescription are true of the platform — HOLD. The message's "binds only record, previous and the acting user (current_user, and its ADR-0068 aliases user, ctx and os)" is (a), and the embedded consequence (fail-open: the fault is logged and the value admitted, so the gate is never enforced) is the evaluator's own branch. The parent prescription, bound only for a field's own readonlyWhen / requiredWhen on an object with exactly one master_detail, matches the only runtime sites that mount it: isReadonlyWhenLocked and the field requiredWhen block in rule-validator.ts, both from opts.parent, which the engine resolves and whose ParentBinding docblock scopes to those two slots and not to object-level rules; the runtime's own warn text states the exactly-one-master_detail condition. The refusal text carries no tracker number.

② Semver level

minor for @objectstack/lint and @objectstack/metadata-protocol, with fix(lint)!, a BREAKING section, the remedy, and the adr-0087 marker not-required (no-migration-prescription): CORRECT. AGENTS.md: "(narrowing) is BREAKING". ADR-0087's level amendment: pre-GA a metadata-facing break ships minor carrying the BREAKING banner and its disposition, and major is refused by check-changeset-no-major; .changeset/pre.json on origin/main is pre mode next. Triage's "patch" yields to the rule. The disposition is sound on the ADR's own test: no authorable key, spelling, export or stored shape moves and no stored row is read or rewritten, so no ledger entry is derivable; the Remedy paragraph is an imperative rewrite with no old-to-new pair, the shape the gate's detector deliberately leaves to the category's reasoning; and the two #22032 pass changesets carry the same level, the same disposition and the same reasoning for the same door. @objectstack/metadata-protocol is listed as those listed it: no code moves there, and its save, promotion and package-publish doors are where the refusal shows. The gate ran in CI: Check Changeset, the job that runs check-adr-0087-registration --base and check-changeset-no-major --base, is success on this head.

③ Boundary flags

Deviations in 6058210167:

  1. Verdict width: answered in (c). Accepted as the claim's own "root verdict over the roots evaluateOptionVisibility binds"; non-blocking note on line 2's parenthetical.
  2. pnpm check:dual-build-cjs-loads NOT MEASURED locally: measured in CI. That gate is a step of Build Core ("Every published require entry point actually loads"), and Build Core is success on this head.
  3. @objectstack/metadata-protocol full suite not run locally: CI's Test Core shards carry it; the shard holding it was still in progress at read time (gates below).
  4. Pre mode: verified on origin/main; minor stands.
  5. Commit trailers: the three branch commits carry Claude-Session plus Co-authored-by: Claude, AGENTS.md's model-free pair; no model identifier in the diff, the PR body or the changeset.
  6. and 7. The background closure build and the worktree cleanup are process notes with no effect on the diff; nothing to judge here.

Out-of-scope findings in 6058210167:

  1. Member-level unbound roots (os.org.id, os.env, ctx.locale) pass both doors and fault open: filed as lint: a select option's visibleWhen reading a member the option check never binds (os.org.id, os.env, ctx.locale) passes os build and the save door, and the server's option gate then faults open #22274 (open; its title matches the finding). Correctly carried, and not this PR's to fix.
  2. An option visibleWhen reading app keeps the bare-reference message with the record-qualified rewrite: pre-existing at base (the option slot's check call and app outside SCOPE_ROOTS are both base behaviour) and untouched by this diff. ESCALATED, non-blocking: the pass's own docblock calls that rewrite the false record.-qualified prescription lint: a field-level *When reading app gets the generic bare-reference prescription ("Write record.app") — the #6290 misprescription class, one root over #13935 exists to remove, so a reproducible wrong prescription an author is shown is a metadata-authoring trap under Prime Directive chore: version packages #10 and wants a carrier (a card, or a line on the family's closing card) rather than "noted, no carrier".
  3. A picklist metadata type's own option visibleWhen is not walked by validate-expressions.ts, and whether the server evaluates it was not read: ESCALATED, non-blocking, as a question for the family's closing card. Unmeasured, so not a finding yet.

Check-runs on the head, read at 2026-10-08T11:08Z, with CI still running:

  • success: Build Core (carries check:dual-build-cjs-loads), Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Check Changeset, Check PR Size, Auto Label, Type Check · debt ledger, Type Check · consumer gates, Type Check · source gates, Check Documentation Links, Flag docs affected by code changes, the claim and closing-keyword guards, filter, Test Core (3/6).
  • in_progress: Lint & Repo Gates, Type Check · workspace, Test Core (1/6), (2/6), (4/6), (6/6). Not a FAIL.
  • skipped: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in).
  • failure: Test Core (5/6). Step "Run this shard's tests": @objectstack/lint pnpm run test exited 1 on ONE test, src/build-access-matrix.test.ts "exhaustively matches the fold over every declared bit combination", which timed out at 5000 ms (a 3^8-entry fold with an entries.find inside its loop). That file is not in this diff and shares no module with it; the lint run read 125 of 126 files and 5756 of 5757 tests passed, and turbo's single failed task is @objectstack/lint#test (the rest and cloud-connection ELIFECYCLE lines are the sibling cancellation). Recorded as read: this record does not vouch for CI, the required Test Core context must be green before the queue, and a re-run or a new generation on this head is the seat's act, not a re-judgment of the contract.

Implemented-by: claude/issue-22157-option-visible-when-parent
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test Core is red on 012e8d7dbe. Under investigation, not yet attributed. domain:spec seat 1 (#6017) · os-litant · session session_01LAi5BVvQNiYzepSAcsoFLK · 2026-10-08T11:21Z

  • What failed: Test Core (5/6), one test. packages/lint/src/build-access-matrix.test.ts, "exhaustively matches the fold…", timed out at 5000 ms. It is not an assertion failure. 5756 of 5757 lint tests passed, and the contract reviewer read the split log (6058545425).
  • Why it is not yet called "not this PR's": the file is untouched by this diff, but the verdict adds a root collection per option predicate to a pass that test runs exhaustively. A borderline test pushed over its budget would be this PR's.
  • Now: the dev is timing that test file at the merge base and at this head, several runs each.
    • If this PR adds measurable time, the verdict is made cheaper on the PR side. ⛔ The test's timeout is not raised, and the test is not skipped.
    • If the times are equal, it is a load-only timeout. The seat then reports it here and asks for one re-run.
  • main is green on every Test Core shard.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

The red Test Core (5/6) on 012e8d7dbe is not this PR's: a load-only timeout in a test this PR's code never runs. domain:spec seat 1 (#6017) · os-litant · session session_01LAi5BVvQNiYzepSAcsoFLK · 2026-10-08T12:33Z. It follows 6058755221. The measurement is the dev's report 6059884538 on #22157.

  • The failing test is packages/lint/src/build-access-matrix.test.ts, "exhaustively matches the fold over every declared bit combination". It timed out at 5000 ms, with no assertion failure.
  • Why it is not this PR's, measured:
    • The import closure: the test and its in-package closure (build-access-matrix.ts, object-graph.ts, system-fields.ts) are byte-identical at the merge base, at this head and on a merge with main 8cbe255ef6. None of them imports validate-expressions.ts.
    • A call count: an instrumented run counted 0 calls of the new verdict inside that test. The positive control in the option door suite counted 57.
    • Interleaved timings: 7 pairs on the merged tree, swapping only validate-expressions.ts. The PR's version has a median of 700 ms and the base version 862 ms, which is noise. The test is CPU-heavy on its own: 6561 linear find() calls, about 0.6 to 1.0 s on a dev box, and under CI shard contention it reached its 5000 ms budget.
  • On the CI-equivalent tree (this head merged with main 8cbe255ef6): the full lint suite is 5806 / 5806, and the protocol door file is 119 / 119.
  • The fix: none on the PR side. The test belongs to main, and it stays untouched: it is not skipped, not loosened, and its timeout is not raised. One re-run of the job re-measures it.
  • Who can re-run: this seat has no re-run route through its write channel, so the re-run is requested from the maintainer. The contract review PASS 6058545425 still names this head.

This was referenced Oct 8, 2026
…tion-visible-when-parent

Brings main up to b460153, which carries the access-matrix exhaustive
fold fix, so CI re-measures this branch on current main.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c6d1020b90288d16f30ed3c60fd4423924beeb3f
Local-runs: none

A delta record over the PASS 6058545425 on 012e8d7dbe. The head is a merge of main b460153912 into 012e8d7dbe (parents confirmed on the fetched ref); the new merge base is b460153912, the old one 73a0a6bf1d is its ancestor, and 65 commits came in between them.

Inputs, and nothing else: card #22157 (body and its seven comments 6052678360, 6056732334, 6058210167, 6058329084, 6059884538, 6071296830, 6071326077), PR #22268 (body, the 5-file list, the net diff of c6d1020b90 against b460153912, the net diff of 012e8d7dbe against 73a0a6bf1d), the head's check-runs, and the binding text at all four commits: packages/lint/src/validate-expressions.ts at both heads, SCOPE_ROOTS in packages/formula/src/cel-engine.ts and evaluateOptionVisibility in packages/objectql/src/validation/rule-validator.ts at both bases, plus what main changed in the window on the route from that pass to the two doors (authoring-rules.ts, runtime-gate.ts, protocol.ts), read because the question "how is the verdict reached" runs through them. The merge-round report and the seat's delta ACCEPT were read as inputs, not as evidence; each judgment below is re-derived from the diffs and the blobs.

① Derived judgments

(a) The PR's own delta is byte-identical across the merge — HOLDS. Per file, git patch-id --stable of the diff from the merge base is equal at the two heads: .changeset/22157-option-visible-when-parent.md e9ff4dd01003, runtime-gate.object-option-visibility-writes.test.ts ae055b1f4fb8, validate-expressions.test.ts a36f70ee7337, validate-expressions.ts 0a09c3d7eba5, protocol.runtime-authoring-gate.test.ts a763391f5cbf. The two diff texts differ only in their index lines and in the @@ offsets of the source file's four hunks (main's flow-pass deletion moved them 63 lines up); every added and deleted line is the same, 449 added and 6 deleted, 5 files.

(b) What main brought into validate-expressions.ts leaves the option verdict untouched — HOLDS. One commit changes the source file in the window, 0e9e7b7066 (#19939 C half, PR #22259), in three hunks that are all in the flow pass: the import swap (resolveFlowNodeValueSlots out, flowNodeValueTemplateRefusals in), the deletion of the flow-template-grammar import and the three template-hint helpers, and the value-slot loop, which now pushes error refusals from the spec's judge instead of warning hints. None of the symbols the option verdict reads moves. At the merged head collectCelRootIdentifiers and SCOPE_ROOTS are still imported from @objectstack/formula (lines 83 and 85), celSourceOf is at 410, FIELD_TRAVERSAL_CONSEQUENCE at 995, OPTION_VISIBLE_WHEN_BOUND_ROOTS at 1111 with its six roots, optionVisibleWhenRootIssue at 1143, and the option loop at 2154 to 2161 still calls it after check and before the traversal refusal, inside the object and field walk with no runtimeWriteType fence. 2f70c2222d (#22319) changes the source file by 0 lines; in the test it moves one fixture key (itemVariable to iteratorVariable) in the #17493 describe near line 4379, and 0e9e7b7066's test edits are near 3026 (the receivers meta-test's plumbing list, grammar out, templateRefusal in, matching its own source change) and 3857. The PR's #22157 describe is at lines 2005 to 2093, and its other two test files have no main-side change in the window at all. The merge did not interleave with the PR's lines anywhere.

(c) The sources the verdict mirrors are unchanged across the window — HOLDS. Blob ids are equal at 73a0a6bf1d, b460153912, 012e8d7dbe and c6d1020b90: cel-engine.ts 6fa1738be716, stdlib.ts (buildScope) d7d2c4d8e3b0, rule-validator.ts 41ed77fc129d, and the whole packages/formula/src tree fe2e2a3a8d1b. SCOPE_ROOTS is the same 27 roots; evaluateOptionVisibility still hands the engine record: merged, previous, user and permissions and nothing else, and still takes the fail-open continue on a fault. So the bound set is still the six roots and the refused set the same 21, the generated-table pin filters the same list, and USER_SCOPE_ROOTS beside the evaluator is the same reading. The one main change under packages/objectql/src/validation/ is record-validator.ts (4578c56e65, #22282, card #22183): keptOptionValues, the value-in-options membership arm of validateRecord, which binds no expression root and never reaches evaluateOptionVisibility. It does not touch which roots an option visibleWhen may read.

(d) How the verdict reaches the two doors, re-derived under what main changed on that route — HOLDS, with one shape change named. runtime-gate.ts is byte-identical across the window. Two changes sit on the route:

Observed, not vouched for: every Test Core shard is success on the head, so the lint suites and the protocol door file ran green under the re-rendered finding and the reordered door.

(e) The earlier record's reasoning at this head. Its (a), (b) and (e) rest on the binding blobs, which the merge did not touch, and are adopted unchanged. Its (c), the Clause-②: no (narrowing) reading, rests on the diff and the changeset, both equal by patch-id, and is adopted; the non-blocking note that line 2's parenthetical names parent while the verdict refuses 21 roots stands, still non-blocking, for the reasons given there. Its (d), door parity, is not adopted but re-derived above under (d) here, because the merge did touch the rendering on that route.

② Semver level

Unchanged and CORRECT: minor for @objectstack/lint and @objectstack/metadata-protocol, fix(lint)!, the BREAKING section, the remedy, and the ADR-0087 marker not-required (no-migration-prescription). The changeset is the same patch (e9ff4dd01003). The gates read it the same way because nothing that reads it moved in the window: check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, .changeset/pre.json (pre mode, tag next), .changeset/config.json and AGENTS.md ("(narrowing) is BREAKING", line 1076) all have an empty diff from 73a0a6bf1d to b460153912. On the head, Check Changeset's steps "Require an ADR-0087 disposition on a declared-breaking changeset" and "Guard against accidental major bumps (launch window)" are success.

③ Boundary flags

  • Dispatch premise, corrected as the merge-round report also corrects it: fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319 (2f70c2222d) changed validate-expressions.ts by 0 lines; only its test moved, by one fixture line outside the PR's describe.
  • The merge-round deviations in 6071296830: the push before the measurement is one fast-forward and the pushed head is the one measured (parents and tree read on the fetched ref); the amended merge message was never pushed in its first form; the dual-build gate NOT MEASURED locally is measured in CI, where Build Core's step "Every published require entry point actually loads" is success on the head.
  • The Docs Drift Check comment on the PR notes its own checkout carried uncommitted changes; that is the runner's note about its merge checkout, not this PR's tree.
  • Check-runs on the head. One read, for the conclusions: 39 runs, none failed. in_progress: Test Core (1/6), at its step "Run this shard's tests". success: Build Core, Check Changeset, Lint & Repo Gates, TypeScript Type Check with the four Type Check jobs, Test Core (2/6) to (6/6), Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, the claim and closing-keyword guards, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter; the combined status (Vercel) is success. skipped: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the skipped twins of Auto Label and Check PR Size. Test Core (5/6), the shard that was red on 012e8d7dbe, is success. A second read of the same endpoint, made a few minutes later to fetch the runs' output fields, found Test Core (1/6) and the Test Core rollup success as well; no monitor was started and nothing was waited on. This record is written at 2026-10-09T00:01Z; both reads preceded it. No red context exists on this head, so nothing is this PR's to answer. The record does not vouch for CI; its conclusions are read.
  • Reads outside git and gh: two GitHub MCP read calls fetched the tail of the Test Core (5/6) job log (the REST log route is proxy-forbidden here) to name that shard's package set; the tail reached only its last two minutes, and the question became moot once every shard was green. No MCP write tool was called; this comment is the one write.

Implemented-by: claude/issue-22157-option-visible-when-parent
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 00:02
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 00:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit bb4f5cc Oct 9, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22157-option-visible-when-parent branch October 9, 2026 00:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…iltin node types is refused at the build doors; one judge per type (objectstack-ai#22319)

Fixes objectstack-ai#21982

Clause-②: yes (narrowing: an undeclared config key on 10 more builtins,
every strict-contract builtin but try_catch, is refused at the build
doors and the save door, where it passed; widening: a save door with no
flow canonicalizer judges the D2-converted body, so a D2 alias spelling
it refuses today, script functionName / input and subflow flow, is
accepted again, as at every other door)

That line is the claim's, as revised by the seat answer `6063587988`,
copied verbatim. The narrowing covers 10 builtins; `try_catch` stays on
the descriptor walk, as the seat ruled, and is named below with its
measured key difference. The widening is the save door's flow fallback
(below).

## Seat answers to the build round, comment 6063587988

- **Q1: A.** On the save door's flow fallback (no canonicalizer
resolved, or it threw), the gate judges the D2-converted body. The
stored body stays the raw request body, exactly as before. This keeps
the 6 tests in `protocol.save-flow-canonicalization.test.ts` that the
build round turned red green as written.
- **Q2: A.** The two spec controls that pinned 'a builtin's key
membership is nobody's at the build doors' are re-pointed, test-only.
Each keeps an assertion of code and location.

## What changes

- **Spec (`flow-node-config-refusals.ts`).** The key arm of
`flowNodeConfigRefusals` now judges key membership on every builtin in
`getBuiltinNodeConfigContracts()` except `try_catch`. Before, it covered
only `script` and `subflow` (pass 1, PR objectstack-ai#22129).
- The new export `builtinNodeConfigKeysJudged(nodeType)` names exactly
the types it judges.
- An undeclared key is refused as `node-config-refused-by-contract`,
`params: { nodeType, key }`, one refusal per key, anchored where the
author wrote it (`nodes.N.config.bogusKey`,
`nodes.N.config.fields.0.visibleIf`).
- The message carries the contract's own sentence, including its
prescription for a known slip (`fieldValues` → `fields`, `bulk` →
`multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near
miss). It now closes with the remedy the walk's rejection carried:
rename the key to one the contract declares there, or remove it.
- A body-less legacy `loop` is judged on key membership alone. Presence
and values keep `parsedWhen`.
- A key at or under a region slot belongs to `validateControlFlow` at
registration, the same carve-out the value arm has. That covers a key on
a `loop` body or a `parallel` branch, and the keys of their nodes and
edges.
- **`metadata-protocol` (`protocol.ts`, `domain:engine`), the save
door's flow fallback.** When no flow canonicalizer resolved, or it
threw, the gates judge `applyConversionsToFlow(raw body)` from
`@objectstack/spec`. No `reservedNodeTypes` are passed, because no
engine is on that path; the result is used for the verdict only and is
never persisted. The stored body is the raw request body, as before. The
canonicalized path is untouched. Two gates read that verdict body:
  - the schema gate (the surface the seat named);
- the runtime authoring gate. This is one line, `body:
flowGateVerdictBody ?? gatedItem`, declared here as a measured
extension. On an active save the lint's config judge refused the raw
`filters` alias there too: 5 of the 6 tests stayed red with the schema
gate alone, failing at `failed author-time validation … config.filters`.
The credential walk keeps the raw body.
- `duplicatePackage` needs no edit. With no canonicalizer it copies the
raw body (`item = body`, about `:24693`) and re-saves through
`this.saveMetaItem` (about `:24779`), which reaches the same fallback.
- **`service-automation` (`engine.ts`).** `validateNodeConfigKeys`
stands aside for every type `builtinNodeConfigKeysJudged` names, so each
node type has one judge. It keeps `try_catch` and every plugin node
type.
- **Ledger.** One step-18 D3 entry,
`flow-builtin-node-config-undeclared-keys-refused`, with rationale order
89 (the highest on `main` at `dc4a5c630` is 88). `registry.ts` is
regenerated. `api-surface/` and `export-origins/` are regenerated for
the new export.
- **Changeset.**
`.changeset/21982-flow-builtin-node-config-undeclared-keys-refused.md`:
`@objectstack/spec` minor (BREAKING, ADR-0087 `registered`),
`@objectstack/service-automation` patch, and
`@objectstack/metadata-protocol` minor. It carries the revised
`Clause-②` line verbatim. Pre mode is on at `main` `dc4a5c630`
(`.changeset/pre.json` mode `pre`, tag `next`). The grade follows pass 1
and objectstack-ai#21898's launch-window convention for accept-set narrowings.
- **Comments only (`flow.zod.ts`).** The `FlowSchema` superRefine
comment (the surface the seat named), and the module header's
key-membership paragraph, which the change also made false. Both are in
the same file and are comment-only.

## P1: descriptor key sets against contract key sets

Measured at `fbcbcf124` (tsx probe, descriptors from
`installBuiltinNodes`, contracts from the spec dist):

| type | descriptor walk positions and keys | contract keys at those
positions | equal? |
|:--|:--|:--|:--|
| get_record | config {fields, filter, limit, objectName,
outputVariable} | same, strict | yes |
| create_record | config {fields, objectName, outputVariable} | same,
strict | yes |
| update_record | config {fields, filter, multi, objectName} | same,
strict | yes |
| delete_record | config {filter, multi, objectName} | same, strict |
yes |
| notify | config {actionUrl, actorId, channels, message, payload,
recipients, severity, sourceId, sourceObject, template, templateData,
title, topic} | same, strict | yes |
| http | config {body, durable, headers, method, signingSecret,
timeoutMs, url} | same, strict | yes |
| screen | config (9 keys); fields[] (12 keys); fields[].options[]
{label, value} | same at all three, strict | yes |
| map | config {collection, flowName, indexVariable, input, itemObject,
iteratorVariable, outputVariable} | same, strict | yes |
| loop | config {body, collection, indexVariable, iteratorVariable,
maxIterations}; body {edges, nodes} | same, strict | yes (the walk also
judged a body-less loop: kept, see H2) |
| parallel | config {branches}; branches[] {edges, name, nodes} | same,
strict | yes |
| **try_catch** | config {catch, errorVariable, retry, try}; try/catch
{edges, nodes}; retry {maxRetries, backoffMs, backoffMultiplier,
maxRetryDelayMs, jitter} | config/try/catch same and strict; **`retry`
is `RetryPolicySchema`, a plain `z.object` that strips an unknown key**,
plus the `retryDelayMs` tombstone | **no, at `retry`**: not moved |

**`try_catch` (named, not moved).** Moving it would have widened
registration: `retry.bogusKey` would have registered. So it stays on the
walk, whole type, one judge. `os validate` and `os compile` still pass
an undeclared `try_catch` key that registration refuses. The seat files
the `RetryPolicySchema` strictness follow-up at landing.

## Hypotheses, measured

- **H1, confirmed.** All 13 contracts answer `unrecognized_keys` at the
root for `{ bogusKey: 1 }`. Nested positions are strict too, except
`try_catch.retry`.
- **H2, falsified as stated; route per the seat.** Today the walk
refuses an undeclared key on a body-less `loop`. So the key arm judges
loop key membership whatever `parsedWhen` says. Pinned: a body-less loop
with `bogusKey` is still refused at `registerFlow`
(`config-unknown-keys.test.ts`), so `registerFlow` widens nowhere.
- **H3, confirmed.** `assignment` is in neither the contract map nor the
walk.
- **H4, measured.** No alias tolerance is needed in
`validate-expressions.ts`. The whole lint suite had one red, a fixture
writing `itemVariable` (a wrong key, not a D2 alias) on a loop with a
body. Fixed in the test, and `validate-expressions.ts` is untouched.

## Registration verdicts, before and after (59-variant probe)

Every variant that `registerFlow` refused before is still refused, and
every one it registered still registers.

- On the 10 moved types, the refusal now comes from the
`FlowSchema.parse` that `registerFlow` makes first. That covers a
top-level `bogusKey` on each type, the walk's 6 guidance keys, `screen`
`fields[0]` and `fields[0].options[0]`, and a body-less loop's
`bogusKey` / `flowName`.
- Region-object keys (`loop` `body.bogusKey`) and region-node keys are
refused by `validateControlFlow`, as before.
- `try_catch` keys are refused by the walk, as before.
- Free-form-map keys and D2 aliases (converted first) register, as
before.

## Measured at the CLI doors

On `examples/app-showcase`, node `notify` in `showcase_task_completed`.
The mutation went through `scripts/ablation-replace.mjs` (blob
`562e884310` → `87b20a9570`, restored to `562e884310` == HEAD).

| door | control | `message: '{summary}' , bogusKey: 1,` |
|:--|:--|:--|
| `objectstack validate` | exit 0 | exit 1, path
`nodes,2,config,bogusKey`, the spec refusal's text |
| `objectstack compile` | exit 0, artifact without `bogusKey` | exit 2,
no artifact |

The first attempt used an anchor that is a substring of its replacement.
`ablation-replace` refused it before running anything (anchor count 1 →
1) and restored the file, so nothing was measured on that attempt.

## Ablation

At HEAD `b9a3295d1`, `builtinNodeConfigKeysJudged`'s body was replaced
by `return false;` via `ablation-replace` (blob `12eb374153` →
`6ab82dce15`, restored to `12eb374153` == HEAD, `git diff HEAD` empty).
On `flow-builtin-node-config-keys.test.ts` plus
`flow-approval-node-config-contract.test.ts`, 21 of 50 tests went red
and 29 held. The spec tests import `src/`, so no dist preflight applies.

## Tests (real readings)

**Patch round, at `d7466a01b`** (merged with `origin/main` `4e4111ca0`
through `os-regen-merge.sh`):

- `@objectstack/metadata-protocol`, whole suite: 221 files passed, 3
skipped; 28283 tests passed, 19 skipped, 0 failed.
- Pre-change red set: the build round's 6 tests in
`protocol.save-flow-canonicalization.test.ts` (at `b9a3295d1`), all
`flow/purge_flow failed spec validation: nodes.0.config.filters`.
- At `72b8d3e97`, with the schema-gate edit alone, 5 of those 6 were
still red at `failed author-time validation … config.filters`. Only the
draft-mode test had gone green.
- At `d7466a01b` all 6 are green as written, plus the 3 new pins: 19/19
in the file.
- `@objectstack/spec`, `--project local`: 626/626 files, 18732 passed, 1
todo. The two re-pointed controls (Q2) are green.
- Typecheck, exit 0: `metadata-protocol` (`tsc --noEmit`) and `spec`
(with `check:test-typecheck`).

**Build round, unchanged by the patch** (at `b9a3295d1`; pin files
re-run at `414fc860c`):

- `service-automation` whole suite 176/176 files, 2163/2163.
- `lint` whole suite 127/127, 5843/5843.
- `runtime --project local` 336/336, 4752 passed, 19 skipped.
- `trigger-record-change` 11/11, 114/114.
- `dogfood` 17 flow pin files, 105/105.
- Examples: showcase 408/408, todo 238/238, crm 45/45.
- Typecheck exit 0: service-automation, runtime, lint.

## Ablation of the fallback edit (patch round)

At `d7466a01b`, through `ablation-replace`. Each run was restored to
blob `f15e4802b5` == HEAD with `git diff HEAD` empty.

- **Schema gate reverted** to `schema.safeParse(request.item)` (blob
`f15e4802b5` → `a0cc936d94`): 9 of 19 red in
`protocol.save-flow-canonicalization.test.ts`. That is the 6 fallback
tests and all 3 new pins: the `functionName` body is refused, and the
`filters` + `bogusKey` bodies report two paths instead of one.
- **Authoring gate reverted** to `body: gatedItem` (blob `f15e4802b5` →
`19e8bbb4ed`): 5 of 19 red, the active-mode fallback tests. The new pins
hold: the lint already tolerates the script `functionName` alias, and
`bogusKey` is refused either way.

## Acceptance notes

- `approval` has two judges: the spec arm, which judges it whole, and
the walk against `getApprovalNodeConfigJsonSchema()`. `registerFlow`'s
parse throws first, so the walk never decides an approval key. Carrier:
none.
- `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` in `engine.ts` is now unread: every
type it keys is spec-judged, and each entry's prescription lives in that
type's contract. It is kept and marked UNREAD, because removing it also
needs the two comments in `builtin-node-config.zod.ts` that name it
updated. Carrier: the next PR to touch `builtin-node-config.zod.ts`.
- The `node-config-refused-by-contract` docblock in
`flow-node-expression-paths.ts` still names only `script` / `subflow`
for the key half. It is incomplete, not false. Carrier: the next PR to
touch that file.
- `objectstack validate` prints the raw issue array at 'Loading
configuration…' for a refused flow. This predates the PR and was noted
on pass 1.
- The designer's fallback writer (`http` `outputVariable`, `notify`
`url`) is tracked at objectstack-ai/objectui#11968, not changed here.
- Open PR objectstack-ai#22268 also edits
`packages/lint/src/validate-expressions.test.ts`. This PR changes one
fixture line there (`itemVariable` → `iteratorVariable`). Whichever
lands later merges `main`.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, run with no paths, at `d7466a01b` against
merge base `4e4111ca0`, derived 96 commands. Two families joined for the
`metadata-protocol` edit: `check:durability-log-level` and
`check:filter-alias-parity`. All 96 were run, each exit code captured
before any pipe. 95 exited 0. `check:dual-build-cjs-loads` exited 3,
PREREQUISITE NOT MET (packages outside the built closure have no
`dist`), so it is NOT MEASURED and CI answers it. `--ran`: `✓
dispatch-gates --ran: 96 derived famil(ies) accounted for — 95 run, 1
NOT-MEASURED (1 DERIVED from a recorded exit 3).`
`check:adr-0087-registration`: `[BREAKING+clause-②-narrowing] registered
flow-builtin-node-config-undeclared-keys-refused`.

eslint, narrowed (`--no-inline-config --format json`) over the diff's 20
`.ts` files: 20 files, 0 errors, 0 warnings. The population is the 20
`.ts` paths of `git diff --name-only 4e4111c HEAD`, and the file count
is read from the json. Invariance: `parserOptions.project` and
`projectService` are null (checked with `--print-config`), so no
type-aware linting runs and no untouched file's verdict can move.

## Size

23 files, +955 / -203 vs merge base `4e4111ca0` (1158 changed lines). 0
governed paths.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ULE_ID` for the reasoning (objectstack-ai#22339)

Part of objectstack-ai#22161
Clause-②: yes (widening: `os explain` accepts a rule id, and
`packages/lint` exports the rule explanations)

## What changes

- **`field-no-consumers` and `security-owd-unset` print one verdict
sentence and one fix.** Their long reasoning moves into one static
explanation per rule id, `RULE_EXPLANATIONS` / `explainRule()` in
`@objectstack/lint` (new module
`packages/lint/src/rule-explanations.ts`, exported from the root barrel
and from a new import-free entry,
`@objectstack/lint/rule-explanations`). Nothing else carries a copy.
- **`os explain RULE_ID`** (the maintainer's spelling, one positional,
no `rule` sub-word): a schema name resolves exactly as before; otherwise
an exact rule id resolves to its explanation (`--json` prints `{ rule,
covers, paragraphs }`). The no-argument listing also names the rule
explanations (`--json` adds `rules: [{ id, covers }]`). An unknown id
exits 1 and names both lists.
- **The `rule:` line carries the pointer, spelled once** —
`explainPointer()` / `authoringFindingDetailLines()` in
`packages/cli/src/utils/format.ts`, used by the build advisory printer,
the gating-error printer (validate, build, verify, init), the validate
advisory list, and `os lint`'s rule line. It appears only for a rule id
the table holds, so it never names a command that would answer
"unknown". The hint line is labelled `fix:`.
- **`os explain`'s schema lookup reads own keys only.** `os explain
constructor` / `__proto__` printed `Schema: Object … undefined` and
threw `schema.required is not iterable` on main. They are now refused as
unknown ids (`6d2eb857c`; pinned in `test/explain-rule-id.test.ts`; with
the own-key check reverted → 1 failed | 10 passed).
- **The `fix:` line is always a fix** (patch round 2).
`expression-invalid`'s authored source is a quote, not a fix, so it now
ends the finding's `message` as `` — source: `…` `` and its `hint` is
empty: the CLI prints no `fix:` line for it, and the source still
reaches the text face and the runtime 422 issue. Four other hints that
carried no instruction now open with one: `component-props-invalid` (its
consequence moved into the message),
`flow-time-relative-descriptor-invalid`, `react-prop-missing-required`
(the contract-description branch), `liveness-experimental-property`.

The maintainer's shape, as `os validate` and `os build` now print it on
a tutorial-shaped project:

```text
  ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert)
    fix: add it to a view column or a form section, or remove the declaration
    rule: field-no-consumers  at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer
```

```text
  • object "my_app_ticket": custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision
      fix: declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)
      rule: security-owd-unset  at objects[1].sharingModel — `os explain security-owd-unset` for why the baseline must be declared
```

## Measured (local CLI built from this branch; tutorial-shaped project:
`my_app_note` + `my_app_ticket`, a grid view on `title`/`status`)

| | before (`59d993c97`) | after |
|---|---|---|
| `os validate`, `field-no-consumers` | one line, 852 chars; no fix, no
rule id | 114 / 77 / 126 chars (verdict / fix / rule) |
| `os build`, `field-no-consumers` | 852 + 692 + 62 chars | 114 / 77 /
126 |
| `os validate`, `security-owd-unset` | 374 + 175 + 58 chars | 156 / 160
/ 130 |
| one `os dev --compile` run | printed once (the compile child), not
again at serve | printed once, same shape |

- **H1 holds:** the message and the build-time "Give … a consumer" text
(the finding's `hint`) are built in
`packages/lint/src/validate-field-consumers.ts`. `os validate` printed
the registry advisory as its `⚠` line only (`commands/validate.ts`),
with no fix and no rule line; `os build` printed message, hint and rule
line through `printAuthoringAdvisories`.
- **H2 holds, with one addition:** the `rule:` line is the CLI
printer's, not the rules' (`utils/format.ts`, two printers). The pointer
is spelled there once. `os validate`'s advisory list had no rule line at
all, so it now renders the same two lines through the same helper
(below).
- **H3 holds:** 16 `os explain` schema names, 214 rule id constants
exported from `packages/lint` — intersection empty (no rule id is a
single word). Pinned in `packages/cli/test/explain-rule-id.test.ts`
(lowercased, against every exported rule id constant).
- **H4 does not hold:** one `os dev --compile -p PORT --fresh` run
printed the warning once (`grep -c field-no-consumers` = 1, before and
after). No printer change was made for it.
- **H5:** far more than 8 over-long rules (below), so this PR builds the
mechanism and shortens `field-no-consumers` and `security-owd-unset`
only. The dead-button `action-governance` line is not an author-time
rule: it is the boot-time `logger.warn` in
`packages/objectql/src/action-governance.ts` (`[action-governance]
declared script actions with NO handler …` — 163 chars as the source
writes it, plus a `{count, actions}` payload; its sibling "registered
handlers with NO declaration" line is 628). It lives outside
`packages/lint` and outside the CLI printer, so it is named here and not
edited.

## Landing outside the claim's file surface, and why

- `packages/cli/src/utils/format.ts` — the H2 printer:
`explainPointer()` and `authoringFindingDetailLines()`; both printers
render through them.
- `packages/cli/src/commands/validate.ts` — measured: `os validate`
printed a registry warning with no fix and no rule line, so a shortened
message would have reached the maintainer's first-named command with no
pointer. The text face now prints the two lines under each registry
advisory via the same helper. The `warnings` list `--strict` and
`--json` read is unchanged.
- `packages/cli/src/commands/lint.ts` — `os lint` prints the same
shortened message; its rule line gains the same pointer (one call to
`explainPointer`).
- `packages/lint/package.json`, `packages/lint/tsup.config.ts`,
`packages/lint/src/rule-id-barrel-exports.test.ts` — the new
`./rule-explanations` entry. `format.ts` is documented as "a pure
formatter with no rule-engine import", and every command imports it;
loading the `@objectstack/lint` root barrel after `@objectstack/spec`
measured 456–547 ms (three runs), which every command (`os explain
object` included) would otherwise pay. The entry's module imports
nothing (pinned by a source scan); its keys and the `field-no-consumers`
roots list are literals held to the rule's constants by
`rule-explanations.test.ts`.
- `packages/cli/README.md` — the `os explain` row.
- Tests updated for the new text:
`packages/cli/src/utils/author-time-rules.test.ts` (read the field from
`where`, not `message`),
`packages/cli/test/truncation-remainder-notices.test.ts` (`fix:` label),
`packages/cli/test/validate-build-gate-parity.test.ts` (classifies
`authoringFindingDetailLines` as presentation). No test outside
`packages/lint` / `packages/cli` pins either old message.

## Tests, round 1 (all local, this branch; head `315a26618` unless a run
names another; round 2's readings are under `## Patch round 2`)

New pins: `packages/lint/src/rule-explanations.test.ts` (every key is an
exported rule id under its own key; `covers` fits the pointer; no
tracker number in the text; the roots paragraph equals `CONSUMER_ROOTS`
/ `CARRIER_ROOTS`; exact-id lookup; the module imports nothing), the
shape pins in `validate-field-consumers.test.ts` and
`validate-security-posture.test.ts` (verdict line and fix line, exact),
`packages/cli/test/explain-rule-id.test.ts` (H3 disjointness; every
pointer target resolves through `Explain.run`; the printed verdict /
`fix:` / `rule:` lines of each rule's REAL finding; schema lookup
unchanged; unknown id exits 1), and
`packages/cli/test/rule-line-explain-pointer.e2e.test.ts` (spawns `os
validate` and `os build`; a `*.e2e` file, so the nightly tier).

- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` →
`Test Files 128 passed (128)`, `Tests 5853 passed (5853)` (at
`ed786eb3e`; no lint file changed after it).
- `pnpm --filter @objectstack/lint run typecheck` → exit 0,
`check:test-typecheck: OK — … 2 file(s) / 6 error(s) / 2 pinned
signature(s) held`.
- `pnpm --filter @objectstack/cli run typecheck` → exit 0,
`check:test-typecheck: OK — … 3 file(s) / 28 error(s) / 6 pinned
signature(s) held` (at `315a26618`).
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2 --shard=N/3` (the full unit tier, in three foreground
shards because one run exceeds the container's foreground cap under
load): shard 1 `89 passed` / `1523 passed` and shard 2 `88 passed, 1
failed` (at `ed786eb3e`); shard 3 `89 passed` / `1264 passed` (at
`315a26618`). The shard-2 failure was
`src/utils/author-time-rules.test.ts` reading the field name from
`message`; fixed in `315a26618` and re-run with
`test/lint-per-package-authoring-seam.test.ts` → `2 passed` / `10
passed`.
- `--project integration` (declared to CI as a whole), the ten files
that spawn validate / build / verify / lint and read their text:
`test/build-text-face-advisory-count`, `verify-author-time-stage`,
`validate-per-package-authoring-parity`, `union-fold-command-parity`,
`authoring-rule-command-parity`, `validate-view-container-name`,
`build-view-container-name`, `picklist-reference-doors`,
`lint-per-package-authoring-parity`,
`validate-lint-mapping-connector-source` → `Test Files 10 passed (10)`,
`Tests 62 passed (62)`.
- `OS_TEST_TIERS=nightly … vitest run
test/rule-line-explain-pointer.e2e.test.ts` → `2 passed`;
`validate-json-warning-parity.e2e.test.ts` (the `⚠` line still pairs
with `--json`) → `3 passed` (both on the `ed786eb3e` tree).
- Ablation (one-shot, nothing kept): `scripts/ablation-replace.mjs`
replaced `explainPointer`'s return with `''` in
`packages/cli/src/utils/format.ts` (anchor 1 → 0, blob `9d90c98c409d` →
`4427f41a866d`), `test/explain-rule-id.test.ts` → `3 failed | 7 passed`;
restored, blob `9d90c98c409d` == HEAD, `git diff HEAD` empty.
- Cross-package type read: `packages/cli` builds against
`@objectstack/lint/rule-explanations`, an entry that exists only in the
rebuilt `dist/` (`dist/rule-explanations.{js,cjs,d.ts,d.cts}`), so the
CLI build read the rebuilt declarations. CJS `require` and ESM `import`
of the entry both load (`['field-no-consumers', 'security-owd-unset']`).
- ESLint, narrowed to the diff: `npx eslint --no-inline-config --format
json` over the 18 changed `.ts` files → 18 files in the JSON report, 0
errors, 0 warnings; `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, its own comment at `:327`), so this diff
cannot move a verdict on an untouched file. Repo-wide `pnpm lint` is
CI's.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands` (no paths) at
`315a26618` derived 78 commands, a superset of the 51 at dispatch. Ran
all 78: 76 exit 0; `pnpm check:dual-build-cjs-loads` and `pnpm
check:i18n-coverage` exit 3, PREREQUISITE NOT MET (packages outside the
CLI's build closure have no `dist/` in this worktree) — NOT MEASURED,
CI's. Reconciliation: `✓ dispatch-gates --ran: 78 derived famil(ies)
accounted for — 76 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit
3).` Also run, exit 0: `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity` (the three
artifact-roster gates whose roster sits under `packages/`),
`check:published-readme-exports`, `check:published-readme-links`,
`check:cli-examples-parity`. Control-character scan over every changed
file: no match.

## Second stage — the over-long rules this PR does not shorten

Measured by running the whole `packages/lint` suite at `59d993c97` (127
files, 5843 tests) with a scratch hook that recorded, per rule id, the
longest `message` of any finding pushed: 240 rule ids fired, 157 with a
message over 200 characters. Lengths are the `message` alone (the
printed line adds `where` and `: `). A rule id that fired in no test is
not in this count.

**Second stage, in `packages/lint` (not touched here) — 124 rule
id(s):**

- `validate-rls-predicate-enforceability.ts`:
`rls-predicate-unparseable` 2056, `rls-predicate-unenforceable` 1679,
`rls-predicate-unknown-user-variable` 1544,
`rls-predicate-unknown-field` 1399, `rls-predicate-over-budget` 1259
- `validate-sharing-rule-enforceability.ts`:
`sharing-rule-unlowerable-condition` 1093,
`sharing-rule-object-not-shareable` 774,
`sharing-rule-object-controlled-by-parent` 660,
`sharing-rule-runtime-variable-condition` 492
- `validate-rule-schema-formats.ts`:
`validation-rule-json-schema-unknown-format` 1049
- `validate-action-dispatch-contract.ts`:
`action-dispatch-contract-mismatch` 927
- `validate-component-props.ts`: `component-props-invalid` 920,
`component-props-unknown-key` 844
- `validate-sortable-fields.ts`: `sort-field-unprovisioned` 844,
`sort-field-unsortable` 369, `sort-field-unknown` 287
- `validate-dataset-measure-aggregates.ts`:
`measure-aggregate-field-type-refused` 809,
`dimension-json-stored-field-refused` 531
- `validate-component-types.ts`: `component-type-unknown` 807
- `validate-flow-trigger-readiness.ts`:
`flow-time-relative-descriptor-invalid` 796,
`flow-time-relative-descriptor-unroutable` 532,
`flow-trigger-unroutable` 518, `flow-api-trigger-secret-missing` 336,
`flow-trigger-unknown-event` 222
- `validate-hook-body-writes.ts`: `hook-body-write-unprovisioned-anchor`
792, `hook-body-write-unknown-field` 465, `hook-body-source-unparseable`
212
- `validate-react-page-props.ts`: `react-chart-drilldown-invalid` 784,
`react-chart-aggregate-invalid` 507, `react-chart-field-unprovisioned`
429, `react-block-needs-record-context` 267,
`react-page-source-unparseable` 211
- `validate-action-body-writes.ts`:
`action-body-write-unprovisioned-anchor` 778,
`action-body-write-unknown-field` 433, `action-record-write-discarded`
293, `action-body-source-unparseable` 212
- `validate-flow-node-writes.ts`: `flow-node-write-unprovisioned-anchor`
739, `flow-node-write-unknown-field` 421
- `validate-security-posture.ts`:
`security-controlled-by-parent-ambiguous-relation` 697,
`security-fls-unknown-field` 593,
`security-controlled-by-parent-no-relation` 526,
`security-master-detail-ungranted` 449, `security-owd-alias` 354,
`security-delegation-missing-reason` 210
- `validate-preset-comparands.ts`: `filter-preset-comparand` 669
- `validate-visibility-predicates.ts`:
`visibility-predicate-unknown-function` 655,
`visibility-predicate-over-budget` 507, `visibility-bare-identifier`
411, `visibility-predicate-syntax` 341, `visibility-root-mislayered` 304
- `validate-predicate-path-refs.ts`: `predicate-rhs-path-shaped` 648,
`predicate-path-unrooted` 434, `predicate-path-unresolved` 371
- `validate-page-visualization-bindings.ts`:
`page/visualization-without-binding` 629
- `validate-translatable-sections.ts`:
`translation-section-name-missing` 553
- `validate-nav-object-servability.ts`: `nav-object-unservable` 528
- `validate-searchable-fields.ts`: `searchable-field-unprovisioned` 512,
`searchable-field-unsearchable` 449, `searchable-field-unknown` 295
- `validate-widget-bindings.ts`: `dashboard-filter-field-unprovisioned`
509, `chart-field-unknown` 407, `dashboard-filter-field-not-included`
370, `widget-filter-field-unknown` 364, `dashboard-filter-field-unknown`
333, `chart-dimensions-missing` 306, `widget-filter-field-not-included`
282, `widget-measures-missing` 255, `widget-sortby-unselected` 242,
`chart-measures-missing` 224, `widget-legacy-analytics-unrenderable` 205
- `validate-rule-compilability.ts`:
`validation-rule-json-schema-uncompilable` 489,
`validation-rule-regex-uncompilable` 482
- `validate-page-field-bindings.ts`: `page-field-unprovisioned` 484,
`page-section-group-unknown` 214
- `data-model-rules.ts`: `unique/legacy-organization-composite` 478,
`unique/unscoped-declared-index` 427, `unique/double-declaration` 381
- `validate-mapping-target-fields.ts`: `mapping-target-field-unknown`
466
- `validate-ai-agent-authoring.ts`: `default-agent-legacy-alias` 466,
`default-agent-outside-roster` 388, `agent-authoring-withdrawn` 352
- `validate-readonly-hook-writes.ts`: `hook-api-update-readonly-field`
464, `hook-api-update-readonly-when-field` 267
- `validate-approval-approvers.ts`:
`approval-approvers-may-resolve-empty` 451,
`approval-approver-not-membership-tier` 272
- `validate-dataset-references.ts`: `dataset-field-not-included` 446,
`dataset-field-unknown` 296, `dataset-filter-field-unknown` 294,
`dataset-include-unknown` 260
- `validate-list-view-field-refs.ts`: `list-view-field-dotted` 445,
`list-view-field-unknown` 355
- `validate-chart-bindings.ts`: `chart-measure-unknown` 442,
`chart-axis-not-selected` 327
- `validate-ai-tool-references.ts`: `ai-skill-tool-unresolved` 441
- `lint-view-refs.ts`: `view-ref-nav-view-missing` 437,
`view-key-collision` 257
- `validate-nav-target-refs.ts`: `nav-target-unresolved` 426
- `validate-managed-api-methods.ts`:
`object/managed-api-method-unaffordable` 412
- `validate-readonly-flow-writes.ts`: `flow-update-readonly-field` 410,
`flow-update-readonly-when-field` 317
- `validate-action-name-refs.ts`: `action-name-undefined` 409
- `validate-readonly-action-writes.ts`:
`action-api-update-readonly-when-field` 396
- `lint-flow-credential-literals.ts`: `flow-credential-literal` 390
- `validate-filter-tokens.ts`: `filter-token-unknown` 386
- `validate-print-page-blocks.ts`: `print-page-block-unprintable` 368
- `validate-org-axis-red-lines.ts`: `org-axis-cross-org-bu-grant` 365
- `validate-nav-access.ts`: `nav-object-ungranted` 353
- `validate-empty-combinators.ts`: `filter-empty-combinator` 352,
`filter-empty-node` 226
- `validate-translation-references.ts`: `translation-target-unknown`
345, `translation-option-key-unknown` 230
- `validate-object-references.ts`:
`object-reference-unregistered-platform` 324
- `validate-object-field-refs.ts`: `object-field-ref-unknown` 320
- `validate-seed-state-machine.ts`: `seed-value-outside-state-machine`
320
- `validate-semantic-roles.ts`: `semantic-role-field-unprovisioned` 291
- `validate-view-containers.ts`: `view-container-shape` 290
- `validate-ai-surface-affinity.ts`: `ai-skill-surface-mismatch` 281
- `validate-flow-filter-tokens.ts`: `flow-filter-token-unknown` 278
- `validate-dashboard-action-refs.ts`:
`dashboard-action-route-unresolved` 242,
`dashboard-action-target-undefined` 239
- `validate-retired-permission-residue.ts`:
`permission-retired-lifecycle-residue` 235
- `validate-seed-replay-safety.ts`:
`seed-insert-mode-duplicates-on-replay` 222
- `validate-capability-references.ts`: `capability-reference-unknown`
219
- `validate-form-layout.ts`: `form-section-group-unknown` 214

**Excluded this round — files open PRs objectstack-ai#22268, objectstack-ai#22315, objectstack-ai#22319 edit — 19
rule id(s):**

- `validate-expressions.ts`: `expression-invalid` 2027
- `lint-flow-patterns.ts`: `flow-multi-write-unfiltered` 656,
`flow-decision-mode-invalid` 528, `flow-loop-body-uncontained` 522,
`flow-try-catch-without-catch` 520,
`flow-approval-revise-target-not-service-owned` 366,
`flow-decision-unconditional-branch` 342, `flow-error-label-not-fault`
315, `flow-inert-node-condition` 286, `flow-runas-unscoped` 284,
`flow-branch-label-unmatched` 272, `flow-decision-inclusive-overlap`
250, `flow-default-edge-with-condition` 239,
`flow-multiple-default-edges` 211, `flow-time-relative-antipattern` 208,
`flow-date-equality-filter` 208
- `validate-flow-template-paths.ts`: `flow-template-field-unprovisioned`
440, `flow-template-lookup-traversal` 349, `flow-template-unknown-field`
258

**Message lives outside `packages/lint` —
`packages/spec/src/kernel/functional-completeness.ts` (named, not
edited) — 8 rule id(s):**

- `functional-completeness.ts`: `view/row-color-without-colors` 793,
`view/layout-without-binding` 673, `webhook/without-triggers` 594,
`view/tree-without-parent-field` 592, `field/summary-without-operations`
319, `field/formula-without-expression` 259,
`field/choice-without-options` 250,
`field/relationship-without-reference` 239

**Not an author-time registry rule — 4 rule id(s):**

- `lint-startup-registry-verdict.ts` (the repo gate
`check:startup-registry-verdict`): `startup-open-vocabulary-verdict`
779, `startup-verdict-assertive-wording` 731
- `data-model-rules.ts` `lintDataModel` (`os lint`'s own data-model
rubric): `relationship/master-detail-required` 462,
`rollup/non-numeric-aggregand` 364

Each second-stage rule takes the same shape: move the long text into
`RULE_EXPLANATIONS` (the pointer then appears on its `rule:` line by
itself), leave one verdict sentence and one fix, and pin the new shape
in the rule's own test. The excluded three files can follow once objectstack-ai#22268,
objectstack-ai#22315 and objectstack-ai#22319 land.

## Acceptance notes

- The `fix:` label now prefixes the hint under every author-time finding
the CLI prints (build, validate, verify, init), not only the two
shortened rules. Round 2 measured every hint producer for text that is
not a fix and changed five (listed under `## Patch round 2`); every
other rule's hint text is unchanged. Borderline rows were counted as
fixes, because each carries an instruction or a spelling to write:
`validate-component-types.ts:150`,
`validate-flow-trigger-readiness.ts:632`, `runtime-gate.ts:1020`,
`lint-liveness-properties.ts:254` / `:273`, and the `fix` snippets in
`functional-completeness.ts`.
- `expression-invalid`'s runtime 422 issue now carries `hint: ''`; its
message carries the source. objectui's save-advisory toast already skips
an empty hint (`saveAdvisoryToast.ts:97`). The one place that prints the
bare value is the deduped operator log line in `metadata-protocol`
`runtime-authoring-gate.ts:1130` (`… (${advisory.hint})`), which now
ends in `()` for an `expression-invalid` warning. It is cosmetic,
server-log only, and not changed here.
- `os validate`'s text face now shows `fix:` and `rule:` lines under
every registry warning (it showed neither before); the `warnings` list
`--strict` and `--json` read is unchanged, so
`validate-json-warning-parity.e2e.test.ts` still pairs the faces.
- Runtime publish gate: `security-owd-unset` also runs at the metadata
write door, so a Studio / REST / MCP refusal carries the shorter message
and hint too; the explanation is reachable from the CLI only.
- `origin/main` `e9a1f5c40` is merged (`d33862bde`, a merge commit).
- No new gate and no length ratchet (the ruling); each shortened rule's
own test pins its shape.

## Patch round 2 (seat order `6067462250` → `74bed8f56`)

Written into this body by the `domain:spec` seat 2 at 2026-10-08T20:46Z
from the dev's report `6068666691`; the role file reserves a later body
edit to the seat.

- **Measured, non-fix hints** (static read of the 274 `hint:` values in
`packages/lint/src`, plus the `fix:` values in
`functional-completeness.ts` and the shared hint helpers). Five, at the
stop condition's limit, none in the three excluded files:
- `authoring-rules.ts:687`, `expression-invalid`: quoted the source. The
source now ends the message, and the hint is empty.
- `validate-component-props.ts:336`, `component-props-invalid`: context
only. The hint is the fix, and the consequence moved into the message (a
CLI-only rule).
- `validate-flow-trigger-readiness.ts:497`,
`flow-time-relative-descriptor-invalid`: context only. The hint opens
with the instruction.
- `validate-react-page-props.ts:1166`, `react-prop-missing-required`:
the hint was the binding's description alone. It is now `Pass REQ={…}:
DESCRIPTION`.
- `lint-liveness-properties.ts:247`, `liveness-experimental-property`:
the hint was a statement. It now opens with an instruction.
- **Pin:** `packages/cli/test/explain-rule-id.test.ts` runs the real
registry adapter on the tutorial's action and prints through
`printAuthoringRuleErrors`. It asserts exactly two lines, the source
inside the verdict line and no `fix:` line. Ablated with the dist leg
(adapter reverted, lint rebuilt): 1 failed | 11 passed. Restored to the
HEAD blob, and the rebuilt dist carries no marker.
- **Docs blocks re-rendered from the printer:**
`content/docs/getting-started/build-with-claude-code.mdx` `:309`–`:313`
and `content/docs/ui/react-pages.mdx` `:361`–`:363`, `:403`–`:405`. The
`:403` block was already stale before this PR (the fallback hint where
the contract has a description). Cross-lane on objectstack-ai#6023.
- **Changeset:** it names the runtime-wire `message` change for
`expression-invalid`. Its count of the reworded rules that reach a
runtime response is corrected in patch round 3, below.
- **Readings:**
- lint: 128 files / 5853 passed, and typecheck exit 0, both at
`e84732425`.
- cli: unit 4 files / 120 passed and integration 4 files / 21 passed
(the spawn tests that print or read `expression-invalid`), and typecheck
exit 0, all at `819444f50`.
  - ESLint on the 7 changed `.ts` files: 0 errors / 0 warnings.
- `dispatch-gates --commands` (no paths) at `819444f50`: 106 derived
(round 1's 78 plus 28 docs/spec families), all 106 exit 0.
- The three dist-reading gates exited 3 on the fresh worktree and exit 0
after the remaining packages were built.
- `--ran`: 106 run, 0 NOT-MEASURED. The 20 changeset/text families
re-ran on `74bed8f56`: exit 0.
- Round 1's two NOT-MEASURED gates (`check:dual-build-cjs-loads`,
`check:i18n-coverage`) also exit 0 at `6d2eb857c`.
- **Line budget:** round 2 is 10 files, +87 / -18. The whole PR against
`e9a1f5c40` is 28 files, +919 / -81. Governed paths touched: 0.

## Patch round 3 (contract review FAIL `6068965879` → seat order
`6068983639` → `1e016895c`)

Written into this body by the `domain:spec` seat 2 at 2026-10-08T21:10Z
from the dev's direct report; the role file reserves a later body edit
to the seat. One commit, `.changeset/22161-rule-message-one-line.md`
only (+4 / -2). Each sentence was checked against `surfaces`,
`runtimeTypes` and severity in the code before it was written.
- **The reworded hints at the gate.** Only
`flow-time-relative-descriptor-invalid` reaches a 422 `hint`: it is an
`error` on `flow` writes.
- `liveness-experimental-property` is always a `warning` on
`email_template`, `mapping` and `datasource` writes, so it would ride
the 2xx `advisories`. No ledger row on those types is `experimental`
today, so it reaches no runtime response yet. This corrects the seat's
own order, which put it on the 422.
  - `component-props-invalid` is CLI-only.
  - `react-prop-missing-required` judges no `page` write at the gate.
- **`security-owd-unset` at the `object` write door.** A custom object
(neither `isSystem` nor `sys_`-named) with no `sharingModel` is refused
with a 422, and its issue now carries the new `message` and `hint`, both
quoted verbatim. `where` and `path` still carry the object; `os explain
security-owd-unset` prints the incident.
- **`expression-invalid`**: the source rides the issue `message` at the
gate for `flow`, `action`, `hook` and `object` writes. An `error` lands
in the 422, a `warning` in the 2xx `advisories`. The runtime `hint` is
`''`.
- **`os explain` unknown id:** it still exits 1. The text changes from
`Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a
`Rules with an explanation: …` line. The `--json` `error` changes the
same way.
- **Gates at `1e016895c`:** the 20 families `dispatch-gates --commands`
derives for the changeset, plus `check-changeset-fixed.mjs`, all exit 0.
No `PREREQUISITE NOT MET`. `main` was not merged (the push was
accepted).

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

---------

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