Skip to content

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

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-21982-builtin-undeclared-keys
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-21982-builtin-undeclared-keys

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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_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 feat(spec)!: the build doors refuse an undeclared key on a script / subflow node config, with its location #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 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.
  • 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

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 4e4111ca0 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

claude added 7 commits October 8, 2026 12:53
…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>
…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>
…eConfigKeysJudged (wip)

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

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/approvals.mdx (via try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const), registerFlow (symbol, a method of class AutomationEngine), try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/concepts/metadata-lifecycle.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class), try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), FlowSchema (symbol, a top-level const), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class), try_catch (literal, a string literal in BUILTIN_KEYS_JUDGED_AT_REGISTRATION))
  • content/docs/releases/v17/17-7.mdx (via FlowSchema (symbol, a top-level const), registerFlow (symbol, a method of class AutomationEngine))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 043f58c4a9d8ac57f4cf59233167fc748004b587 — the merge of head 6e56f2f68a5439d2ed797a91e7016750782a9662 into base 28bff18d0c4013db86d61eba87c739c3145e17fd, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 28bff18d0c4013db86d61eba87c739c3145e17fd → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 3 commits October 8, 2026 15:46
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Read: card #21982 (body and all 18 comments, the triage grade 6015698142, the claim 6059609053 as revised by 6060189970 / 6063587988 / 6063670139, the four dev reports and the ACCEPT 6064942288), PR #22319 (body, 23-file list, net diff against merge base f2626c71db), the head's check-runs, and on origin/main AGENTS.md (changeset rules, Prime Directives) and ADR-0087 (D2, D3, the pre-launch level ruling, the disposition vocabulary). Source at the head read with git show refs/pm/review-22319:PATH.

Check-runs on the head at this read (33): 22 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 0 failed, 8 still in_progress: Lint & Repo Gates, Test Core (1/6 to 6/6), Type Check · workspace. Among the completed successes: Check Changeset, Build Core, Dogfood Regression Gate (all three shards), Dogfood Verify CLI, Temporal Conformance, Type Check · source / consumer / debt ledger, Spec property liveness, Governed Surface Queue Guard, the three card/branch guards. The in-progress eight are recorded as such and not waited for; their conclusions remain the landing's gate verdicts.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong.

  1. The spec key arm judges key membership on 10 more builtins (get_record, create_record, update_record, delete_record, notify, http, screen, map, loop, parallel) at every door that parses FlowSchema. Right. At the head getBuiltinNodeConfigContracts() holds 13 types; BUILTIN_KEYS_JUDGED_AT_REGISTRATION holds exactly try_catch; builtinNodeConfigKeysJudged answers true for the 12 others (the 10 plus script / subflow from pass 1). The refusal reuses node-config-refused-by-contract with params: { nodeType, key }, one per key, anchored at nodes.N.config.KEY; no new code joins FLOW_SLOT_REFUSAL_CODES. A narrowing of the build doors to what registerFlow already refused: the triage pin (refused at objectstack validate and compile, located; the control passes) is pinned in flow-builtin-node-config-keys.test.ts and measured by the dev on examples/app-showcase (notify + bogusKey: validate exit 1 at nodes,2,config,bogusKey, compile exit 2).

  2. New public export builtinNodeConfigKeysJudged(nodeType) from @objectstack/spec/automation. Right, additive. automation/index.ts re-exports the module wholesale; api-surface/automation.json and export-origins/automation.json carry the new row; the root index re-exports only defineFlow / defineWebhook from automation, so root.json owes no row.

  3. A body-less legacy loop is now judged on key membership alone (parsedWhen gates presence and values only). Right. The loop body at flow-node-config-refusals.ts:703-746: parsedAtRun false with keysJudged true parses the contract, pushes only unrecognized_keys refusals (with the "executor reads no key … registration refuses the whole flow" closing) and continues past every other issue. Registration's walk already refused an undeclared key on a body-less loop (H2, measured; pinned at registerFlow in config-unknown-keys.test.ts), so the build doors narrow to registration's verdict and registerFlow widens nowhere.

  4. A key at or under a region slot stays validateControlFlow's (unknownKeyUnderRegionSlot: path[0] is a FLOW_REGION_SLOTS_BY_TYPE slot). Right. Keys on a loop body, a parallel branch, or their nodes and edges are refused at registration as before and not at the build doors; the value arm has the same carve-out (insideRegion). No second judge, no accept-set change there.

  5. try_catch stays registration's, whole type. Right. P1 measured its contract's retry as the shared plain RetryPolicySchema (strips an unknown key) against a descriptor that closes retry to five keys; moving it would have widened registerFlow. Named in the PR and the changeset with that reason, as triage's step 2 asks for a builtin that is not strict; os validate / compile keep passing its undeclared key, stated plainly. Pinned on the walk (config-unknown-keys.test.ts: retry key refused, located).

  6. service-automation validateNodeConfigKeys stands aside for every spec-judged type and keeps try_catch and plugin node types. Right; registerFlow's accept set is unchanged. The method is private with one call site (engine.ts:4407), after FlowSchema.parse(converted) in canonicalizeStoredFlow (:4367), so the parse refuses first on every registration path (boot, sys_metadata, REST). The move changes no verdict only because the descriptor key sets equal the contract key sets at every walked position on the 10 types (P1 table, the 59-variant probe) — accepted as the dev's measurement, not re-run. What changes is the refusal's carrier: the parse's located issue instead of the walk's Flow '…' rejected: N undeclared config key(s) text. The runtime REST face keeps its 400 VALIDATION_FAILED envelope with the key located at nodes.0.config.KEY (re-pointed and pinned in automation-register-error-class.test.ts and automation-put-post-error-parity.test.ts); no non-test consumer keys on the walk's text (grepped). FLOW_NODE_UNKNOWN_KEY_GUIDANCE is kept and marked unread, so no service-automation export moves.

  7. metadata-protocol save door, flow fallback (no canonicalizer resolved, or it threw): the schema gate and the runtime authoring gate judge applyConversionsToFlow(request.item); the stored body, the credential walk (item: gatedItem) and duplicatePackage keep the raw body. Right, and it is two changes. (a) A widening against main: main's fallback parsed the raw body and its pass-1 key arm already refused script functionName / input and subflow flow there; they are accepted again, converted first, as at every other door. (b) The narrowing of item 1 reaches this door in canonical spelling, so a live D2 alias on the 10 types (filters, notify to / subject / body / url, map flow) is accepted and an undeclared key is refused 422 INVALID_METADATA at its path (three new pins; the six invariant tests green as written; the dev's two ablations attribute the schema-gate line and the authoring-gate line separately). The two grafts after a passing verdict (graftNormalizedOperators, graftFoldedFormSections, read at protocol.ts:1376 / :1462) walk the AUTHORED keys and rewrite only an operator string or the groups to sections fold where a parsed counterpart exists; a converted key has no authored counterpart, so nothing from the converted parse enters the stored body. One edge, within the declared narrowing: with no reservedNodeTypes the live flow-node-http-callout-rename conversion renames http_request / http_call / webhook to http for the verdict unguarded; on the no-engine path nothing local can be reserved, an engine-backed save keeps its 409 conflict guard, and the one known writer of http_request + outputVariable (the objectui fallback inspector, objectui#11968) produces a flow registration already refuses.

  8. @objectstack/lint: no source line moves; its judge is the spec's. Right, carried by the spec changeset. A raw pre-conversion alias on the 10 types handed directly to validateStackExpressions now draws a finding; the lint's declared pre-conversion tolerance is scoped to the script functionName alias alone (validate-expressions.ts:1673-1683), and every real door converts first (os validate at load; the authoring gate now on both save paths). The one red fixture was a wrong key (itemVariable), fixed in the test file.

  9. The ledger. Right. One D3 semantic entry 18.flow-builtin-node-config-undeclared-keys-refused.ts whose id matches the changeset marker and is new in the diff; surface carries no backtick and no pipe; no tombstone and no D2 conversion, which is correct for a change that removes no key. STEP18_RATIONALE order 89 is unique at the head; origin/main's highest is 88 and packages/spec/src/migrations has not moved on main since the merge base, so 89 is still free. registry.ts regenerated (CI check:generated sits in the in-progress Lint & Repo Gates).

  10. The card closes. The PR's first line Fixes #21982 is its only closing keyword; the remainder named in 6051476304 (the 11 descriptor-configSchema builtins, and the judge at registerFlow) is covered: 10 moved, the judge decided (one per type), try_catch named as staying registration's with its follow-up owed at landing. The card this PR closes must claim this branch: success.

② Semver level

  • @objectstack/spec: minor, with the **BREAKING** banner and the marker adr-0087: registered flow-builtin-node-config-undeclared-keys-refused. Right. An accept-set narrowing on published authoring surfaces, shipped minor under ADR-0087's pre-launch level ruling (.changeset/pre.json on origin/main: mode pre, tag next); the banner and the disposition carry the breaking-ness; the body carries the FROM → TO table and the one-line fix, and names what stays as it was.
  • @objectstack/metadata-protocol: minor. Right. The fallback path widens back to the D2 spellings (item 7a) — yes takes at least minor.
  • @objectstack/service-automation: patch. Right. Source moved (engine.ts), no accept-set change at registerFlow, no public-face change.
  • No @objectstack/lint or runtime line: only test files move there. Right.
  • Clause-②: line: yes (narrowing: … 10 more builtins … ; widening: a save door with no flow canonicalizer judges the D2-converted body …), byte-identical in the PR body and the changeset, and the claim's revised line (6063587988). Read through the gates' own reader (scripts/pm/clause2-line.mjs): value yes; the arm is the first token inside the parenthetical, narrowing (followed by :, so the exact-spelling regex matches and the reasoning is kept, as no (narrowing — …) is documented to do); the later "widening:" is reasoning, not a second arm. One arm from the closed pair, so AGENTS.md's rule holds, and yes (narrowing) is exactly the reader's documented shape for "a diff that widens one surface and narrows another". check:adr-0087-registration reads [BREAKING+clause-②-narrowing] registered … (dev, at the head); Check Changeset on the head: success. The ! in the PR title agrees.

③ Boundary flags

Every dev flag and open_questions entry on this PR's rounds, answered or escalated:

  1. 6060083863 Q1 — P2 found one writer (Studio's fallback inspector writes http outputVariable). Seat 6060189970: A, proceed; triage's step 3 (send a writer to the maintainer's box) declined on the ground that this writer's output does not work today — registerFlow already refuses it, so the narrowing removes no working behaviour and moves the refusal earlier and located; the writer filed as objectui#11968. Verified on the diff: the save door now answers 422 at nodes.N.config.outputVariable where it stored and then silently dropped. Answered by the seat. Named here visibly: it is the one triage direction declined on this card, the seat itself wrote that the maintainer may overrule before enqueue, and no maintainer ruling on the card touches it.
  2. 6060083863 Q2 — try_catch P1 fails at retry. Seat: A, stays on the walk; the RetryPolicySchema strictness card is to be filed when this PR lands. Answered; the card is not yet filed — a landing to-do, not a blocker.
  3. 6063482248 Q1 — the fallback refuses D2 aliases. Seat 6063587988: A, judge the converted body, store raw. Answered and built (item ①7).
  4. 6063482248 Q2 — two spec controls pinned the old reading. Seat: A, test-only with code and location. Answered and built (flow-builtin-node-config-values.test.ts, flow-node-config-required.test.ts).
  5. Dev deviation — the authoring gate reads the verdict body too (body: flowGateVerdictBody ?? gatedItem, one line beyond the named schema gate). Accepted by the seat in 6064942288; verified the credential walk still receives the raw gatedItem. Answered.
  6. Dev deviation — the PR body was not PATCHed by the dev (role file). The seat wrote the dev's pr_body_revision; the stored body carries the revised Clause-② line and Fixes #21982 as the only closing keyword. Answered.
  7. Dev deviation — grafts after a converted parse, and applyConversionsToFlow defaults (live window, includeRetired false, no reservedNodeTypes). Read at the head (item ①7): authored-key walks, nothing converted is stored; the one unguarded rename is named above. Answered.
  8. Out-of-scope carriers, named not filed: FLOW_NODE_UNKNOWN_KEY_GUIDANCE unread (next PR on builtin-node-config.zod.ts); the node-config-refused-by-contract docblock in flow-node-expression-paths.ts naming only script/subflow (incomplete, not false); approval's two judges where the walk never decides; objectstack validate's raw issue print (predates this PR). Each has its carrier or is recorded in the PR's Acceptance notes.
  9. Serial: fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads parent (#22157) #22268 also edits packages/lint/src/validate-expressions.test.ts (one fixture line here; whichever lands later merges main); metadata-protocol/spec: the _drafts header carries no label, so a draft-only item can only be shown by its machine name (ListDraftsResponseSchema drops the label the repository already reads) #22200 (in flight, this seat) edits a disjoint hunk of protocol.ts. Since the merge base, origin/main moved only on two runtime test files outside this PR's list. No conflict.
  10. Gates still in progress at this read (Lint & Repo Gates, Test Core 1-6, Type Check · workspace): not waited for; landing reads them. No completed check-run on the head failed.

Implemented-by: claude/issue-21982-builtin-undeclared-keys
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI on beda06e97: Test Core (6/6) red on its timing-drift step, not on a test · domain:spec seat 2 (#18549) · session session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-08T17:24Z.


Generated by Claude Code

claude added 2 commits October 8, 2026 17:26
… 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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 18:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 18:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 2f70c22 Oct 8, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21982-builtin-undeclared-keys branch October 8, 2026 18:58
os-litant pushed a commit that referenced this pull request Oct 8, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ULE_ID` for the reasoning (objectstack-ai#22339)

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

## What changes

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

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

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

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

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

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

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

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

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

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

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

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

## Gates

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

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

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

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

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

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

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

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

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

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

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

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

## Acceptance notes

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

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

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

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

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

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

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

---------

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants