Skip to content

Establish standards for internal APIs - #863

Closed
mike-gorman-bitwarden wants to merge 8 commits into
mainfrom
docs/adr-0035-internal-api-standards
Closed

mike-gorman-bitwarden wants to merge 8 commits into
mainfrom
docs/adr-0035-internal-api-standards

Conversation

@mike-gorman-bitwarden

@mike-gorman-bitwarden mike-gorman-bitwarden commented Sep 13, 2026

Copy link
Copy Markdown

I would suggest #862 land first, since it establishes the over-arching Service-Oriented Architecture initiative. Then, after that one lands, we'll rebase this one and change the ADR number to 0036 and link the ADR 0035's services.md reference to API Standards to the standards introduced by this PR.

Adds ADR 0035, recording the decision to base internal service-to-service APIs on JSON:API,
adopted selectively, with formally versioned APIs — and publishes the standard itself under
Architecture › Service-Oriented Architecture.

The ADR and the standard land together, so the ADR ships as Accepted and the standard is adopted
on merge — the same pattern PR #842 used for ADR-0034 and the documentation standard.

The standard covers the full request/response contract: API paths and versioning, breaking changes
and deprecation, JSON and naming conventions, standard responses, the CRUD operations, filtering,
sorting, paging, sparse fieldsets, advanced queries, bulk operations, optimistic concurrency,
request validation, and errors. It closes with a FAQ anticipating the questions we expect.

This also renames docs/architecture/server/ to docs/architecture/service-oriented-architecture/
and relabels the section from "Server Architecture". Command Query Separation and Model separation
of concerns move with it, and the inbound link in ADR-0008 is updated.

Two areas are deliberately deferred and marked as such in the standard: a standard for
authentication, authorization, and request context, and a standard for jobs (the resource a
202 Accepted returns). Both are listed in the ADR's Plan.

The previously published URLs under /architecture/server/ are preserved. All in-repo references
were updated, and static/_redirects carries a single rule that 301s the old paths to the new
section — Cloudflare Pages reads that file from the build root, so this needs no plugin or config
change.

@mike-gorman-bitwarden mike-gorman-bitwarden added the ai-review Request a Claude code review label Sep 13, 2026
@github-actions github-actions Bot added the adr label Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Re-reviewed at 580c68d, focusing on what the last three commits changed: the result-shaping parameter exception in Unrecognized fields, the POST exception list and content-negotiation rewording in 7567420, the 422 consolidation in 43cc6df, and the new static/_redirects. Both findings from the previous pass are settled — the filter/sort/fields/paging exception generalizes the accepted one correctly, and the version readOnly suggestion was declined with a reasoning that holds (specific rule supersedes general).

Structural checks are clean: no /architecture/server references remain outside the redirect file, every in-page anchor in the standard and the #adopt-jsonapi-strictly anchor in the ADR resolve, sidebar_position values in the renamed section are 0/1/2/3 with no collision, the _category_.yml keeps position: 6, and the three custom-words.txt additions cover the new terms.

The three findings below are gaps rather than contradictions, and none blocks the merge. The permanent one is the same fail-open shape as the finding you generalized in 580c68d, applied to the one parameter that list does not reach.

Code Review Details
  • ⚠️ : permanent=true falls under the MUST ignore rule, so a service without hard deletes reports a 204 for an erasure that did not happen
    • docs/architecture/service-oriented-architecture/internal-api-standards.md:154
  • 🎨 : page[size] has no documented maximum or default, so an unbounded page is a conforming request
    • docs/architecture/service-oriented-architecture/internal-api-standards.md:594
  • 🎨 : The splat rule does not match the bare /architecture/server index URL, which still 404s
    • static/_redirects:1

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 13, 2026

Copy link
Copy Markdown

Deploying contributing-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 580c68d
Status: ✅  Deploy successful!
Preview URL: https://c9b47bf0.contributing-docs.pages.dev
Branch Preview URL: https://docs-adr-0035-internal-api-s.contributing-docs.pages.dev

View logs


```http
Deprecation: @1688169599
Sunset: Sun, 30 Jun 2024 23:59:59 UTC

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: The Sunset example ends in UTC, but an HTTP-date must end in GMT.

Details and fix

RFC 8594 defines Sunset as an HTTP-date per RFC 7231 §7.1.1.1. All three accepted forms (IMF-fixdate, RFC 850, and asctime-with-zone) terminate in the literal token GMT; UTC is not valid in any of them. Strict parsers reject it — Go's http.ParseTime, .NET's "r" round-trip format, and Python's email.utils.parsedate_to_datetime all fail on UTC.

The table immediately below this example already describes the value correctly as "an HTTP date", so the example is the only thing out of step — and it is the part that gets copied.

Suggested change
Sunset: Sun, 30 Jun 2024 23:59:59 UTC
Sunset: Sun, 30 Jun 2024 23:59:59 GMT

Reference: RFC 8594 §3

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4d434aa — changed to GMT.

Worth recording how this got in: the example was copied verbatim from RFC 9745 §5, which itself shows Sunset: Sun, 30 Jun 2024 23:59:59 UTC. But Sunset is defined by RFC 8594, which specifies Sunset = HTTP-date and uses GMT in both of its own examples. So RFC 9745's example contradicts the RFC it references, and this finding is correct against the defining spec.

{
"filter": {
"and": [
{ "==": [{ "var": "status" }, "invited"] },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: This example uses lowercase "invited" where the standard's own rule calls for all caps.

Details and fix

The JSON section states that constrained values "should enumerate the valid values in all caps (e.g. RED, GREEN, BLUE) both in the OpenAPI spec and in example JSON". The sibling examples follow it — Advanced queries uses "ACTIVE" / "INVITED" and Bulk updates uses "PENDING" / "DISABLED". Only this one deviates.

Runtime behaviour is unaffected (the same section requires case-insensitive validation), but this is example JSON in a document whose stated audience includes AI agents, so it is the form most likely to be copied into a real enum definition.

Suggested change
{ "==": [{ "var": "status" }, "invited"] },
{ "==": [{ "var": "status" }, "INVITED"] },

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4d434aa — changed to "INVITED".

Also checked the rest of the page for the same slip; this was the only one.

@mike-gorman-bitwarden

Copy link
Copy Markdown
Author

All four findings from the review are addressed in 4d434aa:

  • ⚠️ Sunset header now ends in GMT — replied in thread with the detail.
  • 🎨 Bulk-delete filter now uses "INVITED".
  • 🎨 "perform a soft deletes" → "perform a soft delete".
  • 🎨 "However, think an action works" → "However, we think an action works".

The last two had no inline thread, so noting them here.

Comment on lines +749 to +750
A request that uses any other operator, or references a field that does not exist, `MUST` be
rejected with `400`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: This MUST return 400, but the page's own "400 vs. 422" rule sends this case to 422.

Details and fix

Two normative statements conflict:

  • 400 vs. 422 (line 205): "Return 400 for requests that are malformed and cannot even be parsed (e.g. invalid JSON)." Line 206: "Return 422 for invalid requests."
  • Request validation (line 868) lists "Field names specified by sort or fields parameters are valid field names" as part of exhaustive validation — which routes to 422.

A JSON Logic body using an unsupported operator, or naming a field that does not exist, parses fine as JSON. It is a semantic validation failure, and an unknown field name here is the same class of error as an unknown name in sort or fields, which the page already assigns to 422. Two services reading this will implement different status codes for the same request, which is the divergence the standard exists to prevent.

Suggested change
A request that uses any other operator, or references a field that does not exist, `MUST` be
rejected with `400`.
A request that uses any other operator, or references a field that does not exist, `MUST` be
rejected with `422`.

If 400 is deliberate here — on the grounds that the filter expression is the request rather than a field within it — then it belongs in the "400 vs. 422" section as a named exception, the way the payload-reference case is called out in 400 vs 404.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 43cc6df, and the underlying inconsistency you surfaced was resolved rather than patched.

  • Advanced queries now rejects an unsupported operator or unknown field with 422.
  • The 400 vs 404 section is now 404 vs. 422 — a payload reference that does not resolve is 422, not 400.
  • The Errors example moved to "status": "422", since it illustrates exactly that case.
  • The four disambiguation subsections were reordered into numeric sequence after the rename.

So payload-content errors are now uniformly 422 across the page, and 400 is reserved for requests that cannot be parsed.

Comment on lines +881 to +882
Errors `MUST` be returned as an array of error objects within a top-level `errors` field where each
error populates the following fields:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: "each error populates the following fields" reads as all nine being required, which the example below contradicts and the source members make impossible.

Details and fix

Two things follow from the literal reading:

  1. source.header, source.parameter, and source.pointer are alternatives — a single error is attributable to a header, or a query parameter, or a body member. They cannot all be populated.
  2. The example on lines 898-912 omits id, yet id is the one field the page later depends on: the 500 guidance below says "The id field is how a caller and an operator correlate a report with the logged detail." If id is genuinely required, the canonical example is the thing engineers copy and it is missing it.

Suggest splitting the table into the members every error carries (code, detail, id, status, title) and the source members, of which at most one applies — and adding id to the example. This is the most-copied section of any API standard, and the page names AI agents as part of its audience, so a literal reading is likely to be implemented literally.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 7a3c077 — softened the lead-in so it no longer reads as all nine fields being required, and called out that at most one source member applies:

Each error populates the following fields, except that at most one source member applies to any one error.

Also added id to the example, since the 500 guidance below it depends on id for correlation and the example was the one place it was missing.

Comment on lines -1 to -5
---
sidebar_position: 0
---

# Server Architecture

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: The published /architecture/server/* URLs can be preserved without adding a Docusaurus plugin — Cloudflare Pages reads a _redirects file.

Details and fix

The PR description notes the 404s as a known consequence, with "no redirect plugin is configured in this repo" as the reason. That is true of @docusaurus/plugin-client-redirects, but this site deploys to Cloudflare Pages, which honours a _redirects file at the root of the build output. Docusaurus copies everything in static/ to that root, so a new static/_redirects containing one line needs no dependency and no config change:

/architecture/server/* /architecture/service-oriented-architecture/:splat 301

That covers the section index, command-query-separation, and model-separation-of-concerns in one rule. Worth it because /architecture/server/command-query-separation is the kind of URL that gets pasted into PR reviews and onboarding notes outside this repo, where the in-repo link updates do not reach.

Reference: Cloudflare Pages redirects

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — done in 7a3c077. Added static/_redirects with the single splat rule you suggested.

Verified the mechanism before adopting it: Cloudflare's docs confirm _redirects belongs in the static asset directory (static/ for Docusaurus), that :splat and 301 are supported, and that the 2,000-rule cap is not a concern here. The PR description has been left to be updated, since "no redirect plugin is configured" is no longer the operative reason.

@mike-gorman-bitwarden mike-gorman-bitwarden changed the title docs: add ADR 0035, internal API standards Establish standards for internal APIs Sep 14, 2026
Comment on lines +265 to +266
The only exceptions are [advanced queries](#advanced-queries), [bulk updates](#bulk-updates), and
[bulk deletes](#bulk-deletes), which use `POST` because the request carries a body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: The "only exceptions" list omits actions, which also MUST use POST without creating a resource.

Details and fix

The table above declares that POST MUST mean "Create a resource", and this sentence claims to enumerate every exception to it. Acting upon resources is a fourth: it states "HTTP verb MUST be POST", and POST /api/v1/users/{id}/actions/send-email creates nothing. It also carries a body, so the stated rationale applies to it too.

As written, two MUSTs conflict for the one shape most likely to send a reader to this section — the non-CRUD operation the General section introduces as the "5th type of API".

Suggested change
The only exceptions are [advanced queries](#advanced-queries), [bulk updates](#bulk-updates), and
[bulk deletes](#bulk-deletes), which use `POST` because the request carries a body.
The only exceptions are [actions](#acting-upon-resources), [advanced queries](#advanced-queries),
[bulk updates](#bulk-updates), and [bulk deletes](#bulk-deletes), which use `POST` for operations
that are not creates.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7567420 — actions added to the list, and the reason reworded from "the request carries a body" to "without creating a resource", which is the property all four actually share.

Comment on lines +176 to +177
- Because our APIs are largely based on JSON:API, services `MUST` accept `application/vnd.api+json`
if provided by the client, even though the API itself only advertises `application/json`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: "accept ... if provided by the client" does not say whether it means Content-Type or Accept, and the two readings produce different status codes.

Details and fix

This bullet sits between two that each govern one header: the one above governs Accept and mandates 406, the one below governs Content-Type and mandates 415. This one says only "if provided by the client", so both readings are available:

  • Read as Content-Type: a client sending Accept: application/vnd.api+json — the default for an off-the-shelf JSON:API client — falls under the 406 rule, because the service only returns application/json.
  • Read as Accept: a client sending Content-Type: application/vnd.api+json falls under the 415 rule.

Either way one direction is rejected by a MUST, and two teams implementing from this page can land on different codes for the same request. ADR-0035 writes it as "accepts and returns the application/vnd.api+json media type" in its JSON:API-strictly con, which reads as though both directions were intended.

Suggested change
- Because our APIs are largely based on JSON:API, services `MUST` accept `application/vnd.api+json`
if provided by the client, even though the API itself only advertises `application/json`.
- Because our APIs are largely based on JSON:API, services `MUST` accept `application/vnd.api+json`
in either the `Content-Type` or the `Accept` header, even though the API itself only advertises
`application/json`. An `Accept` of `application/vnd.api+json` `MUST NOT` be rejected with `406`.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7567420 — the rule now names both directions explicitly: services MUST accept a request whose Content-Type is application/vnd.api+json, and MUST honor an Accept of application/vnd.api+json.

Comment on lines +553 to +556
APIs that support specifying multiple values for a query parameter `MUST` do so via a
comma-delimited list. This allows APIs that started with single-value query parameters to evolve to
supporting multiple-values without changing the external-facing contract. When multiple values are
provided, "or" query semantics `MUST` be applied.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: Comma is the multi-value delimiter but no escape is defined, so a value containing a comma cannot be filtered on.

Details and fix

filter[name]=Acme, Inc. parses as two values, Acme and Inc., and the standard gives no way to say otherwise. The same gap applies to a literal * under Filtering for an API that supports wildcards. Person names, organization names, and address fields routinely contain commas, so this is reachable on the first real filterable string attribute.

It also cuts against the rationale in this paragraph. Evolving a single-value parameter to multi-value does change the external-facing contract for exactly those callers: a request that previously matched the one literal value Acme, Inc. afterwards matches two.

Two ways to close it, either of which is a sentence:

  1. Define an escape for a literal delimiter, and say it applies to * as well.
  2. State that exact matches on values containing the delimiter go through advanced queries, where the value is a JSON string and needs no delimiter at all.

Option 2 costs nothing to specify and the mechanism already exists on the page — but it is worth saying out loud, since a reader hitting this will otherwise invent their own encoding.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taking this one. Defining an escape syntax for commas in filter values is more specification than the case warrants — and callers who need a value containing a comma have advanced queries, where the expression is JSON and the delimiter problem does not arise.

Comment on lines +147 to +148
To keep from having to _also_ revert all of the consumers, APIs `MUST` ignore unrecognized fields,
parameters, and headers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: An unrecognized filter[...] parameter falls under this MUST ignore rule, so a filter silently widens the result set instead of erroring.

Details and fix

The same semantic error gets opposite treatment depending on which mechanism the caller used:

  • Advanced queries (line 750): a JSON Logic expression that "references a field that does not exist" MUST be rejected with 422.
  • This rule (line 147): an unrecognized query parameter MUST be ignored — and filter[{name}] is a query parameter, with name only SHOULD-ing to match a resource attribute (Filtering, line 546).

Ignoring an unrecognized filter is not a no-op the way ignoring an unrecognized field is. GET /api/v1/users?filter[organizatonId]=X (typo, or a filter the service has not shipped yet) returns every user rather than an error, and the caller cannot tell the difference between "no filter was applied" and "everything matched". A read-many result that is broader than the caller asked for is the shape that leads to acting on records that were never meant to be selected.

Suggested change
To keep from having to _also_ revert all of the consumers, APIs `MUST` ignore unrecognized fields,
parameters, and headers.
To keep from having to _also_ revert all of the consumers, APIs `MUST` ignore unrecognized fields,
parameters, and headers. The one exception is an unrecognized `filter[{name}]` parameter, which
`MUST` be rejected with `422`: ignoring a filter silently widens the result set rather than
narrowing it.

The trade-off is worth naming: rejecting cuts against this section's rollback rationale, since a client passing a filter a rolled-back service no longer knows would now fail. If that is the preferred outcome, the alternative is equally short — say explicitly that filters are best-effort and that callers MUST NOT rely on an unrecognized filter having been applied. Either way it wants to be stated, because as written the two sections disagree and implementers will split.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and taken. The asymmetry you identified is the decisive part: ignoring an unrecognized field means the service does less than the caller asked, which is fail-safe; ignoring an unrecognized filter means it returns more, which is not.

Added to the Unrecognized fields section:

The one exception is an unrecognized filter[{name}] parameter, which MUST be rejected with 422. Ignoring an unrecognized field means doing less than the caller asked; ignoring an unrecognized filter means returning more.

On the trade-off you named — yes, this does cut against the rollback rationale, and a client passing a filter a rolled-back service no longer knows will now fail rather than degrade. We judged the failure mode of the alternative worse: a rolled-back service silently returning every record is a worse outcome than a caller getting a 422. It also makes the treatment consistent with Advanced queries, where an unknown field name is already a 422.

Commit to follow shortly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Landed in 580c68d, and generalized past the original finding. Rather than carving out filter alone, the exception now covers every parameter that shapes the result:

The exception is a parameter that shapes the result. Ignoring an unrecognized field means doing less than the caller asked; ignoring one of these means returning something other than what was asked for. APIs MUST reject the request with 422 when given:

  • a filter[{name}], sort, or fields parameter naming a field they do not support, or
  • paging parameters when the API does not support paging, or for a paging style it does not implement.

sort and fields have the same fail-open shape you described for filter — an ignored fields returns every field, an ignored sort returns arbitrary order — and mismatched paging parameters were a third case in the same family that the standard had not addressed at all.

Comment on lines +69 to +70
- Fields whose value is set by the server `MUST` be marked as `readOnly`. If a request includes a
read-only field, the API `MUST` ignore it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: This MUST makes version readOnly, which Optimistic concurrency explicitly forbids.

Details and fix

version is set by the server — "Upon successfully updating the resource, the API MUST update the version identifier" (line 471) — so this rule requires it to be marked readOnly, and the second sentence then requires the API to ignore it on the way in. Line 466 requires the opposite: "a version field marked required and not readOnly".

The consequence is not just editorial. The Request validation section anticipates the SDK stripping non-conforming input before it reaches developer code; a framework applying this rule generically would strip version from requests, and the 409 on line 470 would then never fire. A lost update that silently succeeds is the failure mode optimistic concurrency exists to prevent.

version is the only field with this property — createdAt, updatedAt, createdBy, and updatedBy are all server-set and genuinely read-only — so a named carve-out closes it:

Suggested change
- Fields whose value is set by the server `MUST` be marked as `readOnly`. If a request includes a
read-only field, the API `MUST` ignore it.
- Fields whose value is set by the server `MUST` be marked as `readOnly`, the one exception being
`version` — see [Optimistic concurrency](#optimistic-concurrency). If a request includes a
read-only field, the API `MUST` ignore it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taking this one — it is a deliberate decision rather than an oversight.

The contradiction is real and was raised in review before this PR was opened. The chosen resolution was to keep the general rule simple and handle the exception where version is actually discussed, which is why Optimistic concurrency reads "marked required and not readOnly". Specific rules superseding general ones is a normal reading convention, and the alternative means carving a named exception into a general rule for the single field it affects.

The framework argument was also on the table at the time — a generator or SDK applying the readOnly rule generically could strip version and silently disable the 409. That is a real risk, and the answer is that the implementation follows the specific rule, not that the general rule grows a clause.

Comment on lines +154 to +156
- a `filter[{name}]`, `sort`, or `fields` parameter naming a field they do not support, or
- paging parameters when the API does not support paging, or for a paging style it does not
implement.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: permanent=true is the one fail-open parameter this list misses — an ignored hard-delete request silently leaves the data recoverable.

Details and fix

The new exception covers filter, sort, fields, and paging. It does not cover the two parameters in Soft deletes, and permanent fails in the same direction the exception exists to prevent — worse, in fact, because the divergence is in the effect rather than the result set.

Soft deletes is MAY/SHOULD guidance, so a service that soft-deletes but never implemented hard deletes treats permanent as unrecognized. Then:

  1. Caller sends DELETE /api/v1/users/123?permanent=true.
  2. Line 147 applies — unrecognized parameter, MUST ignore.
  3. Service soft-deletes and returns 204, per line 494.
  4. The caller has an explicit success for an erasure that did not happen, and no way to tell.

For a delete requested as permanent — the shape a retention or subject-erasure request takes — "returning something other than what was asked for" understates it: the record is still there. includeDeleted=true has the same gap but fails the safe way (fewer records than asked), so it is worth naming in the same bullet rather than a separate rule.

Suggested change
- a `filter[{name}]`, `sort`, or `fields` parameter naming a field they do not support, or
- paging parameters when the API does not support paging, or for a paging style it does not
implement.
- a `filter[{name}]`, `sort`, or `fields` parameter naming a field they do not support, or
- paging parameters when the API does not support paging, or for a paging style it does not
implement, or
- a `permanent` or `includeDeleted` parameter when the API does not implement it. Silently
soft-deleting a resource the caller asked to delete permanently reports an erasure that did not
happen.

Comment on lines +594 to +595
APIs `SHOULD` support paging and those that do `MUST` support either **offset/limit** style paging
or **cursor** style paging.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: page[size] has no upper bound, so page[size]=1000000 is a conforming request the service must honor.

Details and fix

Neither paging style documents a maximum or a default for page[size], and the page opens with "Where our standards are silent, JSON:API standards are assumed" — JSON:API is silent here too. So a conforming service has no basis to refuse an arbitrarily large page, and a caller has no basis to expect one to be refused.

Two things follow that the standard otherwise prevents:

  1. Services diverge. One clamps to 200, one returns the lot, one 422s. A caller that works against the first two breaks against the third.
  2. A single unauthenticated-at-the-edge-but-service-to-service call can pull an entire table into memory, on the read-many endpoint that every resource has.

The second bullet matters more now that line 152 makes mismatched paging parameters a 422 — the page has taken a position on paging parameters it cannot honor, but not on one it can honor and should not.

One sentence in this section closes it, e.g.:

APIs that support paging MUST document a maximum page[size] and a default applied when the caller omits it. A request exceeding the maximum MUST be clamped to it rather than rejected, and the applied size MUST be reported in meta.pageSize.

Clamping rather than rejecting keeps it consistent with meta.pageSize already being described as "the current page size" rather than the requested one.

Comment thread static/_redirects
@@ -0,0 +1 @@
/architecture/server/* /architecture/service-oriented-architecture/:splat 301

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: The splat does not match the section index itself, so /architecture/server still 404s.

Details and fix

/architecture/server/* requires the literal prefix /architecture/server/ — trailing slash included — before the splat matches. A request for /architecture/server is one character short of that prefix and falls through to a 404, because the asset that used to answer it (docs/architecture/server/index.md) is deleted in this PR.

That bare path is the published URL, not a variant: docusaurus.config.js sets no trailingSlash, so Docusaurus emits /architecture/server in links and the sitemap. It is also the URL for the section landing page — the one most likely to have been bookmarked or linked from outside this repo, which is the case the redirect was added for.

The child pages are fine; /architecture/server/command-query-separation matches. Only the index is affected, and one more line covers it:

Suggested change
/architecture/server/* /architecture/service-oriented-architecture/:splat 301
/architecture/server /architecture/service-oriented-architecture/ 301
/architecture/server/* /architecture/service-oriented-architecture/:splat 301

More specific rule first, since Cloudflare applies the first match.

Reference: Cloudflare Pages redirects

@theMickster
theMickster marked this pull request as draft September 15, 2026 08:30
@mike-gorman-bitwarden

Copy link
Copy Markdown
Author

Superseded by #865, which carries the same ADR and standard as ADR-0036, without the directory rename.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adr ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant