Summary
The opt-in type-check gate (ADR-005) does not check calls made through the serenity transport, or through any other value that arrives as @param {object}. Such a value is any, so member existence, argument count and argument types are all unenforced on it. A contract-violating request body reached the wire past a green type-check, and only the spec-generated vendor mock in it-postgres caught it.
This is about the gate's reach, not about the generated Semrush client types, which are correct and do enforce the contract when a typed value actually reaches them.
What happened
In the sub-workspace no-allocation change, workspace-lifecycle.js stopped passing an allocation and called:
transport.createSubworkspace(parentWorkspaceId, title)
against a transport method still declared as createSubworkspace(parentWorkspaceId, title, resources). Inside the transport, resources was therefore undefined and the body { title, resources } serialized to { title }.
resources is required by handlers.createWorkspaceV2Form (every field inside createWorkspaceV2Resources is optional, so {} is the schema-valid way to express "no allocation"). The live Semrush gateway accepts the omission, so a live end-to-end probe against LLMO-Dev-2 passed. type-check passed. it-postgres failed with 502-instead-of-201, because the Counterfact mock is generated from the spec and correctly refuses the body.
Root cause
Three behaviours compose. Each was verified with tsc against this repo's tsconfig.json, not inferred.
1. The generated client types are sound. Calling the typed User Manager client directly with a malformed body is rejected. All of the following error today: resources omitted (TS2322 "Property 'resources' is missing"), resources: undefined (TS2322), body omitted (TS2345), unknown property (TS2353), wrong primitive (TS2322), missing path param (TS2345), unknown route (TS2345). So the contract is enforced — at the point where a typed value meets the client.
2. @param {object} is any, and unknown-member access is silent. tsconfig.json sets noImplicitAny: false. Under that setting, in JS files, TS suppresses TS2339 entirely. A function declared @param {object} transport accepts transport.anyNameAtAll() with no error; flipping noImplicitAny to true makes the same line report TS2339. Every serenity lifecycle function receives its transport this way, so no call through transport.* is checked at all.
There are roughly 140 @param {object} / @param {Object} annotations across the files in the include set — 19 in workspace-lifecycle.js, 11 in brand-provisioning.js, 8 in rest-transport.js itself.
3. Undocumented parameters are optional, so arity is not enforced either. This is the part that makes "just annotate the transport" insufficient. Typing the transport as ReturnType<typeof createSerenityTransport> yields a correct 34-method object type, but because the transport methods' own parameters carry no JSDoc, they are implicitly any and TS treats them as optional in a JS file. Against that inferred type, createSubworkspace('a') and even createSubworkspace() both pass — TS reports the signature as "0-2 arguments".
The contrast within the same object is the clearest evidence: createPromptsByIds is JSDoc-typed (string, string, string[], string[]), and against the identical inferred transport type it correctly reports "Expected 4 arguments, but got 2" and rejects a number passed for a string.
So the chain is: an any transport erases the call site, and undocumented method parameters erase arity even after the transport is typed. The sound generated types are simply never reached.
Why the other gates did not cover it
- Live probe — the real gateway tolerates the omitted key, so exercising the real API cannot detect it.
- Unit tests — the transport is stubbed, so the wire body is not validated against the spec.
it-postgres — the only gate that caught it, because the Counterfact mock is generated from the OpenAPI spec and validates the body. That makes the mock-backed integration suite, not type-check, the de-facto contract gate against Semrush today.
Proposed direction
ADR-005 already records noImplicitAny: false as a deliberate, temporary relaxation to be ratcheted to true later. This is a concrete argument for doing that, and a measurement of the cost.
Flipping noImplicitAny: true on the current include set yields 815 errors:
| code |
count |
nature |
| TS7006 / TS7031 / TS7034 / TS7005 / TS7053 |
431 |
missing parameter and variable annotations |
| TS2339 |
320 |
member access on a value annotated {object} |
| TS2345 / TS2322 / TS18047 |
19 |
assignability and possibly-null |
| TS7016 |
1 |
@adobe/helix-shared-utils ships no declarations |
Concentrated in controllers/brands.js (151), rest-transport.js (76), handlers/markets.js (75), handlers/markets-subworkspace.js (65), handlers/prompts.js (59).
Most of the 19 assignability errors look like fallout from the same loose annotations (object narrowing where a string is wanted) rather than latent defects, but two possibly-null reports in brands.js warrant a look. That set needs triage; nothing here asserts they are bugs.
Suggested sequencing, smallest useful step first:
- Give the transport a name and annotate its methods. Export a
SerenityTransport typedef from rest-transport.js, replace @param {object} transport with it across the serenity module, and JSDoc the transport methods' parameters. Both halves are required — the typedef alone does not restore arity checking, as measured above. This is what would have caught this specific defect, and it covers the highest-value surface (every outbound Semrush call) without touching the other files.
- Then ratchet
noImplicitAny to true, per-directory or via a baseline, rather than in one sweep.
Independently worth deciding: whether the spec-generated mocks should be acknowledged as the contract gate. They currently are, in effect, and they only run in it-postgres.
Reproduction
Add a file under src/support/serenity/ with // @ts-check and run npx tsc -p tsconfig.json:
/** @typedef {ReturnType<typeof createSerenityTransport>} SerenityTransport */
/** @param {object} loose */
export async function a(loose) {
await loose.noSuchMethodAnywhere(); // silent; TS2339 under noImplicitAny
}
/** @param {SerenityTransport} t */
export async function b(t) {
await t.createSubworkspace(); // silent: params undocumented -> optional
await t.createPromptsByIds('a', 'b'); // TS2554: params are JSDoc-typed
}
Context
Surfaced by #2923 (issue #2922). The defect itself is already fixed there; this issue is only about the gate that did not catch it.
Summary
The opt-in
type-checkgate (ADR-005) does not check calls made through the serenity transport, or through any other value that arrives as@param {object}. Such a value isany, so member existence, argument count and argument types are all unenforced on it. A contract-violating request body reached the wire past a greentype-check, and only the spec-generated vendor mock init-postgrescaught it.This is about the gate's reach, not about the generated Semrush client types, which are correct and do enforce the contract when a typed value actually reaches them.
What happened
In the sub-workspace no-allocation change,
workspace-lifecycle.jsstopped passing an allocation and called:against a transport method still declared as
createSubworkspace(parentWorkspaceId, title, resources). Inside the transport,resourceswas thereforeundefinedand the body{ title, resources }serialized to{ title }.resourcesis required byhandlers.createWorkspaceV2Form(every field insidecreateWorkspaceV2Resourcesis optional, so{}is the schema-valid way to express "no allocation"). The live Semrush gateway accepts the omission, so a live end-to-end probe against LLMO-Dev-2 passed.type-checkpassed.it-postgresfailed with 502-instead-of-201, because the Counterfact mock is generated from the spec and correctly refuses the body.Root cause
Three behaviours compose. Each was verified with
tscagainst this repo'stsconfig.json, not inferred.1. The generated client types are sound. Calling the typed User Manager client directly with a malformed body is rejected. All of the following error today:
resourcesomitted (TS2322 "Property 'resources' is missing"),resources: undefined(TS2322), body omitted (TS2345), unknown property (TS2353), wrong primitive (TS2322), missing path param (TS2345), unknown route (TS2345). So the contract is enforced — at the point where a typed value meets the client.2.
@param {object}isany, and unknown-member access is silent.tsconfig.jsonsetsnoImplicitAny: false. Under that setting, in JS files, TS suppresses TS2339 entirely. A function declared@param {object} transportacceptstransport.anyNameAtAll()with no error; flippingnoImplicitAnytotruemakes the same line report TS2339. Every serenity lifecycle function receives its transport this way, so no call throughtransport.*is checked at all.There are roughly 140
@param {object}/@param {Object}annotations across the files in theincludeset — 19 inworkspace-lifecycle.js, 11 inbrand-provisioning.js, 8 inrest-transport.jsitself.3. Undocumented parameters are optional, so arity is not enforced either. This is the part that makes "just annotate the transport" insufficient. Typing the transport as
ReturnType<typeof createSerenityTransport>yields a correct 34-method object type, but because the transport methods' own parameters carry no JSDoc, they are implicitlyanyand TS treats them as optional in a JS file. Against that inferred type,createSubworkspace('a')and evencreateSubworkspace()both pass — TS reports the signature as "0-2 arguments".The contrast within the same object is the clearest evidence:
createPromptsByIdsis JSDoc-typed(string, string, string[], string[]), and against the identical inferred transport type it correctly reports "Expected 4 arguments, but got 2" and rejects a number passed for a string.So the chain is: an
anytransport erases the call site, and undocumented method parameters erase arity even after the transport is typed. The sound generated types are simply never reached.Why the other gates did not cover it
it-postgres— the only gate that caught it, because the Counterfact mock is generated from the OpenAPI spec and validates the body. That makes the mock-backed integration suite, nottype-check, the de-facto contract gate against Semrush today.Proposed direction
ADR-005 already records
noImplicitAny: falseas a deliberate, temporary relaxation to be ratcheted totruelater. This is a concrete argument for doing that, and a measurement of the cost.Flipping
noImplicitAny: trueon the currentincludeset yields 815 errors:{object}@adobe/helix-shared-utilsships no declarationsConcentrated in
controllers/brands.js(151),rest-transport.js(76),handlers/markets.js(75),handlers/markets-subworkspace.js(65),handlers/prompts.js(59).Most of the 19 assignability errors look like fallout from the same loose annotations (
objectnarrowing where astringis wanted) rather than latent defects, but two possibly-null reports inbrands.jswarrant a look. That set needs triage; nothing here asserts they are bugs.Suggested sequencing, smallest useful step first:
SerenityTransporttypedef fromrest-transport.js, replace@param {object} transportwith it across the serenity module, and JSDoc the transport methods' parameters. Both halves are required — the typedef alone does not restore arity checking, as measured above. This is what would have caught this specific defect, and it covers the highest-value surface (every outbound Semrush call) without touching the other files.noImplicitAnytotrue, per-directory or via a baseline, rather than in one sweep.Independently worth deciding: whether the spec-generated mocks should be acknowledged as the contract gate. They currently are, in effect, and they only run in
it-postgres.Reproduction
Add a file under
src/support/serenity/with// @ts-checkand runnpx tsc -p tsconfig.json:Context
Surfaced by #2923 (issue #2922). The defect itself is already fixed there; this issue is only about the gate that did not catch it.