Skip to content

feat(spec)!: the build doors refuse an undeclared key on a script / subflow node config, with its location - #22129

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

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21982-schemaless-builtin-undeclared-key

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #21982
Clause-②: no (narrowing)

Draft, patch round 1 done (claim revision 6050287134). This PR lands the script / subflow half of the card. The card stays open for the remainder named below.

What changes

The build doors now refuse a key that a script or subflow node's executor contract does not declare. They refuse it at its location, in the existing family of flow slot refusal codes.

  • The doors: FlowSchema.parse / defineFlow(), defineStack, objectstack validate, objectstack compile, an artifact's parse, the metadata save door's flow type schema, and registerFlow, which parses FlowSchema first.
  • The code: the existing node-config-refused-by-contract, params: { nodeType, key }. There is one refusal per undeclared key, anchored at the key (nodes.N.config.bogusKey), and its message is the contract's own sentence.
  • The edit: packages/spec/src/automation/flow-node-config-refusals.ts, the builtin branch of flowNodeConfigRefusals. It judges key membership where builtinKeysJudged(nodeType) holds. That is a builtin contract whose type is in the spec's own schemaless class, SCHEMALESS_NODE_CONFIG_SCHEMAS.
  • Why the narrow lift: a schemaless descriptor publishes no configSchema, so registerFlow's undeclared-key walk skips it. Its executor still parses the strict contract, so it refuses the node at every run.
  • Unchanged: getBuiltinNodeConfigContracts() keeps its 13 entries, and no new code joins FLOW_SLOT_REFUSAL_CODES.
  • Premise corrected: builtinValueJudged's docblock said "registration refuses an undeclared key against the descriptor". That was false for exactly these two types. The docblock now says which door owns which key.
  • Comments made true: the FlowSchema header in flow.zod.ts and the node-config-refused-by-contract docblock in flow-node-expression-paths.ts now name each arm that judges key membership: approval whole (build: objectstack validate / compile accept unknown keys in a plugin node's config (e.g. an approval node's escalation) — the build-time refusal map covers built-in node types only #21850), builtin values (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), and script / subflow keys.
  • No service-automation source line moves, and there is no second key check anywhere.

Built-ins: which get the key refusal, and why (measured at 15ec50e528)

type in getBuiltinNodeConfigContracts descriptor configSchema contract strict executor parses it key refusal here
script yes none strictObject yes (screen-nodes.ts parseNodeConfig) yes
subflow yes none strictObject yes (subflow-node.ts parseNodeConfig) yes
decision no none strictObject no: its executor reads conditions[] raw no. An undeclared key fails no run. decisionShapeRefusals judges the conditions shape, not keys.
wait, connector_action no none their contracts are the FlowNode sibling blocks waitEventConfig / connectorConfig, strictObject inside FlowNodeSchema they read the sibling block, not config no. FlowSchema already refuses an unknown key in those blocks.
get_record, create_record, update_record, delete_record, notify, http, screen, map, loop, parallel, try_catch yes yes strictObject (all 11) yes no: the remainder, below
assignment no yes (keyValue map) no: open top-level variable names no single contract no

The remainder: the card stays open for it

Triage's direction step 2 covers every builtin whose executor contract is strict. All 13 are. This PR takes the two schemaless ones, by the seat's ruling 6050287134.

  • Remainder 1, the 11 descriptor-configSchema builtins at the build doors: get_record, create_record, update_record, delete_record, notify, http, screen, map, loop, parallel and try_catch.
    • They have the same gap at the build doors. Measured at this branch's round-0 head f281d801f on examples/app-showcase, flow showcase_task_completed, node notify, with config.bogusKey: 1:
      • objectstack validate exits 0;
      • objectstack compile exits 0, and the artifact carries "bogusKey":1;
      • registerFlow refuses the same node: "Flow 'p' rejected: 1 undeclared config key(s). … unknown config key bogusKey at config.bogusKey".
    • So boot drops the flow with a warning, and the build never says so.
  • Remainder 2, an open design choice, not decided here: once the spec arm covers those 11 types, who judges a builtin's undeclared key at registerFlow? The spec arm pre-empts registration's descriptor walk, and with it that walk's pinned prescriptions (service-automation config-unknown-keys.test.ts).

Census first (triage step 1): no writer found

corpus read at script nodes subflow nodes with a key outside the contract
this repo: examples/**, packages/platform-objects/**, packages/apps/**, packages/create-objectstack/** (templates), skills/**, content/docs/** 15ec50e528 6 2 0
hotcrm, whole tree c9678036d9 0 5 0
objectui flow designer FLOW_NODE_CONFIG pin a58626c88d (same file at objectui main 9990f9e122) form writes function, inputs, outputVariable form writes flowName, input, outputVariable 0 (its timeoutMs field writes the node; the five retired script keys sit behind a showWhen no field satisfies)
objectui designer seeds defaultNodeExtras a58626c88d empty config empty config 0
objectui console preview samples a58626c88d 4 0 0
  • Method: a TypeScript-AST scan for object literals carrying id and type: 'script' / 'subflow', reading the keys of their config. Code fences in .md / .mdx and .json files were parsed too.
  • Control: over this whole repo, tests included, the same scan finds 103 nodes and flags 17. All 17 are fixtures:
    • 9 in the D2 conversion fixtures (conversions/registry.ts);
    • 5 in lint tests;
    • 3 test-double keys in service-automation engine.test.ts, repaired below.

Doors, measured

  • objectstack validate / compile. Built CLI at round-0 head f281d801f, examples/app-showcase node summarize (script), one edit: function: 'summarizeCompletedTask' , bogusKey: 1,.
    • Control: validate exit 0, compile exit 0, and the artifact has no bogusKey.
    • With bogusKey: validate exit 1, compile exit 2, and no artifact is written. Both print the refusal at path nodes, 1, config, bogusKey.
    • The mutation was made with scripts/ablation-replace.mjs: anchor 1 → 0, then restored to the HEAD blob, git diff HEAD empty.
  • registerFlow (real builtin executors):
    • script / subflow with bogusKey are refused at nodes.1.config.bogusKey;
    • both controls register;
    • a script functionName alias registers: it is converted before the parse;
    • http bogusKey is still refused by the descriptor walk.
  • Pinned in flow-builtin-node-config-keys.test.ts (19 tests):
    • the refusal at FlowSchema (also inside a region body), defineStack (STACK_SCHEMA_INVALID 422 at flows.1.nodes.1.config.bogusKey), ObjectStackDefinitionSchema, the save door's flow type schema and an artifact parse;
    • the controls: no extra key; a descriptor type's key still left to registration (http, create_record, screen); decision; a retired script key keeps its tombstone path.
  • Reverse verification at round 0, builtinKeysJudged mutated to return false: 12 of 19 went red and the 7 controls held. It was restored to the HEAD blob.

Cross-lane fixtures repaired (claim revision 6050287134)

  • service-automation, test only:
    • The doubles in engine.test.ts ("should execute unconditional branches in parallel", "should fail when parameter type is wrong") and in input-schema-retry-parity.test.ts now register under the type probe_step, executor and nodes alike, never the builtin script.
    • The function: 'noop' filler went with them.
    • inputSchema reads top-level config keys, which a real script executor refuses.
  • lint: validateStackExpressions keeps the pre-conversion tolerance it declares.
    • The filter in validate-expressions.ts also hands the judge's undeclared-key refusal for a script node's functionName alias to the callable check, which already reads that alias.
    • A new pin holds that every other undeclared script key is still refused there (bogusKey, on a canonical and on an alias source).
    • The changeset gains '@objectstack/lint': patch.

Red → green. Round 0 at f281d801f had 4 red in service-automation and 2 red in lint. All six now pass at ef0dfb44d:

  • engine.test.ts › "should execute unconditional branches in parallel" ✓
  • engine.test.ts › "should fail when parameter type is wrong" ✓
  • input-schema-retry-parity.test.ts › "never executes a node whose config mis-types its declared inputSchema — on ANY attempt" ✓
  • input-schema-retry-parity.test.ts › "still retries a VALID flow normally …" ✓
  • validate-expressions.test.ts › "accepts a script node that names a callable via the functionName alias" ✓
  • validate-expressions.test.ts › "a script with no function is ONE finding, the callable check's …" ✓

ADR-0087

Merge

  • origin/main 8fc50b764 was merged by scripts/pm/os-regen-merge.sh as merge commit 521e16f1f, with parents f281d801f and 8fc50b764. There were no conflicts.
  • Step 2 took main's side of the generated artifacts that main moved, and there was nothing more to commit.
  • After a spec build on the merged tree, check:generated reported all 15 artifacts up to date. The delta against main was exactly this PR's 5 round-0 files.
  • gen:schema was not run.
  • Round 1's edits are commit ef0dfb44d on top.

Verification at ef0dfb44d

  • Spec:
    • check:generated: all 15 artifacts up to date.
    • The pin files flow-builtin-node-config-keys.test.ts and flow-builtin-node-config-values.test.ts: 63/63.
    • Round 0's whole spec suite at f281d801f: 624 files, 18628 tests passed.
  • lint: the whole suite, 123 files, 5689/5689.
  • service-automation: the whole suite, 175 files, 2120/2120. The first attempt collided with a concurrent gate run that left @objectstack/spec/automation unresolvable for 17 files; it was re-run alone.
  • Typecheck: @objectstack/lint exit 0 and @objectstack/service-automation exit 0, both including check:test-typecheck, over a closure rebuilt with declarations.
  • eslint, narrowed (--no-inline-config --format json) over the diff's 10 .ts files: 10 files, 0 errors, 0 warnings. The population is read from the json count. parserOptions.project and projectService are null for each file, so there is no type-aware linting and no untouched file's verdict can move.
  • Gates: dispatch-gates --commands --repo objectstack-ai/objectstack derives 92 commands from the merged head. All ran, with exit codes captured before any pipe. The --ran reconciliation reads 91 run, 1 NOT-MEASURED, 0 UNRUN (check:dts-closure recorded at its re-run).
    • 91 exit 0.
    • check:dual-build-cjs-loads: exit 3, PREREQUISITE NOT MET (packages outside this worktree's build closure have no dist). It is read from CI, as are round 0's cli published-subpath pins.
    • check:dts-closure first exited 1, naming exactly the 19 closure packages built with OS_SKIP_DTS=1 for the test runs. Re-run after the closure was rebuilt with declarations, it exits 0: 169/169 declaration files across 71 built packages.

Generated by Claude Code

claude added 3 commits October 7, 2026 23:55
…ubflow node config (WIP)

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…flow door; ADR-0087 D3 entry

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…bflow undeclared-key refusal

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 2 package(s): @objectstack/lint, @objectstack/spec, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/automation/flow.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/releases/v17/17-0.mdx (via functionName (literal, a string literal in runStackExpressionPasses))

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
  • 1 changed file(s) yielded no anchor (packages/spec/src/automation/flow.zod.ts) — 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 — 139 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 8fc50b7647d30db2a0837877cf251c1163a3239e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8fc50b7647d30db2a0837877cf251c1163a3239e

⚠️ 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 8fc50b7647d30db2a0837877cf251c1163a3239e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 8, 2026 01:29
…ance, comment truths

- service-automation tests: the input-schema and parallel-branch doubles
  register under a type of their own (probe_step), never the builtin script,
  whose contract now refuses their undeclared keys at the flow parse.
- lint: validateStackExpressions keeps its declared pre-conversion tolerance
  for a script node's functionName alias; every other undeclared script key
  is still refused there (pinned).
- spec comments: the FlowSchema header and the node-config-refused-by-contract
  docblock say which arm judges key membership.
- changeset: @objectstack/lint patch.

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Reviewed from the card (#21982, body and all 7 comments: triage grade 6015698142, unlock 6042634083, claim 6049115454, dev reports 6050249047 / 6050924694, claim revision 6050287134, ACCEPT 6050941111), the PR (#22129 body, its 11-file list, its one comment, the net diff against base 8fc50b764, 11 files +566/−39) and the check-runs on the head. Check-runs read twice: 2026-10-08T02:31:50Z and 2026-10-08T02:37:47Z, both times 46 runs, all completed: 38 success, 8 skipped, 0 failed, 0 in_progress (Check Changeset ×3, Part-of PR must not also close its card ×2, Test Core 1–6/6, Type Check ×5, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Dogfood Regression Gate 1–3/3 all success). Files at the head read through the raw contents API; main read once at ef1fcb26a2 (2026-10-08T01:58Z, past the base).

① Derived judgments

  1. FlowSchema.parse narrows, and every door that parses through it narrows with it — right. A script node config carrying a key outside function / inputs / outputVariable, or a subflow node config carrying a key outside flowName / input / outputVariable, is now refused as node-config-refused-by-contract, params: { nodeType, key }, path at the key, one refusal per key, message wrapping the contract's own unrecognized-key sentence. Both contracts are strictObject (schemaless-node-config.zod.ts :285 and :372 at the head), both executors parse them before acting (the card's measured run failure), and neither descriptor publishes a configSchema, so registerFlow's validateNodeConfigKeys walk skipped them. Refused-at-save now equals refused-at-run. Doors covered and pinned in flow-builtin-node-config-keys.test.ts: FlowSchema / defineFlow(), defineStack (STACK_SCHEMA_INVALID 422 at flows.N.nodes.M.config.KEY), ObjectStackDefinitionSchema (the objectstack validate / compile parse; dev measured exit 1 / exit 2, path nodes,1,config,bogusKey), ArtifactStagePackageBodySchema, getMetadataTypeSchema('flow'), and registerFlow, which converts, then FlowSchema.parses (engine.ts :4348), then walks descriptors (:4386). Triage's three pins (validate + compile refuse with location; registerFlow refuses; control passes) are all met.
  2. The judged set is exactly script and subflow — right. builtinKeysJudged is hasOwnProperty on SCHEMALESS_NODE_CONFIG_SCHEMAS (script, subflow, decision), asked only for a type in getBuiltinNodeConfigContracts() (13 entries, unchanged); decision is not among the 13 and takes its own early-return arm (:616). Pinned by the control that filters the 13 for a bogusKey refusal and gets ['script', 'subflow'].
  3. The 11 descriptor-configSchema builtins keep their undeclared keys at registration — right for this PR, under the seat's q3 ruling. flowNodeConfigRefusals leaves unrecognized_keys alone for them (pinned on http, create_record, screen), so their build doors stay green on a key registration refuses. The shadowing argument holds structurally: the spec arm runs inside the parse that precedes the descriptor walk, so lifting it would pre-empt that walk's pinned prescriptions. The PR body names this as Remainder 1 and 2, the first line is Part of #21982, the stored body has zero closing-keyword hits and one #21982, and Part-of PR must not also close its card is success twice on the head. The card stays open.
  4. Tombstoned retired script keys stay where they were — right, and outside this card's class. retiredKey() yields invalid_type expecting never, not unrecognized_keys; builtinValueJudged still returns false for it, so the lint and the D2 conversion flow-node-script-branch-keys-removed keep them. Pinned (actionType: 'email' → no refusal from the arm, the tombstone message intact).
  5. D2 alias spellings at the direct parse door — right as a narrowing, with one disclosure note (③.6). At the converting doors (defineStack, os validate, os compile, registerFlow) functionName / input on a script and flow on a subflow are rewritten before the judge, so nothing moves there (dev measured a functionName alias registering). At a direct FlowSchema.parse or defineFlow() (flow.zod.ts :1738 is FlowSchema.parse(config) with no conversion) they are now refused as undeclared keys. For functionName and flow the accept set does not move: the required canonical key was already missing and refused since [finding] a decision branch with no label registers and validates clean, then at run time the decision takes EVERY out-edge; a non-object conditions element also registers #20316. For input beside a present function on a script, the direct-parse accept set narrows net-new (inputs is optional, so nothing refused it before). The refusal carries SCRIPT_KEY_GUIDANCE.input's prescription, the census's AST scan flags any key outside the contract (input included) and found 0 production writers, and the changeset discloses the alias refusal at direct parse. Judged right.
  6. Region bodies — right. A script / subflow inside an ADR-0031 region is refused at the author's path (nodes.N.config.body.nodes.M.config.KEY), pinned; the same walk 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 used.
  7. Key arm and value arm compose — right. With keysJudged true, an unrecognized_keys issue is consumed (continue), and every other issue still passes builtinValueJudged (not whole), so the {token}, run-resolved and ledger-slot carve-outs keep holding for script / subflow values. Pinned: function: '' beside bogusKey yields exactly ['bogusKey', 'function'].
  8. @objectstack/lint validateStackExpressions — right. Its public output gains one error finding at config.KEY for an undeclared script / subflow key, through the shared judge; the filter at validate-expressions.ts :1724 hands both the function-missing refusal and the new functionName-undeclared refusal to the callable check, so the declared pre-conversion tolerance holds exactly for the alias that check reads. Pinned: the judge names ['bogusKey', 'function', 'functionName'] on a raw alias source and the lint keeps only config.bogusKey. A raw input (script) or flow (subflow) handed directly to the lint is now a finding too; no real door hands the lint a pre-conversion source (os validate converts at load), the lint suite carries no such fixture (5689/5689), and the changeset's lint paragraph says every other undeclared key is refused there.
  9. service-automation — right, test files only. The doubles in engine.test.ts and input-schema-retry-parity.test.ts register under probe_step, executor and nodes alike, keeping each test's meaning (delay on parallel branches; inputSchema reading a top-level count); the function: 'noop' filler goes with them. No source line moves (file list confirms). Red at f281d801f, green at the head; Test Core green.
  10. Public surface otherwise unchanged — right. No new export, no new code in FLOW_SLOT_REFUSAL_CODES, FlowSlotRefusalParams['node-config-refused-by-contract'] keeps its type (docblock only), getBuiltinNodeConfigContracts() keeps 13 entries; the added SCHEMALESS_NODE_CONFIG_SCHEMAS import comes from a module flow-node-config-refusals.ts already imported, so no new import edge. flow.zod.ts and flow-node-expression-paths.ts edits are comment-only (diff read).
  11. ADR-0087 registration — right. MIGRATIONS_BY_MAJOR[18].semantic gains one D3 entry with no conversionIds and no RETIRED_KEYS_BY_MAJOR row (no key removed, no tombstone owed); STEP18_RATIONALE gains order: 87; the generated copy in registry.ts is byte-identical to the entry file; check:generated is inside the green Lint & Repo Gates. On main ef1fcb26a2 the highest step-18 order is 86 (now held three times, after a landing since the base) and no 87 exists; duplicates at 74, 77 and 86 pre-exist in that array, so order uniqueness is not a gate, and the PR's 87 stays the next free order. mergeable_state reads clean.
  12. Triage's value guard — right. "A template value is never refused for its key's value": the key arm judges membership only; values stay under builtinValueJudged with its {token} carve-out.

② Semver level

  • @objectstack/spec: minor, BREAKING — right. The diff narrows the accept set of FlowSchema, a published authoring surface, which the rulebook (AGENTS.md :1076) classes as BREAKING for (narrowing); this repo ships BREAKING accept-set narrowings as minor under the launch-window convention (packages/cli/src/utils/spec-release-changes.ts :17–18, the phrase in 125 repo hits including sibling changesets), and .changeset/pre.json is absent at the head and on main (HTTP 404 both). The changeset carries the migration the rulebook demands for a breaking entry: a FROM → TO table, the one-line fix, the measured who-is-affected, and exactly one ADR-0087 marker, adr-0087: registered flow-script-subflow-config-undeclared-keys-refused, matching the D3 entry id in ①.11. Check Changeset is success ×3 and check:adr-0087-registration sits in the green Lint & Repo Gates.
  • @objectstack/lint: patch — right. The lint's own diff is a tolerance-preserving filter plus a pin; the new finding class arrives through its spec dependency and is declared on spec.
  • Clause-②: no (narrowing) — right. Present at a line start in the PR body (line 2) and in the changeset (line 14), identical. Well-formed under AGENTS.md :1075–1076: the closed pair allows no (narrowing); only no (widening) is malformed, and (narrowing) is BREAKING, which the changeset states. Triage's grade 6015698142 wrote yes (narrowing); the claim 6049115454 read the line's test as widening-only (as on lint: a conditional validation rule's nested then / otherwise predicate is never validated, so os build passes and the object save door stores a predicate the top-level rule would refuse #22042). The diff accepts nothing new and exports nothing new, so no matches the diff; the arm (narrowing) is what the ADR-0087 gate reads, and the level is minor under either reading, so the discrepancy moves neither the gate nor the level. No skip-changeset anywhere: right, two released packages publish.

③ Boundary flags

  1. OQ1 (service-automation doubles) — answered. Seat ruled A in 6050287134; implemented at the head exactly as ruled (①.9).
  2. OQ2 (lint pre-conversion tolerance) — answered. Seat ruled A; implemented with the filter, the new pin and the @objectstack/lint: patch line (①.8).
  3. OQ3 (scope: 2 of 13 strict builtins) — answered. Seat ruled the narrow reading with Part of and the remainder on the card; implemented and gated (①.3). For the seat's landing act: the ACCEPT's Release: line returning the card to pm:queue must name Remainder 1 (the 11 builtins at the build doors) and Remainder 2 (which judge owns a builtin's undeclared key at registerFlow), and the merge must not close build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982. Nothing in the stored body or the gate would close it today.
  4. Round-0 NOT MEASURED items (cli published-subpath pins, check:dts-closure, check:dual-build-cjs-loads; round-1 whole spec suite not re-run) — answered by the head's check-runs: Test Core 1–6/6, Build Core, Lint & Repo Gates, all five Type Check runs success.
  5. closingIssuesReferences GraphQL NOT MEASURED — answered: Part-of PR must not also close its card success ×2 on the head; my own whole-word scan of the stored body finds 0 closing-keyword hits.
  6. Escalated to the seat, non-blocking (disclosure wording): the changeset's sentence "Met by a direct FlowSchema.parse or defineFlow(), it is refused like any other undeclared key, as its missing canonical key already was" is exact for functionName and flow, but input beside a present function on a script is a net-new refusal at defineFlow() (①.5): no canonical key was missing, since inputs is optional. The refusal itself already carries the right prescription and the census found no writer, so the narrowing is right and disclosed; only the justification clause under-describes it. A one-clause changeset edit before landing would make the CHANGELOG sentence exact; not required for PASS.
  7. H4 live save door (draft + publish through runtime) not measured — accepted residual. Pinned at getMetadataTypeSchema('flow'), the schema that door validates against; Dogfood Regression Gate 1–3/3 success.
  8. Merge commit 521e16f1f without the session trailer pair; first commit subject carries (WIP) — not a contract matter. check:commit-card-trailers passed on push; the squash title is the PR title.
  9. service-automation first run collided with a concurrent gate (import resolution, 0 assertion failures) — answered: green alone and in CI.
  10. Out-of-scope carriers: the two comment-only staleness items were folded into this PR by the claim revision (①.10); the objectstack validate raw-ZodError print on a defineFlow source predates this PR and stays noted-not-filed, as on build: objectstack validate / compile accept unknown keys in a plugin node's config (e.g. an approval node's escalation) — the build-time refusal map covers built-in node types only #21850.
  11. Docs Drift Check (advisory): content/docs/releases/v17/17-0.mdx is release-owned and untouched; flow.zod.ts yielded no anchor, and its edit is comment-only, so no page can be falsified by it.
  12. main moved past the base (ef1fcb26a2, a third step-18 order: 86): no path of this PR is touched, mergeable_state is clean, and 87 stays free (①.11). No action.

Implemented-by: claude/issue-21982-schemaless-builtin-undeclared-key
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 02:42
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 02:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 78f841b Oct 8, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21982-schemaless-builtin-undeclared-key branch October 8, 2026 03:18
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… verdict (objectstack-ai#22032 pass 3) (objectstack-ai#22151)

Part of objectstack-ai#22032
Clause-②: no (narrowing)

This is pass 3 of objectstack-ai#22032: a field option's `visibleWhen`. Pass 4 (the
object's own action predicates) stays fenced, and the card stays open
for it.

## What changes

**The object save door gives the build's verdict on a field option's
`visibleWhen`.** `formulas.mdx` says "the same `validateExpression`
validator backs `os build` and metadata registration". After pass 2 the
object door ran the whole field walk except the per-option loop, which
kept its own guard. So an object whose option carried `visibleWhen:
'amount > 1'` (a bare field reference) still saved with a 200, while `os
build` refused it at error. The server's option check cannot evaluate
such a predicate and fails open (logged, allowed through), so the gate
it declares is never enforced (read from `evaluateOptionVisibility` in
`packages/objectql/src/validation/rule-validator.ts`).

- **The lift is the guard, nothing else (H1 held).** On `origin/main`
`8fc50b7647` the loop read `for (const [oi, opt] of (objectWrite ? [] :
recordsOf(f.options)).entries())` (`validate-expressions.ts:2072`),
under a `[objectstack-ai#22032] FENCED on an object write (pass 3 …)` comment. It now
reads `recordsOf(f.options)`. On an object write the loop runs at the
build's own position in the field walk, so the door gets both of the
option's checks:
  - `check(optionWhere, opt.visibleWhen, objectName, 'record')`;
- `refuseFieldTraversal(optionWhere, 'option visibleWhen', …)`, the
refusal of a read through a reference field on `record` or `previous`.
- **`current_user` keeps the build's two verdicts (H2).** An option's
evaluator binds the acting user (ADR-0068 D1), so the build accepts
`current_user` on an option and refuses it on the field-rule slots one
level up. The door now gives both verdicts as the build does. The
showcase's role gate, `'org_admin' in current_user.positions`, still
saves on an option, and the same text on the field's own `visibleWhen`
is refused at both doors. Both are pinned.
- **No registry change (H3 re-verified).** The
`validateStackExpressions` entry declares `runtimeTypes: ['flow',
'action', 'hook', 'object']` (`authoring-rules.ts`), and
`runtimeAuthoringRulesFor('object')` (`runtime-gate.ts`) dispatches it.
`runtime-gate.ts` is untouched.
- **Docblocks made true.** `StackExpressionOptions.runtimeWriteType` now
names four admitted passes and one fenced pass.
`AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one
line that reaches a built `.d.ts`, names the per-option pass. The
function-head comment and the field walk's two comments move with it. In
`authoring-rules.ts` the registry entry gains a `[objectstack-ai#22032, pass 3]`
measurement comment, as passes 1 and 2 added theirs. Comments in four
sibling test files are corrected so that none of them still says option
`visibleWhen` is fenced.
- **The door's verdict is the build's finding (H4).** The door's 422
issue and `runAuthoringRules('build', …)` give the same rule
(`expression-invalid`), location (`object 'fx_option' · field 'province'
option 'zj' visibleWhen`), path, message and hint. The pins compare
these key by key.
- **No code change in `packages/metadata-protocol`.** Only its test file
gains the door-level pins.

## Pins

- **Lint door:**
`packages/lint/src/runtime-gate.object-option-visibility-writes.test.ts`
(new, 14 tests).
- LIT: one refused body per finding the pass gives. These are a bare
`amount > 1`, an unregistered `sqrt(record.amount) > 1`, an unknown
field `record.amont > 1`, a syntax error `record.country ==`, and the
traversal refusal through `record.account` and through
`previous.account`. Each is located at the option and asserted on its
named subject.
- CONTROL (the still-accepted cases): the `record.country` cascade, the
showcase's `current_user.positions` role gate, a grant check plus role
gate (`current_user.can(…) && …`), and a reference compared as a value
(`record.account != null`). Each is clean at the door and at the build.
- CONTRAST: `current_user` on the option and on the field's own
`visibleWhen` in one body. The build and the door both give exactly one
finding, at the field slot.
- PARITY: for each refused body, the door's findings equal the build's.
- The differential: a stored sibling's broken options are not this
write's to answer for.
- **The fence (enumeration pin)** in
`packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The
fenced site is now pass 4's alone (an action `visible`). The option
`visibleWhen` site moves to the lifted sites, beside the validation rule
and the `requiredWhen`. The build flags all four sites. The object door
flags the three lifted sites in the build's order, and
`runStackExpressionPasses` on an object write returns exactly the
build's findings for the admitted passes.
- **Protocol door:** a new pass-3 block in
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
through the real `saveMetaItem`, `publishMetaItem` and
`publishPackageDrafts`.
- (a) A bare reference, an unregistered function and a read through a
reference field are each refused on an active save. The answer is a 422
`INVALID_METADATA` carrying the build's located finding, and nothing
lands.
- (a) The card-shaped body is refused on a draft's promotion and on a
package draft publish (`outcome: 'refused'`, `failed` naming the object
with `INVALID_METADATA`, the row left a draft). The draft saves
themselves still succeed.
- (b) The showcase's cascade and its `current_user` role gate still
save, and the row lands active. The role gate rides every refused body
too, which each yield exactly one finding.
- (d) For each refused body, the door and `os build` give the same
finding on rule, where, path, message and hint.

## Reverse verification (one-off, from committed HEAD `23ce6c494c`)

- **What was mutated.** `scripts/ablation-replace.mjs` (wrap mode) put
the guard back on the option loop. The new text was `const
ablationFence22032p3 = objectWrite;` followed by `for (const [oi, opt]
of (ablationFence22032p3 ? [] : recordsOf(f.options)).entries()) {`. The
anchor was hit once, 1 to 0, and the blob went `879fcb828037` to
`73bd026d1554`. The outer script carried `trap restore EXIT INT TERM` on
the absolute path.
- **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and
`ablation-dist-preflight` found the marker in 4 built files.
- **Lint suites (source): 9 failed and 14 passed, as predicted.**
- Red: the 6 LIT tests, PARITY, and the two fence tests that assert the
lifted sites.
- Green: the 4 CONTROL tests, CONTRAST, the differential, the registry
test, the build-flags-each-site test and the six formula-door tests.
- **Protocol pass-3 block (dist-mediated): 6 failed and 1 passed, as
predicted.** Red: the three (a) saves, (a) on promotion, (a) on package
publish, and (d). Green: (b).
- **Restore.** The tool restored the file: blob `879fcb828037` equals
HEAD, and `git diff HEAD` is empty. The whole tree had 0 changed paths.
Lint was rebuilt, and `--absent` found the marker gone from all 14 built
files. Both suites went green again: lint 23 of 23, and the protocol
file 99 of 99.

## Measurements

- **Corpus first: the stop condition was not met (H5).** Every object
this tree ships was judged before the door changed. That is every
`*.object.ts` under `packages/**` and `examples/**` (111 files) plus the
two `app-multi-package` sub-stacks: 118 objects in 18 groups. Each was
judged at the raw shape and at the `ObjectSchema.parse` shape (0 parse
failures), with its own group as context.
- 5 option predicates on 2 fields of 1 object, all on
`showcase_cascade`: `province`'s four `record.country` cascades (`zj`,
`gd`, `ca`, `tx`) and `tier`'s `restricted` role gate (`'org_admin' in
current_user.positions`).
- At base `8fc50b7647`: 0 build errors and 0 build warnings for the
option pass, through `validateStackExpressions` and through
`runAuthoringRules('build')`, at both shapes. There were 0 door
expression findings over all 118 objects.
- Non-vacuity: the 5 sites are judged. Mutating one cascade to a bare
`country` and the role gate to `sqrt(record.amount) > 1`, in a copy,
gave 2 build errors at those two options.
- At head `23ce6c494c`, and again at the merged heads `06d3cad963` and
`3c1d3262ee`: 0 door errors and 0 door advisories over every object,
through `runRuntimeAuthoringRules` with type `object`, at both shapes.
The harness passes each object's own group as context, so at
`3c1d3262ee` every one of those saves is an update and also runs the
stored-universe pass that objectstack-ai#22118 added.
- Positive control in the same harness (an option `visibleWhen: 'amount
> 1'`): 1 build error at every head. The door gave 0 at base and 1 at
head.
- **Which doors newly answer 422.** The active publish save, a draft's
promotion, and a package draft publish. Each was measured through the
real methods above. A draft save stays ungated, measured by the same
pins.

## Clause-② (measured)

- **Accept set: narrowing.** An object write in publish mode answered
200 for an option `visibleWhen` the validator refuses. It now answers
422 on the three doors above.
- **Built entry declarations.** In `@objectstack/lint` one doc comment
moves (`AuthoringRuleContext.runtimeWriteType`).
`StackExpressionOptions` and `runStackExpressionPasses` are not in the
built declarations. No exported signature moves.
- **Changeset.**
`.changeset/22032-object-save-door-option-visible-when.md` covers
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It
carries `fix(lint)!`, the `Clause-②: no (narrowing)` line, a BREAKING
section with the remedy, and ADR-0087 `not-required
(no-migration-prescription)`. `@objectstack/metadata-protocol` is listed
as passes 1 and 2 listed it, although no code moves there: its save,
promotion and package-publish doors are where the BREAKING behaviour can
be seen. `.changeset/pre.json` is absent on `origin/main` (read at
`8fc50b7647`, 2026-10-08T01:54Z, at `ef1fcb26a2`, 2026-10-08T02:40Z, and
at `7ef50a4fbb`, 2026-10-08T03:39Z), so the bump is `minor` with the
BREAKING banner, as in passes 1 and 2. The changeset says it supersedes
the earlier objectstack-ai#22032 entries' line that option `visibleWhen` is not judged
at this door.

## Merges

- **`06d3cad963`** merges `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103),
through `scripts/pm/os-regen-merge.sh`. It was clean.
- **`3c1d3262ee`** merges `origin/main` `7ef50a4fbb` (parents
`06d3cad963` and `7ef50a4fbb`), through `scripts/pm/os-regen-merge.sh`.
That brought PR objectstack-ai#22129 (objectstack-ai#21982), PR objectstack-ai#22094, PR objectstack-ai#22134, PR objectstack-ai#22133
(objectstack-ai#22118, the gate's object-write baseline keeps the written item's
stored self), PR objectstack-ai#22126 and PR objectstack-ai#22140.
- `validate-expressions.ts` auto-merged. PR objectstack-ai#22129's edit is in the flow
`script` / `subflow` region; the option loop's lift is unchanged.
- One conflict:
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
one hunk. Both sides had added a new top-level `describe` block at the
same place, after the pass-2 block. It was resolved by stacking: the
pass-3 block first, then objectstack-ai#22118's stored-self block, and every line of
both was kept. Against `7ef50a4fbb` the file shows only this branch's
lines; against `06d3cad963` it shows only objectstack-ai#22118's 114 lines.
- No generated path was touched by this branch. The rerun of the script
took `origin/main`'s side of every generated path main moved, and that
left nothing to commit.
- This branch's own changes are the same before and after the merge: for
each of the 9 files, the added and removed lines against the merge base
are identical. The delta against `7ef50a4fbb` is 9 files, +467 / −45.
- **The interaction with objectstack-ai#22118, measured.** objectstack-ai#22118 adds a third,
stored-universe pass on an update into a context collection. A finding
whose path names no entry is judged as one on the written item. The
expression rule's paths are `where` strings, so an option finding is
always judged that way.
- A probe ran the real gate (`runRuntimeAuthoringRules`, type `object`)
over 20 cases, against this branch's lint source before the merge
(`06d3cad963`) and after it (`3c1d3262ee`). The cases are a create, an
update over a stored self that is broken and over one that is clean, and
a stored sibling that is broken, each for three refused bodies, plus two
still-accepted bodies.
- The verdicts are identical, case for case. Re-saving a broken option
is still refused, including over a broken stored self, because re-saving
a row is writing it. A clean save over a broken stored self passes. A
broken sibling is never charged.
- The pass-3 pins (differential, PARITY, LIT, CONTROL, CONTRAST) and
objectstack-ai#22118's pins all pass at `3c1d3262ee`: lint 48 of 48 across the three
files, and the protocol file 103 of 103.
- This matches the code. The option pass reads only the written object's
own field index, and binds `record` and `previous`, never `parent`, so
the stored universe has nothing extra to offer it.

## Tests and gates (all at `3c1d3262ee`, the merge of `origin/main`
`7ef50a4fbb`)

- **`main` moved during the run (H6).** PR objectstack-ai#22103 landed as `ef1fcb26a2`
and touched two files this pass edits, `authoring-rules.ts` and
`runtime-gate.object-writes.test.ts`, in other hunks. It was merged with
`scripts/pm/os-regen-merge.sh` as a merge commit. The merge was clean,
and no generated path was taken from either side. After the merge: `pnpm
install --frozen-lockfile`, a full turbo build (72 tasks), and
`@objectstack/spec check:generated` ("All 15 generated artifacts are up
to date"). objectstack-ai#22118 and objectstack-ai#21982 had not landed at that read
(2026-10-08T02:40Z). Both have since landed, and they are merged at
`3c1d3262ee` (see Merges). After that merge the same sequence ran: a
full turbo build (72 tasks) and `check:generated` ("All 15 generated
artifacts are up to date").
- **`@objectstack/lint`:** 125 files and 5729 tests passed. `typecheck`
exit 0, with its test-typecheck included (`--listFiles`: the five
touched or new lint test files are in the `tsconfig.test.json` program).
- **`@objectstack/metadata-protocol`:** 221 files passed and 3 skipped;
28260 tests passed and 19 skipped. `typecheck` exit 0 (`--listFiles`:
the door test file is in the program).
- **Consumer readings.** Every package was built at the head named.
- `@objectstack/objectql`, 8 files and 282 tests passed:
`publish-package-drafts-response-conformance`,
`save-meta-response-conformance`, `publish-meta-response-conformance`,
`plugin.integration`, `engine-field-predicate-fault`,
`engine-option-permission-predicate`,
`validation/rule-validator.option-visibility` and `engine`.
- `@objectstack/rest`, 11 files and 197 tests passed: every
`meta-object-*` file and `meta-publish-package-scope`.
- `@objectstack/cli`, 3 files and 16 tests passed, run as `--project
integration` because the tier predicate puts them there:
`validate-field-predicate-traversal` (which asserts an option
`visibleWhen` `expression-invalid` finding),
`authoring-rule-command-parity` and `verify-author-time-stage`. The
three nightly-tier `*.e2e` files that assert `expression-invalid` are
left to CI.
- The search for other consumers covered every test file in the
repository carrying `visibleWhen`, matched against the save-door entry
points. Only the two packages above save an option `visibleWhen` through
a door.
- **Gates.** `dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands, the same set at
`23ce6c494c`, `06d3cad963` and `3c1d3262ee`. At `3c1d3262ee` all 63 exit
0, each exit code captured before any pipe. `--ran` reconciles 63
derived, 63 run, 0 NOT-MEASURED and 0 UNRUN, with an exit code recorded
for each. The printed artifact-roster block names 51 families, and all
51 ran at `3c1d3262ee`, each with exit 0. That is 47 in the same
battery, one (`check:engine-double-contract`) already among the 63, and
the three PR-scoped ones (`check-partof-closing-keyword`,
`check-closing-target-claim`, `check-single-claim-paths`) run with this
PR's number and this body. The same 63 also ran at `06d3cad963`, all
exit 0. At `23ce6c494c`, before the merge, the same 63 ran: 61 exited 0
on the first run. `check:dual-build-cjs-loads` and
`check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no full
build) and exited 0 after the full build.
- **ESLint, narrowed to the 8 touched TypeScript files**
(`--no-inline-config --format json`): 8 files, 0 errors and 0 warnings.
Each file is matched by `eslint.config.mjs` (`--print-config`), and none
was ignored. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file.

## Acceptance notes

- **Out of this pass, reported for the seat: an option `visibleWhen`
reading `parent` gets no verdict at the build, so none at the door
either.**
- Measured at `23ce6c494c` with scratch tests that were deleted
afterwards. Through the real `saveMetaItem`, an object whose option
carries `visibleWhen: "parent.status == 'closed'"` saved with success,
and the row landed `active`. `runAuthoringRules('build')` gave 0
findings.
- The server's option check (`evaluateValidationRules`, authenticated
caller, the option picked) then logged `failed to evaluate
(authenticated caller) — allowed through`, with `Unknown variable:
parent`, and admitted the value. The option evaluator binds `record`,
`previous`, the user and permissions only.
- This is a gap in the build, which the door now mirrors. This card's
contract is parity with the build, so it is not changed here.
- **A code comment names an old spelling.** The comment above the option
loop calls the showcase's legal usage `'admin' in
current_user.positions`. The showcase now writes `'org_admin' in
current_user.positions`, and the pins use that spelling. The comment is
not changed here: per the contract review, it rides pass 4.
- **The pending objectstack-ai#22032 changesets.** Pass 1's and pass 2's changesets
each list option `visibleWhen` under "Unchanged", which was true at
their heads. This pass's changeset says it supersedes that line rather
than editing them, the same way pass 2 left pass 1's changeset alone.
Per the contract review, reconciling those lines rides pass 4.
- `formulas.mdx` could name the object save door. That would be a docs
addition, not a correction of a false line.
- **Contract review.** Triage's grade asks for one per pass. A PASS is
on record for head `06d3cad963` (`6051573476`). The head has since moved
to `3c1d3262ee` by the merge above, so the review for this head is the
seat's.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…iltin node types is refused at the build doors; one judge per type (objectstack-ai#22319)

Fixes objectstack-ai#21982

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

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

## Seat answers to the build round, comment 6063587988

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

## What changes

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

## P1: descriptor key sets against contract key sets

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

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

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

## Hypotheses, measured

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

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

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

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

## Measured at the CLI doors

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

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

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

## Ablation

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

## Tests (real readings)

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

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

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

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

## Ablation of the fallback edit (patch round)

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

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

## Acceptance notes

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

## Gates

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

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

## Size

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants