Repository navigation
fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads parent (#22157) - #22268
Conversation
…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>
📓 Docs Drift Check4 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
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Contract reviewServed-tier: Inputs, and nothing else: card #22157 (body and its four comments ① Derived judgments(a) The allowlist is exactly the bound set — HOLDS. (b) Every refused root is genuinely unbound at the option check — HOLDS. The verdict refuses (c) (d) The door's finding equals the build's — HOLDS. One pass serves both doors: the new verdict sits inside the option loop of (e) The message and prescription are true of the platform — HOLD. The message's "binds only ② Semver level
③ Boundary flagsDeviations in
Out-of-scope findings in
Check-runs on the head, read at 2026-10-08T11:08Z, with CI still running:
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
|
|
The red
|
…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>
Contract reviewServed-tier: A delta record over the PASS Inputs, and nothing else: card #22157 (body and its seven comments ① Derived judgments(a) The PR's own delta is byte-identical across the merge — HOLDS. Per file, (b) What (c) The sources the verdict mirrors are unchanged across the window — HOLDS. Blob ids are equal at (d) How the verdict reaches the two doors, re-derived under what
Observed, not vouched for: every (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 ② Semver levelUnchanged and CORRECT: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
…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>
Fixes #22157
Clause-②: no (narrowing:
os buildand the object save door refuse a select option'svisibleWhenthat readsparent, which the runtime's option check cannot bind)What changes
A select option's
visibleWhenis a gate the server enforces on write. The server's option check (evaluateOptionVisibilityinpackages/objectql/src/validation/rule-validator.ts) bindsrecord,previousand the acting user, and nothing else. An option predicate that readparent.status == 'closed', on a detail with exactly onemaster_detail, passedos buildand the object save door. Then every write that picked the option faulted (Unknown variable: parent) and was admitted unchecked.optionVisibleWhenRootIssueinpackages/lint/src/validate-expressions.tsis an allowlist of what the option check binds:record,previous, and the acting user ascurrent_userwith its ADR-0068 aliasesuser,ctxandos. Every otherSCOPE_ROOTSmember is refused aterror,parentincluded. The finding is located at the option slot (object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen). The message names the option, the field and the root. Forparentit says that only a field's ownreadonlyWhen/requiredWhenbinds that root, and it gives the denormalise-onto-the-detail remedy.runStackExpressionPasses, since 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-3 crossing), so the door's finding is the build's finding.evaluateOptionVisibilitystill binds noparent. The constant's docblock says a root joins the list only in the change that binds it there.SCOPE_ROOTS, not the field-rule's judged vocabulary. The gap is exactly the set of roots the strict env declares. A root it does not declare, such asapp, is already refused on this slot by the bare-reference check, so judging it here would give one mistake two verdicts. The existing lint: a field-level*Whenreadingappgets the generic bare-reference prescription ("Writerecord.app") — the #6290 misprescription class, one root over #13935 pin ("appthere keeps the bare-reference verdict") is untouched and green. Because the judged roots are all declared,checknever reports a bare reference to them, so the two verdicts stay disjoint by construction.The dispatch's hypotheses, measured
73a0a6bf1d, through the built@objectstack/lint, the bodyparent.status == 'closed'on a one-master detail gave 0 findings fromrunAuthoringRules('build')and 0 door errors fromrunRuntimeAuthoringRules({ type: 'object' }). So didinput.x == 1anddata.x == 1. The option loop had no root verdict, andparentis aSCOPE_ROOTSmember, so the bare-reference check stayed silent.evaluateOptionVisibilitycallsExpressionEngine.evaluate(expr, { record: merged, previous, user, permissions }).buildScopeturns that intorecord,previous(when defined) and, when the write carries a user, the sameEvalUserundercurrent_user,user,ctx.userandos.user. In the expression env the acting user is spelledcurrent_user(ADR-0068 D1's canonical root);user,ctxandosare aliases.permissionsmounts no root: it reachescanthrough the environment. The allowlist is exactly those six bare roots, the same root-granularity reading as the runtime's ownUSER_SCOPE_ROOTSnext to the evaluator.*.object.tsunderpackages/**andexamples/**(112 files) plus the twoapp-multi-packagesub-stacks: 119 objects in 18 groups, 0 import or parse failures. Corpus B is the example stacks asdefineStackcomposes them: 33 objects in 5 groups. Both carry the same 5 option predicates, all onshowcase_cascade: fourrecord.countrycascades and one'org_admin' in current_user.positionsgate. Their roots arerecord×4 andcurrent_user×1. Option findings: 0 at the build and 0 at the door (errors and advisories), at both shapes, at base73a0a6bf1dand at head48b9329137. Positive control in the same harness: theparentbody gave build 0 and door 0 at base, and build 1 and door 1 at head.@objectstack/lint(73a0a6bf1d). Through the realsaveMetaItem, 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 422INVALID_METADATA, because the door runs the same pass.Pins (Zone 3)
validate-expressions.test.ts, new describe#22157):parent.status == 'closed'on a one-master detail is refused aterror, at the option slot, with a message naming the option, the field and the root.parentread on the field's ownreadonlyWhenpasses.record,previous,current_user,user,ctx.user,os.user,current_user.can(...)and arecordmember spelled like the root (record.parent_code) all pass.SCOPE_ROOTSmember outside the allowlist is refused, one finding each. The table is generated from the realSCOPE_ROOTS.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 forrecord.xand thecurrent_userrole gate.protocol.runtime-authoring-gate.test.ts, new#22157block, through the real methods):INVALID_METADATA, with oneexpression-invalidissue at the option, and nothing lands.draft. Its promotion throughpublishMetaItemis refused 422, and a package draft publish answersrefusedwith 0 published.record.x == 'a'saves and landsactive.rule,where,path,messageandhintare equal at the door and at the build.Reverse verification (ablation)
The run was made from the committed head
012e8d7dbethroughscripts/ablation-replace.mjsin WRAP mode, with an outertraprestore on EXIT, INT and TERM against the absolute path.Reflect.has(Object, 'ablation22157'), which is always false. The anchor went x1 to x0 and the blob went9781066c15f0to570d3d5df714.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.@objectstack/lintwas then rebuilt, andablation-dist-preflightfound the marker in 4 built files. Protocol: 4 failed, 115 passed, the predicted four.9781066c15f0, equal to HEAD, andgit diff HEADis empty. After a rebuild,--absentfound 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 oforigin/mainb460153912)git patch-id --stableof the diff from the merge base is equal at012e8d7dbe(base73a0a6bf1d) and atc6d1020b90(baseb460153912).pnpm --filter @objectstack/lint test: 128 files, 5862 tests passed.pnpm --filter @objectstack/lint typecheck:tsc --noEmitandcheck:test-typecheckboth 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. Itstsconfig.jsonincludessrc/**/*and excludes onlynode_modulesanddist, so the test file is in the program.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatc6d1020b90derived the same 63 commands as at claim time. 62 exited 0.pnpm check:dual-build-cjs-loadsexited 3 with PREREQUISITE NOT MET: 68 packages unrelated to this diff have nodist/in the local worktree. NOT MEASURED: dual-build-cjs-loads, reason: prerequisite not met locally; CI builds the full tree. The--ranreconciliation reads 63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN..tsfiles with--no-inline-config --format json, gave 4 files, 0 errors and 0 warnings. The population was read fromeslint.config.mjs:**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minus the build-dir ignores. That config enables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any file it does not touch. The repo-widepnpm lintis CI's.Grade and changeset
.changeset/22157-option-visible-when-parent.mdlists@objectstack/lintand@objectstack/metadata-protocolasminor. It hasfix(lint)!, the Clause-② line above, a BREAKING section with the remedy, and the ADR-0087 dispositionnot-required (no-migration-prescription).check-adr-0087-registrationreads it as[BREAKING+bang+clause-②-narrowing].minor, not triage'spatch. 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-protocolis 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.jsonis present onorigin/main, in pre modenext. The bump is stillminor, andcheck-changeset-no-majoris green.@objectstack/lint'sindex.d.ts: the docblock above the exportedFIELD_RULE_BOUND_ROOTSgains 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 inpackages/lint/src/(validate-expressions.test.tsandruntime-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.tswas read, not edited. The diff is +449/-6 lines.Acceptance notes
ctxandoscarry only theirusermember:evaluateOptionVisibilitypasses noorgorenv.012e8d7dbe, an optionvisibleWhenofos.org.id != '',os.env == 'prod'orctx.locale == 'en'passes the build and the door with 0 findings.evaluateValidationRuleswith an authenticated caller,os.org.id != ''andctx.locale == 'en'are admitted withpredicate-fault(No such key: org,No such key: locale).os.envwas not run.visibleWhenreadingappkeeps the bare-reference message, which prescribes therecord-qualified rewrite that lint: a field-level*Whenreadingappgets the generic bare-reference prescription ("Writerecord.app") — the #6290 misprescription class, one root over #13935 removed on the field-rule slots. It is still refused aterror, so only the prose is affected. Carrier: none.validate-expressions.tsnever walks a picklist's own optionvisibleWhen(thepicklistmetadata type). Whether the server's option check ever evaluates those predicates was not read. Carrier: none.Generated by Claude Code