What the platform’s published APIs have stopped offering, when, and what replaced it.
A deprecation is an operation still served but signalled as going — marked deprecated: true in the
contract, with the replacement named in its description. A removal is an operation or document that
is gone. Both are recorded here, and in the owning repository’s CHANGELOG.md.
If an entry affects you and no replacement is named, raise it with the platform team rather than assuming the behaviour moved.
Errors array and the Error object, now one RFC 7807 ProblemThese three APIs answered errors with a bare array of { code, title, detail, instance } — or, on
automation, a single object of the same shape. The other four APIs on the estate answer with RFC 7807
application/problem+json. A generic client cannot read both, so D-37 converged the estate on one
shape rather than documenting the divergence and converting later.
Every 4xx and 5xx response on all three now carries a single application/problem+json object:
{
"type": "https://gradera.dev/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "No active group exists with slug \"reviewers\".",
"instance": "/identity/v1/groups/reviewers",
"code": "IDENTITY_GROUP_NOT_FOUND_404",
"errorId": "c56246b6-d666-4717-8a21-bd850c8bc2b6"
}
code is unchanged on all three. It is what a client branches on, so every value these APIs have
published survives — IDENTITY_GROUP_NOT_FOUND_404, ADMIN_ASSIGNMENT_DUPLICATE_409,
INVALID_REQUEST, INTERNAL_SERVICE_ERROR, SUBSCRIBER_DUPLICATE_409, SORT_INVALID_400 and the
rest. What changed is the envelope around it. A client that read body[0].code must read body.code;
a client that read code off a parsed problem needs no change at all.
Three smaller changes go with it:
title is now the HTTP reason phrase. RFC 7807 wants a summary of the problem type, identical
on every occurrence. What used to be in title — “Resource not found”, “Subscriber already exists” —
is in detail.instance means the path and query the error occurred on, not a link to error documentation,
which is how these contracts described it.id becomes errorId (analytics), and every response now has one. It appears in the service’s
log line for that error.errors is added: a map of field name to messages, present only on a validation failure. Analytics
previously sent one Error object per failed field; one problem object now carries them all.
Automation’s contract was wrong before this change, which the other two were not. It published a
bare { code, title, detail, instance } for most statuses and an Errors array for 400, while the
service sent a problem-shaped object with type, status, correlationId and timestamp. The
contract and the code had never agreed.
correlationId and timestamp have left automation’s response body. The correlation ID is on the
x-correlation-id response header, which all three services now set from the exception filter — before
this, a guard rejecting a request returned no correlation header at all, because the interceptor that
set it runs after guards.
Contracts moved: nexus-flow/v1-identity.yaml 1.0.0 → 2.0.0, nexus-flow/v1-automation.yaml
1.0.0 → 2.0.0, nexus-flow/v1-analytics.yaml 0.2.1 → 0.3.0.
A sweep of every UI repo and BFF under the platform root found nothing that read the array: the hits
were test fixtures and BFF stubs, and the shared UI SDK’s toProblemDetail discarded array payloads
outright. v1-alerts, v1-audit and v1-notifications still publish the old Error shape, which
their services still send; they are named as exemptions in .spectral.yaml so the remaining
divergence can be counted.
links on the two inbox endpoints, now _linksGET /messages/v1/inbox and GET /messages/v1/inbox/subscriber returned their page navigation under
links. Every other API on the estate uses _links, and this contract has published _links
for these two endpoints all along — so a client generated from the contract read _links, found
nothing, and had no page to follow.
The cause was a missing [JsonPropertyName] on one model property, which left it taking the camelCase
default. The contract did not change; the service caught up to it.
No dual-emit window. D-03 chose a straight rename: the consumer check found nothing reading either
spelling — the inbox front end pages by offset arithmetic and has zero references to links or
_links — so emitting both for a release would have been ceremony protecting nobody. It is recorded
here for any external caller the survey cannot see.
Two related changes shipped with it, neither of them contract-visible:
https://host/messages/v1/inbox?…) where they were relative paths.
Callers concatenating a base URL were getting a double prefix from this API and a correct URL from
every other one.self appeared on a full page and vanished on an
empty one, so a client re-reading self to refresh lost it exactly when the list was empty.GET /alerts/v1/alerts/catalog/flows declares its _linksNot a deprecation: a contract that under-described the service. The endpoint has always returned the
house pagination envelope including _links (self, next, prev), on the empty page as well as a
full one. Its schema listed offset, limit, total and data and stopped there, so a generated
client had no links to follow — while its sibling GET /alerts/v1/alerts/rules declared them.
CatalogFlowListResponse now carries _links: HATEOASLinks, matching AlertRuleListResponse.
No service change, and no caller behaviour changes. The links were already on the wire.
Found by the One Pagination Envelope survey (H-09), which grades every paged GET in these contracts
against {offset, limit, total, data, _links}. That survey now runs in CI as a report-only step.
403 on DELETE /messages/{messageId}/locksReleasing a lock is now idempotent in every case. The operation answers 204 whenever the caller ends up
holding no lock on the message, including when another user holds it — that holder’s lock is left in
place. It previously answered 403 (NF403.Message.Lock, “You are not the lock holder for this
message.”) in that one case.
Replacement: none needed. A client that released a lock it did not hold was already getting the end
state it asked for; it now gets a success code for it. Clients that special-cased the 403 can drop that
branch. Acquiring a lock is unchanged: POST /messages/{messageId}/locks still answers 409 when someone
else holds it, which is the code to check if you need to know who may work on a message.
PATCH /messages/{messageId}The message update operation was retired as obsolete (NF-69, gradera-platform-api-messages #13).
Replacement: none directly. A message’s status is set by the platform as the flow advances; the
client-facing actions are POST /messages/{messageId}/responses to answer a message and
POST /messages/{messageId}/locks / DELETE …/locks to hold and release it.
If you hold an older copy of v1-messages.yaml, it may still describe this operation. It appears in
fifteen unmerged branches of platform-api-docs. The contract published here is authoritative.
GET /messages/v1/exampleA scaffold stub the service template left behind — summary “Example GET endpoint stub”, returning
{"message": "Gradera Platform Messages API is running."}, and marked anonymous. It was never in this
published contract, so no documented behaviour changes. Recorded here because it was live on the
deployed service and anyone who found it by probing will see it disappear.
Replacement: none needed. Use GET /messages/health to check the service is up.
GET /message-types/{slug} and POST /messages/{messageId}/locks have always been served by the
Messages API and were missing from this contract. The lock contract previously documented only the
release half (DELETE), which made acquiring a lock undiscoverable to anyone reading it.
No behaviour changed. v1-messages.yaml moved 0.4.0 → 0.4.1.
v1-alerts.yaml described 13 operations against 26 the service serves. Rule configuration was
published; the user-facing feed was not — the inbox, unread count, status changes, preferences,
settings, alert types, delivery channels, the SSE badge stream and the action endpoints.
All of it is now in the contract. No behaviour changed; every one of these operations has always been served. The contract was half of one.
The document is retitled from NexusFlow Configure Alerting API to NexusFlow Alerts API, since it
no longer describes only configuration. v1-alerts.yaml moved 0.1.0 → 0.2.0.
POST /admin/demo/alerts is documented too, as what it is: an administrative seeding endpoint for
demonstrations and local testing, not intended for client use. It is served, so the contract says so.
sort_by on GET /flows/v1/flowsUse sort instead. Same grammar, same fields, different spelling — and sort is the one this
contract has named all along, under #/components/parameters/sort.
sort_by remains accepted and applied for one release, after which it is removed. If both are sent,
sort wins and sort_by is ignored.
Why it moved. sort is what the Identity API implements, what the SLA screen already sends, and what
this contract published for /flows and /slas while the service bound sort_by — so a caller
following the contract was silently ignored. It is also the only underscored query parameter across the
.NET services; every other one is userName, groupSlug, flowSlug.
What else changed at the same time, none of it a deprecation:
sort on the flow list now covers displayName, owner, status, createdDts, updatedDts and
cname, in a comma-delimited priority order, and rejects an unrecognised field or direction with
400 naming what is supported. It previously accepted three bare literals, ascending only, and
silently ordered by created_at DESC for everything else — including every value it advertised.GET /flows/v1/slas gains sort and search. It has no sort_by alias: the parameter was
documented there for months and never read, so nothing can be relying on it.owner on the flow list filters by SoS Admin user GUID, repeated once per owner
(owner=a&owner=b, never owner=a,b).Flow.owner is no longer required, and now names the real ownerowner was built from the flow’s created_by string: a GUID parse falling back to all zeros, with an
email and a niqUri invented from whatever text was there. On development that presented four of five
flows as owned by “System”. It is now read from the flow’s owning user row.
What a consumer sees change: owner.userGuid, displayName, email and niqUri all become real
where they were invented, and niqUri now matches niq://{tenant}/user/{email} rather than the
niq://user/{name}/{version} shape only the fabrication ever produced.
owner also leaves the required list. An owner that cannot be resolved is absent rather than
invented — a foreign key makes that unreachable in practice, so its absence means the data is broken,
not that the flow has no owner. A client that assumed owner was always present should guard it.
nexus-flow/v1.yaml and nexus-flow/latest.yamlThese two URLs were linked from this site’s landing page and its README for several months. They were never published. A combined Nexus Flow document was authored on a feature branch and the merge that would have delivered it did not happen, so the links resolved to 404 for their whole life.
Replacement: the per-service documents, which are and always were the real contracts —
v1-flows.yaml, v1-messages.yaml, v1-audit.yaml, v1-alerts.yaml, v1-analytics.yaml,
v1-notifications.yaml, v1-automation.yaml, v1-identity.yaml. The landing page now links these
directly and the two aggregate URLs have been retired rather than restored: the branch copies date from
May–July 2026 and describe a surface that has moved a long way since.
POST /flows/{flowSlug}/flow-instancesRecorded here because it looks like a change and is not one. The /properties, /message-types,
/complex-types and /folders groups, and POST /flows/{flowSlug}/flow-instances, have always been
registered and functional in the Flows API. They were held out of the published contract under NF-141
pending a review of whether any client depended on them; that review completed and every one has a live
consumer, so the contract now describes them. No behaviour changed and nothing is deprecated.
v1-flows.yaml moved 0.3.1 → 0.3.2.
GET /flows/{flowSlug}/versions/{…} path parameterThe parameter was published as flowVersionId, typed format: uuid. The endpoint has always
accepted either a sequential version number or a version UUID — its own summary reads “Get a
published flow version by version number or version ID” — so the contract made half the endpoint
undiscoverable, and a validating client or gateway generated from it would reject …/versions/3.
Now published as versionNumberOrId, typed string, with an example of each form. v1-flows.yaml
moved 0.3.2 → 0.3.3.
If you generated a client from 0.3.2 or earlier, the method argument for this one operation may be
named flowVersionId and typed as a UUID. Regenerate. Calls that pass a UUID keep working unchanged —
the widening is additive at the wire level; only the generated signature and any client-side validation
change.
/flows/health, /flows/health/live, /flows/health/readyThese are served, but they sit outside API versioning and therefore outside the /flows/v1 server base
every published operation is written against. They cannot be expressed in this contract and their
absence is not drift. Call them directly on the service host.
sortKey and sortDirUse sort instead, on both GET /alerts/rules and GET /alerts/rules/{ruleId}/fires:
field:direction, with the direction optional and asc by default. Both old parameters remain
accepted for one release and are marked deprecated in the contract; sort wins if both are
sent. With neither, each list keeps its own default — rules by scope ascending, fires by fired-at
descending — so nothing a current caller does changes.
Why it moved. This was the only list on the platform taking two parameters where every other one
takes sort=field:direction. The two-parameter form shipped first because this contract had never
been published at all and something had to go out; it was recorded at the time as needing revision,
and this is that revision.
Nothing is lost by the change. The enum-typed sortKey was this API’s one real advantage — a
generated client could see which fields were sortable, where field:direction is an opaque string.
The sortable set is still typed into the contract, sort is validated against the same enum, and the
400 names its values:
GET /alerts/v1/alerts/rules?sort=bogus:asc
→ 400 Unsupported sort field "bogus". Supported fields: name, trigger, scope, notify, status, lastFired, owner.
sort_by on GET /users/{userId}/groups/detailsUse sort instead — same fields (groupName, flowCount), same default (groupName:asc), and now
the same grammar as every other list in the estate. sort_by remains accepted for one release, after
which it is removed; sort wins if both are sent.
One widening came with it: the direction is now optional, so sort=flowCount means flowCount:asc.
sort_by previously required field:direction and rejected a bare field. No caller breaks.
GET /invitations actually acceptsNothing about the endpoint changed. The contract was wrong about it, in three places:
status is repeatable, not a single value. Filter several states with
status=pending&status=accepted. A comma-joined value (status=pending,accepted) is rejected
with 400 — and until this release the endpoint’s own _links.next emitted exactly that form, so a
client following the API’s own link on a two-status filter failed on page 2. The link now repeats the
key.email is a substring match, not an exact one. It has been since
sos-admin-api #26.search exists — a case-insensitive substring over the invitee’s email or name. It has worked for
months and appeared in no contract and no endpoint description.search on GET /groups coverssearch matches the group’s display name and slug. The contract described it as matching “display
name or description text”; the description has never been searched.
sort on the user, group and invitation lists, search on usersNot a deprecation, but the same release: all three lists accept sort in the estate’s grammar, and
GET /users accepts a search matched against userName or email. Every list keeps createdDts
descending as its default, so a caller who sends no sort sees nothing move. Paging links on all three
now carry their filters and the sort, and take their scheme from X-Forwarded-Proto.
DELETE /groupUserAssignments/{guaId}The camelCase management route is retired (sos-admin-api, API-Spec-Reconciliation X-30a). It was the
only route under that prefix, while the contract had described the same resource as
/group-user-assignments since it was first written — the code had never caught up.
Replacement: DELETE /group-user-assignments/{groupUserAssignId} (deleteGroupAssignment). Same
behaviour, same identifier; only the path changed. The old path now answers 404. The Location
header on every assignment create, and the self link on assignment resources, already point at the
new path.
GET /group-user-assignments, POST /group-user-assignments and GET /group-user-assignments/{id}
were in this contract but not in the service. They are now served, over the same handlers the nested
routes use. The contract’s assignment schemas (GroupAssignment, GroupAssignmentsPage) were
corrected to the shape the service has always returned — guaId, groupName, userFullName,
userEmail, userStatus, and a paging object keyed links — which the nested routes now reference too.
GET /users/me, GET /users/me/groups, GET /users/{userId}/groups/details,
GET /groups/{groupSlug}/flows, POST /groups/{groupSlug}/duplicate and
PATCH /invitations/{invitationId} have always been served and were missing from this contract
(X-30b). No behaviour changed. sos-admin/v1.yaml moved 1.0.0 → 1.1.0.
/tenants/…, not /tenant-man/…The contract described every operation under /tenant-man/v1/… and /tenant-man/access-controls/v1/….
The service has only ever served /tenants/v1/… and /tenants/access-controls/v1/… (its health route
is /tenants/health), and the gateway routes /tenants to it; /tenant-man/* on staging falls through
to the gateway’s 401, the same as any path that does not exist (X-31).
No behaviour changed — the document was wrong, not the service. Every path was rewritten to the
/tenants/… form and tenant-management/v1.yaml moved 1.0.0 → 1.0.1. The one remaining
/tenant-man/ reference in the estate, the demo compose file’s health check, was corrected in the
same change.
GET/POST /tenants/v1/tenants/{tenantId}/role-group-bindings and GET/POST
/tenants/v1/tenants/{tenantId}/widgets have always been served and were missing from this contract
(X-32). No behaviour changed.
Two operationIds are corrected to match the code: getHostById → getHost and getTenantById →
getTenant. The paths, parameters and responses are unchanged — only the identifier a generated
client uses to name the method. If you generate a client from this contract, two method names change.
tenant-management/v1.yaml moved 1.0.1 → 1.0.2, and tenant-management is now in exact parity with
its generated document. All ten published contracts are.
If you parse an error body from any of these four APIs, check what you are parsing. Four contracts
described error responses as a JSON array of a bespoke Error object. No service has ever sent
one. All four serve a single RFC 7807 object, on application/problem+json, with exactly these seven
members:
{ "type": "https://gradera.dev/problems/not-found", "title": "Not Found", "status": 404,
"detail": "…", "instance": "/sos-admin/v1/users/…", "code": "ADM404",
"errorId": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d" }
Confirmed by calling all four on development: sos-admin, flows, messages and tenant-management
each return those same seven keys and that same content type.
What the contracts said instead, and it was different in each one:
| Contract | Declared | Missing from what is served |
|---|---|---|
nexus-flow/v1-flows.yaml |
Errors array of {errorId, code, title, detail} |
type, status, instance |
nexus-flow/v1-messages.yaml |
Error object of {code, title, detail, instance} |
type, status, errorId |
sos-admin/v1.yaml |
Errors array of {id, code, title, detail} |
type, status, instance; and id is not a member — the field is errorId |
tenant-management/v1.yaml |
Errors array of {id, code, title, detail}, id required |
as above |
What changed here. Each contract gains one Problem schema — the seven members above, with type,
title, status, code and errorId required — and every error response points at it and declares
application/problem+json instead of application/json. Error and Errors are removed; nothing
references them. SoS Admin’s ten error examples and six inline examples in Messages were rewritten,
since an example that does not satisfy a required property fails Spectral.
code is not always this API’s prefix. A framework error takes it (ADM404); a domain error
carries its own (Flow.NotFound, Tenant.NotFound, MESSAGE_NOT_FOUND_404). Match on status and
read code as a detail, rather than the other way round.
Why it went unnoticed for so long. spec-parity.py compares method and path only, and reported
sos-admin: OK — 38 operations match throughout. It is the same blind spot that let latest.yaml sit
two envelope shapes out of date: a check that compares one dimension says nothing about the others.
_links are absolute, and follow the caller’s schemeFour APIs changed the value of every _links.self, _links.next and _links.prev href on their
paged lists. No schema moved and no published contract changed, so nothing here is visible in a spec
diff — but a caller that treated the href as a path rather than a URL is affected.
| API | Was | Is now |
|---|---|---|
| Identity | /identity/v1/users?limit=25&offset=0 |
https://<host>/identity/v1/users?limit=25&offset=0 |
| Alerts | /alerts/catalog/flows?offset=0&limit=25 |
https://<host>/alerts/catalog/flows?offset=0&limit=25 |
| Analytics | /analytics/v1/system/audit-log?offset=0&limit=25 |
https://<host>/analytics/v1/system/audit-log?offset=0&limit=25 |
| Automation | http://<host>/automation/v1/subscribers?… |
https://<host>/automation/v1/subscribers?… |
Why it is a correction and not a change of mind. Flows, messages, SoS Admin and tenant-management
have always sent absolute URLs, and every one of these four APIs’ own published examples already showed
absolute https:// URLs — the documents were right and the services disagreed with them. Automation
was the near miss: it built an absolute URL but took the scheme from req.protocol, which behind TLS
termination is http, so it handed https callers http:// links.
All four now read the first hop of X-Forwarded-Proto and ignore a scheme the API does not serve, so
a header cannot rewrite the links a service emits. This completes Q-16, which had reached flows,
sos-admin and messages in the sort/filter scope and none of these four.
Follow the href as given. A consumer that concatenated a base URL onto these values will now produce a double prefix; one that follows the href as a URL needs no change.
index.html at the repository rootA second landing page, 697 lines, listing v1 and latest links for each API by hand.
Nothing served it. The site is generated by site/build.mjs into dist/, and the Dockerfile
copies site/, the contract directories and dist/ — never the root index.html. Every reference to
index.html in this repository points at the generated one. It had also gone stale twice over: it
still advertised the latest.yaml URLs removed above, and listed neither the alerts, analytics,
automation nor identity contracts that nexus-flow/ has carried since 2026-09-16.
Replacement: the generated landing page at /docs/, which is built from the contract files
themselves and so cannot fall behind them.
sos-admin/latest.yaml and tenant-management/latest.yamlBoth are gone, and with them the …/sos-admin/latest.yaml and …/tenant-management/latest.yaml URLs.
Use the v1.yaml of each API. sos-admin/v1.yaml and tenant-management/v1.yaml are unchanged
and have always carried the same content the alias was supposed to mirror.
Why. docs/deployment.md described the pair as “byte-identical aliases”. They were not: on
2026-09-18 sos-admin/latest.yaml differed from sos-admin/v1.yaml by 512 lines and
tenant-management/latest.yaml by 106. The stale copies still described the nested
{ data, paging: { … } } envelope and bare-string pagination links — two shapes behind the services
they documented, and published at their own URL the whole time.
Three things had to line up for that to go unnoticed, and all three did:
scripts/pagination-envelope.py excluded */latest.yaml by filename, so the blocking envelope
gate never graded them. It no longer excludes anything: every tracked contract is graded.spec-parity.py compares method and path only, so it reported sos-admin: OK — 38 operations match
against a document whose response bodies were obsolete.The estate already had the answer. Flows, messages, alerts, analytics, automation, identity,
notifications and audit publish exactly one document each and have never had a latest alias — and
the nexus-flow/latest.yaml URLs removed on 2026-09-16 turned out never to have been published at
all. Two APIs out of ten carried an alias; both were wrong.
sos-admin/v1-{users,groups,group-user-assignments,group-role-assignments,entity-user-roles,permissions,entitlements}.yaml
and tenant-management/v1-{tenants,hosts,access-controls}.yaml are deleted (X-34, D-17).
They were merged into each directory’s v1.yaml in 92b96c2 and left behind. Nothing referenced them —
not the landing page, not the site builder, not a README — but CI linted them, and after X-30 and X-31
they still described the retired DELETE /groupUserAssignments/{guaId} and the /tenant-man/… paths
that were never routed.
Replacement: sos-admin/v1.yaml and tenant-management/v1.yaml, which have been the published
contracts throughout. Nothing a reader could reach has changed. (Each also had a latest.yaml at the
time; both were removed on 2026-09-18 — see below.)
If you generate a client from sos-admin/v1.yaml, regenerate it. The contract’s response schemas were
hand-authored in a convention the service never used, so a generated client deserialised these resources
into empty or partly-empty objects (X-33). Operation-level parity could not see this, by design — it
compares METHOD /path, not fields.
| Schema | Was documented as | Is actually served as |
|---|---|---|
Group |
groupName, groupSlug, groupDesc, created_dts, updated_dts |
groupId, displayName, slug, description, type, createdDts, updatedDts, roles |
Invitation |
sent_dts, expires_dts |
sentDts, expiresDts |
User |
— | gains createdDts, updatedDts, status |
GroupRoleAssignment |
role, groupSlug, graId |
gains createdDts, _links |
GroupRoleAssignmentCollection |
{ data, paging } |
{ data, total, offset, limit, _links } |
EntityUserRole |
role, entityType, entityId, userId, eurId |
gains createdDts, _links |
Group, User and Invitation are the item type of GroupsPage, UsersPage and InvitationsPage, so
the list responses are corrected by the same change. Every example was rewritten to match, including the
assignments links, which pointed at /group-assignments?groupSlug=… — a path that has never existed.
One operationId is corrected: getEntityUserRoles → listEntityUserRoles, to match the code.
GroupRoleAssignmentCreate is removed — it described a request body carrying groupSlug, which that
endpoint takes from the path; the operation already documents its real inline { role } body.
sos-admin/v1.yaml moved 1.1.0 → 1.2.0. No service behaviour changed: the service already served all
of this. Only the description of it was wrong.
deploy/k8s/ reference manifestsdeploy/k8s/ holds copies of three other repositories’ GKE policies. All three checked
requestPath: /health/ready. No service on this estate serves that path: health routes are
registered as /{serviceName}/health/{live,ready}, and only the bare status body is mapped at
the root — so /health answers 200 while /health/live and /health/ready answer 404.
These manifests are not applied, and never have been from here (N-10). Nothing in this
repository reconciles deploy/k8s/: the two deploy workflows run
helm upgrade platform-api-docs ./deploy/helm/app and touch nothing else, and there is no
kustomization, ArgoCD Application or Flux Kustomization pointing at the directory. A read-only
kubectl get against the cluster on 2026-09-18 confirms they could not apply if something tried —
the namespace they target, sos-platform, does not exist, and neither do the Services they name.
The live policies for these APIs are deployed by each service’s own Helm chart, under different names, and were already correct:
| This directory named | Live Service | Live policy requestPath |
|---|---|---|
sos-admin-api (7001) |
gradera-platform-api-admin |
/sos-admin/health/ready |
tenant-management-api (7003) |
gradera-platform-api-tenants |
/tenants/health/ready |
nexusflow-api (7002) |
retired — no Service, nothing on 7002 | — |
No cluster behaviour changed, because nothing here was ever reconciled. The two manifests that
still map to a running service were corrected to the path that service serves, so the reference
stops contradicting the Helm chart it copies, and each file now carries a header saying it is not
applied — matching qa-apis-httproute.yaml, which has been marked that way in the same directory
since June 2026.
/health was considered as the target instead, since it is the one health path that does answer
200. It was rejected: it reports the bare status body and answers 200 whatever the state of the
service’s dependencies, so a backend check against it can never fail and would mark a pod with a
dead database as ready to serve. /health/ready runs the dependency checks and answers 503 when
one is down, which is what lets the load balancer drain the backend.
nexusflow-api HealthCheckPolicydeploy/k8s/nexusflow-api-policies.yaml names a Service that no longer exists in any environment,
and no Service in the estate listens on its port 7002. The nexus-flow monolith was split into
gradera-platform-api-flows (7004, /flows/health/ready) and gradera-platform-api-messages
(7005, /messages/health/ready), each shipping its own HealthCheckPolicy from its own Helm chart.
Its requestPath was left as it was rather than corrected — there is no path to correct it to,
and inventing one would be worse than recording the history. The file is kept as a historical record
and marked as retired. It is a candidate for deletion once that history is no longer wanted.
code each carriesgradera-error-response-has-an-example is now enforced at error severity. It fired on 22 shared
error responses — sos-admin (1), tenant-management (10), flows (6), messages (5) — each of which
declared application/problem+json and a $ref to Problem with nothing to show for it. A client
author could see which members exist but not what this API puts in them.
No behaviour changed and no contract surface changed. Only examples were added, generated by
scripts/problem-components.py --examples.
The code values are not uniform, and the obvious guess is wrong. All four services set
CodePrefix = "ADM", so ADM{status} is only the fallback the shared factory applies when the
caller passes no code of its own. What the documented operations actually return:
| 400 | 401 | 403 | 404 | 409 | 415 | 422 | 500 | |
|---|---|---|---|---|---|---|---|---|
| sos-admin | ADM400 |
ADM401 |
ADM403 |
not_found |
ADM409 |
ADM415 |
ADM422 |
ADM500 |
| tenant-management | ADM400 |
ADM401 |
ADM403 |
domain, e.g. Tenant.NotFound |
domain, e.g. RoleGroup.NameAlreadyExists |
ADM415 |
domain, e.g. Host.HasTenants |
ADM500 |
| flows | domain, e.g. FLOW400.Sort.Invalid |
ADM401 |
FLOW403.FlowControl.NotOwnerOrAdmin |
domain, e.g. ADM404.Flow |
domain | ADM415 |
domain | ADM500 |
| messages | NF400 |
ADM401 |
ADM403 |
not_found |
ADM409 |
ADM415 |
ADM422 |
ADM500 |
Three mechanisms produce that spread, all of them deliberate in the services’ own source:
DomainErrorHttpMapping publishes the domain error’s own code, never the status
family. tenant-management and flows use it unmodified, so their documented 404/409/422 carry a
domain code.SpecHttpResults with a local class and hard-code two values
that predate the shared package — messages’ NF400 and not_found, sos-admin’s not_found.
Both call these a published contract rather than a default.NF{status} on framework-generated problem bodies only — the
400 .NET writes for a DataAnnotations failure — which is a different path from any of the above.Each contract’s Problem.code description already warned that a domain error carries its own code.
The examples now show it.
429 and 503 that the service cannot produce. No rate limiter
exists in the service or the shared package, and tenant-management defines no
DependencyUnavailableDomainError, which is the only route to a 503 through the shared mapping.
Their examples use the prefix rule’s ADM429/ADM503 — what the code would be. Removing the
two responses would change the contract surface and is a separate decision.title values are descriptions (Resource not found, Authentication required) where the
service sends the HTTP reason phrase, and NotFoundExample publishes ADM404 where the service
publishes not_found on every documented path. They are outside the 22 this change was scoped to.