feat(restapi): full CLI-parity endpoints (Waves 1-4: diagnostics, a11y, fsync, webinspector, UI automation, streams, codesigning) + uidriver - #821
Conversation
…ts, no-panic marshal) Foundational hardening for running the REST API in production. No endpoint behavior changes; the auth model from #792 (GO_IOS_API_KEY / --disable-auth) is preserved. - Serve via an explicit http.Server with graceful shutdown on SIGINT/SIGTERM (drains in-flight requests, 10s timeout). - Bound abuse without breaking streams: ReadHeaderTimeout, IdleTimeout, MaxHeaderBytes. Deliberately no WriteTimeout so /syslog,/listen,/ostrace, /notifications can stream indefinitely. - Optional TLS via --tls-cert/--tls-key; configurable bind via --addr (default :8080). Flags parsed in the same tolerant FlagSet as --disable-auth. - Unauthenticated /healthz and /readyz probes outside /api/v1. - Gate the swagger UI behind auth (served under /api/v1) when a token is set. - MustMarshal no longer panics on an unmarshalable value — it returns a JSON error envelope, so a stream/handler can't be crashed by it. - Add RespondError helper for a consistent {"error":...} envelope. Tests: MustMarshal no-panic + valid-unchanged, health endpoints, RespondError, parseServerConfig (defaults, flags, tolerates unknown args). go build/vet/test ./restapi/... green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…s, mobilegestalt, ps, lockdown)
First batch of CLI-parity endpoints, all read-only, each mirroring the exact
go-ios library call the corresponding `ios` CLI command uses:
- GET /device/:udid/devicename -> ios.GetValues (ios devicename)
- GET /device/:udid/date -> ios.GetValues (ios date)
- GET /device/:udid/battery -> ios.GetBatteryDiagnostics (ios batterycheck)
- GET /device/:udid/diagnostics -> diagnostics.AllValues (ios diagnostics list)
- GET /device/:udid/mobilegestalt -> diagnostics.MobileGestaltQuery (ios mobilegestalt), keys via ?key=
- GET /device/:udid/processes -> instruments.ProcessList (ios ps), ?apps=true filters apps
- GET /device/:udid/lockdown -> ios.GetValues (ios lockdown get)
Handlers surface library errors as {"error":...} via RespondError (no discarded
errors, no panics). Wired through registerDeviceInfoRoutes in routes.go.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…e, lang, memlimitoff) Mirrors the ios CLI: diagnostics.Reboot/Shutdown, mcinstall.Erase (gated by ?confirm=true), amfi.EnableDeveloperMode + imagemounter.IsDevModeEnabled + amfi.RevealDevMode, ios.Get/SetLanguage, and instruments ProcessControl DisableMemoryLimit. Errors surfaced via RespondError; no panics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…h, ios crash ls/rm) Uses the iOS 17+ file service and streams pull/push through the HTTP body, so there is no caller-supplied host path and no host-side traversal. Domains: app|app-group|crash|temp. Crash reports: list + remove (crash-log downloads are available via /files?domain=crash). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
- GET/PUT /wallpaper: springboard GetHomeScreenWallpaperPNG (image/png); set via multipart (image+p12 supervisor identity+screen) -> mcinstall.SetWallpaperSupervised. - GET/PUT /icon-layout: springboard Get/SetIconLayout. - GET/PUT /pasteboard: pasteboard Get/SetText (iOS 17+). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
…age list/unmount) - POST /profiles: mcinstall AddProfile / AddProfileSupervised (multipart p12); DELETE /profiles/:name. - GET /image/list: mounted image signatures (hex); DELETE /image: imagemounter.UnmountImage. GET /profiles and GET/PUT /image already existed; only the missing verbs added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
- GET/PUT /assistivetouch: ios.Get/SetAssistiveTouch. - GET/PUT /timeformat: ios.Get/SetUses24HourClock. - PUT/DELETE /wifi: mcinstall.PrepareWifi/RemoveWifi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
GET /sysmontap streams instruments Sysmontap CPU-usage samples (matches the existing syslog/listen streaming pattern). pcap is deferred until ios/pcap exposes a packet-callback streaming API (it currently writes a local file).
…, clear-passcode, clear-screen-time-password) All under /device/:udid/mdm, POST multipart with a p12 supervisor identity + password (escalated mcinstall session via conn.Escalate). Credentials stay in memory, never logged or persisted. clear-passcode also takes a base64 token.
…streamed logs Long-running device operations now run as background jobs: - POST /jobs/runtest, /jobs/runwda, /jobs/forward -> 202 + job id - GET /jobs (per-device), GET /jobs/:id (status), DELETE /jobs/:id (stop) - GET /jobs/:id/logs streams that job's isolated log (history + live tail) Each job captures its output on a dedicated jobLog sink (io.Writer wired into the testmanagerd TestListener), so concurrent jobs never interleave. Lifecycle events are logged via ios/golog with module=go-ios/restapi + udid + job attrs. Terminal state is immutable, so stopping a job isn't relabeled as a failure when its context-cancelled goroutine returns. In-memory job manager + jobLog are unit-tested (incl. -race): lifecycle, stop-is-terminal, per-device isolation, log stream/close. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJXoR6KAaEXEqzyJien7WJ
PUT /httpproxy (supervised, multipart host/port/p12/user/pass/password) -> mcinstall.SetHttpProxy; DELETE /httpproxy -> mcinstall.RemoveProxy.
Agent-level, not device-scoped, so they live at /api/v1 (behind auth): - GET /tunnels list running tunnels - DELETE /tunnels/:udid stop a device tunnel - POST /tunnels/:udid/refresh refresh a device tunnel - POST /tunnel-agent/shutdown stop the tunnel agent They query the running agent via ios.HttpApiHost/HttpApiPort. 'tunnel start' is not exposed (privileged long-running daemon). Added a route-registration smoke test that builds the full tree so gin route conflicts fail loudly.
httptest-based tests exercising the request-validation branches (which run before any device I/O): missing/invalid params across files, mobilegestalt, wifi, mdm, crashes, devmode, and the job endpoints (erase confirm-gate, missing bundle/ports, job-not-found 404). Closes the biggest coverage gap for the parity endpoints.
Add RateLimitUDID: a token-bucket (golang.org/x/time/rate) gin middleware keyed by device UDID, applied across the /device/:udid group. Requests over the limit get 429; each UDID has its own bucket so devices don't throttle each other. Configurable via --rate-limit (req/s, default 20) and --rate-burst (default 40); 0 disables. Uses the atomic sync.Map LoadOrStore pattern (no create race). Tests (device-free, -race): burst-then-429, disabled-when-zero, per-device isolation, and a concurrent-hammer test asserting the shared bucket isn't exceeded under load.
Adversarial review follow-ups on the 56-endpoint REST parity work: Security/correctness fixes: - Bound in-memory uploads: readFormFile and the raw-body reads in SetPasteboard and AddProfile now go through readAllLimited (256 MiB cap) so an authenticated client can't OOM the daemon with an oversized multipart file or body. PushFile already streams (Content-Length gated) and is unaffected. - Fix a lost-line gap in GET /jobs/:id/logs: snapshot() then subscribe() raced, dropping any line written in between. Added jobLog. snapshotAndSubscribe() which takes the backlog and the live subscription under one lock. - Bound the process-wide job registry: DELETE /jobs/:id on an already terminal job now purges it (jobManager.remove, terminal-only) so finished jobs' buffered logs don't accumulate forever. Running jobs are still stopped, never silently dropped. Tests (httptest + in-context device): - Auth coverage: BearerAuth accept/reject, and a tree-walk asserting all 80 registered /api/v1 routes return 401 unauthenticated. - Files: Content-Disposition base-name sanitisation (traversal-y remote can't inject a host path), ls/pull/push validation, push 411 without Content-Length. - Upload limits: readAllLimited boundaries + oversized pasteboard body. - Proxy/MDM multipart validation. - Jobs: full HTTP lifecycle (create/list/get/stop/delete), per-device isolation (cross-udid GET/DELETE 404 and no stop), remove-terminal-only, atomic snapshot+subscribe. go build/vet/test ./restapi/... green incl. -race; gofmt clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Make the REST daemon match the TypeSpec-authored OpenAPI contract the
official SDKs are generated from.
Real SSE framing (event:/data:/blank-line) on all six streaming
endpoints, with a periodic heartbeat on idle, replacing the previous
NDJSON/concatenated-JSON writes:
- /syslog -> event "syslog" (SyslogMessage)
- /notifications-> event "appstate" (AppStateNotification)
- /ostrace -> event "ostrace" (OsTraceEntry)
- /listen -> event "attachdetach" (AttachDetachEvent)
- /sysmontap -> event "sample" (CpuUsageSample)
- /jobs/{id}/logs-> event "log" (JobLogLine)
- all -> event "heartbeat" ({}) on idle
Payload models use the spec's camelCase field names; a shared streamSSE
helper drives frames + heartbeats and flushes after each write.
Other spec conformance:
- setlocation: longtitude -> longitude (query param, checks, messages,
swagger annotations)
- screenshot: content-type image/png (was application/octet-stream)
- streaming error paths now use the GenericResponse envelope
Device-free unit tests cover the SSE framing + heartbeats, payload
mappers, longitude, and the removed misspelled param.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Add device diagnostics and network endpoints under /device/:udid: - GET /diskspace -> afc.(*Client).DeviceInfo (filesystem info) - GET /ip -> pcap.FindIp (MAC/IPv4/IPv6) - GET /rsd -> device.Rsd.GetServices (RSD service list; 400 if no tunnel) - GET /battery/registry-> diagnostics.(*Connection).Battery (IORegistry stats) Extend the existing GET /lockdown handler to accept an optional ?domain= query param, returning domain-scoped values via GetValueForDomain. Handlers live in a new diagnostics_net_endpoints.go with device-free unit tests for the RSD capability/error paths and route registration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Expose the non-interactive `ios webinspector` operations over the REST API under /device/:udid/webinspector: - GET /pages -> list inspectable pages (client.ListPages) - POST /launch -> open a URL via a remote automation session (OpenApp + AutomationSession + Start + Navigate) - POST /eval -> evaluate JS in a page (client.Evaluate) The interactive commands (js-shell, cdp) are intentionally not exposed. "Web Inspector / Remote Automation not enabled" conditions map to 424; missing url/script and a non-existent page map to 4xx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Extract the private WDA/DeviceKit UI-automation HTTP client that lived in cmd_ui.go into a new, exported ios/uidriver package so the CLI and the upcoming REST ui endpoints can share one driver. uidriver.Driver is constructed against a backend base URL (the forwarded WDA :8100 / DeviceKit :12004 address) and exposes Tap/Swipe/LongPress/ Type/PressButton/Screenshot/Source/WindowSize/Orientation/SetOrientation/ AppLaunch/AppTerminate/AppForeground/Status/API/Stream. Methods return values and errors instead of calling os.Exit, so the package is safe to embed. Request/response types are exported and JSON-tagged. cmd_ui.go keeps arg parsing, backend resolution (wda/devicekit/auto) and output formatting, delegating all HTTP work to the driver. CLI behavior is unchanged. Adds ios/uidriver unit tests driving the client against an httptest backend (methods/paths/bodies for every action plus the chunked streaming helper). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Adds the AFC-based (com.apple.afc) filesystem surface under /device/:udid/fsync (ls, tree, pull, push, rm, mkdir), scoped to the media directory or, with ?bundleID=, to an app data container via house_arrest -- mirroring 'ios fsync'. Distinct from the iOS 17+ fileservice /files surface. Also adds provisioning parity: GET /device/:udid/cloudconfig (mcinstall GetCloudConfiguration) and the device-free fleet route GET /prepare/skip-options (mcinstall GetAllSetupSkipOptions). Caller-supplied device paths are validated by safeDevicePath, which rejects any '..' element so a request cannot traverse out of the AFC root; pull additionally relies on the afc package's own entry-name filtering. Uploads stream to the device and are bounded by maxUploadBytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Add device REST endpoints for accessibility + location parity: - GET/PUT /device/:udid/voiceover -> ios.GetVoiceOver / ios.SetVoiceOver - GET/PUT /device/:udid/zoom -> ios.GetZoomTouch / ios.SetZoomTouch - POST /device/:udid/ax/audit -> accessibility RunAudit (bounded timeout) - GET /device/:udid/ax -> accessibility element snapshot (no listeners) - PUT /device/:udid/setlocation/gpx -> simlocation.SetLocationGPX (multipart) Set handlers accept enabled via JSON body or query param. GPX upload is size-bounded via readFormFile, written to a temp file and cleaned up. Device-free tests cover validation/error paths, enabled parsing precedence, audit timeout validation, and GPX multipart parsing/temp handling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
…feature/restapi-complete
…to feature/restapi-complete
…ure/restapi-complete # Conflicts: # restapi/api/errors.go
…ture/restapi-complete # Conflicts: # restapi/api/errors.go # restapi/api/routes.go
…nto feature/restapi-complete # Conflicts: # restapi/api/routes.go
Add /api/v1/device/:udid/ui endpoints that drive on-device UI automation through the ios/uidriver package against a running, forwarded WebDriverAgent (or DeviceKit) backend. Endpoints (all under /device/:udid/ui): POST tap, swipe, longpress, type, button GET screenshot (image/png), source, size, orientation, status PUT orientation POST app/launch, app/terminate, app/foreground POST api (raw uidriver.API passthrough) Backend is selected per request via ?backend=wda|devicekit and addressed via ?wdaUrl=<url> (defaults 127.0.0.1:8100 / :12004), with an optional ?timeout. A fresh uidriver.Driver is built per request. uidriver errors are mapped: *HTTPError and transport/unreachable failures -> 502, unsupported actions (button/foreground/stream) -> 501, bad input -> 400. Tests stand up an httptest server mimicking WDA and assert each endpoint forwards the correct method/path/body and returns the mapped response, including unreachable-backend -> 502 and bad-input -> 400. Fully device-free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
…terface
Add host-local /api/v1/sign/{certificate,provision,app} and prepare routes
(/prepare/create-cert host, /device/:udid/prepare) to the daemon. Endpoints
depend only on a new Signer interface (restapi/api/signing_adapter.go); the
default signingAdapter wraps ios/signing and is swappable for the future
in-repo ios/codesign impl with no endpoint/test changes.
Uploads are bounded by maxUploadBytes and written to a per-request temp dir
that is always cleaned up; generated P12/profile artifacts are read then
removed from disk. No key bytes or passwords are ever logged.
Tests inject a mock Signer to verify multipart parsing, temp-dir write +
cleanup, correct Signer args, secret redaction, and artifact streaming —
fully device-free and impl-free — plus a compile-time Signer assertion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
…re/restapi-complete
Merged Wave 2 (UI automation) + Wave 4 (codesigning)
Merge notes: both branches merged with no real conflicts — Wave 2 fast-forwarded, Wave 4 auto-merged (only additive Endpoint total on this branch: 121 (Wave-1 baseline 101 + 15 UI + 5 sign; counting distinct gin route registrations). Verification: Still open: Wave 3 is not yet included, and final integration (docs/swag regeneration, cross-wave review) remains for the integrator. |
| req.Header.Set("Content-Type", "application/json") | ||
| } | ||
| golog.Debug("ui request", "module", logModule, "backend", string(d.backend), "method", method, "endpoint", endpoint, "udid", d.udid) | ||
| resp, err := d.httpClient.Do(req) |
| if path == "" { | ||
| return nil, nil | ||
| } | ||
| data, err := os.ReadFile(path) |
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if rmErr := os.Remove(path); rmErr != nil { |
Refactor ios/pcap to a context-aware streaming core: new Stream(ctx, device, io.Writer) writes a valid pcap stream (global header + per-packet records) until ctx is canceled, then returns cleanly by closing the device connection to unblock the read (no goroutine leak). The existing Start(device) CLI entry point keeps its signature and is reimplemented on top of Stream, so `ios pcap` and its caller are unchanged. Add GET /device/:udid/pcap streaming the live capture as application/vnd.tcpdump.pcap (chunked, flushed per write). Capture lifetime is the request context plus an optional ?timeout= (seconds, default 60s, capped at 3600s); client disconnect or the deadline stops it. Registered in routes.go in a delimited block. Tests are device-free: ios/pcap capture is verified against a fake connection feeding canned frames (valid pcap out, stop on cancel/deadline, error surfaced when not canceled, single close); the endpoint covers timeout parse/validation and route registration. Live capture bytes cannot be mocked at the endpoint layer (pcap.Stream connects internally), so that behavior is covered in the pcap package tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
Add two streaming REST endpoints to the go-ios daemon: - GET /device/:udid/ui/stream?codec=mjpeg|h264 proxies a forwarded WDA/DeviceKit UI video stream straight through to the client, preserving the backend's Content-Type and flushing frames until the client disconnects or the backend ends. Maps ErrStreamUnsupported->501 and unreachable backends->502. - GET /device/:udid/screenshot/stream serves a multipart/x-mixed-replace MJPEG stream of device screenshots via the instruments screenshot service, flushing each frame until the client disconnects. uidriver: add StreamWithContentType (Stream now delegates to it) so the proxy can forward the backend's Content-Type verbatim; existing Stream signature and tests are unchanged. instruments: extract the shared MJPEG core (pngToJPEG conversion and a reusable, context-driven StreamJPEGFrames frame source) out of the package-global screenshot loop. StartMJPEGStreamingServer and mjpegHandler keep their prior behavior, now fanning a single StreamJPEGFrames source out to registered consumers. Tests are device-free: ui/stream is exercised against an httptest backend (pipe-through, Content-Type preservation, codec/backend error mapping, client-disconnect stop); the screenshot multipart framing core is tested via an injected frame channel; pngToJPEG has round-trip coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk
…tree-agent-a9c552bbe20d85f56 # Conflicts: # restapi/api/routes.go
Wave 3 merged: streaming endpointsBoth Wave-3 streaming branches are now integrated into
Conflicts: only the expected additive collision in Endpoint total: the Verification: Parity status: the daemon is now at CLI parity except the documented NOT-REST exclusions, which are inherently interactive/stateful and unsuitable for stateless REST:
|
| return nil, "", fmt.Errorf("uidriver: failed creating stream request: %w", err) | ||
| } | ||
| golog.Info("opening ui stream", "module", logModule, "backend", string(d.backend), "url", rawURL, "udid", d.udid) | ||
| resp, err := d.httpClient.Do(req) |
| } | ||
|
|
||
| func (f flushWriter) Write(p []byte) (int, error) { | ||
| n, err := f.w.Write(p) |
Integrate the five per-language examples suites (typescript, python, java, csharp, mcp) and tie them together: - sdks/docs/EXAMPLES.md: top-level overview (docs + pre-release smoke test), per-language index, shared GO_IOS_BASE_URL/GO_IOS_API_KEY/GO_IOS_UDID/RUN_UI convention, how to run them all. Linked from sdks/README.md. Documents the canonical daemon port (8080, matching the daemon's --addr default) and the SDK-library-vs-daemon default discrepancy (libraries default to :60105 from the spec servers URL) for a follow-up reconcile. - sdks.yml: add the device-free MCP list-tools smoke check (npm run examples) to the mcp job so a broken MCP server fails CI. - verify-sdk-examples.yml: new dispatch-only, farm-gated workflow that runs every SDK's example runner against a live go-ios REST daemon + device. Inert until the full REST daemon lands on main (#817/#821); the daemon-start step is a documented placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wave 1: CLI-parity REST endpoints +
ios/uidriverThis integrates the Wave-1 REST parity branches and the reusable UI-driver
library onto a single branch so Wave 2 can build on top of it. It is stacked
on #817 (
feature/restapi-parity, the base) and #820 (refactor/uidriver-extract)and should be merged after both of those land.
Roughly 22 new REST endpoints are added, grouped below.
Diagnostics / network (w1a)
GET /device/:udid/diskspace— disk space usageGET /device/:udid/ip— device IP addressGET /device/:udid/rsd— RSD info (iOS 17+ tunnel required)GET /device/:udid/battery/registry— raw battery IORegistry valuesAccessibility / location (w1b)
GET/PUT /device/:udid/voiceover— read / toggle VoiceOverGET/PUT /device/:udid/zoom— read / toggle ZoomPOST /device/:udid/ax/audit— run an accessibility auditGET /device/:udid/ax— accessibility inspector snapshotPUT /device/:udid/setlocation/gpx— simulate location from an uploaded GPX fileFilesystem / provisioning (w1c)
GET /device/:udid/fsync/ls— list a directory (AFC)GET /device/:udid/fsync/tree— recursive directory treeGET /device/:udid/fsync/pull— download a single filePOST /device/:udid/fsync/push— upload a fileDELETE /device/:udid/fsync/rm— remove a pathPOST /device/:udid/fsync/mkdir— create a directoryGET /device/:udid/cloudconfig— read the installed cloud configurationGET /prepare/skip-options— supported Setup Assistant skip options (host-scoped)WebInspector (w1d)
GET /device/:udid/webinspector/pages— list inspectable pagesPOST /device/:udid/webinspector/launch— launch a URL / bundlePOST /device/:udid/webinspector/eval— evaluate JavaScript in a pageios/uidriverextraction (#820)Extracts the reusable UI-automation logic out of
cmd_ui.gointo a newios/uidriverpackage (with unit tests) and rewritescmd_ui.goto consume it.This is a prerequisite for the Wave 2 UI-automation REST endpoints.
This is Wave 1 of the feature-complete effort. Waves 2-4 to follow:
UI automation, streams, and codesigning.
Merge conflicts during integration were confined to
restapi/api/routes.go(route-registration calls) and
restapi/api/errors.go(error sentinels); bothare additive and were resolved by keeping the union of all waves.
🤖 Generated with Claude Code
https://claude.ai/code/session_01J8eMENxJ1nec9CeHp4tjWk