Skip to content

POST /api/v1/automation answers 500 INTERNAL_ERROR for a malformed flow body — including the #4277 undeclared-config-key refusal the #7545 ruling leans on #8055

Description

@baozhoutao

Same error-class mismatch #7535 just fixed on the sibling POST /automation/:name/toggle route.

Symptom

Four distinct malformed registration bodies, all answered 500 INTERNAL_ERROR where 400 is the right class. Reproduced 2×:

  1. A node missing label → 500 carrying a raw zod issue array: {expected:'string', code:'invalid_type', path:['nodes',0,'label']}
  2. An unknown node key → 500 {code:'unrecognized_keys', keys:['next'], path:['nodes',0]}
  3. A malformed try_catch region → 500 "try_catch 'g' try: invalid region — Invalid input: expected object, received array"
  4. The important one — an undeclared node config key → 500:

    Flow 'x' rejected: 1 undeclared config key(s) (#4277). - node 'n' (notify): unknown config key \totallyBogusKey` at config.totallyBogusKey. It is not declared by this node type's configSchema…`

Expected 400 VALIDATION_FAILED with an ADR-0114 details.fields[], as the sibling /toggle route now returns and as the descriptor routes return.

Contrast controls run in the same pass: a well-formed body still registers 200, and the unknown-flow toggle correctly answers 404 RESOURCE_NOT_FOUND. So the catch arm on this one handler is mapping caller-input refusals to INTERNAL_ERROR.

The substance is fine — only the class and the message shape are wrong

The #7545 premise still holds: the refusal is hard and is located. Case 4 in particular produces exactly the self-correcting message #4277 designed. It is delivered under an HTTP status that says the opposite.

Impact — the one #7535 spelled out

An authoring client with retry-on-5xx will retry a request that can never succeed. And an agent authoring a flow reads "the server broke" instead of "your metadata is wrong" — the inverse of the self-correcting shape #4277 chose. Case 1 additionally leaks a raw zod issue array where the house envelope belongs.

Severity

P3.

Source

Found by the platform checklist retest of automation.flow-node-type-matrix (framework 279ee48a), while verifying the #7545 ruling. Held no clause of that item.

Activity

  1. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in packages/runtime/src/domains/automation.ts (the registration handler's catch arm, per the route map at :244 and registerFlow at :370-386) → domain:cli, queued. Re-laned from domain:automation — that label is not in the lane table; per the anchoring rule the label is the domain of the package the fix lands in, and this is packages/runtime. Type intent: Bug (native type field not set — list_issue_types still answers 403 for this Routine's credential).

    Serial constraint for the executing seat: #8054 touches the same file (domains/automation.ts:737 plus query-param.ts) — same-file hard serial, never the same batch.

    Size/model suggestion: S–M, opus — same error-class-mapping shape as the sibling /toggle fix this card cites.


    Generated by Claude Code

  2. self-assigned this
    on Aug 12, 2026
  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claimed and dispatched — domain:cli PM seat (#6024), session session_01B3Kurx8qufrDzNjk4rag7V, branch claude/issue-8055-automation-register-error-class.

    Honoring the triage seat's serial constraint: #8054 and #7968 also land in packages/runtime/src/domains/automation.ts and are held out of this batch. #8055 goes first as the longer pole; the other two are queued behind it and will be dispatched once this lands.

    Dispatch shape: mirror the sibling /toggle fix (#7535) — the catch arm must classify caller-input refusals as 400 VALIDATION_FAILED with an ADR-0114 details.fields[], not 500 INTERNAL_ERROR. Case 4 (the #4277 undeclared-config-key refusal) is the one that matters most: it already produces exactly the self-correcting message #4277 designed, delivered under a status that says the opposite. Case 1's raw zod issue array must stop reaching the wire — the house envelope belongs there.

    ⛔ Not licensed by this card: widening the refusal, softening any of the four refusals into a 200, or changing which bodies are rejected. Only the class and the envelope shape move. A contrast control that a well-formed body still registers 200 is part of the deliverable, not an optional extra.


    Generated by Claude Code

  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor
    {
      "issue": 8055,
      "status": "done",
      "branch": "claude/issue-8055-automation-register-error-class",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8125",
      "premise_still_valid": true,
      "summary": "The premise was verified against origin/main and holds exactly as filed: AutomationEngine.registerFlow raises every refusal (FlowSchema.parse, validateControlFlow, validateNodeConfigKeys #4277, validateFlowExpressions) as a plain throw with no .status, the POST / branch in packages/runtime/src/domains/automation.ts did not catch, and dispatch() re-throws non-denials — so the transport's outer catch answered 500 and copied any .issues into details verbatim. The branch now wraps its registerFlow call and converts the engine's verdict through a new module-local flowDefinitionRefusal: 400 VALIDATION_FAILED with an ADR-0114 details.fields[], the raw Zod issue array mapped to fields[] instead of forwarded, and the #4277 self-correcting message preserved verbatim (a 400 never reaches the 5xx sanitiser). An error that declares its own .status/.statusCode still keeps it, and which bodies are refused is unchanged. IMPORTANT DEVIATION FROM THE DISPATCH MODEL, deliberate and argued in the PR: #7535's fix (PR #7558) does NOT transfer as a mechanism — it fixed a 404 not-found with an existence probe BEFORE the service call, over a closed one-condition refusal set. This route's refusals are open (arbitrary Zod parse failures plus two descriptor-driven engine checks) and none is decidable without calling registerFlow, so a pre-check would mean re-implementing the engine's validation in the transport. What carries over, and is why the two routes converge rather than growing a third spelling, is #7558's stated rule that the HTTP class of a plain domain error is the serving boundary's decision, made at the domain handler and not in a generic catch.",
      "tests": "NEW packages/runtime/src/domains/automation-register-error-class.test.ts (9 tests). Cases 1-3 are produced by PRODUCTION code — the fake's registerFlow calls the real FlowSchema.parse and validateControlFlow from @objectstack/spec/automation, the same calls canonicalizeStoredFlow makes, in the same order; the captured shapes are byte-identical to the issue's ({expected:'string',code:'invalid_type',path:['nodes',0,'label']}; {code:'unrecognized_keys',keys:['next'],path:['nodes',0]}; \"try_catch 'g' try: invalid region — Invalid input: expected object, received array\"). Only #4277 is modelled, from engine.ts's own construction, because that check is descriptor-driven and lives in a package runtime does not depend on. ANTI-VACUITY: no assertion is status!==500 or status>=400; each case asserts status AND error.code AND error.httpStatus AND the presence and per-entry shape of details.fields[], plus its substance — case 1 pins Object.keys(details)===['fields'], details.issues gone, per-entry keys exactly field/code/message, no Zod-internal KEY (\"expected\"/\"received\"/\"path\") anywhere in the serialised body, and nodes.0.label surviving; case 4 pins the #4277 text clause by clause (#4277, unknown config key `totallyBogusKey`, at config.totallyBogusKey, \"not declared by this node type's configSchema\", the \"Declared here: ...\" prescription, node 'n' (notify)) and that the same text reaches the field entry. CONTRAST CONTROLS, green both directions: well-formed body still 200 and still reaches the service; #3899 nameless-definition check still refuses before registerFlow; unknown-flow toggle still 404 RESOURCE_NOT_FOUND. REVERSE VERIFICATION (git checkout, never git stash; restored from a patch file; restore confirmed byte-identical by sha256sum -c with git diff --stat unchanged) — 6 of 9 red, 3 green, and the 3 green are the contrast controls, which is the correct direction for them. Verbatim: ' × case 1 — a node missing `label`: the raw Zod issue array never reaches the wire / ZodError: [' ; ' × case 2 — an unknown node key: located, named, and free of Zod internals / ZodError: [' ; \" × case 3 — a malformed `try_catch` region: the engine's own sentence survives / Error: try_catch 'g' try: invalid region — Invalid input: expected object, received array\" ; \" × case 4 — the #4277 self-correcting text arrives INTACT under the honest status / Error: Flow 'welcome_flow' rejected: 1 undeclared config key(s) (#4277).\" ; ' × no malformed body reaches 500 by any route — no retry-on-5xx client is provoked / ZodError: [' ; ' × an engine error that DECLARES its own class keeps it — the boundary supplies a default, not a verdict / Error: flow store unreachable' ; 'Tests  6 failed | 3 passed (9)'. HONEST NOTE, not dressed to fit the template: the reversal red is 'the promise rejects', not 'the status was 500' — that is the truthful pre-fix behaviour AT THIS SEAM (the handler produced no response at all; the 500 is what the transport's outer catch makes of the escape). The general rule for that escape is already pinned by dispatcher-validation-error.test.ts (#3918, 'leaves a non-validation error on its old path'), so it is cited rather than duplicated. GATES: pnpm --filter @objectstack/runtime test = 143 files / 2185 tests all passing; pnpm --filter @objectstack/runtime typecheck = clean; pnpm check:type-check-debt = OK, runtime TEST_DEBT ceiling 227 held EXACTLY (not raised, and runtime is not among the entries the gate reports as lowerable — the new test file added zero hidden-layer errors); it required a full closure build first (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'), which the gate refuses to run without; node scripts/check-nul-bytes.mjs = OK plus a control-character self-scan of every changed file; eslint --no-inline-config on both changed files = clean. CI on PR #8125 was still in_progress at report time (per the draft-PR-time reporting contract).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8123: PUT /api/v1/automation/:name makes the identical registerFlow call in the same file and still answers 500 for all four bodies — after this PR merges, POST and PUT disagree about the class of an identical refusal until #8123 lands. Not fixed here because #8055's scope is the POST route; the helper added is module-local and route-agnostic so that fix is one line. Carries a Blocked-by: #8055 line.",
        "filed as #8124: packages/runtime/src/validation-failure.ts's fieldsFromZodIssues passes Zod's issue codes straight through to fields[].code, which ADR-0114 D2/D3 closed (FieldErrorCode, 27 members; 'unrecognized_keys' is not one). Affects /analytics, /notifications and this route — the pass-through is the package's, not any route's, and the compliant mapper (zodIssuesToFields) is module-local inside @objectstack/rest's rest-server.ts. Filed as one card with three fix options rather than forked locally, which would make automation a third dialect inside one package."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions