Files
scadalink-design/code-reviews/InboundAPI/findings.md

30 KiB

Code Review — InboundAPI

Field Value
Module src/ScadaLink.InboundAPI
Design doc docs/requirements/Component-InboundAPI.md
Status Reviewed
Last reviewed 2026-05-16
Reviewer claude-agent
Commit reviewed 9c60592
Open findings 1

Summary

The InboundAPI module is small (8 source files) and the happy-path flow — extract key, validate, deserialize parameters, execute script, serialize result — is clean and readable. However the review surfaced several real problems concentrated in two themes: concurrency and security. The InboundScriptExecutor is a singleton that mutates a plain Dictionary from concurrent ASP.NET request threads with no synchronization, which can corrupt the handler cache or crash the process under load. On the security side, API-key comparison is a non-constant-time database string match (timing oracle), compiled scripts run with no enforcement of the documented script trust model (forbidden APIs such as System.IO/Process/Reflection are fully reachable), there is no request-body size limit, and the executor's catch-all swallows OperationCanceledException from genuine client disconnects as a "timeout". Design-doc adherence is also incomplete: the Database.Connection() script API described in the design doc is entirely absent from InboundScriptContext, and the endpoint never enforces that the API is central-only. Testing covers the validators well but there is no coverage of the HTTP endpoint, concurrency, or recompilation. None of the findings are data-loss-class, but the concurrency and trust-model issues are High severity and should be addressed before production use.

Checklist coverage

# Category Examined Notes
1 Correctness & logic bugs CoerceValue returns null for legitimately-null/String values indistinguishably; parameter-definition edge cases noted.
2 Akka.NET conventions Module is ASP.NET-hosted, no actors of its own; routes to actors via CommunicationService. No correlation-ID issues — IDs are set in RouteHelper.
3 Concurrency & thread safety Singleton InboundScriptExecutor mutates a non-thread-safe Dictionary from concurrent request threads — see InboundAPI-001/002.
4 Error handling & resilience Catch-all conflates client cancellation with timeout (InboundAPI-004); compilation-failure path repeats work on every request (InboundAPI-009).
5 Security Non-constant-time key comparison, no trust-model enforcement, no body-size limit, missing-method enumeration oracle — see InboundAPI-003/005/006/011.
6 Performance & resource management Up to 3 separate DB round-trips per request in ApiKeyValidator; uncapped lazy recompilation.
7 Design-document adherence Database.Connection() script API missing; central-only hosting not enforced; lazy-compile diverges from "compiled at startup".
8 Code organization & conventions ParameterDefinition is an API-shaped POCO declared in the component project rather than Commons; otherwise conventions followed.
9 Testing coverage Good unit coverage of the two validators; no endpoint, concurrency, recompilation, or timeout-vs-cancel tests.
10 Documentation & comments ApiKeyValidationResult.NotFound XML/name says "NotFound" but returns HTTP 400 — misleading (InboundAPI-013).

Findings

InboundAPI-001 — Singleton script handler cache mutated without synchronization

Severity High
Category Concurrency & thread safety
Status Resolved
Location src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:17, :32, :40, :89, :123-128

Description

InboundScriptExecutor is registered as a singleton (ServiceCollectionExtensions.cs:11) and its handler cache is a plain Dictionary<string, Func<...>> (InboundScriptExecutor.cs:17). RegisterHandler, RemoveHandler, CompileAndRegister, and the lazy-compile path in ExecuteAsync all read and write this dictionary with no lock. ASP.NET serves inbound API requests on concurrent thread-pool threads, so two requests for an as-yet-uncompiled method (or a request racing a CLI-triggered CompileAndRegister) can mutate the dictionary concurrently. Dictionary is explicitly not safe for concurrent read/write — this can corrupt internal buckets, throw InvalidOperationException, or return a torn/null handler, crashing the request or the process.

Recommendation

Replace the Dictionary with a ConcurrentDictionary<string, Func<...>>, or guard all access with a lock. For the lazy-compile path use GetOrAdd so concurrent first-callers compile at most once.

Resolution

Resolved 2026-05-16 (commit <pending>): replaced the plain Dictionary handler cache with a ConcurrentDictionary; RemoveHandler now uses TryRemove; the lazy-compile path in ExecuteAsync compiles outside the cache and inserts atomically via GetOrAdd so concurrent first-callers share one handler. Regression tests ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache and ConcurrentRegisterAndExecute_DoesNotThrow added.

InboundAPI-002 — Lazy compilation is a check-then-act race with no atomicity

Severity Medium — re-triaged: already fixed by the InboundAPI-001 fix; verified and closed
Category Concurrency & thread safety
Status Resolved
Location src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:152-161

Description

ExecuteAsync does if (!_scriptHandlers.TryGetValue(...)) { CompileAndRegister(method); handler = _scriptHandlers[method.Name]; }. Even setting aside the unsynchronized dictionary (InboundAPI-001), this is a check-then-act sequence: between TryGetValue failing and the re-read on line 128, another thread could RemoveHandler the entry, causing the indexer on line 128 to throw KeyNotFoundException — an unhandled-in-context exception that is then caught only by the broad catch on line 143 and reported to the caller as "Internal script error". Multiple concurrent first-callers will also each compile the same script redundantly (wasted Roslyn work).

Recommendation

Make compile-and-fetch a single atomic operation (ConcurrentDictionary.GetOrAdd with a lazily-evaluated factory, or a per-method lock), and have CompileAndRegister return the handler it produced rather than requiring a separate dictionary read.

Resolution

Resolved 2026-05-16 (commit pending): re-triage — verified against the current source, this finding was already fixed by the InboundAPI-001 fix. The InboundScriptExecutor.cs:152-161 lazy-compile path no longer does check-then-act re-read: Compile(method) runs unconditionally (it never reads the cache) and the result is published via the atomic _scriptHandlers.GetOrAdd(method.Name, compiled). There is no separate dictionary indexer read, so the KeyNotFoundException race the finding describes cannot occur, and concurrent first-callers all share the single handler that GetOrAdd keeps. Regression test LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound added (asserts a concurrent RemoveHandler storm against lazy-compiling callers never yields the catch-all "Internal script error"); it passes against the current code, confirming the fix.

InboundAPI-003 — API key compared with non-constant-time string equality

Severity High
Category Security
Status Resolved
Location src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:22-23, consumed by src/ScadaLink.InboundAPI/ApiKeyValidator.cs:33

Description

API-key authentication resolves the key with FirstOrDefaultAsync(k => k.KeyValue == keyValue) — an ordinary equality match translated to a SQL WHERE KeyValue = @p comparison. The secret is matched with ordinary (early-exit) string/SQL comparison rather than a constant-time comparison, which is a classic timing side-channel for secret material. Combined with the design's explicit "no rate limiting" decision, an attacker with network access to the central API can mount a timing attack to recover valid keys. The API key is the sole credential for the inbound API, so this is the primary authentication path.

Recommendation

Look the key up by a non-secret indexed identifier (e.g. a key prefix/id) or fetch candidate rows, then verify the secret in-process using CryptographicOperations.FixedTimeEquals over the UTF-8 bytes. Preferably store only a salted hash of the key value and compare hashes. Avoid leaking secret-length and match-position timing.

Resolution

Resolved 2026-05-16 (commit <pending>): ApiKeyValidator no longer calls the secret-equality lookup GetApiKeyByValueAsync (the SQL WHERE KeyValue = @secret timing oracle). It now fetches all keys via GetAllApiKeysAsync and matches the secret in-process with CryptographicOperations.FixedTimeEquals over the UTF-8 bytes, scanning every candidate so neither match position nor secret length is observable. Regression tests ValidateAsync_DoesNotUseSecretEqualityLookup, ValidateAsync_WrongKey_ConstantTimePath_Returns401, and ValidateAsync_KeyOfDifferentLength_Returns401 added. Note: the timing-oracle method GetApiKeyByValueAsync remains on IInboundApiRepository (it is outside this module); removing it from the repository is left as separate follow-up since the validator no longer depends on it.

InboundAPI-004 — Client disconnect is misreported as a script timeout

Severity Medium
Category Error handling & resilience
Status Resolved
Location src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:117-141

Description

ExecuteAsync creates a linked CTS from httpContext.RequestAborted and the method timeout, then catches OperationCanceledException and unconditionally returns "Script execution timed out". When the client aborts the request (RequestAborted fires), the same exception type is thrown, so a normal client disconnect is logged as a timeout (_logger.LogWarning("Script execution timed out ...")) and an attempt is made to write a 500 timeout body to an already-gone connection. This pollutes the failure log (which the design says is reserved for genuine script errors) and obscures real timeout incidents.

Recommendation

Distinguish the two cancellation sources: if cancellationToken (the request token) is cancelled, treat it as a client abort — do not log a timeout and do not attempt to write a response. Only when the timeout CTS fired should the result be "timed out". Check cts.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested or use a dedicated timeout CancellationTokenSource so the two are separable.

Resolution

Resolved 2026-05-16 (commit pending): ExecuteAsync now uses a dedicated timeout CancellationTokenSource (new CancellationTokenSource(timeout)) linked with the request-abort token, so the two cancellation sources are separable. The OperationCanceledException handler reports "Script execution timed out" (and logs a warning) only when the timeout CTS fired and the request token did not; a client abort instead returns "Request cancelled by client" and logs at Debug — the failure log stays reserved for genuine script-execution timeouts. HandleInboundApiRequest additionally short-circuits with Results.Empty (no warning log, no 500 body write) when RequestAborted is cancelled, since the connection is already gone. Regression tests ClientDisconnect_IsNotReportedAsTimeout and GenuineTimeout_StillReportedAsTimeout added.

InboundAPI-005 — Compiled API scripts run with no script-trust-model enforcement

Severity High
Category Security
Status Resolved
Location src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:56-93

Description

CLAUDE.md's Akka.NET conventions state the script trust model forbids System.IO, Process, Threading, Reflection, and raw network access. CompileAndRegister compiles arbitrary C# with CSharpScript.Create and only restricts the default imports (WithImports("System", ...)). Imports are a convenience, not a sandbox — a script can still fully-qualify any type (System.IO.File.Delete(...), System.Diagnostics.Process.Start(...), System.Reflection, raw Socket) because the core framework assemblies are referenced and Roslyn scripting performs no API allow/deny-listing. Inbound API scripts execute on the central node with the host process's privileges, so a malicious or buggy method definition has full host access. Note the Design role authors these scripts (less trusted than Admin), making enforcement material.

Recommendation

Add a compile-time analyzer/SyntaxWalker (as the Site Runtime does for instance scripts) that rejects forbidden namespaces/types before registering a handler, and/or run scripts under a constrained boundary. At minimum, share the Site Runtime's forbidden-API checker so the trust model is enforced consistently. Reject the method (and log) when a violation is found instead of registering it.

Resolution

Resolved 2026-05-16 (commit <pending>): added ForbiddenApiChecker, a Roslyn CSharpSyntaxWalker that statically rejects scripts referencing forbidden namespaces (System.IO, System.Diagnostics, System.Threading except Tasks, System.Reflection, System.Net, System.Runtime.InteropServices, Microsoft.Win32) whether reached via a using directive or a fully-qualified name. CompileAndRegister now runs the check before Roslyn compilation and refuses to register (and logs) a violating method; ExecuteAsync's lazy-compile path is gated by the same check. Regression tests CompileAndRegister_ForbiddenApi_RejectsScript (theory), ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure, and CompileAndRegister_PermittedScript_StillRegisters added.

InboundAPI-006 — No request body size limit on the inbound endpoint

Severity Medium
Category Security
Status Resolved
Location src/ScadaLink.InboundAPI/EndpointExtensions.cs:54-62

Description

HandleInboundApiRequest calls JsonDocument.ParseAsync(httpContext.Request.Body, ...) with no explicit body-size cap and no [RequestSizeLimit]/endpoint metadata. Although Kestrel has a default max request body size, this endpoint accepts arbitrary JSON from external systems, fully buffers it into a JsonDocument, and then Clone()s the root element (:61) which materializes the entire document on the heap. With no rate limiting (a deliberate design choice) a single caller can drive large allocations. Deep/wide JSON also makes the CoerceValue object/list deserialization (ParameterValidator.cs:113,117) expensive.

Recommendation

Set an explicit, modest body-size limit on the endpoint (.WithMetadata(new RequestSizeLimitAttribute(...)) or IHttpMaxRequestBodySizeFeature) and consider a JsonDocumentOptions MaxDepth. Reject oversized bodies with 413 before buffering.

Resolution

Resolved 2026-05-16 (commit pending): added InboundApiEndpointFilter, an IEndpointFilter applied to POST /api/{methodName} via .AddEndpointFilter<>(). It rejects requests whose declared Content-Length exceeds InboundApiOptions. MaxRequestBodyBytes (default 1 MiB) with HTTP 413 before the handler buffers the body into a JsonDocument, and also lowers the per-request IHttpMaxRequestBodySizeFeature cap so a chunked/unknown-length stream is cut off by Kestrel while being read. The limit is configurable via the bound ScadaLink:InboundApi options section. Regression tests OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler, BodyAtLimit_RunsHandler, and FilterCapsMaxRequestBodySizeFeature added.

InboundAPI-007 — Database.Connection() script API from the design doc is not implemented

Severity Medium
Category Design-document adherence
Status Resolved
Location src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:188-203

Description

Component-InboundAPI.md ("Script Runtime API -> Database Access") specifies Database.Connection("connectionName") as an available script capability for querying the configuration/machine-data databases. InboundScriptContext exposes only Parameters, Route, and CancellationToken — there is no Database member. Any method script that follows the documented API will fail to compile. Either the code is incomplete or the design doc is stale; the two must be reconciled.

Recommendation

If database access is in scope, add a Database property to InboundScriptContext backed by a connection-factory service. If it is not, remove the "Database Access" section from Component-InboundAPI.md so the design doc stops advertising an absent API.

Resolution

Resolved 2026-05-16 (commit <pending>). The drift was confirmed real: InboundScriptContext (InboundScriptExecutor.cs:188-203) exposes only Parameters, Route, and CancellationToken — there is no Database member, so a method script following the documented Database.Connection("name") API would fail to compile. Resolution direction: the design doc is stale, not the code. Implementing Database.Connection() would hand inbound API scripts a raw MS SQL client, in direct tension with the ScadaLink script trust model (scripts are forbidden System.IO, raw network, etc.; ForbiddenApiChecker statically enforces this). Rather than carve a hole in the trust model, the "Database Access" section was removed from docs/requirements/Component-InboundAPI.md and replaced with an explicit "No direct database access" note explaining that scripts interact only through the curated Route/Parameters surfaces, and that any future data access must go behind a dedicated scoped helper added as an explicit design change. Code and doc now agree; no code or test change required.

InboundAPI-008 — Inbound API endpoint not restricted to the active central node

Severity Medium
Category Design-document adherence
Status Resolved
Location src/ScadaLink.InboundAPI/EndpointExtensions.cs:19-23, src/ScadaLink.Host/Program.cs:149

Description

The design states the Inbound API is "Central cluster only (active node)" and "fails over with it". MapInboundAPI registers POST /api/{methodName} unconditionally, and Program.cs maps it inside the central-role branch but with no active-node gating — unlike /health/active which has an active-node predicate. A standby central node will happily serve inbound API calls, executing scripts and Route.To() calls from a non-leader, which can race the active node or run against stale singleton state.

Recommendation

Gate the endpoint on active-node status (reuse the cluster active-node health check or a leader-state check) and return 503 on the standby, so Traefik/clients only reach the live node — consistent with how the Management API and /health/active are treated.

Resolution

Resolved 2026-05-16 (commit pending): introduced IActiveNodeGate, an abstraction the inbound API uses to ask whether this node is the active (cluster-leader) central node. The new InboundApiEndpointFilter (applied to POST /api/{methodName}) consults the gate and short-circuits a standby node with HTTP 503 before any auth/script work, so Traefik/clients only reach the live node — consistent with /health/active. The gate is resolved optionally: when no implementation is registered (non-clustered host / tests) the endpoint defaults to "allow", preserving prior behaviour. Regression tests StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler, ActiveNode_PassesGate_RunsHandler, and NoGateRegistered_PassesGate_RunsHandler added. Follow-up (outside this module's scope): ScadaLink.Host should register an IActiveNodeGate implementation backed by ActiveNodeHealthCheck / Cluster.State.Leader in the central-role branch of Program.cs so the gate is actually enforced in production; until then the endpoint defaults to "allow".

InboundAPI-009 — Failed compilation is retried on every subsequent request

Severity Low
Category Performance & resource management
Status Resolved
Location src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:123-128

Description

When a method's script fails to compile, CompileAndRegister returns false and nothing is stored in _scriptHandlers. Every subsequent call to that method re-enters the lazy-compile branch and recompiles the broken script via Roslyn from scratch. Roslyn compilation is expensive; a single broken method definition repeatedly invoked by an external caller (no rate limiting) becomes a CPU amplification vector.

Recommendation

Cache the compilation failure (e.g. store a sentinel handler that immediately returns the compile error, or keep a HashSet of known-bad method names with the diagnostic) so a broken script is compiled at most once until the definition is updated via CompileAndRegister.

Resolution

Resolved 2026-05-16 (commit pending): confirmed the root cause — a failed Compile stored nothing in _scriptHandlers, so every subsequent request re-entered the lazy-compile branch and re-ran Roslyn. Added a _knownBadMethods ConcurrentDictionary of method names whose compilation failed; ExecuteAsync's lazy-compile path short-circuits before Roslyn when the method is already known-bad, and records the failure on a fresh failed compile. CompileAndRegister also records failures and clears the record on a successful (re)compile, so a fixed method definition is re-evaluated. Regression tests FailedCompilation_IsNotRetriedOnEveryRequest (asserts the compile-failure log fires exactly once across 5 requests) and FailedCompilation_RecompilesAfterCompileAndRegisterCalledAgain added.

InboundAPI-010 — ParameterValidator ignores extra body fields and cannot validate Object/List element types

Severity Low
Category Correctness & logic bugs
Status Resolved
Location src/ScadaLink.InboundAPI/ParameterValidator.cs:64-90, :112-118

Description

Two related correctness gaps: (1) The validator iterates only over defined parameters; any extra top-level fields in the request body are silently ignored rather than reported, so callers get no feedback on typo'd parameter names. (2) For Object and List types the validator only checks the JSON kind (Object/Array) and then blindly JsonSerializer.Deserializes the raw text — the design's extended type system describes Objects as "named structure with typed fields" and Lists as collections "of objects or primitive types", but no field-level or element-level type validation is performed. Invalid nested structures pass validation and surface only as runtime script errors.

Recommendation

Optionally warn/400 on unexpected body fields. For the extended types, either parse a richer ParameterDefinition (with nested field definitions / element type) and validate recursively, or document explicitly that Object/List are validated only for shape — and update the design doc to match.

Resolution

Resolved 2026-05-16 (commit pending): both gaps addressed along the lines the recommendation offers. (1) ParameterValidator.Validate now enumerates the request body's top-level fields and returns an Invalid result naming any field that does not match a defined parameter ("Unexpected parameter(s): ..."), so a typo'd parameter name is reported instead of silently ignored. (2) For Object/List, recursive field/element-level type validation is deliberately not added — it requires a richer nested ParameterDefinition schema, a design decision; instead the shape-only behaviour is now explicitly documented in the CoerceValue XML comment so the code's contract is unambiguous. Re-triage note: the design doc (Component-InboundAPI.md line 43) lists only Boolean/Integer/Float/String as method parameter types — the Object/List extended types are a CLAUDE.md decision; reconciling the design doc is out of this module's editable scope and left as a doc-owner follow-up. Regression tests UnexpectedBodyField_ReturnsInvalid and OnlyDefinedFields_StillValid added.

InboundAPI-011 — Method-existence check leaks to unapproved callers (enumeration oracle)

Severity Low
Category Security
Status Resolved
Location src/ScadaLink.InboundAPI/ApiKeyValidator.cs:39-52

Description

ValidateAsync returns 400 Method '{methodName}' not found when the method does not exist, but 403 API key not approved for this method when it exists but the key is not approved. A caller holding any valid enabled key can therefore enumerate which method names exist on the central API by observing 400-vs-403 responses. The error message also echoes the caller-supplied methodName back verbatim into the JSON response (EndpointExtensions.cs:47), a minor reflected-input concern.

Recommendation

Return an indistinguishable response (e.g. 403/404) for both "method not found" and "key not approved" so existence is not observable to unapproved callers. Avoid echoing raw caller input in error bodies, or sanitize it.

Resolution

Resolved 2026-05-16 (commit pending): confirmed — ValidateAsync returned 400 Method '{methodName}' not found for a missing method but 403 API key not approved for this method for an existing-but-unapproved one, an enumeration oracle, and echoed the caller-supplied method name verbatim. Both cases now return an identical response: HTTP 403 with the single shared message API key not approved for this method (a NotApprovedMessage constant); the method name is no longer interpolated into any error body, removing both the existence oracle and the reflected-input concern. Regression tests ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved and ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName added; the pre-existing ValidKey_MethodNotFound_Returns400 test was updated to assert the new indistinguishable contract.

InboundAPI-012 — ParameterDefinition POCO declared in the component project, not Commons

Severity Low
Category Code organization & conventions
Status Open
Location src/ScadaLink.InboundAPI/ParameterValidator.cs:128-133

Description

ParameterDefinition is a persistence-/contract-shaped POCO: it is the deserialized form of ApiMethod.ParameterDefinitions (a column in the configuration database) and describes the public API contract. CLAUDE.md's code-organization rules place persistence-ignorant entity/contract types in ScadaLink.Commons. Defining it inside the InboundAPI project means any other component that needs to read or produce method parameter definitions (e.g. Central UI's method editor, CLI, Management Service) cannot share the type and will duplicate it.

Recommendation

Move ParameterDefinition (and a matching return-definition type, if added) to ScadaLink.Commons under the InboundApi entity/types namespace so it is shared by all components that work with method definitions.

Resolution

Unresolved — left Open; the fix crosses this module's editable boundary. Re-triage 2026-05-16: confirmed against the source — ParameterDefinition (ParameterValidator.cs:128-133) is indeed an API-contract-shaped POCO declared in the component project. However the recommended fix is to create the type in ScadaLink.Commons (and update the validator plus any other consumers to reference it), which edits files outside this module's editable scope (src/ScadaLink.InboundAPI, tests/, this file only). It also touches a shared contract type: a Commons namespace placement and a likely-paired return-definition type are a cross-component code-organization decision. Surface to the design/Commons owner: add ParameterDefinition (and a return-definition counterpart) under a ScadaLink.Commons InboundApi types namespace, then repoint ParameterValidator and any other consumers at it.

InboundAPI-013 — ApiKeyValidationResult.NotFound factory returns HTTP 400, contradicting its name

Severity Low
Category Documentation & comments
Status Resolved
Location src/ScadaLink.InboundAPI/ApiKeyValidator.cs:78-79

Description

The static factory is named NotFound and is used for the "method not found" case, but it builds a result with StatusCode = 400 (Bad Request), not 404. The name strongly implies 404 and will mislead future maintainers; EndpointExtensions faithfully propagates whatever status code the factory sets, so the misnaming directly affects the wire contract.

Recommendation

Rename the factory to match its behaviour (e.g. BadRequest) or change the status code to 404 if that is the intended contract — and document the chosen "method not found" status in Component-InboundAPI.md's Error Handling section, which currently does not list it.

Resolution

Resolved 2026-05-16 (commit pending): the misnamed NotFound factory (which built a StatusCode = 400 result) was the only producer of the "method not found" result, and the InboundAPI-011 fix made "method not found" return 403 via the existing Forbidden factory instead. The misleading NotFound factory is therefore now removed entirely — it has no remaining callers in or out of the module (ApiKeyValidationResult is InboundAPI-internal), eliminating the name/behaviour contradiction. No separate regression test is needed: the InboundAPI-011 tests cover the new method-not-found status, and removing dead code cannot regress. Doc-owner follow-up: Component-InboundAPI.md's Error Handling section still does not list a "method not found" status; it should note that it is reported as 403 (indistinguishable from "key not approved"), but that doc edit is outside this module's editable scope.