Repository navigation
feat(spec)!: the build doors refuse a builtin node config value its executor contract refuses, with its location - #21974
Conversation
…xecutor contract refuses The executor-contract arm of flowNodeConfigRefusals stops being presence-only: a present value a builtin node's contract refuses is refused at parse as node-config-refused-by-contract, anchored at the key, wherever the build can know what the run parses. A value carrying a token, key membership, region slots, predicate and value ledger slots, and http's signingSecret are left to the judges that own them. Adds the D3 entry flow-builtin-node-config-values-refused (protocol 18, rationale order 85) and its BREAKING minor changeset. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…value as unjudged flow-node-config-required's presence control and the write-target arm's envelope control each asserted that a present value its contract refuses passes the parse; the value arm now refuses both under node-config-refused-by-contract, so each control states that instead. The lint fixture with a non-array screen `fields` (declared on the lint lane) now expects exactly the screen contract's refusal at config.fields. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…iltin-node-config-values-judged
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ea7ec58e2f22c93fb209410d91714fadab5e7409 && git checkout ea7ec58e2f22c93fb209410d91714fadab5e7409
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3d9188502e1b07ae70df8b0b5e733fce44a74cfa c5545a54a61f33284db8ebddd8cb981a115cd56b && git checkout -B drift-repro 3d9188502e1b07ae70df8b0b5e733fce44a74cfa && git merge --no-ff c5545a54a61f33284db8ebddd8cb981a115cd56b
node scripts/docs-audit/affected-docs.mjs --json 3d9188502e1b07ae70df8b0b5e733fce44a74cfa
|
…iltin-node-config-values-judged
…tures that carried a value the build doors now refuse The execute-time parse tests in config-parse and notify-node now pin the door half (registration refuses the value at its key) and still reach the executor's parse the way the suite already does for a key left out: register a value the contract accepts, then write the refused one into the stored flow before the run. The "clean" advisory flows in metadata-protocol and objectql write their delete_record filter in the record form the contract declares instead of a rule array. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…iltin-node-config-values-judged
…polates before its parse
The changeset's FROM -> TO rows and the D3 entry's replacement told an
author to write a {token} template in limit or maxIterations, but
get_record and loop parse their config as authored, so such a value
passes the build doors and fails every run. A number or boolean slot
outside http now takes a literal only, http's slots keep the sole-token
form, and the "still accepted" token bullet says the hold-back is no
promise the value runs. The step-18 fragment says the same; the
registry region is regenerated.
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…iltin-node-config-values-judged Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…iltin-node-config-values-judged Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…registration refuses, the executor refuses past the doors Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21898
Clause-②: yes (narrowing)
Merge gate (the ruling's timing): this PR merges only once
.objectui-shaonmainis at or after5ba255538a(objectui#11670, the Studio designer storing a screen field's Min / Max as numbers). At this base the pin is0abd4f9f87, which does not carry it. This PR does not move the pin.At landing: both gates are met.
.objectui-shareadsa58626c88d, which contains5ba255538a.maincame in by two pure merges,42ce99cf91and00bf19bdb5. Each tree equals itsgit merge-tree.One test commit,
c5545a54a6, adds a 16th file:packages/services/service-automation/src/builtin/notify-template-slots.test.ts.title: 42case came with feat(spec): notify title/message are template slots — bare string or tmpl envelope #22063. It now asserts the registration refusal, then the executor's refusal past the doors (claim revision6040112148).6041691539onc5545a54a6.Draft. Patch round 1 re-judges the cross-lane fixtures that the claim revision
6012822762declared (#6021 comment6012831917; #6367 comment6012842570). See "Cross-lane fixtures re-judged" below. No source line moved in any of those packages.The build doors now refuse a value that a builtin node's executor contract refuses, with its location, at
FlowSchema.parse,objectstack validateandobjectstack compile. Ruling6010677104(A) gives the pin, the measured pair:create_recordwithconfig.outputVariable: 42, and a screen field with a stringmin. Each is refused at save with its location. Until now both passed every build door and registered, and then every run that reached the node failed at the executor's contract parse.What changes
packages/spec/src/automation/flow-node-config-refusals.ts: the builtin executor-contract arm offlowNodeConfigRefusalsis no longer presence-only. A contract issue at a key the author wrote is now refused with the existing closed-set codenode-config-refused-by-contract,params: { nodeType, key }, anchored at the key. It is the same code, and the same message builder, the approval contract uses. No new code joinsFLOW_SLOT_REFUSAL_CODES.The refusal is kept only where the build knows what the run will parse (
builtinValueJudged). Each carve-out sits where another judge owns the finding, or where the run may parse something other than what was authored:unrecognized_keys) and a tombstoned one (aretiredKey(),invalid_typeexpectingnever). Registration refuses an undeclared key against the descriptor, with its own prescriptions. The lint names the retired script keys. The D2 layer rewrites retired spellings first at the two doors that convert.try: 5, a one-branchparallel). Region shape belongs tovalidateControlFlow; region nodes are judged as graphs of their own.predicateorvalueledger slot, at or inside it: a screen field'svisibleWhen, and a CRUDfieldsvalue.predicateSlotRefusaljudges the first (its non-strings are left toregisterFlowandvalidateby ruling, per theflow.zod.tsnote). The value-envelope pass judges the second.http.signingSecret. A secret held in the credential channel replaces the literal before the parse.{token}anywhere inside it. It is never refused for its pre-interpolation type. The pattern is the interpolator's own, which also matches the double-brace and dollar-brace spellings.getBuiltinNodeConfigContracts()keeps its export, its shape and its 13 entries, and the lazy-build cycle note stands. The approval arm is unchanged and still judged whole. The docblocks that said "presence-only" moved: the module header, the judge's docblock,absentAt, two blocks inflow.zod.ts, and the approval test's control title and header.Built-ins not judged whole, each with its reason
http: its executor parses after interpolating the whole config. So a value is judged only when nothing inside it carries a token (interpolation is then the identity). A rule is judged only when the whole config carries none.signingSecretis never judged.loop: its executor parses only when there is abody(parsedWhen). A legacy flat-graph loop is judged for nothing. A loop with a body is judged oncollection,iteratorVariable,indexVariableandmaxIterations.loop(body),parallel(branches) andtry_catch(try,catch): they hold regions, which are judged as graphs of their own. Each container is judged on the keys beside its regions, such astry_catch'serrorVariableandretry.parallelhas only its region slot, so it is judged for presence alone.Every other builtin is judged on every present value:
get_record,create_record,update_record,delete_record,notify,screen,script,subflowandmap.Census (round 1, report
6006631317)The census took every builtin node
configat833d57c9cf. For each it listed what the planned arm refuses, using the arm as its predicate. A lit control (planted file) found all 6 planted refusals and exempted both planted tokens.packages/qa. None is refused. 190 template strings are counted separately, all in string-typed slots.4054ec2680: 138 nodes, none refused.After this change I re-ran the census with the implemented judge in place of the predicate. On every statically evaluable node it agrees with the predicate. The only differences are values the walker cannot evaluate.
Census, extended in patch round 1 to helper calls, same-file consts and property assignments. The walker now also reads three shapes:
f(…, 'TYPE', …, { … }), taking the first object after the type as the config;….configor….config.KEY.A lit control found a helper-call refusal, a const-resolved refusal and a rule-array assignment, and passed a token.
d1c7d8d392: 1065 configs (839 literal nodes, 224 helper calls, 2 JSON), plus 70 assignment sites. The judge refuses static values in 27 rows. Each is one of four things:lintFlowCredentialLiterals,validateFlowNodeWrites,lintFlowPatterns,resolveFlowNodeExpressions), green;4054ec2680: 138 configs, 0 refused.configFor(type, …)) or a loop over a sweep;idorlabel;Reproduction, before and after (a scratch copy of
examples/app-showcase, removed afterwards)833d57c9cf(base)create_taskoutputVariable: 42✓ Validation passed) · compile 0, artifact carries42customatnodes.1.config.outputVariable, no artifactmin: '1'customatnodes.1.config.fields.0.minget_recordlimit: '{inquiry_cap}'Every edit was proved on disk with
grep -c, anchor 1→0 and injected 0→1. The validate door now reads: "Thiscreate_recordnode's config is refused atoutputVariableby the create_record contract: Invalid input: expected string, received number. Its executor parses the config against that contract before it does anything else and refuses the node on any finding, …".At base, at the engine (built
service-automation):outputVariable: 42registers, then runs 1 and 2 each fail withcreate_record 'mk': config does not satisfy the create_record contract — config.outputVariable: …and 0 inserts. The designer-shaped screen node registers, then its run fails atconfig.fields[0].min.Pins (
flow-builtin-node-config-values.test.ts, 44 tests)FlowSchema(customatnodes.1.config.outputVariableand atnodes.1.config.fields.0.min), with the judge's code, params and path. It is also refused inside aloopbody, atnodes.1.config.body.nodes.0.config.outputVariable.limit,multi,severity, anotifyrule,timeoutMs,durable,headers.X-Kind,mode,fields[0].required, an emptyfunction/flowName,collection,maxIterations,errorVariable,retry.maxRetriesand others.defineStack(STACK_SCHEMA_INVALID/ 422 atflows.1.nodes.1.config.outputVariable),ObjectStackDefinitionSchema(both pins), the registeredflowtype schema the save door uses (the screen pin) and the artifact parse. The CLI doors are in the table above.Ablation (reverse verification). I committed first, then used
scripts/ablation-replace.mjsto restore the presence-only line (if (!absent && !whole) continue;): anchor 1→0, blob01e47e2d7e35→2d67f51da4c4. The subject resolves throughsrcby relative import, so no build was needed.flow-node-config-required.test.ts, 3 inflow-write-node-stored-metadata-target.test.ts).Tests 40 failed | 129 passed (169), 36 / 1 / 3 as predicted.git diff HEADempty.A second ablation deleted the token exemption line. Predicted 1 red (the token control); observed
Tests 1 failed | 70 passed (71), then restored the same way.The ADR-0087 kit
entries/semantic/18.flow-builtin-node-config-values-refused.ts. Itsregistry.tsregion was regenerated bygen:migration-registry.origin/mainat230e4944b0just before opening: the highest order there is 84, and this id is absent.minorfor@objectstack/spec, with theregisteredmarker and theClause-②line.check-adr-0087-registrationpasses.check:generatedreports all 15 artifacts up to date. The public exports did not change.Fixtures re-judged in this PR
spec/.../flow-node-config-required.test.ts: a control pinned "a present wrong-typed value is not refused". That is exactly the branch removed, so the control is replaced. It now asserts the value arm's code, and that this rule still reports absence only.spec/.../flow-write-node-stored-metadata-target.test.ts: the "dynamic objectName" control included an expression envelope inobjectName. The CRUD contract declaresobjectNamea string, so the run refused that envelope too. The template cases keep their control. The envelope now asserts exactly the value arm's refusal, and still none from the write-target arm.spec/.../flow-approval-node-config-contract.test.ts: only the builtin control's title and header wording; the assertion is unchanged.lint/src/validate-expressions.test.ts:2360, declared on [PM seat] domain:devx @ objectstack — 🟢 baozhoutao · session_01VDtqoecgES7ScQYGbFVDRv · R9 · landed 3 · #20004 awaits skip-changeset · in flight 0 #6023 in comment6011223490: the screenfields = 'nope'fixture now expects exactly the screen contract's refusal atconfig.fields. Nopackages/lintsource line moved.Cross-lane fixtures re-judged (patch round 1, declared on their lanes)
Each of these fixtures registered or saved a flow carrying a value its contract refuses, so every one of those flows also failed at its first run. The round-1 census missed them because each config arrives through a helper argument or a property assignment. The step-7 suites caught them.
service-automation,config-parse.test.ts(the tests formerly at:118,:129,:184,:193and:317) andnotify-node.test.ts(formerly:274). Each test keeps the subject its title names: the executor's execute-time parse.runPatchedbeside the existingrunStripped; innotify-node, it is the stored-flow edit its "no recipient" test already uses.limit,timeoutMs,mode,flowNameandtemplate.service-automationsource line moved.metadata-protocolprotocol-publish-drafts-advisories.test.ts:205, andobjectqlpublish-meta-response-conformance.test.ts:413andsave-meta-response-conformance.test.ts:298. The "clean" flow'sdelete_recordfiltermoves from the rule array to the record form the contract declares:{ created_at: { $lt: '2020-01-01' } }.flow-multi-write-unfiltered,lint-flow-patterns.tsfilterCarriesNoCondition) stays silent. Measured:reduceFilterVerdictanswers'clause'for the record form, so a condition is written.filter, which the contract refuses outright.filteras a rule array.0abd4f9f87: the designer maps the slot (descriptortype: 'object', additionalProperties: true) to itskeyValuewidget, which commits a record. It keeps an array only when an array is already stored, and then in its{ variable, value }form; it never writes a rule array.packages/qa) and hotcrm4054ec2680: 0 CRUD-node array filters, by an AST scan with a lit control. The 70 array filters found are page, view and API filters.Verification (at
22f54b0738, after mergingorigin/mainc9761cd2fb)Tests
@objectstack/spec: full suite 673 files / 19431 passed / 1 todo, exit 0.typecheckexit 0 (check:test-typecheckdebt held).check:generated: all 15 up to date.@objectstack/service-automation: 173 files / 2112 passed,typecheck0. The ledger trio (node-config-contract-ledger,config-expression-ledger,node-config-required-keys) is green inside it.@objectstack/metadata-protocol: 218 passed / 3 skipped files, 27996 passed / 19 skipped tests;typecheck0.@objectstack/objectql: 378 files / 7507 passed;typecheck0.@objectstack/lint: 119 files / 5629 passed;typecheck0.@objectstack/cli,--project unit: 259 files / 3786 passed.--filter='!@objectstack/docs', VERDICT 0), so every suite reads a currentdist/.Gates
dispatch-gates --ranat22f54b0738: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.check-engine-split-ratio --days 90first refused on the shallow clone (exit 2). I deepened withgit fetch --shallow-since=2026-07-01; it then exits 0, with the ratio at 98.4% and the oldest visible commit at 2026-07-01, before the window.ESLint, narrowed to this PR's own changed files, at
22f54b0738:isPathIgnoredreports all 14 changed.tsfiles as linted.--format jsonreports 14 files, 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change the result for any file it does not touch.Declared to CI: the full
pnpm lint, the dogfood suite, the cli integration tier, and every package not named above.Acceptance notes (not filed)
script(and by reading,subflow) node with an undeclared config key passesFlowSchemaandvalidateStackExpressions, then registers, then fails every run.registerFlow's key check skips schemaless types, and this arm judges no key membership. It belongs to this card's family, key half; it was measured at the functions the doors call, at833d57c9cf. Carrier: none.get_recordlimit: '{n}'or a screen fieldmin: '{m}'is still refused at every run. The ⛔ above keeps it out of the build doors. Carrier: none.PARSED_AFTER_INTERPOLATION(http) andRUN_RESOLVED_KEYS(http.signingSecret) are new private tables in the judge.service-automation's ledger reconciles the contract map against the executors'parseNodeConfigcalls but reads neither table. A new after-interpolation executor or credential slot would have to be added here by hand. Carrier: none.defineFlowthrows while the CLI loads its config, the CLI prints the raw ZodError JSON, with the path relative to the flow and no flow name or file.Generated by Claude Code