Request validation is now enforced across the API
Every endpoint gained a request validator. A validator is not additive metadata — it replaces the request object, so calls that were previously tolerated in silence can now be rejected.- Malformed identifiers return
422, previously400. An id that is not 24 hexadecimal characters used to reach the database, raise a cast error and surface as a400with no field detail. It is now rejected up front witherror.details[0].fieldnaming the parameter. limitis capped at 100. A larger value previously ran a query of whatever size was asked for.pageorlimitof0, a negative number, or a non-numeric string were silently replaced with defaults. All of these are now422.sortis validated. A value the database could not parse previously produced a500; it is now422.- Undeclared query parameters are stripped before the handler runs rather than ignored further down. Behaviour is unchanged, but it is now unchanged deliberately.
400 for these routes, add 422. Anything rendering
error.details needs no change and now gets better text.Issuer scoping fixes
Three endpoints were returning other issuers’ data toissuer_admin users. Their sibling endpoints
scoped correctly; these did not. Nothing changes for ta_admin or audit_user.GET /transactions/ledgerwas returning journal entries for every issuer. It now scopes to the caller, sototalDocsandtotalPagesshrink and a page that used to be full may now be empty. Entries whose security cannot be attributed to an issuer are no longer returned to an issuer admin.GET /issuers/{id}now returns404when an issuer admin requests an issuer that is not its own —404rather than403, deliberately, because a403would confirm the other issuer exists.GET /investors/{id}/securitiesnow returns only securities belonging to the calling issuer, and404for an investor with no relationship to it.
Webhook requests are now signature-verified
POST /webhook/sync now verifies x-signature as an HMAC and refuses a reused or stale x-nonce.Senders must switch to hex(HMAC-SHA256(secret, "<nonce>.<body>")). The previous receiver accepted
any signature, so a sender can change first with no downtime. See Webhooks.sort now honours multi-field values
Sorting is split on whitespace internally, but the validator only accepted the comma form. So
?sort=name,-createdAt was accepted and then sorted by a single field literally named
name,-createdAt, which does not exist — the result came back in natural order with no error. The
space form that would have worked was rejected as 422.Both forms are now accepted and normalised. ?sort=name,-createdAt starts sorting. No client
change is required, but any list view that sent a multi-field sort and set its expectations against
the previous unsorted output will now see a different — correct — order, and stable pagination it
did not have before.Reports gained query validation
The four report endpoints had none.?securityId=must be a valid id on/reports/mshfand/reports/control-book. Both services guarded it and silently dropped it when it failed to parse, so a typo returned every security in scope. A screen that built a single-security view from a bad id was showing the whole book. A malformed value is now422.?limit=is capped at 100 on all four, and?issuerId=is stripped — scope comes from the token.
Corrections to previously published behaviour
No code changed here; the documentation was wrong.POST /auth/forgot-passwordnever returns404. It always returns200, by design, so the endpoint cannot be used to discover which addresses have accounts. Any branch on404is dead code and the message it would have shown was a privacy leak.POST /auth/logoutdoes not always revoke other sessions. It bumpstokenVersiononly when the request carries a token that still verifies. Treat “log out of all devices” as a separate feature.POST /api-keysno longer acceptsexpiresIn. It was accepted and never read — keys do not expire. It is now stripped, which is the same outcome at runtime.

