Repository navigation
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
Conversation
…very builtin but try_catch; the descriptor walk stands aside (wip) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…spec refusal; keep plugin and try_catch cases on the walk (wip) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
… key is the spec's; loop fixture uses its declared key (wip) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…eConfigKeysJudged (wip) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…iltin-undeclared-keys
📓 Docs Drift CheckThis PR changes 3 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 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 043f58c4a9d8ac57f4cf59233167fc748004b587 && git checkout 043f58c4a9d8ac57f4cf59233167fc748004b587
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28bff18d0c4013db86d61eba87c739c3145e17fd 6e56f2f68a5439d2ed797a91e7016750782a9662 && git checkout -B drift-repro 28bff18d0c4013db86d61eba87c739c3145e17fd && git merge --no-ff 6e56f2f68a5439d2ed797a91e7016750782a9662
node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd
|
…iltin-undeclared-keys
…onverted body, stores the raw one; re-point two spec controls (wip) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…-converted verdict body on the flow fallback (wip) Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
…iltin-undeclared-keys
… refuses only an undeclared key Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read: card #21982 (body and all 18 comments, the triage grade Check-runs on the head at this read (33): 22 ① Derived judgmentsEach accept-set or public-surface change the diff implies, named right or wrong.
② Semver level
③ Boundary flagsEvery dev flag and
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
CI on
Generated by Claude Code |
…iltin-undeclared-keys
… flow parse; try_catch and plugin types stay on registerFlow's walk Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <noreply@anthropic.com>
… merging main at 41d0d40 (step 18: 64 conversions, 324 semantic entries) main added two step-18 semantic entries since 6729e10: flow-builtin-node-config-undeclared-keys-refused (#22319) and platform-global-object-organization-column-retired (#22331). At protocol 18 both generators project every step-18 entry, so both documents gain them. The conversion ids are unchanged. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…or before the checks that judge its body, on every kernel topology (objectstack-ai#22338) Fixes objectstack-ai#22220 Clause-②: no ## What changes `saveMetaItem`'s package door (`refusePackagedBaseOverride`) is now asked on every kernel topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and before every check that reads the body or the store. It used to sit behind `environmentId !== undefined`. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) met the same predicate only at `SysMetadataRepository.assertAllowed`, the first statement of `repo.put`, which is the method's last act. So on that kernel every refusal in between answered first. - `packages/metadata-protocol/src/protocol.ts`: the `environmentId` wrapper around the door is removed. The `_lock` gate's guard `packagedBaseRefusal(...) === null` (its hand-written deferral to the door on host-config) always answered "no refusal" once the door runs first on every kernel, so it is removed. The invariant comment on that gate is extended, and the door's call site records why no acceptance set moves. Two TSDoc paragraphs that said the door is environment-only are corrected. - New pins: `packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts` (one table over both kernel topologies, `code` + `status` per row). - Three downstream test files pinned the old host-config ordering and are updated (below). - `scripts/engine-double-contract.pinned.json`: the new pin file's `findOne` double, recorded by `check-engine-double-contract.mjs --write`. - `.changeset/22220-package-door-before-gates.md`: `@objectstack/metadata-protocol` patch. ## Door table, live (base `4e4111ca05` vs head `9e763ec0bc`, both built and booted before the `main` merge) Seeded admin, default composition, `OS_METADATA_WRITABLE` unset. Bodies: **served** = the `GET` item; **gate-refused** = served plus one autonumber field whose format names a missing field (`autonumber-references-unknown-field`); **spec-refused** = served plus an undeclared top-level key (`unrecognized_keys`). Every cell is `status code`. `examples/app-crm`, `PUT /api/v1/meta/object/crm_account` (packaged under `com.example.crm`; `object` is `allowOrgOverride: false`). Environment kernel = `pnpm dev:crm -- --fresh` (`env_local`). Host-config kernel = the same stack under `OS_MODE=off` (the lightweight assembler, `environmentId` undefined; confirmed by the repository's sentence on the base row). | body, mode | env kernel, base | env kernel, head | host-config, base | host-config, head | |:--|:--|:--|:--|:--| | served, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | | served, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | | gate-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | gate-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | | spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | spec-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | `permission/crm_sales_user`, spec-refused, publish | not measured | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | `position/sales_rep`, spec-refused, publish | not measured | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | control: env-local `zz_local_obj`, gate-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | | control: env-local `zz_local_obj`, spec-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | At head the host-config 403 carries the environment kernel's sentence: the two `crm_account` gate-refused publish bodies compare byte-equal. At base the host-config 403s carried the repository's sentence (`'object' is not allowOrgOverride in the registry ...`), and a packaged `permission`'s plain publish carried plugin-security's lock sentence. The environment kernel's code path is unchanged by this diff, which is why its two unmeasured base cells are left unmeasured rather than inferred. The card's own composition, `examples/app-showcase` (`pnpm dev -- --fresh`, host-config), `PUT /api/v1/meta/object/showcase_task`: base answered 422 for gate-refused publish and for spec-refused publish and draft, 403 for the rest; head answers 403 `NOT_OVERRIDABLE` for all six rows, and the env-local controls answer 422 `INVALID_METADATA`. ## Door table, unit pins (both kernels; base = `protocol.ts` at the merge base, head = this branch) Measured through the real `saveMetaItem` over an engine double (the new pin file's harness). "gate reached" = `assertRuntimeAuthoringRules` was called. | request | env base | env head | host-config base | host-config head | |:--|:--|:--|:--|:--| | object, served body, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403 NOT_OVERRIDABLE | | object, gate-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA, gate reached | 403 NOT_OVERRIDABLE | | object, gate-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403 NOT_OVERRIDABLE | | object, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | object, spec-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | object, `?package=` naming its read-only package, gate-refused, publish | 403 ITEM_LOCKED | 403 ITEM_LOCKED | 422 INVALID_METADATA, gate reached | 403 ITEM_LOCKED | | object, fields dropped (destructive), publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 409 DESTRUCTIVE_CHANGE | 403 NOT_OVERRIDABLE | | position, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | permission, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | control: env-local object, gate-refused, publish | 422, gate reached | 422, gate reached | 422, gate reached | 422, gate reached | | control: env-local object, spec-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | | control: packaged view (`allowOrgOverride: true`), spec-refused | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | Registry-wide (a packaged `pkg_TYPE` with a spec-refused body, publish, every type in `DEFAULT_METADATA_TYPE_REGISTRY`): at base the host-config kernel answered differently from the environment kernel for 15 types (`object` 409 `DESTRUCTIVE_CHANGE`; `hook`, `seed`, `mapping`, `page`, `app`, `action`, `dataset`, `datasource`, `doc`, `book`, `permission`, `position`, `tool`, `skill` 422 `INVALID_METADATA`). At head both kernels give the same envelope for every type, and every type the door governs (`allowOrgOverride: false`, `allowRuntimeCreate: true`) answers 403 `NOT_OVERRIDABLE`. The other 13 types answered alike on both kernels at base and at head. ## The window the door now precedes (M2) Refusals `saveMetaItem` could answer on a host-config kernel between the door's position and `repo.put`, for a packaged `allowOrgOverride: false` save: - the ADR-0010 `_lock` gate, 403 `ITEM_LOCKED` (already deferred to the door by hand; that guard is removed); - the ADR-0029 D9.9 package mismatch, 422 `OBJECT_OVERLAY_PACKAGE_MISMATCH`; - the destructive diff, 409 `DESTRUCTIVE_CHANGE` (measured above); - the layered-envelope refusal, 422 `INVALID_METADATA`; the save-name refusal, 400 `VALIDATION_ERROR`; - the flow conversion conflict, 409 `FLOW_CONVERSION_CONFLICT`; - the spec-conformance parse, 422 `INVALID_METADATA`, draft and publish (measured); - the stored-hook body refusal, 400 `VALIDATION_ERROR`; - the runtime authoring gate, 422 `INVALID_METADATA`, publish only (measured); - the domain plugins' authoring gates: plugin-security's permission-set lock (403 `NOT_OVERRIDABLE`, its own error class and sentence; measured live) and its object posture gate R1 (403, wire `code` `PERMISSION_DENIED`, `declaredCode` `owd_widening_forbidden`). The view-container collision refusal also sits there but judges only `view`, which allows overlays, so the door never precedes it. ADR-0070 D1 (`WRITABLE_PACKAGE_REQUIRED`) judges only `runtime-only` writes, which the door never refuses. ## Mechanism assumptions, as measured - **M1** reproduced at base on both compositions above. The authoring-gate body was built from what `main` refuses (`autonumber-references-unknown-field`); no pass-4 lift was needed. - **M2** the spec parse also answers 422 ahead of the door on host-config, in draft and publish mode; the full window is listed above. - **M3** the predicate is `refusePackagedBaseOverride`'s own, already topology-independent (`packagedBaseRefusal` asks it on every topology for the `/automation` doors). It and `assertAllowed` read the same registry-derived `allowOrgOverride` set, the same `OS_METADATA_WRITABLE` hatch and the same `isWritablePackage` (`package-writability.ts`), and both throw the same `readOnlyBaseOverrideError` for a named read-only base. They differ only in the fallback sentence for a type with no ADR-0126 regime row. - **M4** read `0b997ea447`. The door's input is `isArtifactBacked`, the same input the repository's intent uses, so stack-declared positions are refused by the door on both kernels (measured live for `position/sales_rep`). The `security`-domain types are `permission`, `position` and `capability`; `capability` is code-only and answers the code-only refusal ahead of the door, unchanged. - **M5** held: a draft is still not judged by the authoring gate; env-local bodies still answer 422 on both kernels; a packaged `view` and a packaged object with the hatch open are still judged by the gates. ## What does not move (Clause-② measurement, on this head) - **Accept sets.** The door refuses exactly when `artifactBacked && !isOverlayAllowed(type)`. `repo.put` runs `assertAllowed` with intent `override-artifact` whenever `artifactBacked`, and that refuses on the same predicate, on every topology. `saveMetaItem` has no success return before `repo.put` (the one `return` in that window is inside a closure). So a request the door refuses was refused before, and a request it admits meets the same checks as before. The unit tables above show every measured row refused at base and at head. - **Built entry declarations.** `dist/index.d.ts` and `dist/index.d.cts` of `@objectstack/metadata-protocol`, built from this head and from the merge base's `protocol.ts` (`fe98cc63a4`): the only differences are the two TSDoc paragraphs corrected above. No declaration line changes. - **Wire.** For a packaged `allowOrgOverride: false` save on a host-config kernel that one of the checks above refused, the answer is now 403 `NOT_OVERRIDABLE` (or 403 `ITEM_LOCKED` for a named read-only base) with the door's sentence, which is what an environment kernel already answered. ## Downstream pins that encoded the old host-config order Each relied on the host-config kernel skipping the door. Each now reaches the check it tests the way an environment kernel always had to. - `packages/objectql/src/protocol-destructive.test.ts`: saved a destructive edit of a packaged object with no `environmentId` "to bypass the overlay opt-in gate". It now opens `OS_METADATA_WRITABLE=object`, the one route by which a packaged object's write reaches the destructive diff. Expectations unchanged. - `packages/rest/src/meta-object-owd-gate.test.ts`: the lint-before-R1 order case and the two R1 refusals drove a packaged-object overlay with the hatch shut. They now open the hatch (the path R1's docblock names). Expectations unchanged. Two comments that said the door was environment-scoped are corrected. - `packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts`: the hatch-closed case pinned the lock's error class on host-config. With the hatch closed the door now answers first there, as on an environment kernel, so the case asserts the same `NOT_OVERRIDABLE` / 403 envelope and that the class is not the lock's. The hatch-open cases still pin the lock's class. ## Verification - Reverse verification: the new pin file run against the merge base's `protocol.ts` (swapped on disk, blob-verified, restored by trap to the HEAD blob): 23 failed / 26 passed of 49, every failure a host-config row or a registry row; at head 49/49. - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 222 files passed, 3 skipped; 28329 tests passed (at `9e763ec0bc`, before the `main` merge, which touched only the seed loader in this package). - At `81a42b1203`, after `git merge origin/main` and a rebuild: the new pin file 49/49; `objectql` `protocol-destructive.test.ts` 7/7; `plugin-security` `packaged-permission-set-lock-gate.test.ts` 7/7; `rest` `meta-object-owd-gate.test.ts` 14/14; the five `qa/dogfood` files that drive `saveMetaItem` 27/27. `typecheck` (with `check:test-typecheck` where the package has it) green for `metadata-protocol`, `objectql`, `plugin-security` and `rest`. - Before the merge: `objectql` full `--project local` (383 files passed; the 3 failures were `protocol-destructive.test.ts`, updated above), `plugin-security` full (181 files passed; the 1 failure was the lock-gate case updated above), the 35 `runtime` files and 24 `rest` files that call `saveMetaItem` (all green except the 3 `meta-object-owd-gate` cases updated above). - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 78 commands on `81a42b1203`. 70 ran on `32d94c2d00` (the merge commit; the one later commit only adds the ledger row); the 8 that row adds, the changeset gates and the ratchet families re-ran on `81a42b1203`. 77 exited 0; `--ran` reconciles 78 derived, 77 run, 1 unrun. `check:engine-double-contract` first exited 1 for the missing ledger row and exits 0 with it recorded. `check:type-check-debt` (a repository-wide re-measure) hit a 400 s local timeout: **NOT MEASURED**, left to CI. - Lint, narrowed: ESLint's own config matches 5 of the 7 changed paths (the changeset and the JSON ledger answer "no matching configuration"); `--format json` reports 5 files, 0 errors, 0 warnings. This config enables no type-aware linting, so the diff cannot move a verdict on an untouched file. ## Serial notes - PR objectstack-ai#22319 edits `saveMetaItem`'s flow canonicalization and spec-parse region; this diff stays out of those lines. - PR objectstack-ai#22323 edits `protocol.ts` around the drafts listing and `sys-metadata-repository.ts`; no overlap with this diff. ## Acceptance notes - `packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts`'s `hostConfigDoor` docblock still says `saveMetaItem` skips its package door on host-config. The test measures `repo.put` directly and stays correct; only that sentence is now stale. Not edited here (outside the claimed file surface). - `packaged-base-regime.ts` and `sys-metadata-repository.ts` describe the protocol door as environment-scoped (incomplete now, not false). Not edited here, for the same reason. - ADR-0005 §"Whitelist enforcement" still says single-kernel deployments keep "any type writable". The repository's `assertAllowed` has refused these writes on those kernels since before this change; this diff moves no acceptance set, so it reverses no ADR decision. The ADR text is stale relative to shipped behaviour, not to this change. - `deleteMetaItem` keeps its own `environmentId`-scoped removal door; this card is about the save door only. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- 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 #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_catchstays 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
protocol.save-flow-canonicalization.test.tsthat the build round turned red green as written.What changes
flow-node-config-refusals.ts). The key arm offlowNodeConfigRefusalsnow judges key membership on every builtin ingetBuiltinNodeConfigContracts()excepttry_catch. Before, it covered onlyscriptandsubflow(pass 1, PR feat(spec)!: the build doors refuse an undeclared key on a script / subflow node config, with its location #22129).builtinNodeConfigKeysJudged(nodeType)names exactly the types it judges.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).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.loopis judged on key membership alone. Presence and values keepparsedWhen.validateControlFlowat registration, the same carve-out the value arm has. That covers a key on aloopbody or aparallelbranch, 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 judgeapplyConversionsToFlow(raw body)from@objectstack/spec. NoreservedNodeTypesare 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:body: flowGateVerdictBody ?? gatedItem, declared here as a measured extension. On an active save the lint's config judge refused the rawfiltersalias there too: 5 of the 6 tests stayed red with the schema gate alone, failing atfailed author-time validation … config.filters. The credential walk keeps the raw body.duplicatePackageneeds no edit. With no canonicalizer it copies the raw body (item = body, about:24693) and re-saves throughthis.saveMetaItem(about:24779), which reaches the same fallback.service-automation(engine.ts).validateNodeConfigKeysstands aside for every typebuiltinNodeConfigKeysJudgednames, so each node type has one judge. It keepstry_catchand every plugin node type.flow-builtin-node-config-undeclared-keys-refused, with rationale order 89 (the highest onmainatdc4a5c630is 88).registry.tsis regenerated.api-surface/andexport-origins/are regenerated for the new export..changeset/21982-flow-builtin-node-config-undeclared-keys-refused.md:@objectstack/specminor (BREAKING, ADR-0087registered),@objectstack/service-automationpatch, and@objectstack/metadata-protocolminor. It carries the revisedClause-②line verbatim. Pre mode is on atmaindc4a5c630(.changeset/pre.jsonmodepre, tagnext). The grade follows pass 1 and service-automation: a built-in node's config value its own contract refuses still registers, then fails every run — the built-in half of #21848's class #21898's launch-window convention for accept-set narrowings.flow.zod.ts). TheFlowSchemasuperRefine 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 frominstallBuiltinNodes, contracts from the spec dist):retryisRetryPolicySchema, a plainz.objectthat strips an unknown key, plus theretryDelayMstombstoneretry: not movedtry_catch(named, not moved). Moving it would have widened registration:retry.bogusKeywould have registered. So it stays on the walk, whole type, one judge.os validateandos compilestill pass an undeclaredtry_catchkey that registration refuses. The seat files theRetryPolicySchemastrictness follow-up at landing.Hypotheses, measured
unrecognized_keysat the root for{ bogusKey: 1 }. Nested positions are strict too, excepttry_catch.retry.loop. So the key arm judges loop key membership whateverparsedWhensays. Pinned: a body-less loop withbogusKeyis still refused atregisterFlow(config-unknown-keys.test.ts), soregisterFlowwidens nowhere.assignmentis in neither the contract map nor the walk.validate-expressions.ts. The whole lint suite had one red, a fixture writingitemVariable(a wrong key, not a D2 alias) on a loop with a body. Fixed in the test, andvalidate-expressions.tsis untouched.Registration verdicts, before and after (59-variant probe)
Every variant that
registerFlowrefused before is still refused, and every one it registered still registers.FlowSchema.parsethatregisterFlowmakes first. That covers a top-levelbogusKeyon each type, the walk's 6 guidance keys,screenfields[0]andfields[0].options[0], and a body-less loop'sbogusKey/flowName.loopbody.bogusKey) and region-node keys are refused byvalidateControlFlow, as before.try_catchkeys are refused by the walk, as before.Measured at the CLI doors
On
examples/app-showcase, nodenotifyinshowcase_task_completed. The mutation went throughscripts/ablation-replace.mjs(blob562e884310→87b20a9570, restored to562e884310== HEAD).message: '{summary}' , bogusKey: 1,objectstack validatenodes,2,config,bogusKey, the spec refusal's textobjectstack compilebogusKeyThe first attempt used an anchor that is a substring of its replacement.
ablation-replacerefused 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 byreturn false;viaablation-replace(blob12eb374153→6ab82dce15, restored to12eb374153== HEAD,git diff HEADempty). Onflow-builtin-node-config-keys.test.tsplusflow-approval-node-config-contract.test.ts, 21 of 50 tests went red and 29 held. The spec tests importsrc/, so no dist preflight applies.Tests (real readings)
Patch round, at
d7466a01b(merged withorigin/main4e4111ca0throughos-regen-merge.sh):@objectstack/metadata-protocol, whole suite: 221 files passed, 3 skipped; 28283 tests passed, 19 skipped, 0 failed.protocol.save-flow-canonicalization.test.ts(atb9a3295d1), allflow/purge_flow failed spec validation: nodes.0.config.filters.72b8d3e97, with the schema-gate edit alone, 5 of those 6 were still red atfailed author-time validation … config.filters. Only the draft-mode test had gone green.d7466a01ball 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.metadata-protocol(tsc --noEmit) andspec(withcheck:test-typecheck).Build round, unchanged by the patch (at
b9a3295d1; pin files re-run at414fc860c):service-automationwhole suite 176/176 files, 2163/2163.lintwhole suite 127/127, 5843/5843.runtime --project local336/336, 4752 passed, 19 skipped.trigger-record-change11/11, 114/114.dogfood17 flow pin files, 105/105.Ablation of the fallback edit (patch round)
At
d7466a01b, throughablation-replace. Each run was restored to blobf15e4802b5== HEAD withgit diff HEADempty.schema.safeParse(request.item)(blobf15e4802b5→a0cc936d94): 9 of 19 red inprotocol.save-flow-canonicalization.test.ts. That is the 6 fallback tests and all 3 new pins: thefunctionNamebody is refused, and thefilters+bogusKeybodies report two paths instead of one.body: gatedItem(blobf15e4802b5→19e8bbb4ed): 5 of 19 red, the active-mode fallback tests. The new pins hold: the lint already tolerates the scriptfunctionNamealias, andbogusKeyis refused either way.Acceptance notes
approvalhas two judges: the spec arm, which judges it whole, and the walk againstgetApprovalNodeConfigJsonSchema().registerFlow's parse throws first, so the walk never decides an approval key. Carrier: none.FLOW_NODE_UNKNOWN_KEY_GUIDANCEinengine.tsis 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 inbuiltin-node-config.zod.tsthat name it updated. Carrier: the next PR to touchbuiltin-node-config.zod.ts.node-config-refused-by-contractdocblock inflow-node-expression-paths.tsstill names onlyscript/subflowfor the key half. It is incomplete, not false. Carrier: the next PR to touch that file.objectstack validateprints the raw issue array at 'Loading configuration…' for a refused flow. This predates the PR and was noted on pass 1.httpoutputVariable,notifyurl) is tracked at flow inspector fallback form: thehttp_requestgroup writesconfig.outputVariable, a key thehttpexecutor contract does not declare, so registration drops the saved flow objectui#11968, not changed here.packages/lint/src/validate-expressions.test.ts. This PR changes one fixture line there (itemVariable→iteratorVariable). Whichever lands later mergesmain.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run with no paths, atd7466a01bagainst merge base4e4111ca0, derived 96 commands. Two families joined for themetadata-protocoledit:check:durability-log-levelandcheck:filter-alias-parity. All 96 were run, each exit code captured before any pipe. 95 exited 0.check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET (packages outside the built closure have nodist), 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.tsfiles: 20 files, 0 errors, 0 warnings. The population is the 20.tspaths ofgit diff --name-only 4e4111ca0 HEAD, and the file count is read from the json. Invariance:parserOptions.projectandprojectServiceare 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