Repository navigation
[Decision] One seam, two strictness levels: POST /api/v1/automation hard-refuses an undeclared node config key but accepts an unknown node type #7545
Description
Activity
Triage:
domain:servicesappended; decision state untouched (disjunction-5 routing repair — a decision card with no lane has no seat to execute the ruling once it lands).- Landing anchor (read, not guessed): every premise P1–P4 anchors in
packages/services/service-automation/src/engine.ts, verified onorigin/main@34c01a5—nodeTypeVocabularySealedat:1139, the sealed-vocabulary WARN branch at:2318-2323, the 3b — wire the flow executors toparse()their config, and tighten the undeclared-key warning into an error #4277 config-key REJECT at:4479.packages/services/*⇒domain:services. Caveat recorded: an Option-A ruling would land its main edit checklist-side (docs/qa/, devx) with only the contract note here — the label follows the contract owner and recommendation-B seam; re-route on ruling if A is taken. - Dup check: registerFlow's three validators still walk top-level nodes only — a region's malformed structure, unknown node type and undeclared config key all pass registration #4389 (closed — traversal, explicitly distinguished by the card), bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 (closed — the ordering incident), [Decision] A failed
try_catchtry-region produces no step at all — after a caught failure an operator cannot tell what failed, how many attempts ran, or which node threw #7546 (sibling decision from the same run, different seam: try_catch step reporting). No open card duplicates this strictness fork. target:v17: not applied — decision card; current behaviour is loud-at-runtime, no data/security face.
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- Landing anchor (read, not guessed): every premise P1–P4 anchors in
Maintainer ruling recorded 2026-08-11 (PM session, executing the maintainer's direct instruction in chat — verbatim: 「接受你的全部建议,请更新 issue 的状态和标签」, accepting the four-lens decision-inbox review in full).
Ruling: status quo upheld. Registration-time soft-fail on an unknown node
typeis the intended posture. The rationale is real and documented (ADR-0018 §M1: a flow authored against a currently-absent plugin must still register; #4771 fixed the ordering bug this protects against), and the failure is already loud at three points (registration WARN with the full vocabulary, trigger answerssuccess: false, run recordedfailed). This is deliberate late binding with a declaration — not tolerance masking an error, so the hard-refuse instinct does not apply here.Remaining scope of this card: record this ruling in the
flow-node-type-matrixchecklist clause so future runs read the 200-on-unknown-type as pass-by-design, then close.State:
needs-user-decision→pm:queue(domain:servicesseat, checklist-recording scope only — zero engine code).
Generated by Claude Code
Claim: PM loop, services lane, wave 2 round 4
Session:session_015fkdTyGmMD5s8ZtEifvuGy
Branch:claude/issue-7545-node-type-clause-ruling
Worktree:objectstack-issue-7545
Domain:domain:services
File surface:docs/qa/platform-checklist/areas/automation.json(theflow-node-type-matrixclause) — recording the maintainer's status-quo ruling so future runs read 200-on-unknown-type as pass-by-design. Zero engine code, per the ruling. Stop on breach.
Container & model: S-grade but prose-judgment (the clause must state the WARN + named-runtime-failure contract accurately — its failure mode is wording, not gates),mode:subagent,model: opusper the borderline-goes-up rule.
Serial constraints cleared:⚠️ #7546 (sibling ruling,flow-error-handlingclause) lives in the SAME file and is deliberately held to the NEXT round — this card lands first, alone, per the same-file serial rule. No other in-flight claim touchesdocs/qa/platform-checklist/.
Generated by Claude Code
<!-- os-dev-report -->
os-dev-report{ "issue": 7545, "status": "done", "branch": "claude/issue-7545-node-type-clause-ruling", "pr": "https://github.com/objectstack-ai/objectstack/pull/7664", "premise_still_valid": true, "summary": "Premise verified before editing: the flow-node-type-matrix item's negative clause did demand a registration-time refusal ('must be REFUSED at registerFlow / POST /api/v1/automation'), a promise engine.ts and ADR-0018 §M1 never made — so run #7516's FAIL was against a non-existent contract. Recorded the maintainer's status-quo ruling checklist-side only, zero engine code: the refusal expectation is replaced by an acceptance clause asserting the real four-part late-binding contract (200 + located WARN carrying 'no registered executor or descriptor' with unknownTypes and the full knownTypes vocabulary + trigger success:false with the NO_EXECUTOR message + run recorded 'failed'), stating pass-by-design explicitly and citing #7545 / ADR-0018 §M1 / #4771; the negative is rewritten to name SILENCE as the failure shape with the 200 explicitly disclaimed as a finding. Also added the driving step the clause needs for evidence, the ADR/engine/ruling source anchors, and revision 2 to 3 with the matching history entry. Two accuracy corrections against engine.ts beyond the ruling text: unknownTypes/knownTypes ride the log record's structured meta slot rather than the message (#6654 — a runner grepping the message would wrongly fail the item), and the WARN is gated on BOTH nodeTypeVocabularySealed and the flow being enabled. Did NOT touch flow-error-handling (sibling card #7546). coverage.json indexes this item by id only, no expectation text — grepped and confirmed nothing to keep consistent.", "tests": "node scripts/checklist-select.mjs --self-test -> '✓ checklist-select self-test: 17 cases pass.' | node scripts/checklist-select.mjs automation.flow-node-type-matrix -> 'selector: automation.flow-node-type-matrix → 1 runnable item(s) / P1 mixed automation.flow-node-type-matrix' | node scripts/check-nul-bytes.mjs -> 'OK (scanned 7089 text file(s) -- 7089 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes)' plus a targeted self-scan of the edited file with grep -naP for the control-byte class: no hits. node scripts/check-platform-checklist.mjs (the pnpm check:platform-checklist gate) reports exactly 1 problem — 'coverage.json · qa: UNCLASSIFIED' — which is PRE-EXISTING on main and unrelated to this item: reproduced byte-identically on a pristine origin/main worktree with this PR's changes absent, and already filed as open issue #7347. The item's own structural checks pass (revision==last history revision, every acceptance clause names an oracle, enumSource variants ratchet). JSON re-parsed clean: revision 3, 6 acceptance clauses, 1 negative, 10 steps. Per the card's explicit '⛔ no idle-polling CI / report at draft-PR time', CI was not waited to convergence: at report time ESLint and TypeScript Type Check are in_progress, Test Core / Dogfood Regression Gate / Check PR Size / Console Pin Freshness / ADR maintainer approval are green.", "open_questions": [], "out_of_scope_findings": [ "NOT filed (already tracked): check:platform-checklist is red on main with 'coverage.json · qa: UNCLASSIFIED — the platform has this capability (liveness ledger exists) but the checklist neither tests nor waives it'. Searched open issues before filing and found the exact match — #7347, open since 2026-08-10. Commented nothing there; it needs no new signal. Flagging it because this PR's check:platform-checklist job will be red for that reason and not for this change." ] }Notes for the PM, outside the JSON:
- A surprise on the PR I did not cause and did not act on (rule 2): the stored PR body came back with
</>/ apostrophes rewritten to HTML entities and the attribution footer in its session-URL form. That is the platform's at-rest sanitizer, expected, and evidence about the body only — I changed nothing in response. skip-changesetapplied by me, read back after the bots settled: labels are now["size/s", "skip-changeset"]. I read the existing set first and wrote the union, so thesize/sthe bot applied was not wiped.- Serialization respected:
flow-error-handlingin the same file is untouched, left for [Decision] A failedtry_catchtry-region produces no step at all — after a caught failure an operator cannot tell what failed, how many attempts ran, or which node threw #7546.
Generated by Claude Code
Generated by Claude Code
- A surprise on the PR I did not cause and did not act on (rule 2): the stored PR body came back with
PM review — ACCEPT (session
a31dbd89, PR #7664)Scope check: exactly what the ruling ordered — checklist-recording only, zero engine code, 1-file surface (
docs/qa/platform-checklist/areas/automation.json, +20/−4). Verified against the diff:- Acceptance clause asserts the four-part late-binding contract verbatim from the ruling: (1) 200 + flow really registers, (2) located WARN with the load-bearing phrase
no registered executor or descriptorandunknownTypes/knownTypesin the record's structured meta slot (per finding(service-automation): engine.ts's five name-shaped splices — author-metadata / caller-supplied identifiers interpolated into log messages with no newline constraint #6654 — a message-grep would wrongly fail the item), (3) trigger answerssuccess:falsewith the NO_EXECUTOR-class message, (4) run recordedfailedwith steperror.code 'NO_EXECUTOR'. Rationale block cites ADR-0018 §M1 and bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 and says "do not re-litigate". - Negative rewritten: silence is the FAIL (missing WARN, dropped phrase, missing meta lists, quiet run side); the 200 itself explicitly disclaimed as a finding — exactly the misread that produced run QA run · automation (FULL area) · a86db175 · 2026-08-11 · 5 PASS / 3 PARTIAL / 2 FAIL #7516's false FAIL.
- Driving step added so future runs actually capture the evidence; sources anchored (ADR-0018 §M1, engine.ts warn branch + NO_EXECUTOR, this ruling); revision 2→3 with matching history entry;
flow-error-handlinguntouched (reserved for [Decision] A failedtry_catchtry-region produces no step at all — after a caught failure an operator cannot tell what failed, how many attempts ran, or which node threw #7546). - Gates:
checklist-select --self-test17 pass;check-platform-checklistshows only the pre-existing finding:check:platform-checklistis red onmain— the newqaliveness ledger is neither mapped nor waived in coverage.json #7347 problem (reproduced byte-identical on pristine main);skip-changesetcorrectly applied for a docs/qa-only surface.
Driving PR #7664 to green now; this issue auto-closes on merge.
Generated by Claude Code
- Acceptance clause asserts the four-part late-binding contract verbatim from the ruling: (1) 200 + flow really registers, (2) located WARN with the load-bearing phrase
Background
The
flow-node-type-matrixchecklist item records a FAIL, but it is not a bug and not a regression — it is a checklist-vs-implementation contract disagreement where the implementation side has explicit, documented rationale. The QA run says so itself: this needs a maintainer ruling, not a fix ticket.POST /api/v1/automationwith a node oftype: 'bogus_node'returns 200 and really registers the flow.Crucially it is not silent, which was the #1887 anti-goal:
unknownTypesand the fullknownTypesvocabulary;success: falsewith"No executor registered for node type bogus_node";failed.So the failure is loud, located, and observable at three points. The disagreement is purely about when — registration time versus execution time.
The rest of the item is strong: 20 of 21 node types proven executed
successacross a 235-step sweep over ~100 runs; region tagging (loop-body/try/catch/parallel-branch, plusiterationandparentNodeId) all correct; every step names itsnodeType, none missing. The one other gap is thatendnever appears as an executed step, only as a skipped branch target.Premises
Each premise is independently re-checkable; the command to re-check it is on its own line.
P1 — Registration accepts an unknown node type and logs a WARN rather than refusing. The check is deliberately gated on a sealed vocabulary and on the flow being enabled, and warns once per offending flow.
P2 — The soft-fail is deliberate and documented, not an oversight.
registerFlow()checks membership at that seam and stays soft-fail so that a flow authored against a currently-absent plugin still registers; the rationale is written into the code, into ADR-0018 §M1, and into #4771 (which fixed the ordering bug where approval flows were falsely warned about because validation ran before plugin executors registered).P3 — The WARN is genuinely informative, carrying both the unknown names and the whole known vocabulary. The phrase "no registered executor or descriptor" is load-bearing — tests and log filters count per-flow findings by it.
P4 — The same endpoint HARD-REFUSES an undeclared node
configkey. #4277 shipped the error half of the #4045 unknown-key ladder (#4059 shipped the warn half), with a located message listing the declared keys.P5 — There is prior art on this seam's coverage being incomplete. #4389 (closed) recorded that registerFlow's three validators walked top-level nodes only, so a region's malformed structure, unknown node type, and undeclared config key all passed registration. That was about traversal; the present question is about strictness policy. Worth reading before ruling so the two are not conflated.
The question
One seam, two strictness levels.
POST /api/v1/automationrefuses an undeclared node config key outright, but accepts an unknown node type.That is hard to defend as a coherent contract. The config-key rule says "what the descriptor does not declare, the door does not accept". The node-type rule says "what no executor provides, the door accepts anyway, and you find out when it runs". A flow author hitting both rules on the same POST learns that declaredness is enforced for the inner structure of a node but not for the node's identity — the stricter check guards the less consequential mistake.
Is that split intentional and worth keeping, or should the two arms be aligned?
Options
Option A — Keep soft-fail; revise the checklist item to match
Accept the documented rationale as the contract and rewrite the
flow-node-type-matrixclause to assert what the platform actually promises: registration succeeds, a located WARN is emitted namingunknownTypes+knownTypes, and triggering fails with a namedNO_EXECUTORerror and afailedrun.Option B — Harden to match the config-key strictness
Refuse an unknown node type at registration, with the same shape as the #4277 config-key refusal: a located message naming the offending node and listing the known vocabulary.
sealNodeTypeVocabulary()now makes "the vocabulary is complete" a reliable moment — the code says every registration after the boot seal is against a complete vocabulary, which is a much better position than the one bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 was fixed from. But "a flow authored against a plugin that is genuinely not installed on this instance" remains a real scenario that this option converts from degraded to impossible.parse()their config, and tighten the undeclared-key warning into an error #4277 chose for config keys. This is the axis where Option B wins decisively.Option C — Make it configurable
A strictness setting: refuse by default, soft-fail when a deployment opts in (for the plugin-absent case).
Recommendation
Option B, with the #4771 escape hatch preserved explicitly.
The three axes do not point the same way and the honest reading is that they trade off:
sealNodeTypeVocabulary()is the fix for exactly that. The residual real need is narrower than the current soft-fail: it is "a flow that references a plugin not installed here must still be storable", not "any typo must be accepted".parse()their config, and tighten the undeclared-key warning into an error #4277. An unknown node type is the more consequential error of the two — a wrong key breaks one node's behaviour, a wrong type means the node does not exist — and it is the one currently going unchecked.So: harden the default to refuse, mirroring #4277's message shape (name the node, name the type, list the known vocabulary). Then keep the plugin-absent capability alive deliberately rather than by accident — the cleanest form is for a flow to declare that it depends on a plugin, so an unknown type covered by a declared-but-absent plugin degrades to today's WARN while an undeclared unknown type is refused. That preserves the one measured need without preserving the typo hole.
If that declaration mechanism is judged too much scope for now, take Option A rather than Option C — and rewrite the checklist clause to assert the WARN-plus-named-runtime-failure contract, so the item stops reporting FAIL against a promise the platform never made. A wrong-but-documented contract is recoverable; a per-deployment contract is not.
⛔ Not Option C in any case.
Source
Extracted from the QA run #7516 (framework a86db17). Checklist item
flow-node-type-matrix, recorded there as one of the two FAILs that are contract decisions rather than defects.