Repository navigation
fix(runtime): two hand-built refusals answer FieldErrorCode members (unknown_field, invalid_format), not Zod issue codes - #24683
Conversation
…not Zod issue codes The run-lifecycle body refusal (cancel / restore-suspension, unknown key) wrote Zod's `unrecognized_keys` on `details.fields[].code`; the permission-set clone door's name rule wrote Zod's `invalid_string`. ADR-0114 D2 declares `FieldErrorCode` a closed catalog, so both now answer its members: `unknown_field` and `invalid_format`. Status, `error.code`, messages and every other row field are unchanged. Adds field-error-code-catalog.test.ts: a pin per site, and a closed-set check that drives every hand-built `details.fields[]` row under src/domains/ through the real HttpDispatcher and parses each code under `FieldErrorCode`. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 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 2cb29e527e4039ae1a0ba98f5545685c8a40c79f && git checkout 2cb29e527e4039ae1a0ba98f5545685c8a40c79f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eadea572082976b747e0828d56c24fdf62cd8803 5fd5f5e710311772363cc328c76c36475f826bf2 && git checkout -B drift-repro eadea572082976b747e0828d56c24fdf62cd8803 && git merge --no-ff 5fd5f5e710311772363cc328c76c36475f826bf2
node scripts/docs-audit/affected-docs.mjs --json eadea572082976b747e0828d56c24fdf62cd8803
|
席位核对 — ACCEPT
Generated by Claude Code |
Fixes #24597
Clause-②: no
Basis for the Clause-② line: the two refusals' field codes move onto members the published
FieldErrorCodecatalog already declares (unknown_field,invalid_format). No accept set moves, no status orerror.codemoves, and no catalog member is added;packages/specis not touched.ADR-0114 D2 declares
FieldErrorCode(packages/spec/src/api/errors.zod.ts) a closed catalog: "a service does not get to invent one — it adds a member here". Two refusals inpackages/runtimebuild theirdetails.fields[]rows by hand and wrote Zod's issue codes there.validationFailure(message, fields)typesfieldsasunknown[]andApiErrorSchematypesdetailsloosely, so neither tsc nor the envelope schema saw it. This PR moves both rows onto catalog members, takes a census of every hand-builtdetails.fields[]writer inpackages/runtime, and pins both sites plus a closed-set check over the domain's hand-built refusals.Before and after, per site
Measured through the real
HttpDispatcherhandler (handleAutomation/handleSecurity), onorigin/mainbfc1539fe(before) and this branch (after). A field-by-field diff of the two envelopes shows exactly one path changing per site,body.error.details.fields[0].code. Status,error.code,error.message,httpStatusand the row'sfieldandmessageare byte-identical.1. Run-lifecycle body refusal —
packages/runtime/src/domains/automation.tsrefuseInvalidRunLifecycleBody. Request:POST approval_flow/runs/run_7/cancel(andrestore-suspension), body{"foo":1}, platform-operator caller.Before:
{ "status": 400, "body": { "success": false, "error": { "code": "VALIDATION_FAILED", "httpStatus": 400, "message": "Unknown key `foo` — the cancel body accepts `reason`", "details": { "fields": [ { "field": "foo", "code": "unrecognized_keys", "message": "not a cancel body key — the cancel body accepts `reason`" } ] } } } }After: the same envelope with
"code": "unknown_field"on the row.restore-suspensionis the same with its own door name in both messages,unrecognized_keys→unknown_field.unknown_fieldis what the siblingresumearm on the same run already answers for an unknown key.2. Permission-set clone door, name rule —
packages/runtime/src/domains/permission-set-clone.tsreadCloneBody. Request:POST /permission-sets/crm_sales_rep/clone, body{"name":"My Copy","label":"X"},manage_metadataholder.Before:
{ "status": 400, "body": { "success": false, "error": { "code": "VALIDATION_FAILED", "httpStatus": 400, "message": "'My Copy' is not a valid permission-set name — use lowercase snake_case: start with a lowercase letter, then lowercase letters, digits and underscores, at least 2 characters (e.g. 'sales_rep_copy').", "details": { "fields": [ { "field": "name", "code": "invalid_string", "message": "Identifier must be lowercase snake_case, starting with a letter, and may contain only letters, numbers, and underscores (e.g., \"user_profile\")" } ] } } } }After: the same envelope with
"code": "invalid_format"("a declared pattern/format the value does not match").What changes on the wire, stated in the changeset too: a client that switched on
details.fields[].codeseesunknown_fieldwhere it sawunrecognized_keys, andinvalid_formatwhere it sawinvalid_string. Consumer read:git grepfor either literal inpackages/client,packages/client-react,packages/cliand in../objectui(79cb8c0) finds only Zod-issue reads (issue.code === 'unrecognized_keys'on a local Zod parse), none on these doors' wire rows.Census — every hand-built
details.fields[]writer inpackages/runtimeMethod: a TypeScript-AST walk of
packages/runtime/src/**/*.ts(tests excluded) for every object literal carrying bothfieldandcode, plus every call tovalidationFailure,fieldsFromZodIssues,validationFailureDetailsandinvalidQueryParam. 31 literal rows, 48 helper calls, at this branch's head. Each row checked againstFieldErrorCode; line numbers are this branch's.Hand-built rows (31)
fieldcodedomains/activation-gate.ts:226(body)invalid_typedomains/activation-gate.ts:235unknown_fielddomains/activation-gate.ts:244enabledinvalid_typedomains/analytics.ts:66POST /analytics/queryfiltersunknown_fielddomains/automation.ts:939(body)invalid_typedomains/automation.ts:962→unrecognized_keysunknown_fielddomains/automation.ts:980reasoninvalid_typedomains/automation.ts:1498flowDefinitionRefusalfallback (engine refusal with no status, no issues)(body)invalid_valuePOST /)domains/automation.ts:2699POST /(createFlow)(body)invalid_typedomains/automation.ts:2704namerequired/invalid_typedomains/automation.ts:2870POST /:name/toggle(body)invalid_typedomains/automation.ts:2881unknown_fielddomains/automation.ts:2886enabledinvalid_typedomains/automation.ts:2951POST /:name/clone(flow clone)(body)invalid_typedomains/automation.ts:2960unknown_fielddomains/automation.ts:2974namerequired/invalid_typedomains/automation.ts:2989labelrequired/invalid_typedomains/automation.ts:3153POST /:name/runs/:runId/resume(body)invalid_typedomains/automation.ts:3195unknown_fielddomains/automation.ts:3240inputs/variables/outputinvalid_typeinputs)domains/automation.ts:3248branchLabelinvalid_typedomains/automation.ts:3286inputs(both spellings sent)invalid_valuedomains/automation.ts:3291variables(both spellings sent)invalid_valuedomains/automation.ts:3676PUT /:name(updateFlow)(body)invalid_typedomains/permission-set-clone.ts:170POST /security/permission-sets/:name/clone(body)invalid_typedomains/permission-set-clone.ts:177unknown_fielddomains/permission-set-clone.ts:185namerequired/invalid_typedomains/permission-set-clone.ts:197name(metadata name rule)→invalid_stringinvalid_formatdomains/permission-set-clone.ts:205labelrequired/invalid_typeendpoint-executor.ts:386?ididrequiredsrc/domains/; already pinned exactly inendpoint-executor.test.tsquery-param.ts:81invalidQueryParam(param, code, …)codeargumentsrc/domains/; the parameter is typedFieldErrorCode, so tsc closes it (callers passinvalid_boolean,invalid_number,min_value,max_value,invalid_type,invalid_option)Not hand-built writers (listed so the census is complete):
fieldsFromZodIssues→zodIssuesToFields(ADR-0114 D3):domains/analytics.ts:72,domains/automation.ts:1485,domains/data.ts:120,domains/notifications.ts:221.domains/data.ts:312and:396,validationFailure(versionRefusal, []).flowDefinitionRefusal's pass-through of a service-thrown validation failure (automation.ts,validationFailureDetails(err)),domains/actions.ts:913(an action body's thrownerr.fields), andsandbox/quickjs-runner.ts(fields carried across the VM boundary).domains/packages.ts:888compares a Zod issue's own code (issue.code === 'unrecognized_keys'on the install-request parse) to name misplaced install options in the message. It writes nodetails.fields[]row, and comparing a Zod issue against Zod's vocabulary is correct there.Result: 2 off-catalog rows, both fixed here; 0 listed as remaining.
Pins and their ablation
New file
packages/runtime/src/domains/field-error-code-catalog.test.ts(41 tests):400+error.codeVALIDATION_FAILED), that everydetails.fields[].codeparses underFieldErrorCode, the exact rows ({ field, code }), and that nothing reached the service (cancelRun/restoreConsumedSuspension/saveMetaItemnot called). The run-lifecycle pin sends two unknown keys,resaonandrequestedBy, on both doors. The clone pin coversMy Copy,9copyand_copy.src/domains/in the table above through the realHttpDispatcherand parses eachdetails.fields[].codeunderFieldErrorCode. Each case also names its expected{ field, code }, so a case that reached some other refusal fails instead of passing on a row it never meant to check, and a refusal with no rows fails instead of passing vacuously. No gate script or lint rule is added; the rows are enumerated by hand, and the file's header says a new hand-built row joins the table in the change that adds it.Messages are not pinned.
Ablation, one leg per fixed site, run after the fix was committed (
5fd5f5e71). The test imports../http-dispatcher.jsrelatively, so the subject resolves tosrc/, with nodist/in the path. The mutation went throughnode scripts/ablation-replace.mjs(anchor must hit, blob must change, restore proven againstHEAD) inside a wrapper with anEXIT INT TERMtrap that re-proves the restore by blob hash andgit diff HEAD.code: 'unknown_field' as const,→code: 'unrecognized_keys' as const,84d4f8b34e42→5ad01f696481details.fields[].code "unrecognized_keys" … is not a FieldErrorCode member84d4f8b34e42== HEAD,git diff HEADemptycode: 'invalid_format'→code: 'invalid_string'820646d40ba1→ffdb9582fd37details.fields[].code "invalid_string" … is not a FieldErrorCode member820646d40ba1== HEAD,git diff HEADemptyDirection observed: red, as predicted. In every failing case the closed-set assertion (the catalog parse) is the one that fires, and no other test moved. Green on the restored tree: 41/41.
Tests
At
5fd5f5e71:pnpm --filter @objectstack/runtime test(vitest projectlocal): 426 files passed, 6712 tests passed, 19 skipped, exit 0.pnpm --filter @objectstack/runtime test:repo(vitest projectrepo): 3 files passed, 808 tests passed, exit 0.pnpm --filter @objectstack/runtime typecheck(tsc --noEmitandcheck:test-typecheckovertsconfig.test.json): exit 0. The new test file carries no ledger entry, so it compiles with zero errors.unrecognized_keysorinvalid_stringfrom these two doors. Every hit outsidepackages/runtimereads a Zod issue's own code on a local parse; inpackages/runtimethe hits are the package-install, flow-register and external-validation Zod paths.Gates
All at
5fd5f5e71, the branch's final commit.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackprinted 67 commands (change set: 4 paths against merge basebfc1539fe). Each ran byte for byte, with its exit code captured before any pipe.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: it reads built output, and 39 packages had nodist/). After a fullturbo run build --filter=!@objectstack/docs(76 tasks, 75 cached) it exited 0: 111requireentry points across 69 packages load.check:dts-closure(76 packages swept, against 32 on the first pass),check:sourcemap-no-sources-content,check:lean-entry-closureandcheck:published-files.node scripts/pm/dispatch-gates.mjs --ran ran.listprinted "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN", exit 0.pnpm lint(eslint . --no-inline-config), exit 0, no findings.check-platform-checklist.mjs --changed-fromdoes not apply.origin/mainwas not merged into this branch.Acceptance notes
FieldErrorCodeSchema; the Zod enum the spec exports isFieldErrorCode(packages/spec/src/api/errors.zod.ts), and noFieldErrorCodeSchemaexport exists. The pins parse underFieldErrorCode.validationFailure(message, fields)(packages/types/src/validation-failure.ts) typesfieldsasunknown[], which is how both rows got past tsc. Typing its rows'codeasFieldErrorCodewould close the class at compile time for every caller in every package. It is a signature change to a published@objectstack/typesexport and is outside this claim's file surface, so it is not done here. Carrier: none.packages/*/src(tests excluded), for literal rows{ field, code, message }whose string-literalcodeis not a catalog member, finds three hits, all inpackages/metadata-protocol/src/protocol.ts(field_removed,field_type_change,field_required_no_default). Those areDESTRUCTIVE_CHANGEfindings delivered aserr.issues, a different position with its own vocabulary, notdetails.fields[]. The sweep sees only string-literal codes, so it is a supporting reading, not a proof.packages/spec/**is not touched: the catalog was read, never extended.Generated by Claude Code