From fed99cba59a3b04d9e5a30dcf3f6354e94fc9024 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 10 Jul 2026 17:14:20 +0700 Subject: [PATCH 01/19] docs: add cross-BB API design guide (draft) as standalone GitBook Signed-off-by: Jeremi Joslin --- README.md | 1 + api-design-guide/.gitbook.yaml | 5 + api-design-guide/.gitignore | 1 + api-design-guide/1-introduction.md | 119 ++ api-design-guide/README.md | 46 + api-design-guide/SUMMARY.md | 55 + api-design-guide/all-rules.md | 269 +++ .../appendix/a-companion-documents.md | 17 + api-design-guide/appendix/b-open-questions.md | 37 + .../appendix/c-normative-references.md | 36 + api-design-guide/guides/README.md | 20 + .../guides/maintaining-this-guide.md | 43 + .../guides/spec-editor-checklist.md | 46 + .../guides/using-with-ai-agents.md | 31 + .../guides/validating-your-spec.md | 40 + api-design-guide/how-to-use-this-guide.md | 58 + .../part-a/2-openapi-document-standards.md | 45 + .../part-a/3-asyncapi-document-standards.md | 47 + .../part-a/4-documentation-requirements.md | 27 + .../part-b/5-url-structure-and-versioning.md | 56 + api-design-guide/part-b/6-http-methods.md | 39 + .../part-b/7-http-status-codes.md | 91 + api-design-guide/part-b/8-headers.md | 39 + .../part-c/10-data-types-and-formats.md | 51 + api-design-guide/part-c/11-errors.md | 66 + .../part-c/12-pagination-filtering-sorting.md | 79 + .../part-c/9-json-conventions-and-naming.md | 71 + .../13-authentication-and-authorisation.md | 39 + api-design-guide/part-d/14-idempotency.md | 37 + .../part-d/15-asynchronous-operations.md | 51 + .../part-d/16-cloudevents-and-webhooks.md | 75 + .../part-d/17-asyncapi-channel-rules.md | 91 + .../part-d/18-compatibility-and-lifecycle.md | 43 + api-design-guide/part-e/19-localisation.md | 29 + .../part-e/20-conformance-and-validation.md | 27 + api-design-guide/rules.yaml | 1503 +++++++++++++++++ api-design-guide/tools/build_rules_index.py | 497 ++++++ api-design-guide/tools/check_links.py | 386 +++++ api-design-guide/version-history.md | 38 + 39 files changed, 4251 insertions(+) create mode 100644 api-design-guide/.gitbook.yaml create mode 100644 api-design-guide/.gitignore create mode 100644 api-design-guide/1-introduction.md create mode 100644 api-design-guide/README.md create mode 100644 api-design-guide/SUMMARY.md create mode 100644 api-design-guide/all-rules.md create mode 100644 api-design-guide/appendix/a-companion-documents.md create mode 100644 api-design-guide/appendix/b-open-questions.md create mode 100644 api-design-guide/appendix/c-normative-references.md create mode 100644 api-design-guide/guides/README.md create mode 100644 api-design-guide/guides/maintaining-this-guide.md create mode 100644 api-design-guide/guides/spec-editor-checklist.md create mode 100644 api-design-guide/guides/using-with-ai-agents.md create mode 100644 api-design-guide/guides/validating-your-spec.md create mode 100644 api-design-guide/how-to-use-this-guide.md create mode 100644 api-design-guide/part-a/2-openapi-document-standards.md create mode 100644 api-design-guide/part-a/3-asyncapi-document-standards.md create mode 100644 api-design-guide/part-a/4-documentation-requirements.md create mode 100644 api-design-guide/part-b/5-url-structure-and-versioning.md create mode 100644 api-design-guide/part-b/6-http-methods.md create mode 100644 api-design-guide/part-b/7-http-status-codes.md create mode 100644 api-design-guide/part-b/8-headers.md create mode 100644 api-design-guide/part-c/10-data-types-and-formats.md create mode 100644 api-design-guide/part-c/11-errors.md create mode 100644 api-design-guide/part-c/12-pagination-filtering-sorting.md create mode 100644 api-design-guide/part-c/9-json-conventions-and-naming.md create mode 100644 api-design-guide/part-d/13-authentication-and-authorisation.md create mode 100644 api-design-guide/part-d/14-idempotency.md create mode 100644 api-design-guide/part-d/15-asynchronous-operations.md create mode 100644 api-design-guide/part-d/16-cloudevents-and-webhooks.md create mode 100644 api-design-guide/part-d/17-asyncapi-channel-rules.md create mode 100644 api-design-guide/part-d/18-compatibility-and-lifecycle.md create mode 100644 api-design-guide/part-e/19-localisation.md create mode 100644 api-design-guide/part-e/20-conformance-and-validation.md create mode 100644 api-design-guide/rules.yaml create mode 100644 api-design-guide/tools/build_rules_index.py create mode 100644 api-design-guide/tools/check_links.py create mode 100644 api-design-guide/version-history.md diff --git a/README.md b/README.md index e824f65..94c490e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ and deployment from the `/spec` directory. ```sh README.md /spec # the markdown files which are used to build the specification in GitBook +/api-design-guide # the GovStack Cross-BB API Design Guide (its own GitBook space; see api-design-guide/README.md) /api # the openapi specification /test # the test plan and tests plan.md diff --git a/api-design-guide/.gitbook.yaml b/api-design-guide/.gitbook.yaml new file mode 100644 index 0000000..6acdd6a --- /dev/null +++ b/api-design-guide/.gitbook.yaml @@ -0,0 +1,5 @@ +root: ./ + +structure: + readme: README.md + summary: SUMMARY.md diff --git a/api-design-guide/.gitignore b/api-design-guide/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/api-design-guide/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md new file mode 100644 index 0000000..6077ee1 --- /dev/null +++ b/api-design-guide/1-introduction.md @@ -0,0 +1,119 @@ +--- +description: "Purpose, scope, layering, and enforcement model of the GovStack Cross-BB API Design Guide." +--- + +# 1. Introduction + +## 1.1 Purpose + +This guide defines the cross-BB interface rules that make GovStack API specifications internally consistent, machine-validatable, and implementable from the spec alone. + +## 1.2 Scope + +This guide constrains **what each BB's API specification declares**, and the **interface-level behavioural contract** that follows from it. + +The test for inclusion: *would two BB editors writing two different specs need to agree on this for an integrator to consume both without per-BB glue?* + +**In scope:** + +- REST APIs over HTTP, documented in OpenAPI 3.1. +- Event payloads and metadata, standardised as CloudEvents across HTTP and non-HTTP transports. +- Event-driven APIs over brokered transports and event streams, documented in AsyncAPI 3.0 (MQTT, AMQP, Kafka, WebSockets, SSE). +- Webhook subscriptions, documented via OpenAPI 3.1 `webhooks` for HTTP push. +- The companion files `govstack-openapi-common.yaml` (REST security scheme, error schema, pagination components, common headers, Operation resource) and `govstack-asyncapi-common.yaml` (event envelope, message headers, security schemes, signing metadata, delivery declarations, common error messages) that BBs reference. + +CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally lighter in v0.1: BBs must declare the relevant bindings where they affect the contract, while detailed broker-operation guidance belongs in the Security & Operations companion or a later protocol profile. The 2026 audit is OpenAPI-centric only because the current BB set is predominantly REST, not because the ecosystem should remain so. + +The decision tree below summarises which artifact documents which kind of surface (informative). The document-level rules are in [§2](part-a/2-openapi-document-standards.md) and [§3](part-a/3-asyncapi-document-standards.md); the event rules are in [§16](part-d/16-cloudevents-and-webhooks.md) and [§17](part-d/17-asyncapi-channel-rules.md). + +```mermaid +flowchart TD + Q{"What kind of API surface?"} -->|"Synchronous HTTP request-response"| R["REST: OpenAPI 3.1
canonical file at api/openapi.yaml"] + Q -->|"HTTP push to subscriber URLs"| W["Webhooks: OpenAPI 3.1 webhooks section"] + Q -->|"Brokered channels or event streams
(MQTT, AMQP, Kafka, WebSockets, SSE)"| A["AsyncAPI 3.0
canonical file at api/asyncapi.yaml"] + W --> CE["Domain events use the CloudEvents v1.0.2 envelope"] + A --> CE +``` + +A BB MAY additionally expose surfaces under other industry standards (for example, an OGC API surface for spatial data, an OID4VCI surface for credential issuance, or an SDMX surface for statistical interchange). This is whole-surface adoption: the external standard governs that surface where it conflicts with this guide. [§1.7](#17-precedence-of-external-standards) covers the narrower case where an external standard or convention governs specific fields or envelopes inside a GovStack API surface. The guide's cross-cutting rules that do not conflict with the external standard still apply: [§8.6](part-b/8-headers.md#86-no-personal-data-in-addressable-locations) (no personal data in addressable locations), [§13](part-d/13-authentication-and-authorisation.md) (declaring a security scheme), [§11](part-c/11-errors.md) (a consistent error envelope where the external standard defines none), and [§18](part-d/18-compatibility-and-lifecycle.md) (versioning and deprecation). A surface is exempt from a specific rule only where the adopted standard actually governs that rule. + +**Out of scope:** + +- **Operational behaviour of a deployed BB** (token validation, certificate trust, key rotation, replay enforcement, audit logging, log redaction, alg allowlists, FAPI conformance, infrastructure). Belongs in a separate **GovStack API Security & Operations** companion (not yet drafted). +- **Ecosystem governance** (ratification, enforcement, exception lifecycle, transition timelines, conformance levels, companion-artifact ownership). Expected to be defined in the proposed **GovStack API Lifecycle & Governance** companion document, reconciled with the existing GovStack Specification Framework and CFR compliance model. +- Performance, SLOs, capacity planning. +- gRPC, GraphQL, file protocols, bulk media streaming. +- Implementation guidance for any specific BB. +- Design and maintenance of conformance test packs (separate companion artifact). + +## 1.3 Relationship to existing GovStack documents + +Where this guide overlaps with existing GovStack requirements or BB-specific conventions, precedence must be settled through ratification and reconciliation with the existing GovStack Specification Framework and CFR compliance model. A full reconciliation matrix will accompany v1.0. + +## 1.4 Audience + +1. **Primary.** GovStack BB specification editors (the people writing the OpenAPI files). +2. **Secondary.** Implementers reading a BB specification to build a compliant system. +3. **Tertiary.** Country teams adopting BBs into a national architecture. + +The guide is written for lookup, not end-to-end reading. Sections are self-contained where possible. + +## 1.5 Language + +The guide uses RFC 2119 language: **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **MAY**. A MUST rule is mandatory for conformance. The companion Spectral ruleset enforces the machine-checkable subset; rules that require judgement are assessed through review and the conformance process. Each rule is tagged with an enforcement class ([§1.9](#19-rule-enforcement-classes)) that says which side of that line it falls on, and [§1.8](#18-layering-what-this-guide-constrains) says which layer it constrains. A SHOULD rule is expected by default and requires written justification to skip. A MAY rule is optional. + +## 1.6 Exception process + +A BB editor MAY propose deviating from a MUST rule via the exception process to be defined in the proposed **GovStack API Lifecycle & Governance** companion document. The lifecycle (submission, review, expiry, public log) is governance, not design, and belongs there once ratified. + +## 1.7 Precedence of external standards + +Where this guide adopts an external standard or convention on the OpenAPI 3.1, CloudEvents, or AsyncAPI 3.0 surface, that standard or convention takes precedence over the guide's generic rules for the fields or payloads it covers. The known precedences in v0.1 are: + +- RFC 9457 fields inside error envelopes ([§11.1](part-c/11-errors.md#111-rfc-9457-problem-details); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). +- CloudEvents fields inside event envelopes ([§16.2](part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). +- GovStack-adopted health-check enum values derived from `draft-inadarei-api-health-check` inside `/health` responses ([§5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint); carve-out on [§9.7](part-c/9-json-conventions-and-naming.md#carve-out-from-97)). +- OAuth 2.0 / OIDC field shapes inside tokens, claims, and discovery documents ([§13.2](part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations)). +- Code-list standards (ISO 3166-1, ISO 4217, BCP 47, E.164) inside their respective fields ([§10.5](part-c/10-data-types-and-formats.md#105-e164-phone-numbers)–[§10.10](part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)). + +Each precedence **MUST** be named explicitly at the rule it overrides. Beyond the list above, BBs are encouraged to adopt well-established external standards rather than reinvent shapes for problems those standards already solve; the precedence list will grow as such cases are recognised. A BB adopting a standard not yet listed **SHOULD** declare the precedence in its spec at the rule it covers, and **SHOULD** raise the adoption with the API Working Group so it can be added to the next guide revision and propagated to other BBs facing the same problem. Where the adoption conflicts with an existing MUST rule, the exception process ([§1.6](#16-exception-process)) is the formal path. + +## 1.8 Layering: what this guide constrains + +This guide sits at the top of a stack, and keeping the layers distinct is what stops a design rule from drifting into an implementation decision: + +- **This guide** fixes the *shape* of every BB API (the rules below). It is generic and binds the spec author. +- **A BB specification** (the OpenAPI/AsyncAPI document for one BB) fills that shape with concrete content: which resources, fields, status codes, scopes, and error names that BB has. The guide constrains the shape; the BB spec owns the content. +- **An implementation profile** records the concrete deployment values a spec deliberately leaves open: server URLs, identity-provider endpoints, replay windows, retry counts, retention periods. The guide never fixes these. +- **A running deployment** has operational behaviour (token validation, key rotation, replay enforcement, logging) that no specification expresses. That is the **GovStack API Security & Operations** companion's domain ([§1.2](#12-scope)). + +```mermaid +flowchart TB + G["This guide
fixes the shape of every BB API"] --> S["BB specification
fills the shape with one BB's content"] + S --> P["Implementation profile
records deployment values the spec leaves open"] + P --> D["Running deployment
operational behaviour, owned by the Security and Operations companion"] +``` + +A rule earns a place in this guide only if it constrains the specification document. Every rule is therefore one of three kinds: + +1. **Spec-shape** rules constrain what the spec declares and are verifiable by reading the file (for example, [§9.2](part-c/9-json-conventions-and-naming.md#92-camelcase-field-names) camelCase, [§11.1](part-c/11-errors.md#111-rfc-9457-problem-details) `application/problem+json`). +2. **Documentation-obligation** rules require the spec to write down a contract whose value the guide does not itself fix (for example, [§14.3](part-d/14-idempotency.md#143-documented-replay-window), which makes the spec document its replay-window contract, and [§16.10](part-d/16-cloudevents-and-webhooks.md#1610-documented-delivery-failure-contract)). +3. **Behavioural-contract** rules state run-time behaviour an integrator relies on across BBs (for example, [§14.4](part-d/14-idempotency.md#144-replay-returns-original-response) idempotent replay, [§19.1](part-e/19-localisation.md#191-honour-the-request-language) honouring the request language). They are part of the interface contract but cannot be linted from the spec; they are verified by the conformance test pack ([Appendix A](appendix/a-companion-documents.md)). + +What the guide does **not** do is mandate a concrete deployment value (an implementation profile's job) or operational behaviour (the Security & Operations companion's job). Where a section mixes the three kinds, a **Layer** note at the top of that section says which rules fall where. + +This axis is orthogonal to the enforcement class of [§1.9](#19-rule-enforcement-classes). Spec-shape rules are usually `[M]` or `[M+R]`; behavioural-contract rules are `[R]`, because no linter can reach run-time behaviour, though the rule is no less binding and is still verified through conformance testing. + +## 1.9 Rule enforcement classes + +Each numbered rule carries an enforcement-class tag, shown as a bold badge at the start of the rule text, that says how conformance is checked: + +- **`[M]` Machine-checkable.** A linter (the GovStack Spectral ruleset, [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) or a schema validator can verify the rule from the specification document alone. Conformance is mechanical and unambiguous. +- **`[R]` Review.** The rule requires human judgement; no reliable automated check exists. Conformance is assessed through specification review and the conformance process. +- **`[M+R]` Partly machine-checkable.** A linter can verify the structural part (presence, shape, naming, declared values), but a human reviewer must confirm the semantic part: whether the right construct was used for the right meaning. + +The tag is guidance for the ruleset author and the conformance process, not part of the normative requirement: an `[M]` MUST and an `[R]` MUST are equally binding. The tag only marks where mechanical enforcement ends and review begins. Purely informative or scoping statements (for example [§12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope), [§16.9](part-d/16-cloudevents-and-webhooks.md#169-operational-signing-concerns-out-of-scope), [§18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields)) carry no tag. The machine-checkable subset that [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) expects the Spectral ruleset to cover is the `[M]` rules plus the mechanical portion of the `[M+R]` rules. Enforceability of a given rule also depends on the companion artifacts it references ([§2.8](part-a/2-openapi-document-standards.md#28-pinned-vendored-common-components), [§11.7](part-c/11-errors.md#117-common-error-catalogue), [§15.2](part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape), [§16.8](part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) actually existing; sequencing that against the pilot and ratification plan is governance, not design. + +## 1.10 Applicability and transition + +This guide applies in full to new API surfaces and to new major versions of existing surfaces. An already-published BB specification is not retroactively non-conformant: it is expected to reach conformance at its next major version, and bringing a wire contract into conformance is itself a breaking change ([§18](part-d/18-compatibility-and-lifecycle.md), [note on retrofitting](part-d/18-compatibility-and-lifecycle.md#note-on-retrofitting)). The transition schedule, conformance levels, and enforcement for existing BBs are governance questions for the GovStack API Lifecycle & Governance companion ([Appendix A](appendix/a-companion-documents.md)). The guide itself is versioned with SemVer: a guide minor release only adds rules or relaxes existing ones; removals or strengthened requirements arrive only in a guide major release. Each BB spec declares the guide version it conforms to ([§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)). diff --git a/api-design-guide/README.md b/api-design-guide/README.md new file mode 100644 index 0000000..2c61165 --- /dev/null +++ b/api-design-guide/README.md @@ -0,0 +1,46 @@ +--- +description: "The rules every GovStack Building Block API specification must follow, so that BBs compose into a consistent national platform." +--- + +# GovStack Cross-BB API Design Guide + +{% hint style="warning" %} +**Status: DRAFT v0.2, for GovStack committee feedback.** This guide is not yet ratified. It supersedes the v0.1 document circulated on 2026-05-31; the [version history](version-history.md) lists every change, and [How to use this guide](how-to-use-this-guide.md) maps the old section numbers to the new ones. +{% endhint %} + +**Author:** Jeremi Joslin · **Date:** 2026-07-10 + +## Start here + +- **Editing a BB specification?** Run the [spec editor checklist](guides/spec-editor-checklist.md) against your spec, use [Rules at a glance](all-rules.md) to jump to any rule, and [validate mechanically](guides/validating-your-spec.md) before review. +- **Reviewing this draft for the committee?** [How to use this guide](how-to-use-this-guide.md) says what feedback is most useful at this stage; [Appendix B](appendix/b-open-questions.md) is the decision agenda, with the questions that block v1.0 marked. +- **Pointing an AI coding agent at the rules?** The book ships a machine-readable index of every rule (`rules.yaml`, at the root of this folder in the repository); [Using this guide with AI agents](guides/using-with-ai-agents.md) has a ready-made instruction block for a BB repository. + +## Executive summary + +GovStack has standardised a great deal, but never a single API design guide that every Building Block follows. In its absence each BB team made reasonable local choices that, predictably, diverged. A 2026 cross-BB audit mapped that divergence across all 15 BBs and is the evidence base for every rule below: this guide closes documented gaps, not hypothetical ones. + +The GovStack Cross-BB API Design Guide defines the rules every Building Block API specification must follow, so that an integrator combining several BBs into a national digital platform sees consistent shapes for authentication, errors, identifiers, pagination, events, and lifecycle. It governs OpenAPI 3.1 REST surfaces, CloudEvents event payloads, OpenAPI webhooks, and AsyncAPI 3.0 documentation for brokered event channels and event streams. Operational behaviour (token validation, key rotation, audit logging) and ecosystem governance (ratification, enforcement, exception lifecycle) are out of scope. + +The pay-off is interoperability by construction. A canonical, machine-validatable, consistently shaped specification lets human implementers and AI coding agents generate correct clients or servers from the spec alone; an ambiguous or divergent one yields plausible-but-wrong code that quietly breaks interoperability. A guide precise enough for a linter to enforce is precise enough for an agent to implement. + +This draft is intended to be stress-tested immediately against live specification work, so the rules can be checked for clarity, enforceability, and implementability without excessive ceremony. Lessons from those pilots should feed back into v1.0 before ratification. + +The substantive rules establish: + +- Canonical, machine-validatable OpenAPI and AsyncAPI entrypoints at known locations ([§2](part-a/2-openapi-document-standards.md), [§3](part-a/3-asyncapi-document-standards.md)). +- REST-style URLs with major versions in the path, plural-noun resource names, standard HTTP verb semantics, and an unversioned `/health` endpoint ([§5](part-b/5-url-structure-and-versioning.md)–[§6](part-b/6-http-methods.md)). +- Standard HTTP status codes used consistently, with `ETag` / `If-Match` for optimistic concurrency ([§7](part-b/7-http-status-codes.md)). +- Standard headers for authentication, idempotency, localisation, correlation, and rate limiting; no personal data in URLs, channel addresses, routing keys, or message headers ([§8](part-b/8-headers.md), [§17](part-d/17-asyncapi-channel-rules.md)). +- `camelCase` JSON, RFC 3339 timestamps, decimal-string monetary amounts, E.164 phone numbers, ISO code lists for country / currency / language ([§9](part-c/9-json-conventions-and-naming.md)–[§10](part-c/10-data-types-and-formats.md)). +- One ecosystem-wide error format (RFC 9457 Problem Details, which obsoletes RFC 7807) with GovStack extensions for stable error codes, trace IDs, and field-level validation ([§11](part-c/11-errors.md)). +- Cursor-based pagination by default, with a single envelope shape for collection responses ([§12](part-c/12-pagination-filtering-sorting.md)). +- OAuth 2.0 + OIDC for citizen-facing operations, mutual TLS or OAuth client credentials for BB-to-BB calls ([§13](part-d/13-authentication-and-authorisation.md)). +- An `Idempotency-Key` contract for retry-safe POSTs ([§14](part-d/14-idempotency.md)). +- A single async pattern: `202 Accepted` plus an Operation resource referenced from a shared YAML file ([§15](part-d/15-asynchronous-operations.md)). +- CloudEvents as the normative event envelope and type/source model across transports; OpenAPI `webhooks` and AsyncAPI 3.0 document the event surfaces ([§16](part-d/16-cloudevents-and-webhooks.md), [§17](part-d/17-asyncapi-channel-rules.md)). +- SemVer with major versions visible in the relevant surface contract, additive minor changes, deprecation and sunset headers ([§18](part-d/18-compatibility-and-lifecycle.md)). +- A single language model based on `Accept-Language` and `Content-Language` ([§19](part-e/19-localisation.md)). +- Mechanical validation: every BB spec MUST pass schema validation and the machine-checkable GovStack Spectral ruleset rules ([§20](part-e/20-conformance-and-validation.md)). + +Where the guide adopts an external standard or convention (RFC 9457, CloudEvents, OAuth 2.0 + OIDC, the health-check response convention, ISO code lists), that standard or convention takes precedence over the guide's generic rules ([§1.7](1-introduction.md#17-precedence-of-external-standards)). Rules use RFC 2119 keywords (see [§1.5](1-introduction.md#15-language)). Open design calls are consolidated in [Appendix B](appendix/b-open-questions.md). diff --git a/api-design-guide/SUMMARY.md b/api-design-guide/SUMMARY.md new file mode 100644 index 0000000..36b7f7e --- /dev/null +++ b/api-design-guide/SUMMARY.md @@ -0,0 +1,55 @@ +# Table of contents + +* [GovStack Cross-BB API Design Guide](README.md) +* [How to use this guide](how-to-use-this-guide.md) +* [Rules at a glance](all-rules.md) +* [Version history](version-history.md) +* [1. Introduction](1-introduction.md) + +## Part A. API artifacts + +* [2. OpenAPI document standards](part-a/2-openapi-document-standards.md) +* [3. AsyncAPI document standards](part-a/3-asyncapi-document-standards.md) +* [4. Documentation requirements](part-a/4-documentation-requirements.md) + +## Part B. The API surface + +* [5. URL structure and versioning](part-b/5-url-structure-and-versioning.md) +* [6. HTTP methods](part-b/6-http-methods.md) +* [7. HTTP status codes](part-b/7-http-status-codes.md) +* [8. Headers](part-b/8-headers.md) + +## Part C. Data + +* [9. JSON conventions and naming](part-c/9-json-conventions-and-naming.md) +* [10. Data types and formats](part-c/10-data-types-and-formats.md) +* [11. Errors](part-c/11-errors.md) +* [12. Pagination, filtering, sorting](part-c/12-pagination-filtering-sorting.md) + +## Part D. Behaviour + +* [13. Authentication and authorisation](part-d/13-authentication-and-authorisation.md) +* [14. Idempotency](part-d/14-idempotency.md) +* [15. Asynchronous operations](part-d/15-asynchronous-operations.md) +* [16. CloudEvents and webhooks](part-d/16-cloudevents-and-webhooks.md) +* [17. AsyncAPI channel documentation rules](part-d/17-asyncapi-channel-rules.md) +* [18. Compatibility and lifecycle](part-d/18-compatibility-and-lifecycle.md) + +## Part E. Cross-cutting + +* [19. Localisation](part-e/19-localisation.md) +* [20. Conformance and validation](part-e/20-conformance-and-validation.md) + +## Appendices + +* [Appendix A. Companion documents and artifacts](appendix/a-companion-documents.md) +* [Appendix B. Open questions (consolidated)](appendix/b-open-questions.md) +* [Appendix C. Normative references](appendix/c-normative-references.md) + +## Guides + +* [About these guides](guides/README.md) +* [Spec editor checklist](guides/spec-editor-checklist.md) +* [Validating your spec](guides/validating-your-spec.md) +* [Using this guide with AI agents](guides/using-with-ai-agents.md) +* [Maintaining this guide](guides/maintaining-this-guide.md) diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md new file mode 100644 index 0000000..17d60c2 --- /dev/null +++ b/api-design-guide/all-rules.md @@ -0,0 +1,269 @@ +--- +description: "Every rule in the guide: enforcement class, RFC 2119 strength, surface, and a link." +--- + +# Rules at a glance + +This page is generated from the section pages by `tools/build_rules_index.py`; do not edit it by hand. Class legend: `[M]` machine-checkable, `[R]` review, `[M+R]` both; see [§1.9](1-introduction.md#19-rule-enforcement-classes). + +## 2. OpenAPI document standards + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [2.1](part-a/2-openapi-document-standards.md#21-openapi-310-required) | M | MUST | OpenAPI | OpenAPI 3.1.0 required | +| [2.2](part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint) | M+R | MUST | OpenAPI | One canonical OpenAPI entrypoint | +| [2.3](part-a/2-openapi-document-standards.md#23-no-divergent-openapi-copies) | R | MUST | OpenAPI | No divergent OpenAPI copies | +| [2.4](part-a/2-openapi-document-standards.md#24-passes-openapi-spec-validator) | M | MUST | OpenAPI | Passes openapi-spec-validator | +| [2.5](part-a/2-openapi-document-standards.md#25-complete-info-block) | M | MUST | OpenAPI | Complete info block | +| [2.6](part-a/2-openapi-document-standards.md#26-meaningful-servers-block) | M+R | MUST | OpenAPI | Meaningful servers block | +| [2.7](part-a/2-openapi-document-standards.md#27-complete-operation-metadata) | M+R | MUST | OpenAPI | Complete operation metadata | +| [2.8](part-a/2-openapi-document-standards.md#28-pinned-vendored-common-components) | M | MUST | OpenAPI | Pinned vendored common components | + +## 3. AsyncAPI document standards + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [3.1](part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required) | M | MUST | AsyncAPI | AsyncAPI 3.0.0 required | +| [3.2](part-a/3-asyncapi-document-standards.md#32-one-canonical-asyncapi-entrypoint) | M+R | MUST | AsyncAPI | One canonical AsyncAPI entrypoint | +| [3.3](part-a/3-asyncapi-document-standards.md#33-no-divergent-asyncapi-copies) | R | MUST | AsyncAPI | No divergent AsyncAPI copies | +| [3.4](part-a/3-asyncapi-document-standards.md#34-passes-an-asyncapi-validator) | M | MUST | AsyncAPI | Passes an AsyncAPI validator | +| [3.5](part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block) | M | MUST | AsyncAPI | Complete AsyncAPI info block | +| [3.6](part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages) | M+R | MUST | AsyncAPI | Servers channels operations and messages | +| [3.7](part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata) | M+R | MUST | AsyncAPI | Complete AsyncAPI operation metadata | +| [3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components) | M | MUST | AsyncAPI | Pinned vendored AsyncAPI components | +| [3.9](part-a/3-asyncapi-document-standards.md#39-json-schema-payload-conventions) | M | MUST | AsyncAPI | JSON Schema payload conventions | + +## 4. Documentation requirements + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [4.1](part-a/4-documentation-requirements.md#41-every-schema-described) | M | MUST | Universal | Every schema described | +| [4.2](part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums) | M+R | MUST | Universal | Examples for bodies and enums | +| [4.3](part-a/4-documentation-requirements.md#43-no-placeholder-text) | M+R | MUST | Universal | No placeholder text | +| [4.4](part-a/4-documentation-requirements.md#44-accurate-operation-descriptions) | R | MUST | Universal | Accurate operation descriptions | + +## 5. URL structure and versioning + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [5.1](part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path) | M | MUST | OpenAPI | Major version in the path | +| [5.2](part-b/5-url-structure-and-versioning.md#52-plural-noun-resources) | M+R | MUST | OpenAPI | Plural noun resources | +| [5.3](part-b/5-url-structure-and-versioning.md#53-kebab-case-path-segments) | M | MUST | OpenAPI | Kebab-case path segments | +| [5.4](part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | M | SHOULD | OpenAPI | Shallow path nesting | +| [5.5](part-b/5-url-structure-and-versioning.md#55-identifiers-as-path-parameters) | M+R | MUST | OpenAPI | Identifiers as path parameters | +| [5.6](part-b/5-url-structure-and-versioning.md#56-query-parameter-naming) | M | MUST | OpenAPI | Query parameter naming | +| [5.7](part-b/5-url-structure-and-versioning.md#57-no-verbs-in-crud-paths) | M+R | MUST | OpenAPI | No verbs in CRUD paths | +| [5.8](part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources) | R | MUST | OpenAPI | Actions as sub-resources | +| [5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | M+R | MUST | OpenAPI | Unversioned health endpoint | + +## 6. HTTP methods + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [6.1](part-b/6-http-methods.md#61-get-is-safe-and-idempotent) | M+R | MUST | OpenAPI | GET is safe and idempotent | +| [6.2](part-b/6-http-methods.md#62-post-creates-or-performs-actions) | R | — | OpenAPI | POST creates or performs actions | +| [6.3](part-b/6-http-methods.md#63-put-replaces-the-entire-resource) | R | MUST | OpenAPI | PUT replaces the entire resource | +| [6.4](part-b/6-http-methods.md#64-patch-uses-json-merge-patch) | M+R | MUST | OpenAPI | PATCH uses JSON Merge Patch | +| [6.5](part-b/6-http-methods.md#65-delete-response-semantics) | M+R | MUST | OpenAPI | DELETE response semantics | +| [6.6](part-b/6-http-methods.md#66-post-search-for-complex-queries) | M+R | MUST | OpenAPI | POST search for complex queries | +| [6.7](part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection) | M+R | MUST | OpenAPI | Bulk mutation needs explicit selection | + +## 7. HTTP status codes + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [7.1](part-b/7-http-status-codes.md#71-200-for-successful-reads) | R | — | OpenAPI | 200 for successful reads | +| [7.2](part-b/7-http-status-codes.md#72-201-created-with-location) | M | MUST | OpenAPI | 201 Created with Location | +| [7.3](part-b/7-http-status-codes.md#73-202-accepted-for-async-operations) | M+R | MUST | OpenAPI | 202 Accepted for async operations | +| [7.4](part-b/7-http-status-codes.md#74-204-for-void-responses) | R | — | OpenAPI | 204 for void responses | +| [7.5](part-b/7-http-status-codes.md#75-400-for-malformed-requests) | R | — | OpenAPI | 400 for malformed requests | +| [7.6](part-b/7-http-status-codes.md#76-401-with-www-authenticate) | M+R | MUST | OpenAPI | 401 with WWW-Authenticate | +| [7.7](part-b/7-http-status-codes.md#77-403-when-not-authorised) | R | — | OpenAPI | 403 when not authorised | +| [7.8](part-b/7-http-status-codes.md#78-404-for-missing-resources) | R | — | OpenAPI | 404 for missing resources | +| [7.9](part-b/7-http-status-codes.md#79-409-for-state-conflicts) | R | — | OpenAPI | 409 for state conflicts | +| [7.10](part-b/7-http-status-codes.md#710-410-for-permanent-removal) | R | — | OpenAPI | 410 for permanent removal | +| [7.11](part-b/7-http-status-codes.md#711-422-for-semantic-errors) | R | — | OpenAPI | 422 for semantic errors | +| [7.12](part-b/7-http-status-codes.md#712-429-for-rate-limits) | R | — | OpenAPI | 429 for rate limits | +| [7.13](part-b/7-http-status-codes.md#713-server-errors-documented) | M | MUST | OpenAPI | Server errors documented | +| [7.14](part-b/7-http-status-codes.md#714-all-status-codes-declared) | M | MUST | OpenAPI | All status codes declared | +| [7.15](part-b/7-http-status-codes.md#715-412-for-failed-preconditions) | R | — | OpenAPI | 412 for failed preconditions | +| [7.16](part-b/7-http-status-codes.md#716-etag-and-if-none-match) | M+R | SHOULD | OpenAPI | ETag and If-None-Match | +| [7.17](part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match) | M+R | SHOULD | OpenAPI | Optimistic concurrency with If-Match | +| [7.18](part-b/7-http-status-codes.md#718-405-with-allow-header) | M | MUST | OpenAPI | 405 with Allow header | +| [7.19](part-b/7-http-status-codes.md#719-415-for-unsupported-media-types) | M+R | MUST | OpenAPI | 415 for unsupported media types | +| [7.20](part-b/7-http-status-codes.md#720-no-store-on-error-responses) | M | SHOULD | OpenAPI | No-store on error responses | + +## 8. Headers + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [8.1](part-b/8-headers.md#81-credentials-in-authorization-header) | M+R | MUST | OpenAPI | Credentials in Authorization header | +| [8.2](part-b/8-headers.md#82-accept-language-and-content-language) | M+R | MUST | OpenAPI | Accept-Language and Content-Language | +| [8.3](part-b/8-headers.md#83-idempotency-key-header-accepted) | M+R | MUST | OpenAPI | Idempotency-Key header accepted | +| [8.4](part-b/8-headers.md#84-x-request-id-correlation) | M+R | MUST | OpenAPI | X-Request-Id correlation | +| [8.5](part-b/8-headers.md#85-no-new-x--prefixed-headers) | M | MUST | OpenAPI | No new X- prefixed headers | +| [8.6](part-b/8-headers.md#86-no-personal-data-in-addressable-locations) | R | MUST | OpenAPI | No personal data in addressable locations | +| [8.7](part-b/8-headers.md#87-rate-limit-headers-declared) | M+R | MUST | OpenAPI | Rate-limit headers declared | + +## 9. JSON conventions and naming + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [9.1](part-c/9-json-conventions-and-naming.md#91-json-as-default-media-type) | M+R | MUST | Universal | JSON as default media type | +| [9.2](part-c/9-json-conventions-and-naming.md#92-camelcase-field-names) | M | MUST | Universal | camelCase field names | +| [9.3](part-c/9-json-conventions-and-naming.md#93-real-json-booleans) | M | MUST | Universal | Real JSON booleans | +| [9.4](part-c/9-json-conventions-and-naming.md#94-explicit-nullability) | M | MUST | Universal | Explicit nullability | +| [9.5](part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names) | M | MUST | Universal | No spaces or non-ASCII names | +| [9.6](part-c/9-json-conventions-and-naming.md#96-avoid-abbreviations) | R | SHOULD | Universal | Avoid abbreviations | +| [9.7](part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values) | M | MUST | Universal | Screaming snake case enum values | +| [9.8](part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas) | M | MUST | Universal | Forward-compatible schemas | +| [9.9](part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets) | R | MUST | Universal | No closed enums for growing sets | +| [9.10](part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix) | M+R | MUST | Universal | GovStack extension prefix | +| [9.11](part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code) | M+R | MUST | Universal | Single registered BB code | + +## 10. Data types and formats + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [10.1](part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers) | M+R | MUST | Universal | Opaque server-generated identifiers | +| [10.2](part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps) | M | MUST | Universal | RFC 3339 timestamps | +| [10.3](part-c/10-data-types-and-formats.md#103-rfc-3339-calendar-dates) | M | MUST | Universal | RFC 3339 calendar dates | +| [10.4](part-c/10-data-types-and-formats.md#104-decimal-string-monetary-amounts) | M+R | MUST | Universal | Decimal-string monetary amounts | +| [10.5](part-c/10-data-types-and-formats.md#105-e164-phone-numbers) | M+R | MUST | Universal | E.164 phone numbers | +| [10.6](part-c/10-data-types-and-formats.md#106-rfc-5322-email-addresses) | M+R | MUST | Universal | RFC 5322 email addresses | +| [10.7](part-c/10-data-types-and-formats.md#107-binary-uploads-and-base64-payloads) | M+R | MUST | Universal | Binary uploads and base64 payloads | +| [10.8](part-c/10-data-types-and-formats.md#108-iso-3166-1-country-codes) | M+R | MUST | Universal | ISO 3166-1 country codes | +| [10.9](part-c/10-data-types-and-formats.md#109-bcp-47-language-codes) | M+R | MUST | Universal | BCP 47 language codes | +| [10.10](part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes) | M+R | MUST | Universal | ISO 4217 currency codes | + +## 11. Errors + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [11.1](part-c/11-errors.md#111-rfc-9457-problem-details) | M | MUST | Universal | RFC 9457 problem details | +| [11.2](part-c/11-errors.md#112-standard-problem-fields-present) | M+R | MUST | Universal | Standard problem fields present | +| [11.3](part-c/11-errors.md#113-govstack-error-extension-fields) | M | MUST | Universal | GovStack error extension fields | +| [11.4](part-c/11-errors.md#114-field-level-errors-array) | M+R | MUST | Universal | Field-level errors array | +| [11.5](part-c/11-errors.md#115-namespaced-stable-error-codes) | M+R | MUST | Universal | Namespaced stable error codes | +| [11.6](part-c/11-errors.md#116-stable-codes-across-languages) | R | MUST | Universal | Stable codes across languages | +| [11.7](part-c/11-errors.md#117-common-error-catalogue) | M+R | MUST | Universal | Common error catalogue | + +## 12. Pagination, filtering, sorting + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [12.1](part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate) | M+R | MUST | OpenAPI | Collections must paginate | +| [12.2](part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default) | M+R | MUST | OpenAPI | Cursor pagination by default | +| [12.3](part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope) | M | MUST | OpenAPI | Cursor pagination envelope | +| [12.4](part-c/12-pagination-filtering-sorting.md#124-documented-pagesize-bounds) | M+R | MUST | OpenAPI | Documented pageSize bounds | +| [12.5](part-c/12-pagination-filtering-sorting.md#125-optional-total-count) | R | MAY | OpenAPI | Optional total count | +| [12.6](part-c/12-pagination-filtering-sorting.md#126-offset-pagination-envelope) | M+R | MUST | OpenAPI | Offset pagination envelope | +| [12.7](part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention) | M | MUST | OpenAPI | Sort parameter convention | +| [12.8](part-c/12-pagination-filtering-sorting.md#128-simple-equality-filtering) | M+R | MUST | OpenAPI | Simple equality filtering | +| [12.9](part-c/12-pagination-filtering-sorting.md#129-complex-filtering-via-search) | M+R | MUST | OpenAPI | Complex filtering via search | +| [12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope) | — | — | OpenAPI | Sparse fieldsets out of scope | + +## 13. Authentication and authorisation + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [13.1](part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation) | M | MUST | Universal | Default security on every operation | +| [13.2](part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations) | M+R | MUST | Universal | OAuth and OIDC for citizen operations | +| [13.3](part-d/13-authentication-and-authorisation.md#133-distinct-scheme-for-bb-to-bb-calls) | M+R | MUST | Universal | Distinct scheme for BB-to-BB calls | +| [13.4](part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | M | MUST | Universal | Namespaced OAuth scopes | +| [13.5](part-d/13-authentication-and-authorisation.md#135-authorization-is-the-credential-channel) | M+R | MUST | Universal | Authorization is the credential channel | +| [13.6](part-d/13-authentication-and-authorisation.md#136-api-keys-only-for-operational-endpoints) | R | MUST | Universal | API keys only for operational endpoints | + +## 14. Idempotency + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [14.1](part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts) | M+R | MUST | Universal | Idempotency-Key on non-idempotent POSTs | +| [14.2](part-d/14-idempotency.md#142-opaque-client-generated-keys) | R | MUST | Universal | Opaque client-generated keys | +| [14.3](part-d/14-idempotency.md#143-documented-replay-window) | R | MUST | Universal | Documented replay window | +| [14.4](part-d/14-idempotency.md#144-replay-returns-original-response) | R | MUST | Universal | Replay returns original response | +| [14.5](part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch) | R | MUST | Universal | Key reuse and fingerprint mismatch | +| [14.6](part-d/14-idempotency.md#146-naturally-idempotent-designs) | R | MUST | Universal | Naturally idempotent designs | + +## 15. Asynchronous operations + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [15.1](part-d/15-asynchronous-operations.md#151-202-with-operation-location) | M+R | MUST | OpenAPI | 202 with Operation Location | +| [15.2](part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape) | M | MUST | OpenAPI | Shared Operation resource shape | +| [15.3](part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | M | MUST | OpenAPI | Fixed Operation status enum | +| [15.4](part-d/15-asynchronous-operations.md#154-polling-the-operation-resource) | M+R | — | OpenAPI | Polling the Operation resource | +| [15.5](part-d/15-asynchronous-operations.md#155-cancellation-via-cancel-sub-resource) | M+R | MUST | OpenAPI | Cancellation via cancel sub-resource | +| [15.6](part-d/15-asynchronous-operations.md#156-webhook-completion-notification) | R | SHOULD | OpenAPI | Webhook completion notification | +| [15.7](part-d/15-asynchronous-operations.md#157-documented-result-retention) | R | MUST | OpenAPI | Documented result retention | + +## 16. CloudEvents and webhooks + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [16.1](part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented) | M+R | MUST | Event-driven | Event surfaces documented | +| [16.2](part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required) | M | MUST | Event-driven | CloudEvents envelope required | +| [16.3](part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | M | MUST | Event-driven | Reverse-DNS event types | +| [16.4](part-d/16-cloudevents-and-webhooks.md#164-stable-cloudevents-source) | M+R | MUST | Event-driven | Stable CloudEvents source | +| [16.5](part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery) | R | MUST | Event-driven | Signed event delivery | +| [16.6](part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header) | M+R | MUST | Event-driven | GovStack-Signature header | +| [16.7](part-d/16-cloudevents-and-webhooks.md#167-replay-detectable-signed-material) | R | MUST | Event-driven | Replay-detectable signed material | +| [16.8](part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile) | R | MUST | Event-driven | Pinned signature profile | +| [16.9](part-d/16-cloudevents-and-webhooks.md#169-operational-signing-concerns-out-of-scope) | — | — | Event-driven | Operational signing concerns out of scope | +| [16.10](part-d/16-cloudevents-and-webhooks.md#1610-documented-delivery-failure-contract) | R | MUST | Event-driven | Documented delivery-failure contract | +| [16.11](part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces) | M+R | MUST | Event-driven | Subscription management interfaces | + +## 17. AsyncAPI channel documentation rules + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [17.1](part-d/17-asyncapi-channel-rules.md#171-send-and-receive-perspective) | M+R | MUST | AsyncAPI | Send and receive perspective | +| [17.2](part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses) | M | MUST | AsyncAPI | Reverse-DNS channel addresses | +| [17.3](part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels) | R | MUST | AsyncAPI | No personal data in channels | +| [17.4](part-d/17-asyncapi-channel-rules.md#174-declared-channel-parameters) | M+R | MUST | AsyncAPI | Declared channel parameters | +| [17.5](part-d/17-asyncapi-channel-rules.md#175-no-environment-names-in-addresses) | M+R | SHOULD | AsyncAPI | No environment names in addresses | +| [17.6](part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | M | MUST | AsyncAPI | Structured CloudEvents JSON payloads | +| [17.7](part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-message-schema) | M | MUST | AsyncAPI | Shared CloudEvents message schema | +| [17.8](part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata) | M+R | MUST | AsyncAPI | Message headers and idempotency metadata | +| [17.9](part-d/17-asyncapi-channel-rules.md#179-message-localisation-headers) | M+R | MUST | AsyncAPI | Message localisation headers | +| [17.10](part-d/17-asyncapi-channel-rules.md#1710-security-schemes-cover-every-operation) | M+R | MUST | AsyncAPI | Security schemes cover every operation | +| [17.11](part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees) | M+R | MUST | AsyncAPI | Documented delivery guarantees | +| [17.12](part-d/17-asyncapi-channel-rules.md#1712-documented-ordering-guarantees) | M+R | MUST | AsyncAPI | Documented ordering guarantees | +| [17.13](part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities) | M+R | MUST | AsyncAPI | Declared delivery-management capabilities | +| [17.14](part-d/17-asyncapi-channel-rules.md#1714-portable-capability-contract) | R | MUST | AsyncAPI | Portable capability contract | +| [17.15](part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions) | M+R | MUST | AsyncAPI | Machine-readable delivery extensions | +| [17.16](part-d/17-asyncapi-channel-rules.md#1716-async-rejection-error-messages) | M+R | MUST | AsyncAPI | Async rejection error messages | +| [17.17](part-d/17-asyncapi-channel-rules.md#1717-declared-request-reply-correlation) | M+R | MUST | AsyncAPI | Declared request-reply correlation | +| [17.18](part-d/17-asyncapi-channel-rules.md#1718-correlated-completion-signals) | M+R | MUST | AsyncAPI | Correlated completion signals | +| [17.19](part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | M+R | MUST | AsyncAPI | Protocol bindings where relevant | +| [17.20](part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message) | M+R | MUST | AsyncAPI | Examples for every message | + +## 18. Compatibility and lifecycle + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [18.1](part-d/18-compatibility-and-lifecycle.md#181-semver-versioning) | M | MUST | Universal | SemVer versioning | +| [18.2](part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel) | M | MUST | Universal | Major version in path or channel | +| [18.3](part-d/18-compatibility-and-lifecycle.md#183-backward-compatible-minor-changes) | M+R | MUST | Universal | Backward-compatible minor changes | +| [18.4](part-d/18-compatibility-and-lifecycle.md#184-breaking-changes-bump-major-version) | M+R | MUST | Universal | Breaking changes bump major version | +| [18.5](part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers) | M+R | MUST | Universal | Deprecation and Sunset headers | +| [18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields) | — | — | Universal | Clients ignore unknown fields | +| [18.7](part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata) | M+R | MUST | Universal | AsyncAPI deprecation metadata | + +## 19. Localisation + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [19.1](part-e/19-localisation.md#191-honour-the-request-language) | R | MUST | Universal | Honour the request language | +| [19.2](part-e/19-localisation.md#192-never-translate-stable-content) | R | MUST | Universal | Never translate stable content | +| [19.3](part-e/19-localisation.md#193-english-as-default-language) | R | MUST | Universal | English as default language | +| [19.4](part-e/19-localisation.md#194-declare-the-response-language) | M+R | MUST | Universal | Declare the response language | + +## 20. Conformance and validation + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [20.1](part-e/20-conformance-and-validation.md#201-every-file-passes-validation) | M | MUST | Universal | Every file passes validation | +| [20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) | M | MUST | Universal | Passes the GovStack Spectral ruleset | +| [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) | M | MUST | Universal | Declared guide conformance version | + diff --git a/api-design-guide/appendix/a-companion-documents.md b/api-design-guide/appendix/a-companion-documents.md new file mode 100644 index 0000000..3d1d874 --- /dev/null +++ b/api-design-guide/appendix/a-companion-documents.md @@ -0,0 +1,17 @@ +--- +description: "Companion documents and artifacts that pick up the topics this guide places out of scope." +--- + +# Appendix A. Companion documents and artifacts + +[§1.2](../1-introduction.md#12-scope) lists what is out of scope. This appendix names the companion documents and artifacts that pick up those topics. + +| Companion | Status | What it covers | +|---|---|---| +| **GovStack API Lifecycle & Governance** | Proposed local v0.1 outline, not a ratified GovStack artifact | Ratification, enforcement, exception lifecycle, transition timelines, conformance levels, companion-artifact ownership, BB editor support, self-amendment of this guide. Reuses the existing GovStack Specification Framework where applicable and defines only the missing API-specific lifecycle, exception, publication, and conformance processes. | +| **GovStack API Security & Operations** | Not yet drafted | Operational behaviour of a deployed BB: token validation, certificate trust, key rotation, replay enforcement, audit logging, log hygiene, alg allowlists, FAPI conformance. | +| `govstack-openapi-common.yaml` | To be authored alongside v1.0 | Shared security scheme, RFC 9457 error schema, pagination envelope, common headers, Operation resource, common error catalogue ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)). | +| `govstack-asyncapi-common.yaml` | To be authored alongside v1.0 | Shared CloudEvents envelope ([§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)), common message headers ([§17](../part-d/17-asyncapi-channel-rules.md)), common security schemes (OAuth 2.0, OpenID Connect, X.509/mTLS), signing metadata, delivery-semantics extensions, and common error messages referencing the [§11](../part-c/11-errors.md) error envelope. | +| **Spectral ruleset** | To be authored alongside v1.0 | Machine-enforceable subset of the guide's rules ([§20](../part-e/20-conformance-and-validation.md)). v0.1 covers OpenAPI, CloudEvents, and AsyncAPI documentation rules; protocol-profile rules may be added later. | +| **Conformance test pack** | Future companion artifact | Governance-defined contract tests beyond schema and Spectral validation. | +| **Reference BB implementation** | Future companion artifact | Worked example applying the guide end-to-end to one BB. | diff --git a/api-design-guide/appendix/b-open-questions.md b/api-design-guide/appendix/b-open-questions.md new file mode 100644 index 0000000..83edebf --- /dev/null +++ b/api-design-guide/appendix/b-open-questions.md @@ -0,0 +1,37 @@ +--- +description: "Consolidated list of open committee questions referenced inline throughout the guide." +--- + +# Appendix B. Open questions (consolidated) + +The questions below are genuine committee decisions. Each appears inline as `[OPEN-N-X]` next to the relevant rule. + +The **Blocks v1.0?** column marks the questions whose answers shape the shared `govstack-openapi-common.yaml` / `govstack-asyncapi-common.yaml` artifacts or every BB's URL surface; these need a committee decision before v1.0 ratification. The rest can be settled during the v1.0 drafting cycle without blocking pilot work. + +| ID | Topic | Default | Section | Blocks v1.0? | +|---|---|---|---|---| +| OPEN-4-A | BB code in URL path | No (rely on `servers` URL or mediator routing) | [§5](../part-b/5-url-structure-and-versioning.md) | Yes | +| OPEN-4-B | Health endpoint shape: align with `draft-inadarei-api-health-check`, or use a simpler local shape | Align with the draft | [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | No | +| OPEN-4-C | Path nesting depth: soft cap of two levels under `/v{N}/` | Keep as SHOULD with the soft cap | [§5.4](../part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | No | +| OPEN-6-A | 400 vs 422 boundary | Keep both (400 unparseable, 422 semantic) | [§7](../part-b/7-http-status-codes.md) | No | +| OPEN-7-A | RFC 6648 migration of `X-Request-Id` | Retain `X-Request-Id`; strict 6648 rename is the alternative | [§8.4](../part-b/8-headers.md#84-x-request-id-correlation)–[8.5](../part-b/8-headers.md#85-no-new-x--prefixed-headers) | No | +| OPEN-7-B | RateLimit header form: three-header variant vs structured-field form from current IETF draft | Three-header variant in v0.1; re-pick in v1.0 once the draft stabilises | [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared) | No | +| OPEN-10-A | Error code shape: reverse-DNS named code vs reverse-DNS numeric code vs shorter BB-prefixed code | Reverse-DNS named code: `org.govstack.{bb-code}.{error-name}` | [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes) | Yes | +| OPEN-10-B | Common error catalogue: which canonical errors to include | The `google.rpc.Code` set mapped to reverse-DNS GovStack codes | [§11.7](../part-c/11-errors.md#117-common-error-catalogue) | Yes | +| OPEN-12-A | OAuth scope syntax: `bb:{bb-code}:{resource}:{action}` vs reverse-DNS vs `resource.action` | `bb:` prefix for namespacing; reverse-DNS is the alternative | [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | Yes | +| OPEN-14-A | Operation resource: GovStack-local shape vs strict Google AIP-151 mirror | AIP-151-aligned hybrid; strict AIP-151 is the alternative | [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | Yes | +| OPEN-15-A | Event signature scheme: detached JWS over canonicalised structured CloudEvents JSON vs HMAC-SHA256 | Detached JWS, with HMAC allowed only as a governed deployment fallback | [§16.5](../part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery), [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile) | Yes | +| OPEN-15-B | Event `type` naming convention | `org.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | +| OPEN-15-C | Event signature metadata name: `GovStack-Signature` vs other | `GovStack-Signature` (RFC 6648 compliant, namespaced) | [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header) | No | +| OPEN-15-D | AsyncAPI channel naming | `org.govstack.{bb-code}.v{major}.{resource}.{event}` | [§17.2](../part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses) | No | +| OPEN-15-E | CloudEvents binding style for AsyncAPI | Structured CloudEvents JSON payload | [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | No | +| OPEN-15-F | AsyncAPI protocol-binding depth | Require bindings where they affect interoperability; future profiles may add deeper broker-specific rules | [§17.19](../part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | No | +| OPEN-15-G | GovStack AsyncAPI extension names and schemas | Define in `govstack-asyncapi-common.yaml` | [§17.15](../part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions) | No | +| OPEN-16-A | AsyncAPI deprecation metadata | `x-govstack-deprecated` with `since`, `sunset`, `replacement`, `reason` | [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata) | No | +| OPEN-17-A | Mandated language coverage | Per BB | [§19](../part-e/19-localisation.md) | No | +| OPEN-9-A | BB-code register: where the canonical register of BB codes lives and who assigns them | Propose in the Lifecycle & Governance companion; until then, agree codes through the API Working Group | [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code) | No | +| OPEN-20-A | Guide-version declaration: shape of the `x-govstack-api-guide` extension and its exception-record references | As drafted in §20.3 | [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) | No | + +**A note on identifiers.** The `OPEN-N-X` identifiers are frozen from the circulated v0.1 draft and predate the v0.2 section renumbering: the `N` in an identifier refers to the v0.1 section number and is treated as an opaque label, so existing feedback threads stay valid. The Section column shows current section numbers. Questions added in v0.2 or later use current numbering (`OPEN-9-A`, `OPEN-20-A`). The old-to-new section mapping is on [How to use this guide](../how-to-use-this-guide.md). + +Governance-side open questions (ratification process, enforcement actor, exception lifecycle, deviation board) are proposed for the **GovStack API Lifecycle & Governance** companion document, not here. diff --git a/api-design-guide/appendix/c-normative-references.md b/api-design-guide/appendix/c-normative-references.md new file mode 100644 index 0000000..13c5eee --- /dev/null +++ b/api-design-guide/appendix/c-normative-references.md @@ -0,0 +1,36 @@ +--- +description: "Normative references cited throughout the guide." +--- + +# Appendix C. Normative references + +- IETF RFC 2119, *Key words for use in RFCs to Indicate Requirement Levels* +- IETF RFC 3339, *Date and Time on the Internet: Timestamps* +- IETF RFC 5322, *Internet Message Format* +- IETF RFC 6648, *Deprecating the "X-" Prefix in Application Protocols* +- IETF RFC 6749, *The OAuth 2.0 Authorization Framework* +- IETF RFC 6750, *The OAuth 2.0 Authorization Framework: Bearer Token Usage* (cited by [§7.6](../part-b/7-http-status-codes.md#76-401-with-www-authenticate)) +- IETF RFC 6901, *JavaScript Object Notation (JSON) Pointer* +- IETF RFC 6902, *JavaScript Object Notation (JSON) Patch* +- IETF RFC 7396, *JSON Merge Patch* +- IETF RFC 7515, *JSON Web Signature (JWS)* +- IETF RFC 8594, *The Sunset HTTP Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) +- IETF RFC 9110, *HTTP Semantics* (obsoletes RFC 7231) +- IETF RFC 9396, *OAuth 2.0 Rich Authorization Requests* +- IETF RFC 9457, *Problem Details for HTTP APIs* (obsoletes RFC 7807) +- IETF RFC 9745, *The Deprecation HTTP Response Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) +- IETF draft `draft-ietf-httpapi-ratelimit-headers`, *RateLimit Header Fields for HTTP* (active Internet-Draft, not yet an RFC; cited by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared)) +- IETF draft `draft-ietf-httpapi-idempotency-key-header`, *The Idempotency-Key HTTP Header Field* (Internet-Draft, not yet an RFC; cited by [§14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) +- IETF draft `draft-inadarei-api-health-check`, *Health Check Response Format for HTTP APIs* (expired Internet-Draft, never an RFC; cited by [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) +- OpenAPI Specification 3.1.0 +- AsyncAPI Specification 3.0 (cited by [§1.2](../1-introduction.md#12-scope), [§3](../part-a/3-asyncapi-document-standards.md), [§16.1](../part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented), [§17](../part-d/17-asyncapi-channel-rules.md), [§20](../part-e/20-conformance-and-validation.md)) +- OpenID Connect Core 1.0 +- CloudEvents v1.0.2 (CNCF), *CloudEvents Specification* and JSON Format (cited by [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)) +- Google AIP-151, *Long-running operations* (cited by [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum)) +- Google AIP-158, *Pagination* (cited by [§12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) +- GraphQL Cursor Connections Specification (cited by [§12.3](../part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope)) +- gRPC, *google.rpc.Code* canonical error codes (cited by [§11.7](../part-c/11-errors.md#117-common-error-catalogue)) +- ISO 3166-1 alpha-2 (country codes) +- ISO 4217 (currency codes) +- BCP 47 (language tags) +- E.164 (international phone number format) diff --git a/api-design-guide/guides/README.md b/api-design-guide/guides/README.md new file mode 100644 index 0000000..e8d64ad --- /dev/null +++ b/api-design-guide/guides/README.md @@ -0,0 +1,20 @@ +--- +description: "What the Guides group is for, how it relates to the numbered rules, and what it will grow into at v1.0." +--- + +# About these guides + +The pages in this section are non-normative. They exist to make the numbered rules in §1–§20 faster to apply day to day: a checklist to run before review, commands to validate a spec, and notes on how to point an AI coding agent at this guide. Where anything here appears to conflict with a numbered section, the numbered section wins; these guides describe how to comply, not what compliance means. + +Four guides live here today: + +- [Spec editor checklist](../guides/spec-editor-checklist.md): a pre-submission checklist of the rules most often missed, grouped by specification type, each item linked to the rule it enforces. +- [Validating your spec](../guides/validating-your-spec.md): the actual commands to run against an OpenAPI or AsyncAPI file, and an honest account of what those commands do and do not catch. +- [Using this guide with AI agents](../guides/using-with-ai-agents.md): how `rules.yaml`, the published GitBook site, and a BB repository's `AGENTS.md`/`CLAUDE.md` fit together for agent-assisted spec work. +- [Maintaining this guide](../guides/maintaining-this-guide.md): where the canonical copy lives, how to edit a rule without breaking its anchor, and how to regenerate the machine-readable index. + +{% hint style="info" %} +This book is DRAFT v0.2 and not yet ratified. The guides above describe the tooling and workflow as they exist today; some referenced artifacts, most notably the GovStack Spectral ruleset, are v1.0 companions that do not exist yet (see [Appendix A](../appendix/a-companion-documents.md)). +{% endhint %} + +At v1.0, this section is expected to accrete three things: a worked positive and negative example for every numbered rule, a cross-reference from each `[M]`/`[M+R]` rule to its GovStack Spectral rule ID once that ruleset is authored, and a conformance walkthrough that takes one reference BB specification from a blank file to a passing conformance run. diff --git a/api-design-guide/guides/maintaining-this-guide.md b/api-design-guide/guides/maintaining-this-guide.md new file mode 100644 index 0000000..03cc980 --- /dev/null +++ b/api-design-guide/guides/maintaining-this-guide.md @@ -0,0 +1,43 @@ +--- +description: "Where the canonical copy of this guide lives, how to edit a rule without breaking its anchor, and how to regenerate the machine-readable index." +--- + +# Maintaining this guide + +## Where the canonical copy lives + +The canonical copy of this guide lives in the GovStack `bb-template` repository, in the `/api-design-guide` folder. A BB repository instantiated from the template inherits a snapshot copy of that folder at the time of instantiation, and may delete it entirely if it does not want the guide tracked locally. Edits belong upstream, in `bb-template`, not in an individual BB's copy: a fix made directly to a BB's inherited copy will be silently overwritten the next time that BB pulls a template update, and it never flows back to `bb-template` on its own. If a BB editor finds an error while working locally, the fix has to be raised or applied against `bb-template` directly. + +## Editing a rule + +Edit the rule's body in place, on its page. Do not renumber existing rules, and do not change an existing heading's anchor id (the `` tag on that heading), even if the heading's visible text is reworded. External links, cross-references elsewhere in this book, and `rules.yaml` all depend on those anchors staying stable; changing one silently breaks every inbound reference. New rules within a section are appended at the end of that section's rule list, taking the next unused number. + +## Regenerating the machine layer + +Two scripts derive machine-readable artifacts from the page content and must be re-run whenever a page changes: + +```bash +python3 tools/build_rules_index.py +``` + +Rewrites `rules.yaml` and `all-rules.md` from the current state of the pages. Run this after editing, adding, or reordering any rule. + +```bash +python3 tools/build_rules_index.py --check +``` + +Verifies that `rules.yaml` and `all-rules.md` are still in sync with the pages, without rewriting them. Run this after any page edit; it is also the natural check to add to CI once this folder gets a CI hook (the template's CircleCI config does not run it today). + +```bash +python3 tools/check_links.py +``` + +Validates every internal link and anchor reference in the book, including `SUMMARY.md`. Run this alongside the index check whenever a page's headings or cross-references change. + +## Adding an open question + +Append a row to [Appendix B](../appendix/b-open-questions.md) using an ID of the form `OPEN-{section}-{letter}`, keyed to the current section numbering. Never re-key an existing `OPEN-*` identifier: they are frozen once assigned, as noted on the Appendix B page itself, precisely so that a reference to `OPEN-15-A` in a discussion thread or a companion document keeps meaning the same thing over time. + +## Versioning the guide itself + +This guide is versioned with SemVer, per [§1.10](../1-introduction.md#110-applicability-and-transition). A minor release may only add or relax a rule; removing a rule or strengthening an existing requirement needs a major release. Record every substantive change, in either case, in the [version history](../version-history.md). diff --git a/api-design-guide/guides/spec-editor-checklist.md b/api-design-guide/guides/spec-editor-checklist.md new file mode 100644 index 0000000..aad28e5 --- /dev/null +++ b/api-design-guide/guides/spec-editor-checklist.md @@ -0,0 +1,46 @@ +--- +description: "A pre-submission checklist of the rules most often missed, grouped by specification type, each item linked to the rule it enforces." +--- + +# Spec editor checklist + +Run this before submitting a BB specification for review. Each item links to the rule that governs it; read the rule if the phrase alone is not enough to act on. + +## Every specification + +- [ ] `info` block is complete: SemVer `version`, `title`, `description`, and `contact` are all present. ([2.5](../part-a/2-openapi-document-standards.md#25-complete-info-block), [3.5](../part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block)) +- [ ] `servers` is non-empty and meaningful: no `localhost`, no personal machines, no fake production domains; reference specs use parameterised template URLs. ([2.6](../part-a/2-openapi-document-standards.md#26-meaningful-servers-block), [3.6](../part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages)) +- [ ] Every operation has complete metadata: an `operationId` (or AsyncAPI operation key), `summary`, `description`, and at least one `tag`. ([2.7](../part-a/2-openapi-document-standards.md#27-complete-operation-metadata), [3.7](../part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata)) +- [ ] Every schema has a `description`. ([4.1](../part-a/4-documentation-requirements.md#41-every-schema-described)) +- [ ] Every request and response body has at least one `example`; every `enum` documents what its values mean. ([4.2](../part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums)) +- [ ] No placeholder text anywhere: no `TBD`, no `Lorem ipsum`, no `a, b, c`, no leftover content copied from another BB. ([4.3](../part-a/4-documentation-requirements.md#43-no-placeholder-text)) +- [ ] A security scheme is declared and applied to every operation by default, with per-operation overrides explicit. ([13.1](../part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation)) +- [ ] The file passes its validator (see [Validating your spec](../guides/validating-your-spec.md)). ([20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation)) +- [ ] `info.version` follows SemVer. ([18.1](../part-d/18-compatibility-and-lifecycle.md#181-semver-versioning)) +- [ ] JSON field names are `camelCase`, applied consistently; check the [carve-outs](../part-c/9-json-conventions-and-naming.md#carve-out-from-92) before flagging fields imported from an external standard (RFC 9457, CloudEvents) as violations. ([9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names)) +- [ ] Resource identifiers are opaque, server-generated strings; citizen records are never referred to by a personal identifier in a URL. ([10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) + +## REST surfaces (OpenAPI 3.1) + +- [ ] The file declares `openapi: 3.1.0`. ([2.1](../part-a/2-openapi-document-standards.md#21-openapi-310-required)) +- [ ] The canonical entrypoint is at `api/openapi.yaml`, not `api/swagger.yaml` or a JSON copy. ([2.2](../part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint)) +- [ ] The major version appears in the URL path (`/v{N}/...`). ([5.1](../part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path)) +- [ ] `/health` is unversioned, uses `application/health+json`, and carries no citizen authentication. ([5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) +- [ ] No bulk-mutating or bulk-deleting operation can run against an entire collection with no selection parameter. ([6.7](../part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection)) +- [ ] Every operation declares every status code it can actually return; no operation declares only `200`. ([7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) +- [ ] Error responses use `application/problem+json` (RFC 9457) with `code`, `traceId`, and `timestamp` present alongside the standard fields. ([11.1](../part-c/11-errors.md#111-rfc-9457-problem-details), [11.3](../part-c/11-errors.md#113-govstack-error-extension-fields)) +- [ ] Collection endpoints paginate, and cursor pagination (`pageSize`, opaque `cursor`) is the default. ([12.1](../part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate), [12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) +- [ ] Non-idempotent POST endpoints (creation, payment, submission, subscription, job start) accept an `Idempotency-Key` header. ([14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) +- [ ] The major version is visible in the surface contract, and deprecated endpoints return `Deprecation` and `Sunset` headers. ([18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel), [18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) + +## Event-driven surfaces (CloudEvents / AsyncAPI 3.0) + +- [ ] The file declares `asyncapi: 3.0.0` and lives at `api/asyncapi.yaml`. ([3.1](../part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required), [3.2](../part-a/3-asyncapi-document-standards.md#32-one-canonical-asyncapi-entrypoint)) +- [ ] Every domain event conforms to the CloudEvents envelope (`specversion`, `id`, `source`, `type`, domain payload under `data`). ([16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)) +- [ ] Event `type` values follow the reverse-DNS convention. ([16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types)) +- [ ] Channel addresses follow the reverse-DNS convention. ([17.2](../part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses)) +- [ ] No channel address, topic, queue name, routing key, or channel parameter carries personal data. ([17.3](../part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels)) +- [ ] Every operation documents its delivery guarantee, ordering guarantee, and supported delivery-management capabilities. ([17.11](../part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees)–[17.13](../part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities)) +- [ ] Every message has an example. ([17.20](../part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message)) + +Ticking every box above is the human half of conformance. See [Validating your spec](../guides/validating-your-spec.md) for the mechanical half. diff --git a/api-design-guide/guides/using-with-ai-agents.md b/api-design-guide/guides/using-with-ai-agents.md new file mode 100644 index 0000000..0b6d74e --- /dev/null +++ b/api-design-guide/guides/using-with-ai-agents.md @@ -0,0 +1,31 @@ +--- +description: "How rules.yaml, the published GitBook site, and a BB repository's AGENTS.md fit together for agent-assisted spec work." +--- + +# Using this guide with AI agents + +This guide is written to be read by people and by coding agents working inside a BB repository. Three things make that practical. + +## In the repository + +`rules.yaml` at the root of this book (`api-design-guide/rules.yaml`) is the machine-readable index of every rule: its ID, enforcement class (`[M]`, `[R]`, `[M+R]`), RFC 2119 strength, applicable surface, verbatim rule text, and the page and anchor it lives on. An agent working on an OpenAPI or AsyncAPI file should treat `rules.yaml` as the lookup table, not the whole guide, and follow the page and anchor for each rule it needs to reason about, so it gets the full intent, examples, and carve-outs rather than a one-line summary. + +## On the published site + +Once this book is published as a GitBook space, the platform serves several agent-friendly surfaces automatically, with no extra authoring: an `llms.txt` and `llms-full.txt` at the site root summarising the space for LLM consumption, raw markdown for any page by appending `.md` to its URL, and a read-only MCP server at `{site-url}/~gitbook/mcp` that lets an agent query the space directly. These become available once GovStack wires the space to its final site URL; nothing about them needs to be built here. + +## Wiring a BB repository + +Paste something like this into the BB repository's `AGENTS.md` or `CLAUDE.md`: + +```markdown +This repository's API specifications must conform to the GovStack Cross-BB API Design Guide in /api-design-guide. + +Before writing or reviewing OpenAPI/AsyncAPI content: +- Read api-design-guide/rules.yaml and treat every MUST rule as blocking. +- Cite rule IDs (for example 9.2, 11.1) when flagging or fixing violations. +- Run the validators in api-design-guide/guides/validating-your-spec.md before declaring spec work done. +- Consult api-design-guide/guides/spec-editor-checklist.md for the review pass. + +MUST rules with enforcement class [R] cannot be machine-checked; reason about them explicitly rather than assuming a clean validator run covers them. +``` diff --git a/api-design-guide/guides/validating-your-spec.md b/api-design-guide/guides/validating-your-spec.md new file mode 100644 index 0000000..d29c979 --- /dev/null +++ b/api-design-guide/guides/validating-your-spec.md @@ -0,0 +1,40 @@ +--- +description: "The commands to run against a BB's OpenAPI or AsyncAPI file, and an honest account of what each one does and does not catch." +--- + +# Validating your spec + +## OpenAPI + +Install the validator and run it against the canonical entrypoint: + +```bash +pip install openapi-spec-validator +openapi-spec-validator api/openapi.yaml +``` + +This checks that the file is a structurally valid OpenAPI 3.1.0 document. It is the mechanical check behind [2.4](../part-a/2-openapi-document-standards.md#24-passes-openapi-spec-validator) and [20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation). + +## AsyncAPI + +```bash +npx @asyncapi/cli validate api/asyncapi.yaml +``` + +This checks that the file is a structurally valid AsyncAPI 3.0 document. It is the mechanical check behind [3.4](../part-a/3-asyncapi-document-standards.md#34-passes-an-asyncapi-validator). + +## Spectral + +```bash +npx @stoplight/spectral-cli lint api/openapi.yaml +``` + +Run without a GovStack-specific ruleset file, this applies Spectral's generic built-in OpenAPI rules only: it will catch general structural issues but knows nothing about this guide's rules (naming conventions, header requirements, error envelope shape, and so on). The GovStack Spectral ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) is a v1.0 companion artifact and does not exist yet; see [Appendix A](../appendix/a-companion-documents.md). Until it is published, treat a clean generic Spectral run as a weak signal, not conformance. + +## The book's own machine layer + +Two scripts keep this book itself internally consistent rather than checking a BB's spec: `python3 tools/check_links.py` and `python3 tools/build_rules_index.py --check`. See [Maintaining this guide](../guides/maintaining-this-guide.md) for when and why to run them. + +## What "passing" actually means + +Each rule in this guide carries an enforcement-class tag explained in [§1.9](../1-introduction.md#19-rule-enforcement-classes): `[M]` (machine-checkable), `[R]` (requires human review), or `[M+R]` (a linter can check the structural part, a reviewer must confirm the semantic part). The validators above, and the future GovStack Spectral ruleset, cover the `[M]` rules and the mechanical half of the `[M+R]` rules. Everything else, including every `[R]` rule and the judgement half of every `[M+R]` rule, is verified through specification review, not a command line. A clean validator run is necessary for conformance; it is not sufficient. diff --git a/api-design-guide/how-to-use-this-guide.md b/api-design-guide/how-to-use-this-guide.md new file mode 100644 index 0000000..4a07e4e --- /dev/null +++ b/api-design-guide/how-to-use-this-guide.md @@ -0,0 +1,58 @@ +--- +description: "Entry points by audience, how to review the draft, and the v0.1 to v0.2 section mapping." +--- + +# How to use this guide + +The guide is written for lookup, not end-to-end reading ([§1.4](1-introduction.md#14-audience)). Every rule has its own heading and a stable deep link, an identifier like **9.2**, an enforcement badge (**[M]** machine-checkable, **[R]** review, **[M+R]** both; see [§1.9](1-introduction.md#19-rule-enforcement-classes)), and RFC 2119 keywords in the body. + +## Find your path + +- **BB spec editors** (primary audience): start from the [spec editor checklist](guides/spec-editor-checklist.md), jump to rules via [Rules at a glance](all-rules.md), and run the commands in [Validating your spec](guides/validating-your-spec.md) before submitting for review. +- **Implementers** building against a BB spec: read [§1.8](1-introduction.md#18-layering-what-this-guide-constrains) first to see which promises live in the spec, the implementation profile, or the deployment; then the sections for the surfaces you consume (errors [§11](part-c/11-errors.md), pagination [§12](part-c/12-pagination-filtering-sorting.md), idempotency [§14](part-d/14-idempotency.md), events [§16](part-d/16-cloudevents-and-webhooks.md)). +- **Country teams** adopting BBs: the [Introduction](1-introduction.md) and [§18 Compatibility and lifecycle](part-d/18-compatibility-and-lifecycle.md) describe what consistency you can rely on across the catalogue; concrete deployment values live in implementation profiles, not here. +- **AI coding agents and tooling**: `rules.yaml` at the folder root is the machine-readable index of every rule; [Using this guide with AI agents](guides/using-with-ai-agents.md) explains it and provides a paste-ready instruction block. + +## How to review this draft + +This is a strawman of a normative cross-BB API design guide. It is not yet ratified. At this stage, committee feedback should focus on: + +1. **Scope of harmonisation** (see [§1.2](1-introduction.md#12-scope)). +2. **Structure and depth.** 20 numbered sections ([§1](1-introduction.md)–[§20](part-e/20-conformance-and-validation.md)). The final v1.0 will expand rules with positive/negative examples and link machine-checkable rules to Spectral rule IDs. +3. **Prescriptiveness.** RFC 2119 keywords and linter-backed conformance (see [§1.5](1-introduction.md#15-language) and [§20](part-e/20-conformance-and-validation.md)). +4. **The open questions.** [Appendix B](appendix/b-open-questions.md) consolidates every deliberate design call; the **Blocks v1.0?** column marks the ones that need a committee decision before ratification. + +Companion documents (governance, security/operations, common YAML, Spectral ruleset, conformance pack) are referenced where relevant; their scope is in [Appendix A](appendix/a-companion-documents.md). + +## Section renumbering from v0.1 + +v0.2 eliminates the lettered sections (old §2A and §15A) in favour of a continuous 1–20 numbering. If you are cross-checking against the circulated v0.1 document: + +| v0.1 section | v0.2 section | +|---|---| +| 1 Introduction | [1](1-introduction.md) | +| 2 OpenAPI document standards | [2](part-a/2-openapi-document-standards.md) | +| 2A AsyncAPI document standards | [3](part-a/3-asyncapi-document-standards.md) | +| 3 Documentation requirements | [4](part-a/4-documentation-requirements.md) | +| 4 URL structure and versioning | [5](part-b/5-url-structure-and-versioning.md) | +| 5 HTTP methods | [6](part-b/6-http-methods.md) | +| 6 HTTP status codes | [7](part-b/7-http-status-codes.md) | +| 7 Headers | [8](part-b/8-headers.md) | +| 8 JSON conventions and naming | [9](part-c/9-json-conventions-and-naming.md) | +| 9 Data types and formats | [10](part-c/10-data-types-and-formats.md) | +| 10 Errors | [11](part-c/11-errors.md) | +| 11 Pagination, filtering, sorting | [12](part-c/12-pagination-filtering-sorting.md) | +| 12 Authentication and authorisation | [13](part-d/13-authentication-and-authorisation.md) | +| 13 Idempotency | [14](part-d/14-idempotency.md) | +| 14 Asynchronous operations | [15](part-d/15-asynchronous-operations.md) | +| 15 CloudEvents and webhooks | [16](part-d/16-cloudevents-and-webhooks.md) | +| 15A AsyncAPI channel documentation rules | [17](part-d/17-asyncapi-channel-rules.md) | +| 16 Compatibility and lifecycle | [18](part-d/18-compatibility-and-lifecycle.md) | +| 17 Localisation | [19](part-e/19-localisation.md) | +| 18 Conformance and validation | [20](part-e/20-conformance-and-validation.md) | + +Rule numbers moved with their sections (v0.1 rule 8.2 is now 9.2). The `OPEN-N-X` identifiers in [Appendix B](appendix/b-open-questions.md) are deliberately **not** re-keyed: they are frozen labels from v0.1, so feedback threads that cite them stay valid. + +## About the rule titles + +The short titles on rule headings (for example "9.2 camelCase field names") were added in v0.2 as navigation aids. They are **non-normative**: only the rule body defines the requirement. If a title and its body ever seem to disagree, the body wins, and please report it. diff --git a/api-design-guide/part-a/2-openapi-document-standards.md b/api-design-guide/part-a/2-openapi-document-standards.md new file mode 100644 index 0000000..1e1f1c9 --- /dev/null +++ b/api-design-guide/part-a/2-openapi-document-standards.md @@ -0,0 +1,45 @@ +--- +description: "Rules governing the canonical OpenAPI document: version, location, validation, metadata, and vendored shared components." +--- + +# 2. OpenAPI document standards + +{% hint style="info" %} +**Intent.** Every OpenAPI surface has exactly one canonical, machine-validatable artifact in a known location or registry. Implementers must not have to choose between divergent copies. + +**Applies to:** OpenAPI surface. AsyncAPI document-level rules are in [§3](../part-a/3-asyncapi-document-standards.md). +{% endhint %} + +## 2.1 OpenAPI 3.1.0 required + +**[M]** The spec **MUST** declare `openapi: 3.1.0`. Earlier versions **MUST NOT** be used. + +## 2.2 One canonical OpenAPI entrypoint + +**[M+R]** The canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. (The audit found these files predominantly at `api/swagger.yaml`/`api/swagger.json`; renaming to `api/openapi.yaml` is part of conformance.) It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent API surfaces that version on independent cadences **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml` (which then serves as the single registry of canonical files). Either way there **MUST** be exactly one canonical artifact per surface and no divergent copies. + +## 2.3 No divergent OpenAPI copies + +**[R]** Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it. + +## 2.4 Passes openapi-spec-validator + +**[M]** The file **MUST** pass `openapi-spec-validator` against the 3.1.0 schema. + +## 2.5 Complete info block + +**[M]** The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), `description`, and `contact`. Where a BB ships per-surface canonical files ([2.2](#22-one-canonical-openapi-entrypoint)), each surface carries its own `info.version` and versions independently. + +## 2.6 Meaningful servers block + +**[M+R]** The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}/v1`). Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production. + +## 2.7 Complete operation metadata + +**[M+R]** Every operation **MUST** include `operationId` (camelCase, verb-noun), `summary`, `description`, and at least one `tag`. + +## 2.8 Pinned vendored common components + +**[M]** Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit. + +Vendoring is required because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable. diff --git a/api-design-guide/part-a/3-asyncapi-document-standards.md b/api-design-guide/part-a/3-asyncapi-document-standards.md new file mode 100644 index 0000000..64d46c6 --- /dev/null +++ b/api-design-guide/part-a/3-asyncapi-document-standards.md @@ -0,0 +1,47 @@ +--- +description: "Rules governing the canonical AsyncAPI document: version, location, validation, metadata, and vendored shared components." +--- + +# 3. AsyncAPI document standards + +{% hint style="info" %} +**Intent.** Event-driven BB surfaces other than HTTP push webhooks have the same level of discoverability and mechanical validity as REST surfaces. Implementers must be able to identify each canonical AsyncAPI artifact, validate it, and understand which CloudEvents messages the BB sends or receives. + +**Applies to:** AsyncAPI surface. +{% endhint %} + +## 3.1 AsyncAPI 3.0.0 required + +**[M]** An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3.0 and **MUST** declare `asyncapi: 3.0.0`. Earlier versions **MUST NOT** be used for new GovStack event-driven surfaces. + +## 3.2 One canonical AsyncAPI entrypoint + +**[M+R]** The canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent event-driven surfaces **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml`. + +## 3.3 No divergent AsyncAPI copies + +**[R]** Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it. + +## 3.4 Passes an AsyncAPI validator + +**[M]** The file **MUST** pass an AsyncAPI 3.0 parser/validator (for example, `@asyncapi/parser` or the AsyncAPI CLI). + +## 3.5 Complete AsyncAPI info block + +**[M]** The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), `description`, and `contact`. + +## 3.6 Servers channels operations and messages + +**[M+R]** The file **MUST** declare non-empty `servers`, `channels`, `operations`, and `components.messages`. A document with only schemas and no operations is not an API contract. AsyncAPI `servers` **MUST** describe the intended broker or transport endpoint pattern using AsyncAPI 3.0 server fields (`host`, `protocol`, optional `pathname`, variables, security, and protocol bindings). Reference specifications that are not tied to a live broker **SHOULD** use parameterised server hosts and variables (for example, `host: "{brokerHost}"` with `protocol: mqtt`, `protocol: amqp`, `protocol: kafka`, or `protocol: wss`). Server definitions **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production brokers. Reserved documentation domains **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production. + +## 3.7 Complete AsyncAPI operation metadata + +**[M+R]** Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced CloudEvents message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. + +## 3.8 Pinned vendored AsyncAPI components + +**[M]** Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit. + +## 3.9 JSON Schema payload conventions + +**[M]** AsyncAPI documents **MUST** use JSON Schema compatible with AsyncAPI 3.0 for payload schemas and **MUST** follow the JSON conventions in [§9](../part-c/9-json-conventions-and-naming.md) and [§10](../part-c/10-data-types-and-formats.md) for GovStack-owned payload fields. diff --git a/api-design-guide/part-a/4-documentation-requirements.md b/api-design-guide/part-a/4-documentation-requirements.md new file mode 100644 index 0000000..fb2b651 --- /dev/null +++ b/api-design-guide/part-a/4-documentation-requirements.md @@ -0,0 +1,27 @@ +--- +description: "Documentation requirements for schemas, examples, and operation descriptions across OpenAPI and AsyncAPI surfaces." +--- + +# 4. Documentation requirements + +{% hint style="info" %} +**Intent.** The spec is read by implementers, not only by tools. Operations and schemas need human-readable prose. + +**Applies to:** Universal (OpenAPI and AsyncAPI surfaces). +{% endhint %} + +## 4.1 Every schema described + +**[M]** Every schema **MUST** have a `description`. + +## 4.2 Examples for bodies and enums + +**[M+R]** Every request body and response body **MUST** have at least one `example`. Every `enum` **MUST** document what its values mean (an `example` alone is insufficient when the values are not self-explanatory). + +## 4.3 No placeholder text + +**[M+R]** The spec **MUST NOT** contain placeholder text: `TBD`, `Lorem ipsum`, `a, b, c`, content from other BBs with the original BB name still present, or test plans with literal placeholder steps. + +## 4.4 Accurate operation descriptions + +**[R]** Operation `description` **MUST** describe what the operation actually does. (The audit found at least 9 BBs with cross-endpoint description mismatches from copy-paste.) diff --git a/api-design-guide/part-b/5-url-structure-and-versioning.md b/api-design-guide/part-b/5-url-structure-and-versioning.md new file mode 100644 index 0000000..e6f9766 --- /dev/null +++ b/api-design-guide/part-b/5-url-structure-and-versioning.md @@ -0,0 +1,56 @@ +--- +description: "Rules governing URL path structure, resource naming, and version placement in the API surface." +--- + +# 5. URL structure and versioning + +{% hint style="info" %} +**Intent.** Paths describe resources, not actions. Versions are visible. The same resource lives at the same path across BBs. + +**Applies to:** OpenAPI surface only (HTTP/REST). +{% endhint %} + +## 5.1 Major version in the path + +**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). [`[OPEN-4-A]`](../appendix/b-open-questions.md) + +## 5.2 Plural noun resources + +**[M+R]** Resource paths **MUST** use plural nouns (`/policies`, not `/policy`). + +## 5.3 Kebab-case path segments + +**[M]** Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`). + +## 5.4 Shallow path nesting + +**[M]** Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. [`[OPEN-4-C]`](../appendix/b-open-questions.md) + +## 5.5 Identifiers as path parameters + +**[M+R]** Resource identifiers **MUST** be path parameters, not query parameters. (`DELETE /v1/events/{eventId}`, not `DELETE /v1/event?event_id=...`.) + +## 5.6 Query parameter naming + +**[M]** Query parameter names **MUST** follow the JSON naming convention defined in [§9](../part-c/9-json-conventions-and-naming.md). + +## 5.7 No verbs in CRUD paths + +**[M+R]** Verbs **MUST NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.) + +## 5.8 Actions as sub-resources + +**[R]** Non-CRUD actions **MUST** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`. + +## 5.9 Unversioned health endpoint + +**[M+R]** Each BB **MUST** expose an unversioned operational liveness endpoint at `/health` using media type `application/health+json` with `status` values `"pass" | "fail" | "warn"`. This shape is modelled on `draft-inadarei-api-health-check`, an expired individual Internet-Draft (never adopted as an RFC); GovStack adopts it as a local convention, not as a live IETF standard. A separate `/ready` endpoint **MAY** be exposed for readiness probes. These endpoints **MUST NOT** carry citizen authentication and **MUST NOT** expose system-internal detail. [`[OPEN-4-B]`](../appendix/b-open-questions.md) + +**Example (informative).** A `/health` response (media type `application/health+json`), aligned with `draft-inadarei-api-health-check`: + +```json +{ + "status": "pass", + "description": "health of the registration BB" +} +``` diff --git a/api-design-guide/part-b/6-http-methods.md b/api-design-guide/part-b/6-http-methods.md new file mode 100644 index 0000000..88bf539 --- /dev/null +++ b/api-design-guide/part-b/6-http-methods.md @@ -0,0 +1,39 @@ +--- +description: "Rules defining the meaning, safety, and idempotency guarantees of each HTTP method." +--- + +# 6. HTTP methods + +{% hint style="info" %} +**Intent.** Each verb has one meaning. Specs MUST NOT redefine them. + +**Applies to:** OpenAPI surface only (HTTP/REST). +{% endhint %} + +## 6.1 GET is safe and idempotent + +**[M+R]** `GET` **MUST** be safe and idempotent. Requests **MUST NOT** carry a body. + +## 6.2 POST creates or performs actions + +**[R]** `POST` creates a resource or performs a non-idempotent action. + +## 6.3 PUT replaces the entire resource + +**[R]** `PUT` **MUST** replace the entire resource and **MUST** be idempotent. + +## 6.4 PATCH uses JSON Merge Patch + +**[M+R]** `PATCH` partially updates a resource. Request bodies **MUST** use JSON Merge Patch (RFC 7396) with media type `application/merge-patch+json`. Note that under RFC 7396 a member set to `null` means "remove this member", so Merge Patch cannot set a nullable field ([§9.4](../part-c/9-json-conventions-and-naming.md#94-explicit-nullability)) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** additionally support RFC 6902 JSON Patch via `application/json-patch+json`; such endpoints **MUST** document which media type carries which semantics. + +## 6.5 DELETE response semantics + +**[M+R]** `DELETE` removes a resource. Synchronous hard delete **MUST** return `204 No Content` with no body. Soft delete or async delete (audit retention, undo window) **MAY** return `200` with a body describing the resulting state, or `202` with an Operation per [§15](../part-d/15-asynchronous-operations.md). + +## 6.6 POST search for complex queries + +**[M+R]** Complex queries that cannot fit in a URL query string **MAY** use `POST /v1/{collection}/search` with a request body. The response **MUST** return `200`, not `201`. + +## 6.7 Bulk mutation needs explicit selection + +**[M+R]** A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (The 2026 audit found mutable audit logs and filter-less bulk update/delete that could rewrite or destroy an entire registry.) diff --git a/api-design-guide/part-b/7-http-status-codes.md b/api-design-guide/part-b/7-http-status-codes.md new file mode 100644 index 0000000..e029435 --- /dev/null +++ b/api-design-guide/part-b/7-http-status-codes.md @@ -0,0 +1,91 @@ +--- +description: "Rules mapping API outcomes to standard HTTP status codes, caching, and concurrency headers." +--- + +# 7. HTTP status codes + +{% hint style="info" %} +**Intent.** Status codes are part of the contract. Same outcome, same code, across BBs. + +**Applies to:** OpenAPI surface only (HTTP/REST). +{% endhint %} + +## 7.1 200 for successful reads + +**[R]** `200 OK`: successful read or non-creation action. + +## 7.2 201 Created with Location + +**[M]** `201 Created`: resource creation. Response **MUST** include a `Location` header pointing to the created resource. + +## 7.3 202 Accepted for async operations + +**[M+R]** `202 Accepted`: async operation. Response **MUST** include a `Location` header pointing to an Operation resource (see [§15](../part-d/15-asynchronous-operations.md)). + +## 7.4 204 for void responses + +**[R]** `204 No Content`: successful DELETE or void response. + +## 7.5 400 for malformed requests + +**[R]** `400 Bad Request`: request is malformed or unparseable. + +## 7.6 401 with WWW-Authenticate + +**[M+R]** `401 Unauthorized`: missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For OAuth 2.0 bearer schemes ([§13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations)) it **SHOULD** carry the RFC 6750 challenge with an `error` value such as `invalid_token`. + +## 7.7 403 when not authorised + +**[R]** `403 Forbidden`: authenticated but not authorised. + +## 7.8 404 for missing resources + +**[R]** `404 Not Found`: resource does not exist. + +## 7.9 409 for state conflicts + +**[R]** `409 Conflict`: a conflict with the current state of the resource that is not expressed as a failed precondition (e.g., creating a duplicate of a uniquely-keyed resource, an illegal state transition, or a concurrent in-flight idempotency retry per [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch)). A failed conditional precondition is `412` ([7.15](#715-412-for-failed-preconditions)), not `409`. + +## 7.10 410 for permanent removal + +**[R]** `410 Gone`: resource permanently removed; deprecated endpoint past sunset. + +## 7.11 422 for semantic errors + +**[R]** `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"): request is well-formed but semantically invalid. The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). [`[OPEN-6-A]`](../appendix/b-open-questions.md) + +## 7.12 429 for rate limits + +**[R]** `429 Too Many Requests`: client exceeded rate limit. + +## 7.13 Server errors documented + +**[M]** `500`, `502`, `503`, `504`: server errors. Specs **MUST** document `500` at minimum. + +## 7.14 All status codes declared + +**[M]** Every operation **MUST** declare the status codes it can return. Declaring only `200` is forbidden. + +## 7.15 412 for failed preconditions + +**[R]** `412 Precondition Failed`: conditional request precondition (e.g., `If-Match`) was not satisfied. + +## 7.16 ETag and If-None-Match + +**[M+R]** Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the resource state. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change. + +## 7.17 Optimistic concurrency with If-Match + +**[M+R]** `PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: ` and the server returns `412 Precondition Failed` ([7.15](#715-412-for-failed-preconditions)) if the resource has changed since that ETag. A failed `If-Match` precondition is `412`, not `409`; `409` ([7.9](#79-409-for-state-conflicts)) is reserved for state or uniqueness conflicts that are not expressed as a conditional precondition. + +## 7.18 405 with Allow header + +**[M]** `405 Method Not Allowed`: the target resource does not support the request method. The response **MUST** include an `Allow` header listing the supported methods (RFC 9110). + +## 7.19 415 for unsupported media types + +**[M+R]** `415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints ([§6.4](../part-b/6-http-methods.md#64-patch-uses-json-merge-patch)) **MUST** return `415` when the patch media type is neither `application/merge-patch+json` nor a documented `application/json-patch+json`. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header. + +## 7.20 No-store on error responses + +**[M]** Error responses (`application/problem+json`) and Operation-status responses ([§15](../part-d/15-asynchronous-operations.md)) **SHOULD** declare `Cache-Control: no-store`, so a shared cache cannot replay a transient failure or stale operation state. Broader caching behaviour is operational and out of scope ([§1.2](../1-introduction.md#12-scope)). diff --git a/api-design-guide/part-b/8-headers.md b/api-design-guide/part-b/8-headers.md new file mode 100644 index 0000000..2b81bbd --- /dev/null +++ b/api-design-guide/part-b/8-headers.md @@ -0,0 +1,39 @@ +--- +description: "Rules governing standard, custom, and rate-limit HTTP headers used across the API surface." +--- + +# 8. Headers + +{% hint style="info" %} +**Intent.** Standard headers are reused. Custom headers are namespaced. No essential data lives in the URL. + +**Applies to:** OpenAPI surface only (HTTP/REST). The [§8.6](#86-no-personal-data-in-addressable-locations) principle (no personal data in addressable locations) is universal; its AsyncAPI restatement for channel names, topics, routing keys, and message headers is in [§17](../part-d/17-asyncapi-channel-rules.md). +{% endhint %} + +## 8.1 Credentials in Authorization header + +**[M+R]** Authentication credentials **MUST** travel in the `Authorization` header. They **MUST NOT** appear in query parameters, fragments, or URL paths. + +## 8.2 Accept-Language and Content-Language + +**[M+R]** Localisation requests **MUST** use `Accept-Language`; responses **MUST** echo via `Content-Language`. + +## 8.3 Idempotency-Key header accepted + +**[M+R]** POST endpoints that require idempotency under [§14](../part-d/14-idempotency.md) **MUST** accept an `Idempotency-Key` header, unless [§14.6](../part-d/14-idempotency.md#146-naturally-idempotent-designs) applies. + +## 8.4 X-Request-Id correlation + +**[M+R]** Every request **SHOULD** carry an `X-Request-Id` header for correlation. The server **MUST** echo this header in the response (or generate one if absent). This correlation identifier is distinct from the error-envelope `traceId` ([§11.3](../part-c/11-errors.md#113-govstack-error-extension-fields)); a BB **MAY** reuse the same value but is not required to, and any propagation between them is operational and out of scope ([§1.2](../1-introduction.md#12-scope)). + +## 8.5 No new X- prefixed headers + +**[M]** New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix (per RFC 6648), except for the legacy correlation header explicitly allowed in [§8.4](#84-x-request-id-correlation) pending [`[OPEN-7-A]`](../appendix/b-open-questions.md). + +## 8.6 No personal data in addressable locations + +**[R]** Personal data (national identifier, phone, email, name, date of birth, exact address) **MUST NOT** appear in path segments, query parameters, or header values other than purpose-specific signed assertions (e.g., an OIDC ID token). Opaque server-generated IDs ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) **MUST** be used to refer to citizen records in URLs. + +## 8.7 Rate-limit headers declared + +**[M+R]** Endpoints rate-limited by the BB itself **MUST** declare rate-limit response headers per `draft-ietf-httpapi-ratelimit-headers` (an active, still-evolving Internet-Draft, not yet an RFC); where rate limiting is delegated to an API gateway or interoperability mediator, the spec **MUST** state that, rather than declaring headers the BB does not emit. The default v0.1 form is the three-header variant: `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`, chosen deliberately for its current deployment ubiquity over the draft's newer structured-field form. `429` responses **MUST** additionally declare `Retry-After`. [`[OPEN-7-B]`](../appendix/b-open-questions.md) diff --git a/api-design-guide/part-c/10-data-types-and-formats.md b/api-design-guide/part-c/10-data-types-and-formats.md new file mode 100644 index 0000000..916c1ad --- /dev/null +++ b/api-design-guide/part-c/10-data-types-and-formats.md @@ -0,0 +1,51 @@ +--- +description: "Canonical representations for identifiers, dates, money, phone numbers, emails, binaries, and standardised code fields shared across building blocks." +--- + +# 10. Data types and formats + +{% hint style="info" %} +**Intent.** A given concept has the same representation across BBs. + +**Applies to:** Universal. +{% endhint %} + +## 10.1 Opaque server-generated identifiers + +**[M+R]** Resource identifiers **MUST** be opaque, URL-safe strings, server-generated, and globally unique within the BB. UUID v4 (`format: uuid`) **SHOULD** be the default; ULID, KSUID, or other opaque IDs **MAY** be used where ordering or sortability matters. Clients **MUST** treat all IDs as opaque. **Exception:** registries with statutory identifiers (civil registry numbers, parcel IDs, business numbers, licence numbers) **MAY** use those identifiers in URL paths provided [§8.6](../part-b/8-headers.md#86-no-personal-data-in-addressable-locations) is satisfied (the identifier does not constitute personal data; a parcel number or business number is acceptable, a national ID or passport number is not). The opacity requirement applies to BB-generated identifiers; statutory identifiers are by definition not opaque to clients. + +## 10.2 RFC 3339 timestamps + +**[M]** Timestamps **MUST** be RFC 3339 with timezone, declared as `format: date-time`. + +## 10.3 RFC 3339 calendar dates + +**[M]** Dates without time **MUST** be RFC 3339 calendar dates, declared as `format: date`. + +## 10.4 Decimal-string monetary amounts + +**[M+R]** Monetary amounts **MUST** use the object `{ amount: string (decimal), currency: string (ISO 4217) }`. Floats **MUST NOT** be used for money. Decimal-string is chosen over minor-units because GovStack-adopting countries may include currencies with non-decimal subunits and zero-subunit currencies, which a minor-units convention handles inconsistently. + +## 10.5 E.164 phone numbers + +**[M+R]** Phone numbers **MUST** be E.164 strings. + +## 10.6 RFC 5322 email addresses + +**[M+R]** Email addresses **MUST** be RFC 5322 strings, declared as `format: email`. + +## 10.7 Binary uploads and base64 payloads + +**[M+R]** Large binary uploads **MUST** use `multipart/form-data` or a dedicated binary endpoint. Small inline payloads (signatures, certificates, QR codes, attestations) **MAY** be base64-encoded in JSON bodies; the field **MUST** then be declared with `contentEncoding: base64` and a documented size limit. + +## 10.8 ISO 3166-1 country codes + +**[M+R]** Country codes **MUST** be ISO 3166-1 alpha-2. + +## 10.9 BCP 47 language codes + +**[M+R]** Language codes **MUST** be BCP 47. + +## 10.10 ISO 4217 currency codes + +**[M+R]** Currency codes **MUST** be ISO 4217. diff --git a/api-design-guide/part-c/11-errors.md b/api-design-guide/part-c/11-errors.md new file mode 100644 index 0000000..052dc05 --- /dev/null +++ b/api-design-guide/part-c/11-errors.md @@ -0,0 +1,66 @@ +--- +description: "One RFC 9457 problem-details error envelope, GovStack extension fields, namespaced stable error codes, and a shared common-error catalogue." +--- + +# 11. Errors + +{% hint style="info" %} +**Intent.** One error format ecosystem-wide. A shared error schema lets integrators handle failures uniformly across BBs. + +**Applies to:** Universal at the envelope and code-catalogue level. HTTP status mapping ([§7](../part-b/7-http-status-codes.md)) is OpenAPI-specific; the AsyncAPI surface signals errors via transport-appropriate mechanisms using the same envelope. +{% endhint %} + +## 11.1 RFC 9457 problem details + +**[M]** Error responses **MUST** use media type `application/problem+json` per RFC 9457 (which obsoletes RFC 7807 and retains the `application/problem+json` media type). The standard provides broad client and tooling support and removes the burden of maintaining a custom envelope. + +## 11.2 Standard problem fields present + +**[M+R]** Standard RFC 9457 fields `type`, `title`, `status` **MUST** be present. `type` **SHOULD** be a stable URI for the problem type and **MAY** be a dereferenceable documentation URL, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details (stack traces, hostnames, query fragments). + +## 11.3 GovStack error extension fields + +**[M]** GovStack extensions **MUST** include `code` (machine-stable error code), `traceId` (correlation), and `timestamp`. + +## 11.4 Field-level errors array + +**[M+R]** Where a failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry contains `pointer` (JSON Pointer), `code`, and `message`. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch)). + +**Example (informative).** A `422` validation failure carrying the RFC 9457 fields, the GovStack extensions, and the field-level `errors` array: + +```json +{ + "type": "https://docs.govstack.org/errors/registration/validationFailed", + "title": "Request validation failed", + "status": 422, + "detail": "Two request fields failed validation.", + "instance": "/v1/applications/3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", + "code": "org.govstack.registration.validationFailed", + "traceId": "6f1c3f0e2a9b4c8d", + "timestamp": "2026-07-10T08:30:00Z", + "errors": [ + { + "pointer": "/applicant/phoneNumber", + "code": "org.govstack.registration.invalidPhoneNumber", + "message": "Phone number must be an E.164 string." + }, + { + "pointer": "/applicant/birthDate", + "code": "org.govstack.registration.invalidDate", + "message": "Date must be an RFC 3339 calendar date." + } + ] +} +``` + +## 11.5 Namespaced stable error codes + +**[M+R]** Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` ([§11.2](#112-standard-problem-fields-present)). The default shape is reverse-DNS: `org.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `{error-name}` segment **MUST** use lowerCamelCase, for example `org.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `org.govstack.{bb-code}.{number}`. [`[OPEN-10-A]`](../appendix/b-open-questions.md) + +## 11.6 Stable codes across languages + +**[R]** `title` and `detail` **MAY** be localised; `code` and `type` **MUST** remain stable across languages. + +## 11.7 Common error catalogue + +**[M+R]** A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `org.govstack.common.unauthenticated`, `org.govstack.common.permissionDenied`, `org.govstack.common.notFound`, `org.govstack.common.invalidArgument`, `org.govstack.common.alreadyExists`, `org.govstack.common.aborted`, `org.govstack.common.resourceExhausted`, `org.govstack.common.internal`, `org.govstack.common.unimplemented`. The final list is [`[OPEN-10-B]`](../appendix/b-open-questions.md). diff --git a/api-design-guide/part-c/12-pagination-filtering-sorting.md b/api-design-guide/part-c/12-pagination-filtering-sorting.md new file mode 100644 index 0000000..d675407 --- /dev/null +++ b/api-design-guide/part-c/12-pagination-filtering-sorting.md @@ -0,0 +1,79 @@ +--- +description: "Mandatory pagination for collections, cursor and offset envelopes, page-size bounds, and conventions for sorting and filtering." +--- + +# 12. Pagination, filtering, sorting + +{% hint style="info" %} +**Intent.** Collection endpoints survive production data volumes. + +**Applies to:** OpenAPI surface only (HTTP/REST). AsyncAPI streams use transport-specific backpressure and replay primitives; the declaration requirements for those contracts are in [§17](../part-d/17-asyncapi-channel-rules.md). +{% endhint %} + +## 12.1 Collections must paginate + +**[M+R]** Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden. + +## 12.2 Cursor pagination by default + +**[M+R]** Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken` to align with the wider non-Google ecosystem (GraphQL Relay Connections, GitHub, Twitter). The cursor **MUST** be opaque to clients (server-encoded, typically base64 of an internal representation); clients **MUST NOT** parse or construct cursor values. + +## 12.3 Cursor pagination envelope + +**[M]** Pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. The `pageInfo` wrapper is inspired by the GraphQL Relay Connections specification but deliberately simplified: it uses a flat `items` array rather than Relay's `edges`/`node`, and `nextCursor`/`hasMore` rather than Relay's `endCursor`/`hasNextPage`. + +**Example (informative).** A cursor-paginated collection response (`total` omitted per [§12.5](#125-optional-total-count)): + +```json +{ + "items": [ + { "id": "0d4b2a4e-3f5d-4a83-9b0e-6f2e8d1c7a90", "status": "ACTIVE" }, + { "id": "8a1f9c2b-7e64-4f0d-8a3b-2c5d9e0f1b47", "status": "PENDING_REVIEW" } + ], + "pageInfo": { + "nextCursor": "eyJvZmZzZXQiOjQyfQ", + "hasMore": true + } +} +``` + +## 12.4 Documented pageSize bounds + +**[M+R]** `pageSize` **MUST** have a documented default and maximum. Specific numeric values are per-BB. + +## 12.5 Optional total count + +**[R]** `total` **MAY** be omitted when computing it is expensive. + +## 12.6 Offset pagination envelope + +**[M+R]** Offset pagination **MAY** be used for admin or fixed-size lists. In that case the envelope **MUST** be the flat shape `{ items, offset, limit, total }`, distinct from the cursor `pageInfo` envelope in [§12.3](#123-cursor-pagination-envelope); `total` is required here (unlike [§12.3](#123-cursor-pagination-envelope), where it is optional). + +**Example (informative).** An offset-paginated response for an admin list (`total` required here): + +```json +{ + "items": [ + { "id": "b7f3d9e0-1a2b-4c5d-8e9f-0a1b2c3d4e5f", "name": "Civil registration office 12" } + ], + "offset": 0, + "limit": 20, + "total": 134 +} +``` + +## 12.7 Sort parameter convention + +**[M]** Sort parameter **MUST** be `sort`, values `field` (ascending) or `-field` (descending); multiple criteria separated by commas. + +## 12.8 Simple equality filtering + +**[M+R]** Simple filtering **MUST** use one query parameter per field, equality only. + +## 12.9 Complex filtering via search + +**[M+R]** Complex filtering **MUST** use `POST /v1/{collection}/search` per [§6.6](../part-b/6-http-methods.md#66-post-search-for-complex-queries). For this endpoint, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the [§12.3](#123-cursor-pagination-envelope) envelope. + +## 12.10 Sparse fieldsets out of scope + +Sparse fieldsets (response field selection) are out of scope for v1.0. diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md new file mode 100644 index 0000000..8c2d8d6 --- /dev/null +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -0,0 +1,71 @@ +--- +description: "Field naming, JSON representation, forward-compatibility, and specification-extension conventions applied across every building block." +--- + +# 9. JSON conventions and naming + +{% hint style="info" %} +**Intent.** One naming style ecosystem-wide. The audit found camelCase, PascalCase, snake_case, and fields with literal spaces coexisting within single BBs. + +**Applies to:** Universal. +{% endhint %} + +## 9.1 JSON as default media type + +**[M+R]** Default response media type **MUST** be `application/json` unless the resource is binary or a document export. + +## 9.2 camelCase field names + +**[M]** JSON field names **MUST** use `camelCase`, applied consistently across the entire ecosystem. (See [note below](#note-on-92) on the choice of casing.) + +## 9.3 Real JSON booleans + +**[M]** Boolean fields **MUST** be JSON booleans (`true`/`false`), not strings. + +## 9.4 Explicit nullability + +**[M]** Nullability **MUST** be explicit (`type: [..., "null"]` per OpenAPI 3.1). + +## 9.5 No spaces or non-ASCII names + +**[M]** Field names **MUST NOT** contain spaces or non-ASCII characters. + +## 9.6 Avoid abbreviations + +**[R]** Abbreviations **SHOULD NOT** be used (prefer `quantity` over `qty`). + +## 9.7 Screaming snake case enum values + +**[M]** Enum values **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). + +## 9.8 Forward-compatible schemas + +**[M]** Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values must be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in [§18.6](../part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields) as a non-normative reader expectation. + +## 9.9 No closed enums for growing sets + +**[R]** Fields whose value set is expected to grow **MUST NOT** be declared as a closed OpenAPI `enum`, because client code generated from a closed enum typically rejects values added later, which would make the "adding enum values is non-breaking" guarantee of [§18.3](../part-d/18-compatibility-and-lifecycle.md#183-backward-compatible-minor-changes) false in practice. Such fields **MUST** either be declared as an open `type: string` annotated with `x-extensible-enum` (carrying the known values), or define an explicit fallback member (e.g., `UNKNOWN`) that conforming clients map unrecognised values to. Truly fixed value sets (e.g., ISO-defined codes) **MAY** remain closed enums. + +## 9.10 GovStack extension prefix + +**[M+R]** GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay` in [§17.15](../part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions), `x-govstack-deprecated` in [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata), and `x-govstack-api-guide` in [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)). + +## 9.11 Single registered BB code + +**[M+R]** Every namespace that embeds a BB code (error codes [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), problem-type URLs [§11.2](../part-c/11-errors.md#112-standard-problem-fields-present), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), channel addresses [§17.2](../part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)). The BB-code register is proposed for the Lifecycle & Governance companion ([Appendix A](../appendix/a-companion-documents.md)); until it exists, codes **SHOULD** be agreed through the API Working Group. [`[OPEN-9-A]`](../appendix/b-open-questions.md) + +## Note on 9.2 + +`camelCase` aligns with OAuth/OIDC, OpenID Federation, JSON:API, and the majority of public REST APIs. `snake_case` would align with the Python ecosystem. Either is defensible; consistency across BBs is what matters most. Migration cost for BBs already using snake_case is proposed for the Lifecycle & Governance companion. + +## Carve-out from 9.2 + +(Per [§1.7](../1-introduction.md#17-precedence-of-external-standards).) + +Fields imported wholesale from an external standard retain that standard's naming. The known cases in v0.1 are RFC 9457 error fields (`type`, `title`, `status`, `detail`, `instance`; see [§11.1](../part-c/11-errors.md#111-rfc-9457-problem-details)) and CloudEvents fields (`specversion`, `id`, `source`, `type`, `time`, `datacontenttype`, `data`; see [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)). CloudEvents extension attributes also follow CloudEvents naming rules, not [§9.2](#92-camelcase-field-names), because CloudEvents requires lowercase ASCII attribute names. The carve-out applies only to fields defined by the imported standard or by a CloudEvents extension; GovStack extensions to the RFC 9457 error envelope (the `code`, `traceId`, `timestamp` fields in [§11.3](../part-c/11-errors.md#113-govstack-error-extension-fields)), GovStack-owned transport/application headers, and the contents of the event `data` payload follow [§9.2](#92-camelcase-field-names). + +## Carve-out from 9.7 + +(Per [§1.7](../1-introduction.md#17-precedence-of-external-standards).) + +Enum values defined by an external standard retain that standard's casing. The known case in v0.1 is the `/health` status values `"pass" | "fail" | "warn"` per [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) (IETF `draft-inadarei-api-health-check`). diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md new file mode 100644 index 0000000..136c05b --- /dev/null +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -0,0 +1,39 @@ +--- +description: "Rules for how BB API specs declare security schemes, OAuth scopes, and the credential channel for authentication and authorisation." +--- + +# 13. Authentication and authorisation + +{% hint style="info" %} +**Intent.** Every BB declares its security model in the same way, so integrators see a uniform shape for "how do I call this BB" across the catalogue. + +**Applies to:** Universal. Rules reference OpenAPI `securitySchemes`; AsyncAPI 3.0 uses corresponding declarations under `components.securitySchemes` and operation/server `security` (for example, `oauth2`, `openIdConnect`, and `X509`). +{% endhint %} + +## 13.1 Default security on every operation + +**[M]** Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit. + +## 13.2 OAuth and OIDC for citizen operations + +**[M+R]** Citizen-facing operations **MUST** declare an OAuth 2.0 + OIDC security requirement. On the OpenAPI surface this is either a `type: openIdConnect` scheme carrying `openIdConnectUrl` (the OIDC discovery document) or a `type: oauth2` scheme declaring the relevant `flows`. (A `type: oauth2` scheme does not carry a discovery URL; the discovery URL belongs to the `openIdConnect` scheme type.) Reference specifications that are not tied to a live identity provider **MAY** use documented deployment variables or reserved documentation domains for discovery, authorisation, token, and JWKS URLs. Adopter-specific identity-provider endpoints belong in implementation profiles. + +## 13.3 Distinct scheme for BB-to-BB calls + +**[M+R]** BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service auth. On OpenAPI this is typically `type: mutualTLS` or OAuth client credentials; on AsyncAPI this is typically `type: X509`, OAuth client credentials, or a protocol-specific scheme such as SASL where the broker requires it. The spec **MUST** make clear which operations are citizen-facing vs inter-BB. + +## 13.4 Namespaced OAuth scopes + +**[M]** OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`org.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-12-A]`](../appendix/b-open-questions.md) + +## 13.5 Authorization is the credential channel + +**[M+R]** Credentials **MUST NOT** be declared in URL paths, query parameters, cookies, or fragments. The `Authorization` header is the only declared credential channel for token-based schemes. + +## 13.6 API keys only for operational endpoints + +**[R]** API keys **MAY** be declared on operational endpoints (`/health` and similar per [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)). They **MUST NOT** be declared on operations that read or write personal data. + +## Note on consent propagation + +Cross-service propagation of end-user consent or authorisation context (for example, when one BB calls another on behalf of a data subject) is not specified by this guide. The expected mechanism is OAuth-native (scopes, claims, or RFC 9396 Rich Authorization Requests inside the access token), and the concrete shape belongs in the relevant consent, authorisation, and inter-BB trust specifications, not here. diff --git a/api-design-guide/part-d/14-idempotency.md b/api-design-guide/part-d/14-idempotency.md new file mode 100644 index 0000000..379b476 --- /dev/null +++ b/api-design-guide/part-d/14-idempotency.md @@ -0,0 +1,37 @@ +--- +description: "Rules for the Idempotency-Key header contract that lets clients safely retry non-idempotent POST requests." +--- + +# 14. Idempotency + +{% hint style="info" %} +**Intent.** Safe retries. A shared idempotency contract is what lets a client retry a payment, registration, or message submission without risk of duplicates. + +**Applies to:** Universal. On the AsyncAPI surface the same idempotency concept applies using the message metadata rules in [§17.8](../part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata). + +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§14.1](#141-idempotency-key-on-non-idempotent-posts) and [§14.3](#143-documented-replay-window) constrain the specification: declare the header and document the replay-window contract. [§14.2](#142-opaque-client-generated-keys) and [§14.4](#144-replay-returns-original-response)–[§14.6](#146-naturally-idempotent-designs) are behavioural-contract rules: they bind a conforming implementation at run time and are verified by the conformance test pack, not by spec linting. Concrete replay-window values, key-store retention, and the maximum accepted key length are deployment values for implementation profiles; replay enforcement and key storage are operational concerns for the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). +{% endhint %} + +## 14.1 Idempotency-Key on non-idempotent POSTs + +**[M+R]** Except where [§14.6](#146-naturally-idempotent-designs) applies (a naturally idempotent design), POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support idempotency unless the operation is naturally idempotent by contract and documents its duplicate-handling semantics. Read-like POSTs, such as complex search endpoints, **MAY** support idempotency but are not required to. The header follows the convention established by Stripe and is being standardized in the IETF httpapi working group as `draft-ietf-httpapi-idempotency-key-header` (an Internet-Draft, not yet an RFC). + +## 14.2 Opaque client-generated keys + +**[R]** The key **MUST** be an opaque, client-generated string and **SHOULD** be a UUID. (The cited draft RECOMMENDS a UUID rather than requiring one; per [§1.7](../1-introduction.md#17-precedence-of-external-standards) the guide does not specify past the adopted standard by mandating a particular UUID version.) + +## 14.3 Documented replay window + +**[R]** The spec **MUST** document the idempotency replay-window contract. A reference specification **SHOULD** state the required minimum replay window or the configuration parameter that controls it. Concrete replay-window values belong in implementation profiles. + +## 14.4 Replay returns original response + +**[R]** A repeated request with the same key within the documented window **MUST** return the original response (status, body, headers). + +## 14.5 Key reuse and fingerprint mismatch + +**[R]** A repeated request reusing the same key with a *different* request body **MUST** return `422 Unprocessable Content` (the request fingerprint does not match the original), per the cited draft. A repeated request that arrives while the original is still being processed (a concurrent in-flight retry) **MUST** return `409 Conflict`. The request fingerprint **MUST** be computed over at least the canonicalised request body; a BB **MAY** additionally include the method and target. The spec **MUST** document a maximum accepted key length so oversized keys are rejected deterministically. + +## 14.6 Naturally idempotent designs + +**[R]** Naturally idempotent designs **MAY** satisfy this section without an `Idempotency-Key` header when idempotency is already guaranteed by the resource contract, for example `PUT /v1/resources/{clientProvidedId}` or creation with a documented unique business key that returns the existing resource or a stable conflict on duplicate submission. The spec **MUST** document that duplicate-handling behaviour per operation. Because [§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers) makes BB-generated identifiers server-assigned by default, `PUT`-with-client-id creation is available only where the resource is keyed by a client-supplied or statutory identifier ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)). diff --git a/api-design-guide/part-d/15-asynchronous-operations.md b/api-design-guide/part-d/15-asynchronous-operations.md new file mode 100644 index 0000000..8f4fcc9 --- /dev/null +++ b/api-design-guide/part-d/15-asynchronous-operations.md @@ -0,0 +1,51 @@ +--- +description: "The shared Operation resource shape and polling pattern BBs use for operations that cannot complete synchronously." +--- + +# 15. Asynchronous operations + +{% hint style="info" %} +**Intent.** A single async pattern across BBs. Without one, every BB picks a different status code (200, 201, or 202) and a different polling shape, and integrators write per-BB glue. In this guide, *Operation resource* (capitalised) is the polling resource defined in this section; lowercase *operation* means an OpenAPI or AsyncAPI operation. + +**Applies to:** OpenAPI surface (HTTP/REST). The Operation resource shape is reusable across surfaces; the `202` and polling mechanics are HTTP-specific. +{% endhint %} + +## 15.1 202 with Operation Location + +**[M+R]** Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource. + +## 15.2 Shared Operation resource shape + +**[M]** The Operation resource **MUST** be declared once in `govstack-openapi-common.yaml` and `$ref`'d by all BBs. The default shape is `{ id, status, result, error, createdAt, updatedAt, progress? }`, modelled on Google AIP-151 (Long-Running Operations). A stricter AIP-151 mirror (with `done` and `metadata`) is a defensible alternative. [`[OPEN-14-A]`](../appendix/b-open-questions.md) + +## 15.3 Fixed Operation status enum + +**[M]** Operation `status` **MUST** be drawn from a fixed enumeration declared in the common file. The default set is `PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELLED`. AIP-151's boolean `done` plus a result-or-error union is a defensible alternative. [`[OPEN-14-A]`](../appendix/b-open-questions.md) + +**Example (informative).** An in-progress Operation resource: + +```json +{ + "id": "9c3d2f6a-5b1e-4d7c-a8f0-1e2d3c4b5a69", + "status": "RUNNING", + "createdAt": "2026-07-10T08:30:00Z", + "updatedAt": "2026-07-10T08:30:05Z", + "progress": 40 +} +``` + +## 15.4 Polling the Operation resource + +**[M+R]** Clients poll via `GET /v1/operations/{operationId}`. + +## 15.5 Cancellation via cancel sub-resource + +**[M+R]** Cancellation, when supported, **MUST** be `POST /v1/operations/{operationId}/cancel`. + +## 15.6 Webhook completion notification + +**[R]** Long-running operations **SHOULD** support completion notification via webhook ([§16](../part-d/16-cloudevents-and-webhooks.md)) rather than requiring clients to poll indefinitely. The threshold at which notification becomes expected is operational and per-BB. + +## 15.7 Documented result retention + +**[R]** Operation result availability **MUST** be documented as a consumer-visible contract. A reference specification **SHOULD** state the required minimum retention or the configuration parameter that controls it. Concrete retention values belong in implementation profiles. diff --git a/api-design-guide/part-d/16-cloudevents-and-webhooks.md b/api-design-guide/part-d/16-cloudevents-and-webhooks.md new file mode 100644 index 0000000..6ef680d --- /dev/null +++ b/api-design-guide/part-d/16-cloudevents-and-webhooks.md @@ -0,0 +1,75 @@ +--- +description: "Rules governing the CloudEvents envelope, event-type and source naming, signed and replay-detectable delivery, and subscription management for webhooks, brokered channels, and event streams." +--- + +# 16. CloudEvents and webhooks + +{% hint style="info" %} +**Intent.** A single event contract across BBs. Many BBs need event notifications; a shared CloudEvents envelope, type/source convention, signing contract, and delivery-failure contract is what makes them composable, regardless of transport. + +**Applies to:** Event-driven. CloudEvents rules apply to HTTP webhooks, brokered event channels, and event streams. OpenAPI and AsyncAPI are documentation formats for those surfaces, not alternative event-envelope standards. + +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§16.1](#161-event-surfaces-documented), [§16.3](#163-reverse-dns-event-types), [§16.4](#164-stable-cloudevents-source), [§16.6](#166-govstack-signature-header), [§16.10](#1610-documented-delivery-failure-contract), and [§16.11](#1611-subscription-management-interfaces) constrain the specification (documentation and declaration). [§16.5](#165-signed-event-delivery) and [§16.7](#167-replay-detectable-signed-material) are behavioural-contract rules, verified by the conformance test pack once the event-signature profile ([§16.8](#168-pinned-signature-profile), [`[OPEN-15-A]`](../appendix/b-open-questions.md)) exists; that profile is a v1.0 prerequisite. [§16.9](#169-operational-signing-concerns-out-of-scope) keeps replay enforcement and signing-key rotation in the Security & Operations companion. +{% endhint %} + +## 16.1 Event surfaces documented + +**[M+R]** Event-driven APIs **MUST** be documented. HTTP push **MUST** use OpenAPI 3.1 `webhooks`; brokered transports and event streams (MQTT, AMQP, Kafka, WebSockets, SSE) **MUST** use AsyncAPI 3.0. + +## 16.2 CloudEvents envelope required + +**[M]** GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `"1.0"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. + +## 16.3 Reverse-DNS event types + +**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and does not include the major version; the versioned transport contract is carried in the channel address or equivalent AsyncAPI version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-15-B]`](../appendix/b-open-questions.md) + +## 16.4 Stable CloudEvents source + +**[M+R]** The CloudEvents `source` field **MUST** identify the publishing BB or BB surface in a stable way. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment. + +**Example (informative).** A structured CloudEvents JSON event with a GovStack trace extension attribute: + +```json +{ + "specversion": "1.0", + "id": "5e0c63c2-2b8a-4d3f-9a51-7c6b0d9e8f21", + "source": "org.govstack.registration", + "type": "org.govstack.registration.application.approved", + "time": "2026-07-10T08:30:00Z", + "datacontenttype": "application/json", + "traceid": "6f1c3f0e2a9b4c8d", + "data": { + "applicationId": "3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", + "approvedAt": "2026-07-10T08:29:58Z" + } +} +``` + +## 16.5 Signed event delivery + +**[R]** Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`. [`[OPEN-15-A]`](../appendix/b-open-questions.md) + +## 16.6 GovStack-Signature header + +**[M+R]** On the OpenAPI/webhooks surface the signature **MUST** travel in a single ecosystem-wide HTTP header named `GovStack-Signature` (modelled on Stripe's `Stripe-Signature` and GitHub's `X-Hub-Signature-256`). On the AsyncAPI surface the signature **MUST** travel in the transport's message-metadata channel under the same field name unless the chosen protocol binding defines a more precise field. [`[OPEN-15-C]`](../appendix/b-open-questions.md) + +## 16.7 Replay-detectable signed material + +**[R]** The signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers can detect replays. + +## 16.8 Pinned signature profile + +**[R]** `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** pin the exact bytes that are signed (the canonicalisation: which fields, in which order, with which serialisation), the signing algorithm and its identifier, and the signature verification inputs, so that two independently-built BBs can verify each other's signatures. The default signature scheme is detached JWS over a canonicalised structured CloudEvents JSON payload. HMAC-SHA256 **MAY** be used only where shared-key distribution is explicitly governed. The event-signature profile is required for v1.0 publication because [§16.5](#165-signed-event-delivery) is not mechanically enforceable without it. [`[OPEN-15-A]`](../appendix/b-open-questions.md) + +## 16.9 Operational signing concerns out of scope + +Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). + +## 16.10 Documented delivery-failure contract + +**[R]** HTTP webhook delivery-failure behaviour **MUST** be documented per subscription. A reference specification **MUST** define the portable contract fields: whether redelivery is attempted, whether failed deliveries are stored, and how a subscriber can identify or recover failed deliveries. Concrete retry counts, backoff intervals, and failure-store retention values belong in implementation profiles. Failed deliveries **SHOULD** end in a dead-letter queue or equivalent failure store accessible to the subscription owner. + +## 16.11 Subscription management interfaces + +**[M+R]** Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane. diff --git a/api-design-guide/part-d/17-asyncapi-channel-rules.md b/api-design-guide/part-d/17-asyncapi-channel-rules.md new file mode 100644 index 0000000..b44a7b2 --- /dev/null +++ b/api-design-guide/part-d/17-asyncapi-channel-rules.md @@ -0,0 +1,91 @@ +--- +description: "Rules governing AsyncAPI channel addressing, payload structure, message headers, delivery and ordering guarantees, and examples for brokered and event-stream surfaces." +--- + +# 17. AsyncAPI channel documentation rules + +{% hint style="info" %} +**Intent.** AsyncAPI documents brokered CloudEvents channels and event streams other than HTTP push webhooks. It does not replace CloudEvents and does not solve every broker's operational playbook in this guide. It makes the portable contract complete enough that an integrator can see what a BB publishes or consumes, on which channels, under which security model, and with which delivery guarantees. + +**Applies to:** AsyncAPI surface. If a BB exposes no brokered or event-stream surface (only synchronous REST and/or HTTP push webhooks), [§3](../part-a/3-asyncapi-document-standards.md) and [§17](../part-d/17-asyncapi-channel-rules.md) do not apply. +{% endhint %} + +## 17.1 Send and receive perspective + +**[M+R]** Each AsyncAPI document **MUST** define the BB's perspective. An operation with `action: send` means the BB publishes that message to the channel. An operation with `action: receive` means the BB consumes that message from the channel. + +## 17.2 Reverse-DNS channel addresses + +**[M]** Channel addresses **MUST** follow one ecosystem-wide naming convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). [`[OPEN-15-D]`](../appendix/b-open-questions.md) + +## 17.3 No personal data in channels + +**[R]** Channel addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead. + +## 17.4 Declared channel parameters + +**[M+R]** Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object, and its schema and routing semantics **MUST** be documented. + +## 17.5 No environment names in addresses + +**[M+R]** Environment names (`dev`, `test`, `prod`), broker implementation names, and deployment-specific prefixes **SHOULD NOT** appear in channel addresses. They belong in `servers`, server variables, broker configuration, or deployment routing unless a protocol profile explicitly requires them. + +## 17.6 Structured CloudEvents JSON payloads + +**[M]** AsyncAPI message payloads for GovStack domain events **MUST** use structured CloudEvents JSON: the message payload is the complete CloudEvent, and GovStack-owned domain data lives under the CloudEvents `data` field. This provides one portable, schema-validatable event shape across brokered transports. [`[OPEN-15-E]`](../appendix/b-open-questions.md) + +## 17.7 Shared CloudEvents message schema + +**[M]** AsyncAPI channel message entries **MUST** reference the shared CloudEvents message schema from `govstack-asyncapi-common.yaml` and specialise only the `data` schema for the BB-specific event payload. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0. + +## 17.8 Message headers and idempotency metadata + +**[M+R]** GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. For structured CloudEvents messages, trace and workflow metadata **SHOULD** be carried as CloudEvents extension attributes: `traceid`, `correlationid`, and `causationid`. These names are lowercase because CloudEvents requires lowercase extension-attribute names; the same concepts use camelCase in GovStack-owned JSON bodies and transport/application headers. Transport/application headers **MAY** mirror these values where broker tooling requires header-level metadata, but the CloudEvent remains the normative event envelope. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key. For structured CloudEvents command messages, the key **MUST** be the CloudEvents extension attribute `idempotencykey`; for non-CloudEvents command messages, it **MUST** be the message header `idempotencyKey`. + +## 17.9 Message localisation headers + +**[M+R]** Message headers used for localisation **MUST** be `acceptLanguage` on inbound command/request messages and `contentLanguage` on outbound localised messages. Stable fields such as identifiers, enum values, timestamps, currency codes, and error codes **MUST NOT** be translated. + +## 17.10 Security schemes cover every operation + +**[M+R]** AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Message signing ([§16.5](../part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery)) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication. + +## 17.11 Documented delivery guarantees + +**[M+R]** Each operation **MUST** document its delivery guarantee: `atMostOnce`, `atLeastOnce`, or `effectivelyOnce`. `effectivelyOnce` **MUST** be backed by an idempotency contract, duplicate detection, or a documented resource-state invariant; it **MUST NOT** imply the transport literally delivers a message exactly once. + +## 17.12 Documented ordering guarantees + +**[M+R]** Each operation **MUST** document ordering guarantees, if any. If ordering is partitioned, keyed, or scoped, the key or scope **MUST** be declared. If no ordering is guaranteed, the spec **MUST** state that explicitly. + +## 17.13 Declared delivery-management capabilities + +**[M+R]** Each operation **MUST** declare which delivery-management capabilities the chosen transport contract exposes: redelivery, dead-letter handling, retention, and replay. The declaration **MUST** state whether each capability is supported, unsupported, or not applicable. + +## 17.14 Portable capability contract + +**[R]** A reference specification **MUST** define the portable contract shape, defaults, and allowed bounds for supported delivery-management capabilities. Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings belong in implementation profiles. + +## 17.15 Machine-readable delivery extensions + +**[M+R]** Delivery, ordering, and delivery-management capability declarations **MUST** be machine-readable using GovStack specification extensions declared in `govstack-asyncapi-common.yaml` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay`) as well as human-readable in `description`. [`[OPEN-15-G]`](../appendix/b-open-questions.md) + +## 17.16 Async rejection error messages + +**[M+R]** Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using the common GovStack error envelope from [§11](../part-c/11-errors.md). The error message **MUST** be correlated to the original message using the correlation metadata rules in [§17.8](#178-message-headers-and-idempotency-metadata) or an equivalent protocol binding. + +## 17.17 Declared request-reply correlation + +**[M+R]** Request-reply over messaging **MAY** be used where the protocol and use case support it. When used, the AsyncAPI operation **MUST** declare the reply channel or reply address pattern and the correlation mechanism. Fire-and-forget event publication **MUST NOT** pretend to be request-reply. + +## 17.18 Correlated completion signals + +**[M+R]** Long-running asynchronous work triggered by a message **MUST** expose completion through either an operation-status message based on the [§15](../part-d/15-asynchronous-operations.md) Operation resource or an operation-completed domain event. The spec **MUST** document how clients correlate the completion signal to the initiating message. + +## 17.19 Protocol bindings where relevant + +**[M+R]** Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. [`[OPEN-15-F]`](../appendix/b-open-questions.md) + +## 17.20 Examples for every message + +**[M+R]** AsyncAPI documents **MUST** define examples for every message and **SHOULD** include at least one example showing headers plus payload for each common message family: command, event, error, and operation-completion where applicable. diff --git a/api-design-guide/part-d/18-compatibility-and-lifecycle.md b/api-design-guide/part-d/18-compatibility-and-lifecycle.md new file mode 100644 index 0000000..758b107 --- /dev/null +++ b/api-design-guide/part-d/18-compatibility-and-lifecycle.md @@ -0,0 +1,43 @@ +--- +description: "Rules governing SemVer versioning, backward-compatible and breaking changes, deprecation signalling, and lifecycle transitions across OpenAPI and AsyncAPI artifacts." +--- + +# 18. Compatibility and lifecycle + +{% hint style="info" %} +**Intent.** Specs change without breaking implementers. + +**Applies to:** Universal. SemVer and breaking-change classification apply to both artifacts; URL path versioning in [18.2](#182-major-version-in-path-or-channel) is OpenAPI-specific, channel/version metadata in [18.2](#182-major-version-in-path-or-channel) is AsyncAPI-specific, and the `Deprecation` and `Sunset` *headers* in [18.5](#185-deprecation-and-sunset-headers) are HTTP-specific. AsyncAPI deprecation is declared through AsyncAPI metadata and message/channel/operation descriptions until a stronger ecosystem convention is selected. +{% endhint %} + +## 18.1 SemVer versioning + +**[M]** `info.version` **MUST** follow SemVer. + +## 18.2 Major version in path or channel + +**[M]** Major version increments **MUST** be reflected in the OpenAPI URL path (`/v2/`). AsyncAPI channels **MUST** include the major version in the channel address or an equivalent machine-readable version field documented in `govstack-asyncapi-common.yaml`. + +## 18.3 Backward-compatible minor changes + +**[M+R]** Minor and patch increments **MUST** be backward-compatible. Adding optional fields, adding endpoints, adding enum values (for fields declared extensibly per [§9.9](../part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets)), and relaxing constraints are non-breaking. + +## 18.4 Breaking changes bump major version + +**[M+R]** Breaking changes (removing endpoints, removing fields, narrowing types, narrowing enums, tightening required, changing semantic meaning) **MUST** be released as a new major version. + +## 18.5 Deprecation and Sunset headers + +**[M+R]** Deprecated endpoints **MUST** return a `Deprecation` header per RFC 9745 (a structured-field date carrying the deprecation timestamp, e.g. `Deprecation: @1735689600`) and a `Sunset` header per RFC 8594 indicating planned removal. The minimum deprecation window between announcement and sunset, and the maximum number of concurrent major versions a BB can keep in production, are operational policy and are proposed for the Lifecycle & Governance companion, not here. + +## 18.6 Clients ignore unknown fields + +(informative) Conforming clients are expected to ignore unknown JSON fields and unknown enum values. This is what makes the additive changes in [18.3](#183-backward-compatible-minor-changes) non-breaking; the design constraint that enables it is in [§9.8](../part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas). + +## 18.7 AsyncAPI deprecation metadata + +**[M+R]** AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. [`[OPEN-16-A]`](../appendix/b-open-questions.md) + +## Note on retrofitting + +Bringing an existing BB into conformance with the JSON conventions ([§9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names), [§10.x](../part-c/10-data-types-and-formats.md)) or the opaque-identifier rule ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) changes the wire contract and is therefore a breaking change under [§18.4](#184-breaking-changes-bump-major-version): it **MUST** be released as a new major version. The transition schedule for existing BBs is governance, not design (Lifecycle & Governance companion). diff --git a/api-design-guide/part-e/19-localisation.md b/api-design-guide/part-e/19-localisation.md new file mode 100644 index 0000000..f28e20b --- /dev/null +++ b/api-design-guide/part-e/19-localisation.md @@ -0,0 +1,29 @@ +--- +description: "Rules governing localisation of API content: request-language handling, translation boundaries for stable content, default language, and response-language declaration." +--- + +# 19. Localisation + +{% hint style="info" %} +**Intent.** A single language model for a multi-country ecosystem. + +**Applies to:** Universal. On the OpenAPI surface localisation uses `Accept-Language` and `Content-Language` HTTP headers. On the AsyncAPI surface it uses the `acceptLanguage` and `contentLanguage` message headers defined in [§17](../part-d/17-asyncapi-channel-rules.md). + +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§19.4](#194-declare-the-response-language) constrains the specification: declare the response language header on localised responses. [§19.1](#191-honour-the-request-language)–[§19.3](#193-english-as-default-language) are behavioural-contract rules: they bind a conforming implementation at run time (honour the request language, never translate stable fields, default to English) and are verified by the conformance test pack. The set of languages a given BB must support is per-BB and per-deployment policy ([`[OPEN-17-A]`](../appendix/b-open-questions.md)), not fixed here. +{% endhint %} + +## 19.1 Honour the request language + +**[R]** Localisable content (error `title`/`detail`, enum display labels, free-text status messages) **MUST** respect the request language header appropriate to the surface: `Accept-Language` for HTTP, `acceptLanguage` for AsyncAPI messages. + +## 19.2 Never translate stable content + +**[R]** Stable content (error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated. + +## 19.3 English as default language + +**[R]** Default language **MUST** be English. [`[OPEN-17-A]`](../appendix/b-open-questions.md) + +## 19.4 Declare the response language + +**[M+R]** Responses or messages with localised content **MUST** include the response language header appropriate to the surface: `Content-Language` for HTTP, `contentLanguage` for AsyncAPI messages. diff --git a/api-design-guide/part-e/20-conformance-and-validation.md b/api-design-guide/part-e/20-conformance-and-validation.md new file mode 100644 index 0000000..10180ce --- /dev/null +++ b/api-design-guide/part-e/20-conformance-and-validation.md @@ -0,0 +1,27 @@ +--- +description: "Rules governing mechanical conformance verification of BB API specs: schema validation, the GovStack Spectral ruleset, and declared guide-version conformance." +--- + +# 20. Conformance and validation + +{% hint style="info" %} +**Intent.** The contract is mechanically verified. + +**Applies to:** Universal. AsyncAPI artifacts have parallel validators (`asyncapi/parser` or AsyncAPI CLI) and Spectral support. +{% endhint %} + +## 20.1 Every file passes validation + +**[M]** Every BB OpenAPI file **MUST** pass `openapi-spec-validator`. Every BB AsyncAPI file **MUST** pass an equivalent AsyncAPI parser/validator (e.g., `asyncapi/parser`). + +## 20.2 Passes the GovStack Spectral ruleset + +**[M]** Every BB API spec **MUST** pass the GovStack Spectral ruleset for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of rules tagged `[M+R]` ([§1.9](../1-introduction.md#19-rule-enforcement-classes)). The v0.1 ruleset **MUST** include the OpenAPI rules, CloudEvents event rules, and AsyncAPI documentation rules from [§3](../part-a/3-asyncapi-document-standards.md), [§16](../part-d/16-cloudevents-and-webhooks.md), and [§17](../part-d/17-asyncapi-channel-rules.md). Future protocol profiles may add deeper Kafka, MQTT, AMQP, WebSocket, or SSE rules. + +## 20.3 Declared guide conformance version + +**[M]** Each canonical specification file **MUST** declare the guide version it conforms to via the `info`-level extension `x-govstack-api-guide`: an object with `version` (the guide version targeted, SemVer) and optional `exceptions` (a list of rule IDs, each with a reference to its approved exception record per [§1.6](../1-introduction.md#16-exception-process)). Validation tooling ([§20.2](#202-passes-the-govstack-spectral-ruleset)) selects the matching ruleset version from this declaration. [`[OPEN-20-A]`](../appendix/b-open-questions.md) + +## Note on governance + +Publication gates, conformance levels, exception handling, transition timelines, and CI implementation are governance questions, proposed for the **GovStack API Lifecycle & Governance** companion document. diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml new file mode 100644 index 0000000..a6819c7 --- /dev/null +++ b/api-design-guide/rules.yaml @@ -0,0 +1,1503 @@ +# GENERATED FILE. DO NOT HAND-EDIT. +# Regenerate with: python3 tools/build_rules_index.py +# +# Invariant: for each rule, `page` + `anchor` locate it in the book. +# The same `#anchor` fragment resolves on both GitHub and GitBook. +guide: GovStack Cross-BB API Design Guide +version: 0.2.0-draft +rule_count: 166 +rules: +- id: "2.1" + title: "OpenAPI 3.1.0 required" + class: M + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 21-openapi-310-required + text: "The spec **MUST** declare `openapi: 3.1.0`. Earlier versions **MUST NOT** be used." + open_questions: [] +- id: "2.2" + title: "One canonical OpenAPI entrypoint" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 22-one-canonical-openapi-entrypoint + text: "The canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. (The audit found these files predominantly at `api/swagger.yaml`/`api/swagger.json`; renaming to `api/openapi.yaml` is part of conformance.) It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent API surfaces that version on independent cadences **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml` (which then serves as the single registry of canonical files). Either way there **MUST** be exactly one canonical artifact per surface and no divergent copies." + open_questions: [] +- id: "2.3" + title: "No divergent OpenAPI copies" + class: R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 23-no-divergent-openapi-copies + text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it." + open_questions: [] +- id: "2.4" + title: "Passes openapi-spec-validator" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 24-passes-openapi-spec-validator + text: "The file **MUST** pass `openapi-spec-validator` against the 3.1.0 schema." + open_questions: [] +- id: "2.5" + title: "Complete info block" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 25-complete-info-block + text: "The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), `description`, and `contact`. Where a BB ships per-surface canonical files (2.2), each surface carries its own `info.version` and versions independently." + open_questions: [] +- id: "2.6" + title: "Meaningful servers block" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 26-meaningful-servers-block + text: "The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}/v1`). Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production." + open_questions: [] +- id: "2.7" + title: "Complete operation metadata" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 27-complete-operation-metadata + text: "Every operation **MUST** include `operationId` (camelCase, verb-noun), `summary`, `description`, and at least one `tag`." + open_questions: [] +- id: "2.8" + title: "Pinned vendored common components" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 28-pinned-vendored-common-components + text: "Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit.\nVendoring is required because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable." + open_questions: [] +- id: "3.1" + title: "AsyncAPI 3.0.0 required" + class: M + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 31-asyncapi-300-required + text: "An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3.0 and **MUST** declare `asyncapi: 3.0.0`. Earlier versions **MUST NOT** be used for new GovStack event-driven surfaces." + open_questions: [] +- id: "3.2" + title: "One canonical AsyncAPI entrypoint" + class: M+R + strengths: ["MUST", "MAY"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 32-one-canonical-asyncapi-entrypoint + text: "The canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent event-driven surfaces **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml`." + open_questions: [] +- id: "3.3" + title: "No divergent AsyncAPI copies" + class: R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 33-no-divergent-asyncapi-copies + text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it." + open_questions: [] +- id: "3.4" + title: "Passes an AsyncAPI validator" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 34-passes-an-asyncapi-validator + text: "The file **MUST** pass an AsyncAPI 3.0 parser/validator (for example, `@asyncapi/parser` or the AsyncAPI CLI)." + open_questions: [] +- id: "3.5" + title: "Complete AsyncAPI info block" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 35-complete-asyncapi-info-block + text: "The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), `description`, and `contact`." + open_questions: [] +- id: "3.6" + title: "Servers channels operations and messages" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 36-servers-channels-operations-and-messages + text: "The file **MUST** declare non-empty `servers`, `channels`, `operations`, and `components.messages`. A document with only schemas and no operations is not an API contract. AsyncAPI `servers` **MUST** describe the intended broker or transport endpoint pattern using AsyncAPI 3.0 server fields (`host`, `protocol`, optional `pathname`, variables, security, and protocol bindings). Reference specifications that are not tied to a live broker **SHOULD** use parameterised server hosts and variables (for example, `host: \"{brokerHost}\"` with `protocol: mqtt`, `protocol: amqp`, `protocol: kafka`, or `protocol: wss`). Server definitions **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production brokers. Reserved documentation domains **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production." + open_questions: [] +- id: "3.7" + title: "Complete AsyncAPI operation metadata" + class: M+R + strengths: ["MUST", "MAY"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 37-complete-asyncapi-operation-metadata + text: "Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced CloudEvents message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`." + open_questions: [] +- id: "3.8" + title: "Pinned vendored AsyncAPI components" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 38-pinned-vendored-asyncapi-components + text: "Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit." + open_questions: [] +- id: "3.9" + title: "JSON Schema payload conventions" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 39-json-schema-payload-conventions + text: "AsyncAPI documents **MUST** use JSON Schema compatible with AsyncAPI 3.0 for payload schemas and **MUST** follow the JSON conventions in §9 and §10 for GovStack-owned payload fields." + open_questions: [] +- id: "4.1" + title: "Every schema described" + class: M + strengths: ["MUST"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 41-every-schema-described + text: "Every schema **MUST** have a `description`." + open_questions: [] +- id: "4.2" + title: "Examples for bodies and enums" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 42-examples-for-bodies-and-enums + text: "Every request body and response body **MUST** have at least one `example`. Every `enum` **MUST** document what its values mean (an `example` alone is insufficient when the values are not self-explanatory)." + open_questions: [] +- id: "4.3" + title: "No placeholder text" + class: M+R + strengths: ["MUST NOT"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 43-no-placeholder-text + text: "The spec **MUST NOT** contain placeholder text: `TBD`, `Lorem ipsum`, `a, b, c`, content from other BBs with the original BB name still present, or test plans with literal placeholder steps." + open_questions: [] +- id: "4.4" + title: "Accurate operation descriptions" + class: R + strengths: ["MUST"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 44-accurate-operation-descriptions + text: "Operation `description` **MUST** describe what the operation actually does. (The audit found at least 9 BBs with cross-endpoint description mismatches from copy-paste.)" + open_questions: [] +- id: "5.1" + title: "Major version in the path" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 51-major-version-in-the-path + text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). `[OPEN-4-A]`" + open_questions: ["OPEN-4-A"] +- id: "5.2" + title: "Plural noun resources" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 52-plural-noun-resources + text: "Resource paths **MUST** use plural nouns (`/policies`, not `/policy`)." + open_questions: [] +- id: "5.3" + title: "Kebab-case path segments" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 53-kebab-case-path-segments + text: "Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`)." + open_questions: [] +- id: "5.4" + title: "Shallow path nesting" + class: M + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 54-shallow-path-nesting + text: "Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. `[OPEN-4-C]`" + open_questions: ["OPEN-4-C"] +- id: "5.5" + title: "Identifiers as path parameters" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 55-identifiers-as-path-parameters + text: "Resource identifiers **MUST** be path parameters, not query parameters. (`DELETE /v1/events/{eventId}`, not `DELETE /v1/event?event_id=...`.)" + open_questions: [] +- id: "5.6" + title: "Query parameter naming" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 56-query-parameter-naming + text: "Query parameter names **MUST** follow the JSON naming convention defined in §9." + open_questions: [] +- id: "5.7" + title: "No verbs in CRUD paths" + class: M+R + strengths: ["MUST NOT"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 57-no-verbs-in-crud-paths + text: "Verbs **MUST NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.)" + open_questions: [] +- id: "5.8" + title: "Actions as sub-resources" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 58-actions-as-sub-resources + text: "Non-CRUD actions **MUST** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`." + open_questions: [] +- id: "5.9" + title: "Unversioned health endpoint" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 59-unversioned-health-endpoint + text: "Each BB **MUST** expose an unversioned operational liveness endpoint at `/health` using media type `application/health+json` with `status` values `\"pass\" | \"fail\" | \"warn\"`. This shape is modelled on `draft-inadarei-api-health-check`, an expired individual Internet-Draft (never adopted as an RFC); GovStack adopts it as a local convention, not as a live IETF standard. A separate `/ready` endpoint **MAY** be exposed for readiness probes. These endpoints **MUST NOT** carry citizen authentication and **MUST NOT** expose system-internal detail. `[OPEN-4-B]`" + open_questions: ["OPEN-4-B"] +- id: "6.1" + title: "GET is safe and idempotent" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 61-get-is-safe-and-idempotent + text: "`GET` **MUST** be safe and idempotent. Requests **MUST NOT** carry a body." + open_questions: [] +- id: "6.2" + title: "POST creates or performs actions" + class: R + strengths: [] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 62-post-creates-or-performs-actions + text: "`POST` creates a resource or performs a non-idempotent action." + open_questions: [] +- id: "6.3" + title: "PUT replaces the entire resource" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 63-put-replaces-the-entire-resource + text: "`PUT` **MUST** replace the entire resource and **MUST** be idempotent." + open_questions: [] +- id: "6.4" + title: "PATCH uses JSON Merge Patch" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 64-patch-uses-json-merge-patch + text: "`PATCH` partially updates a resource. Request bodies **MUST** use JSON Merge Patch (RFC 7396) with media type `application/merge-patch+json`. Note that under RFC 7396 a member set to `null` means \"remove this member\", so Merge Patch cannot set a nullable field (§9.4) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** additionally support RFC 6902 JSON Patch via `application/json-patch+json`; such endpoints **MUST** document which media type carries which semantics." + open_questions: [] +- id: "6.5" + title: "DELETE response semantics" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 65-delete-response-semantics + text: "`DELETE` removes a resource. Synchronous hard delete **MUST** return `204 No Content` with no body. Soft delete or async delete (audit retention, undo window) **MAY** return `200` with a body describing the resulting state, or `202` with an Operation per §15." + open_questions: [] +- id: "6.6" + title: "POST search for complex queries" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 66-post-search-for-complex-queries + text: "Complex queries that cannot fit in a URL query string **MAY** use `POST /v1/{collection}/search` with a request body. The response **MUST** return `200`, not `201`." + open_questions: [] +- id: "6.7" + title: "Bulk mutation needs explicit selection" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 67-bulk-mutation-needs-explicit-selection + text: "A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (The 2026 audit found mutable audit logs and filter-less bulk update/delete that could rewrite or destroy an entire registry.)" + open_questions: [] +- id: "7.1" + title: "200 for successful reads" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 71-200-for-successful-reads + text: "`200 OK`: successful read or non-creation action." + open_questions: [] +- id: "7.2" + title: "201 Created with Location" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 72-201-created-with-location + text: "`201 Created`: resource creation. Response **MUST** include a `Location` header pointing to the created resource." + open_questions: [] +- id: "7.3" + title: "202 Accepted for async operations" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 73-202-accepted-for-async-operations + text: "`202 Accepted`: async operation. Response **MUST** include a `Location` header pointing to an Operation resource (see §15)." + open_questions: [] +- id: "7.4" + title: "204 for void responses" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 74-204-for-void-responses + text: "`204 No Content`: successful DELETE or void response." + open_questions: [] +- id: "7.5" + title: "400 for malformed requests" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 75-400-for-malformed-requests + text: "`400 Bad Request`: request is malformed or unparseable." + open_questions: [] +- id: "7.6" + title: "401 with WWW-Authenticate" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 76-401-with-www-authenticate + text: "`401 Unauthorized`: missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For OAuth 2.0 bearer schemes (§13.2) it **SHOULD** carry the RFC 6750 challenge with an `error` value such as `invalid_token`." + open_questions: [] +- id: "7.7" + title: "403 when not authorised" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 77-403-when-not-authorised + text: "`403 Forbidden`: authenticated but not authorised." + open_questions: [] +- id: "7.8" + title: "404 for missing resources" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 78-404-for-missing-resources + text: "`404 Not Found`: resource does not exist." + open_questions: [] +- id: "7.9" + title: "409 for state conflicts" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 79-409-for-state-conflicts + text: "`409 Conflict`: a conflict with the current state of the resource that is not expressed as a failed precondition (e.g., creating a duplicate of a uniquely-keyed resource, an illegal state transition, or a concurrent in-flight idempotency retry per §14.5). A failed conditional precondition is `412` (7.15), not `409`." + open_questions: [] +- id: "7.10" + title: "410 for permanent removal" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 710-410-for-permanent-removal + text: "`410 Gone`: resource permanently removed; deprecated endpoint past sunset." + open_questions: [] +- id: "7.11" + title: "422 for semantic errors" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 711-422-for-semantic-errors + text: "`422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"): request is well-formed but semantically invalid. The idempotency-fingerprint use of `422` is in §14.5. `[OPEN-6-A]`" + open_questions: ["OPEN-6-A"] +- id: "7.12" + title: "429 for rate limits" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 712-429-for-rate-limits + text: "`429 Too Many Requests`: client exceeded rate limit." + open_questions: [] +- id: "7.13" + title: "Server errors documented" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 713-server-errors-documented + text: "`500`, `502`, `503`, `504`: server errors. Specs **MUST** document `500` at minimum." + open_questions: [] +- id: "7.14" + title: "All status codes declared" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 714-all-status-codes-declared + text: "Every operation **MUST** declare the status codes it can return. Declaring only `200` is forbidden." + open_questions: [] +- id: "7.15" + title: "412 for failed preconditions" + class: R + strengths: [] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 715-412-for-failed-preconditions + text: "`412 Precondition Failed`: conditional request precondition (e.g., `If-Match`) was not satisfied." + open_questions: [] +- id: "7.16" + title: "ETag and If-None-Match" + class: M+R + strengths: ["SHOULD", "MAY"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 716-etag-and-if-none-match + text: "Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the resource state. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change." + open_questions: [] +- id: "7.17" + title: "Optimistic concurrency with If-Match" + class: M+R + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 717-optimistic-concurrency-with-if-match + text: "`PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: ` and the server returns `412 Precondition Failed` (7.15) if the resource has changed since that ETag. A failed `If-Match` precondition is `412`, not `409`; `409` (7.9) is reserved for state or uniqueness conflicts that are not expressed as a conditional precondition." + open_questions: [] +- id: "7.18" + title: "405 with Allow header" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 718-405-with-allow-header + text: "`405 Method Not Allowed`: the target resource does not support the request method. The response **MUST** include an `Allow` header listing the supported methods (RFC 9110)." + open_questions: [] +- id: "7.19" + title: "415 for unsupported media types" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 719-415-for-unsupported-media-types + text: "`415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints (§6.4) **MUST** return `415` when the patch media type is neither `application/merge-patch+json` nor a documented `application/json-patch+json`. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header." + open_questions: [] +- id: "7.20" + title: "No-store on error responses" + class: M + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 720-no-store-on-error-responses + text: "Error responses (`application/problem+json`) and Operation-status responses (§15) **SHOULD** declare `Cache-Control: no-store`, so a shared cache cannot replay a transient failure or stale operation state. Broader caching behaviour is operational and out of scope (§1.2)." + open_questions: [] +- id: "8.1" + title: "Credentials in Authorization header" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 81-credentials-in-authorization-header + text: "Authentication credentials **MUST** travel in the `Authorization` header. They **MUST NOT** appear in query parameters, fragments, or URL paths." + open_questions: [] +- id: "8.2" + title: "Accept-Language and Content-Language" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 82-accept-language-and-content-language + text: "Localisation requests **MUST** use `Accept-Language`; responses **MUST** echo via `Content-Language`." + open_questions: [] +- id: "8.3" + title: "Idempotency-Key header accepted" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 83-idempotency-key-header-accepted + text: "POST endpoints that require idempotency under §14 **MUST** accept an `Idempotency-Key` header, unless §14.6 applies." + open_questions: [] +- id: "8.4" + title: "X-Request-Id correlation" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 84-x-request-id-correlation + text: "Every request **SHOULD** carry an `X-Request-Id` header for correlation. The server **MUST** echo this header in the response (or generate one if absent). This correlation identifier is distinct from the error-envelope `traceId` (§11.3); a BB **MAY** reuse the same value but is not required to, and any propagation between them is operational and out of scope (§1.2)." + open_questions: [] +- id: "8.5" + title: "No new X- prefixed headers" + class: M + strengths: ["MUST NOT"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 85-no-new-x--prefixed-headers + text: "New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix (per RFC 6648), except for the legacy correlation header explicitly allowed in §8.4 pending `[OPEN-7-A]`." + open_questions: ["OPEN-7-A"] +- id: "8.6" + title: "No personal data in addressable locations" + class: R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 86-no-personal-data-in-addressable-locations + text: "Personal data (national identifier, phone, email, name, date of birth, exact address) **MUST NOT** appear in path segments, query parameters, or header values other than purpose-specific signed assertions (e.g., an OIDC ID token). Opaque server-generated IDs (§10.1) **MUST** be used to refer to citizen records in URLs." + open_questions: [] +- id: "8.7" + title: "Rate-limit headers declared" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 87-rate-limit-headers-declared + text: "Endpoints rate-limited by the BB itself **MUST** declare rate-limit response headers per `draft-ietf-httpapi-ratelimit-headers` (an active, still-evolving Internet-Draft, not yet an RFC); where rate limiting is delegated to an API gateway or interoperability mediator, the spec **MUST** state that, rather than declaring headers the BB does not emit. The default v0.1 form is the three-header variant: `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`, chosen deliberately for its current deployment ubiquity over the draft's newer structured-field form. `429` responses **MUST** additionally declare `Retry-After`. `[OPEN-7-B]`" + open_questions: ["OPEN-7-B"] +- id: "9.1" + title: "JSON as default media type" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 91-json-as-default-media-type + text: "Default response media type **MUST** be `application/json` unless the resource is binary or a document export." + open_questions: [] +- id: "9.2" + title: "camelCase field names" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 92-camelcase-field-names + text: "JSON field names **MUST** use `camelCase`, applied consistently across the entire ecosystem. (See note below on the choice of casing.)" + open_questions: [] +- id: "9.3" + title: "Real JSON booleans" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 93-real-json-booleans + text: "Boolean fields **MUST** be JSON booleans (`true`/`false`), not strings." + open_questions: [] +- id: "9.4" + title: "Explicit nullability" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 94-explicit-nullability + text: "Nullability **MUST** be explicit (`type: [..., \"null\"]` per OpenAPI 3.1)." + open_questions: [] +- id: "9.5" + title: "No spaces or non-ASCII names" + class: M + strengths: ["MUST NOT"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 95-no-spaces-or-non-ascii-names + text: "Field names **MUST NOT** contain spaces or non-ASCII characters." + open_questions: [] +- id: "9.6" + title: "Avoid abbreviations" + class: R + strengths: ["SHOULD NOT"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 96-avoid-abbreviations + text: "Abbreviations **SHOULD NOT** be used (prefer `quantity` over `qty`)." + open_questions: [] +- id: "9.7" + title: "Screaming snake case enum values" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 97-screaming-snake-case-enum-values + text: "Enum values **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`)." + open_questions: [] +- id: "9.8" + title: "Forward-compatible schemas" + class: M + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 98-forward-compatible-schemas + text: "Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values must be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in §18.6 as a non-normative reader expectation." + open_questions: [] +- id: "9.9" + title: "No closed enums for growing sets" + class: R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 99-no-closed-enums-for-growing-sets + text: "Fields whose value set is expected to grow **MUST NOT** be declared as a closed OpenAPI `enum`, because client code generated from a closed enum typically rejects values added later, which would make the \"adding enum values is non-breaking\" guarantee of §18.3 false in practice. Such fields **MUST** either be declared as an open `type: string` annotated with `x-extensible-enum` (carrying the known values), or define an explicit fallback member (e.g., `UNKNOWN`) that conforming clients map unrecognised values to. Truly fixed value sets (e.g., ISO-defined codes) **MAY** remain closed enums." + open_questions: [] +- id: "9.10" + title: "GovStack extension prefix" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 910-govstack-extension-prefix + text: "GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay` in §17.15, `x-govstack-deprecated` in §18.7, and `x-govstack-api-guide` in §20.3)." + open_questions: [] +- id: "9.11" + title: "Single registered BB code" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 911-single-registered-bb-code + text: "Every namespace that embeds a BB code (error codes §11.5, problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, channel addresses §17.2) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts (§11.7). The BB-code register is proposed for the Lifecycle & Governance companion (Appendix A); until it exists, codes **SHOULD** be agreed through the API Working Group. `[OPEN-9-A]`" + open_questions: ["OPEN-9-A"] +- id: "10.1" + title: "Opaque server-generated identifiers" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 101-opaque-server-generated-identifiers + text: "Resource identifiers **MUST** be opaque, URL-safe strings, server-generated, and globally unique within the BB. UUID v4 (`format: uuid`) **SHOULD** be the default; ULID, KSUID, or other opaque IDs **MAY** be used where ordering or sortability matters. Clients **MUST** treat all IDs as opaque. **Exception:** registries with statutory identifiers (civil registry numbers, parcel IDs, business numbers, licence numbers) **MAY** use those identifiers in URL paths provided §8.6 is satisfied (the identifier does not constitute personal data; a parcel number or business number is acceptable, a national ID or passport number is not). The opacity requirement applies to BB-generated identifiers; statutory identifiers are by definition not opaque to clients." + open_questions: [] +- id: "10.2" + title: "RFC 3339 timestamps" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 102-rfc-3339-timestamps + text: "Timestamps **MUST** be RFC 3339 with timezone, declared as `format: date-time`." + open_questions: [] +- id: "10.3" + title: "RFC 3339 calendar dates" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 103-rfc-3339-calendar-dates + text: "Dates without time **MUST** be RFC 3339 calendar dates, declared as `format: date`." + open_questions: [] +- id: "10.4" + title: "Decimal-string monetary amounts" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 104-decimal-string-monetary-amounts + text: "Monetary amounts **MUST** use the object `{ amount: string (decimal), currency: string (ISO 4217) }`. Floats **MUST NOT** be used for money. Decimal-string is chosen over minor-units because GovStack-adopting countries may include currencies with non-decimal subunits and zero-subunit currencies, which a minor-units convention handles inconsistently." + open_questions: [] +- id: "10.5" + title: "E.164 phone numbers" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 105-e164-phone-numbers + text: "Phone numbers **MUST** be E.164 strings." + open_questions: [] +- id: "10.6" + title: "RFC 5322 email addresses" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 106-rfc-5322-email-addresses + text: "Email addresses **MUST** be RFC 5322 strings, declared as `format: email`." + open_questions: [] +- id: "10.7" + title: "Binary uploads and base64 payloads" + class: M+R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 107-binary-uploads-and-base64-payloads + text: "Large binary uploads **MUST** use `multipart/form-data` or a dedicated binary endpoint. Small inline payloads (signatures, certificates, QR codes, attestations) **MAY** be base64-encoded in JSON bodies; the field **MUST** then be declared with `contentEncoding: base64` and a documented size limit." + open_questions: [] +- id: "10.8" + title: "ISO 3166-1 country codes" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 108-iso-3166-1-country-codes + text: "Country codes **MUST** be ISO 3166-1 alpha-2." + open_questions: [] +- id: "10.9" + title: "BCP 47 language codes" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 109-bcp-47-language-codes + text: "Language codes **MUST** be BCP 47." + open_questions: [] +- id: "10.10" + title: "ISO 4217 currency codes" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 1010-iso-4217-currency-codes + text: "Currency codes **MUST** be ISO 4217." + open_questions: [] +- id: "11.1" + title: "RFC 9457 problem details" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/11-errors.md + anchor: 111-rfc-9457-problem-details + text: "Error responses **MUST** use media type `application/problem+json` per RFC 9457 (which obsoletes RFC 7807 and retains the `application/problem+json` media type). The standard provides broad client and tooling support and removes the burden of maintaining a custom envelope." + open_questions: [] +- id: "11.2" + title: "Standard problem fields present" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: Universal + page: part-c/11-errors.md + anchor: 112-standard-problem-fields-present + text: "Standard RFC 9457 fields `type`, `title`, `status` **MUST** be present. `type` **SHOULD** be a stable URI for the problem type and **MAY** be a dereferenceable documentation URL, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details (stack traces, hostnames, query fragments)." + open_questions: [] +- id: "11.3" + title: "GovStack error extension fields" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/11-errors.md + anchor: 113-govstack-error-extension-fields + text: "GovStack extensions **MUST** include `code` (machine-stable error code), `traceId` (correlation), and `timestamp`." + open_questions: [] +- id: "11.4" + title: "Field-level errors array" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/11-errors.md + anchor: 114-field-level-errors-array + text: "Where a failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry contains `pointer` (JSON Pointer), `code`, and `message`. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, §14.5)." + open_questions: [] +- id: "11.5" + title: "Namespaced stable error codes" + class: M+R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-c/11-errors.md + anchor: 115-namespaced-stable-error-codes + text: "Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` (§11.2). The default shape is reverse-DNS: `org.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `{error-name}` segment **MUST** use lowerCamelCase, for example `org.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `org.govstack.{bb-code}.{number}`. `[OPEN-10-A]`" + open_questions: ["OPEN-10-A"] +- id: "11.6" + title: "Stable codes across languages" + class: R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-c/11-errors.md + anchor: 116-stable-codes-across-languages + text: "`title` and `detail` **MAY** be localised; `code` and `type` **MUST** remain stable across languages." + open_questions: [] +- id: "11.7" + title: "Common error catalogue" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/11-errors.md + anchor: 117-common-error-catalogue + text: "A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `org.govstack.common.unauthenticated`, `org.govstack.common.permissionDenied`, `org.govstack.common.notFound`, `org.govstack.common.invalidArgument`, `org.govstack.common.alreadyExists`, `org.govstack.common.aborted`, `org.govstack.common.resourceExhausted`, `org.govstack.common.internal`, `org.govstack.common.unimplemented`. The final list is `[OPEN-10-B]`." + open_questions: ["OPEN-10-B"] +- id: "12.1" + title: "Collections must paginate" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 121-collections-must-paginate + text: "Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden." + open_questions: [] +- id: "12.2" + title: "Cursor pagination by default" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 122-cursor-pagination-by-default + text: "Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken` to align with the wider non-Google ecosystem (GraphQL Relay Connections, GitHub, Twitter). The cursor **MUST** be opaque to clients (server-encoded, typically base64 of an internal representation); clients **MUST NOT** parse or construct cursor values." + open_questions: [] +- id: "12.3" + title: "Cursor pagination envelope" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 123-cursor-pagination-envelope + text: "Pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. The `pageInfo` wrapper is inspired by the GraphQL Relay Connections specification but deliberately simplified: it uses a flat `items` array rather than Relay's `edges`/`node`, and `nextCursor`/`hasMore` rather than Relay's `endCursor`/`hasNextPage`." + open_questions: [] +- id: "12.4" + title: "Documented pageSize bounds" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 124-documented-pagesize-bounds + text: "`pageSize` **MUST** have a documented default and maximum. Specific numeric values are per-BB." + open_questions: [] +- id: "12.5" + title: "Optional total count" + class: R + strengths: ["MAY"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 125-optional-total-count + text: "`total` **MAY** be omitted when computing it is expensive." + open_questions: [] +- id: "12.6" + title: "Offset pagination envelope" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 126-offset-pagination-envelope + text: "Offset pagination **MAY** be used for admin or fixed-size lists. In that case the envelope **MUST** be the flat shape `{ items, offset, limit, total }`, distinct from the cursor `pageInfo` envelope in §12.3; `total` is required here (unlike §12.3, where it is optional)." + open_questions: [] +- id: "12.7" + title: "Sort parameter convention" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 127-sort-parameter-convention + text: "Sort parameter **MUST** be `sort`, values `field` (ascending) or `-field` (descending); multiple criteria separated by commas." + open_questions: [] +- id: "12.8" + title: "Simple equality filtering" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 128-simple-equality-filtering + text: "Simple filtering **MUST** use one query parameter per field, equality only." + open_questions: [] +- id: "12.9" + title: "Complex filtering via search" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 129-complex-filtering-via-search + text: "Complex filtering **MUST** use `POST /v1/{collection}/search` per §6.6. For this endpoint, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the §12.3 envelope." + open_questions: [] +- id: "12.10" + title: "Sparse fieldsets out of scope" + class: informative + strengths: [] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 1210-sparse-fieldsets-out-of-scope + text: "Sparse fieldsets (response field selection) are out of scope for v1.0." + open_questions: [] +- id: "13.1" + title: "Default security on every operation" + class: M + strengths: ["MUST"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 131-default-security-on-every-operation + text: "Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit." + open_questions: [] +- id: "13.2" + title: "OAuth and OIDC for citizen operations" + class: M+R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 132-oauth-and-oidc-for-citizen-operations + text: "Citizen-facing operations **MUST** declare an OAuth 2.0 + OIDC security requirement. On the OpenAPI surface this is either a `type: openIdConnect` scheme carrying `openIdConnectUrl` (the OIDC discovery document) or a `type: oauth2` scheme declaring the relevant `flows`. (A `type: oauth2` scheme does not carry a discovery URL; the discovery URL belongs to the `openIdConnect` scheme type.) Reference specifications that are not tied to a live identity provider **MAY** use documented deployment variables or reserved documentation domains for discovery, authorisation, token, and JWKS URLs. Adopter-specific identity-provider endpoints belong in implementation profiles." + open_questions: [] +- id: "13.3" + title: "Distinct scheme for BB-to-BB calls" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 133-distinct-scheme-for-bb-to-bb-calls + text: "BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service auth. On OpenAPI this is typically `type: mutualTLS` or OAuth client credentials; on AsyncAPI this is typically `type: X509`, OAuth client credentials, or a protocol-specific scheme such as SASL where the broker requires it. The spec **MUST** make clear which operations are citizen-facing vs inter-BB." + open_questions: [] +- id: "13.4" + title: "Namespaced OAuth scopes" + class: M + strengths: ["MUST"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 134-namespaced-oauth-scopes + text: "OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`org.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-12-A]`" + open_questions: ["OPEN-12-A"] +- id: "13.5" + title: "Authorization is the credential channel" + class: M+R + strengths: ["MUST NOT"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 135-authorization-is-the-credential-channel + text: "Credentials **MUST NOT** be declared in URL paths, query parameters, cookies, or fragments. The `Authorization` header is the only declared credential channel for token-based schemes." + open_questions: [] +- id: "13.6" + title: "API keys only for operational endpoints" + class: R + strengths: ["MUST NOT", "MAY"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 136-api-keys-only-for-operational-endpoints + text: "API keys **MAY** be declared on operational endpoints (`/health` and similar per §5.9). They **MUST NOT** be declared on operations that read or write personal data." + open_questions: [] +- id: "14.1" + title: "Idempotency-Key on non-idempotent POSTs" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 141-idempotency-key-on-non-idempotent-posts + text: "Except where §14.6 applies (a naturally idempotent design), POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support idempotency unless the operation is naturally idempotent by contract and documents its duplicate-handling semantics. Read-like POSTs, such as complex search endpoints, **MAY** support idempotency but are not required to. The header follows the convention established by Stripe and is being standardized in the IETF httpapi working group as `draft-ietf-httpapi-idempotency-key-header` (an Internet-Draft, not yet an RFC)." + open_questions: [] +- id: "14.2" + title: "Opaque client-generated keys" + class: R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 142-opaque-client-generated-keys + text: "The key **MUST** be an opaque, client-generated string and **SHOULD** be a UUID. (The cited draft RECOMMENDS a UUID rather than requiring one; per §1.7 the guide does not specify past the adopted standard by mandating a particular UUID version.)" + open_questions: [] +- id: "14.3" + title: "Documented replay window" + class: R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 143-documented-replay-window + text: "The spec **MUST** document the idempotency replay-window contract. A reference specification **SHOULD** state the required minimum replay window or the configuration parameter that controls it. Concrete replay-window values belong in implementation profiles." + open_questions: [] +- id: "14.4" + title: "Replay returns original response" + class: R + strengths: ["MUST"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 144-replay-returns-original-response + text: "A repeated request with the same key within the documented window **MUST** return the original response (status, body, headers)." + open_questions: [] +- id: "14.5" + title: "Key reuse and fingerprint mismatch" + class: R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 145-key-reuse-and-fingerprint-mismatch + text: "A repeated request reusing the same key with a *different* request body **MUST** return `422 Unprocessable Content` (the request fingerprint does not match the original), per the cited draft. A repeated request that arrives while the original is still being processed (a concurrent in-flight retry) **MUST** return `409 Conflict`. The request fingerprint **MUST** be computed over at least the canonicalised request body; a BB **MAY** additionally include the method and target. The spec **MUST** document a maximum accepted key length so oversized keys are rejected deterministically." + open_questions: [] +- id: "14.6" + title: "Naturally idempotent designs" + class: R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 146-naturally-idempotent-designs + text: "Naturally idempotent designs **MAY** satisfy this section without an `Idempotency-Key` header when idempotency is already guaranteed by the resource contract, for example `PUT /v1/resources/{clientProvidedId}` or creation with a documented unique business key that returns the existing resource or a stable conflict on duplicate submission. The spec **MUST** document that duplicate-handling behaviour per operation. Because §10.1 makes BB-generated identifiers server-assigned by default, `PUT`-with-client-id creation is available only where the resource is keyed by a client-supplied or statutory identifier (§10.1)." + open_questions: [] +- id: "15.1" + title: "202 with Operation Location" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 151-202-with-operation-location + text: "Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource." + open_questions: [] +- id: "15.2" + title: "Shared Operation resource shape" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 152-shared-operation-resource-shape + text: "The Operation resource **MUST** be declared once in `govstack-openapi-common.yaml` and `$ref`'d by all BBs. The default shape is `{ id, status, result, error, createdAt, updatedAt, progress? }`, modelled on Google AIP-151 (Long-Running Operations). A stricter AIP-151 mirror (with `done` and `metadata`) is a defensible alternative. `[OPEN-14-A]`" + open_questions: ["OPEN-14-A"] +- id: "15.3" + title: "Fixed Operation status enum" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 153-fixed-operation-status-enum + text: "Operation `status` **MUST** be drawn from a fixed enumeration declared in the common file. The default set is `PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELLED`. AIP-151's boolean `done` plus a result-or-error union is a defensible alternative. `[OPEN-14-A]`" + open_questions: ["OPEN-14-A"] +- id: "15.4" + title: "Polling the Operation resource" + class: M+R + strengths: [] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 154-polling-the-operation-resource + text: "Clients poll via `GET /v1/operations/{operationId}`." + open_questions: [] +- id: "15.5" + title: "Cancellation via cancel sub-resource" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 155-cancellation-via-cancel-sub-resource + text: "Cancellation, when supported, **MUST** be `POST /v1/operations/{operationId}/cancel`." + open_questions: [] +- id: "15.6" + title: "Webhook completion notification" + class: R + strengths: ["SHOULD"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 156-webhook-completion-notification + text: "Long-running operations **SHOULD** support completion notification via webhook (§16) rather than requiring clients to poll indefinitely. The threshold at which notification becomes expected is operational and per-BB." + open_questions: [] +- id: "15.7" + title: "Documented result retention" + class: R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 157-documented-result-retention + text: "Operation result availability **MUST** be documented as a consumer-visible contract. A reference specification **SHOULD** state the required minimum retention or the configuration parameter that controls it. Concrete retention values belong in implementation profiles." + open_questions: [] +- id: "16.1" + title: "Event surfaces documented" + class: M+R + strengths: ["MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 161-event-surfaces-documented + text: "Event-driven APIs **MUST** be documented. HTTP push **MUST** use OpenAPI 3.1 `webhooks`; brokered transports and event streams (MQTT, AMQP, Kafka, WebSockets, SSE) **MUST** use AsyncAPI 3.0." + open_questions: [] +- id: "16.2" + title: "CloudEvents envelope required" + class: M + strengths: ["MUST", "SHOULD"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 162-cloudevents-envelope-required + text: "GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `\"1.0\"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`." + open_questions: [] +- id: "16.3" + title: "Reverse-DNS event types" + class: M + strengths: ["MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 163-reverse-dns-event-types + text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and does not include the major version; the versioned transport contract is carried in the channel address or equivalent AsyncAPI version metadata (§18.2). `[OPEN-15-B]`" + open_questions: ["OPEN-15-B"] +- id: "16.4" + title: "Stable CloudEvents source" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 164-stable-cloudevents-source + text: "The CloudEvents `source` field **MUST** identify the publishing BB or BB surface in a stable way. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment." + open_questions: [] +- id: "16.5" + title: "Signed event delivery" + class: R + strengths: ["MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 165-signed-event-delivery + text: "Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`. `[OPEN-15-A]`" + open_questions: ["OPEN-15-A"] +- id: "16.6" + title: "GovStack-Signature header" + class: M+R + strengths: ["MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 166-govstack-signature-header + text: "On the OpenAPI/webhooks surface the signature **MUST** travel in a single ecosystem-wide HTTP header named `GovStack-Signature` (modelled on Stripe's `Stripe-Signature` and GitHub's `X-Hub-Signature-256`). On the AsyncAPI surface the signature **MUST** travel in the transport's message-metadata channel under the same field name unless the chosen protocol binding defines a more precise field. `[OPEN-15-C]`" + open_questions: ["OPEN-15-C"] +- id: "16.7" + title: "Replay-detectable signed material" + class: R + strengths: ["MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 167-replay-detectable-signed-material + text: "The signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers can detect replays." + open_questions: [] +- id: "16.8" + title: "Pinned signature profile" + class: R + strengths: ["MUST", "MAY"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 168-pinned-signature-profile + text: "`govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** pin the exact bytes that are signed (the canonicalisation: which fields, in which order, with which serialisation), the signing algorithm and its identifier, and the signature verification inputs, so that two independently-built BBs can verify each other's signatures. The default signature scheme is detached JWS over a canonicalised structured CloudEvents JSON payload. HMAC-SHA256 **MAY** be used only where shared-key distribution is explicitly governed. The event-signature profile is required for v1.0 publication because §16.5 is not mechanically enforceable without it. `[OPEN-15-A]`" + open_questions: ["OPEN-15-A"] +- id: "16.9" + title: "Operational signing concerns out of scope" + class: informative + strengths: [] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 169-operational-signing-concerns-out-of-scope + text: "Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion (§1.2)." + open_questions: [] +- id: "16.10" + title: "Documented delivery-failure contract" + class: R + strengths: ["MUST", "SHOULD"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 1610-documented-delivery-failure-contract + text: "HTTP webhook delivery-failure behaviour **MUST** be documented per subscription. A reference specification **MUST** define the portable contract fields: whether redelivery is attempted, whether failed deliveries are stored, and how a subscriber can identify or recover failed deliveries. Concrete retry counts, backoff intervals, and failure-store retention values belong in implementation profiles. Failed deliveries **SHOULD** end in a dead-letter queue or equivalent failure store accessible to the subscription owner." + open_questions: [] +- id: "16.11" + title: "Subscription management interfaces" + class: M+R + strengths: ["MUST", "MAY"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 1611-subscription-management-interfaces + text: "Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane." + open_questions: [] +- id: "17.1" + title: "Send and receive perspective" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 171-send-and-receive-perspective + text: "Each AsyncAPI document **MUST** define the BB's perspective. An operation with `action: send` means the BB publishes that message to the channel. An operation with `action: receive` means the BB consumes that message from the channel." + open_questions: [] +- id: "17.2" + title: "Reverse-DNS channel addresses" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 172-reverse-dns-channel-addresses + text: "Channel addresses **MUST** follow one ecosystem-wide naming convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment is the BB's single registered code per §9.11. `[OPEN-15-D]`" + open_questions: ["OPEN-15-D"] +- id: "17.3" + title: "No personal data in channels" + class: R + strengths: ["MUST NOT"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 173-no-personal-data-in-channels + text: "Channel addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead." + open_questions: [] +- id: "17.4" + title: "Declared channel parameters" + class: M+R + strengths: ["MUST", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 174-declared-channel-parameters + text: "Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object, and its schema and routing semantics **MUST** be documented." + open_questions: [] +- id: "17.5" + title: "No environment names in addresses" + class: M+R + strengths: ["SHOULD NOT"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 175-no-environment-names-in-addresses + text: "Environment names (`dev`, `test`, `prod`), broker implementation names, and deployment-specific prefixes **SHOULD NOT** appear in channel addresses. They belong in `servers`, server variables, broker configuration, or deployment routing unless a protocol profile explicitly requires them." + open_questions: [] +- id: "17.6" + title: "Structured CloudEvents JSON payloads" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 176-structured-cloudevents-json-payloads + text: "AsyncAPI message payloads for GovStack domain events **MUST** use structured CloudEvents JSON: the message payload is the complete CloudEvent, and GovStack-owned domain data lives under the CloudEvents `data` field. This provides one portable, schema-validatable event shape across brokered transports. `[OPEN-15-E]`" + open_questions: ["OPEN-15-E"] +- id: "17.7" + title: "Shared CloudEvents message schema" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 177-shared-cloudevents-message-schema + text: "AsyncAPI channel message entries **MUST** reference the shared CloudEvents message schema from `govstack-asyncapi-common.yaml` and specialise only the `data` schema for the BB-specific event payload. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0." + open_questions: [] +- id: "17.8" + title: "Message headers and idempotency metadata" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 178-message-headers-and-idempotency-metadata + text: "GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. For structured CloudEvents messages, trace and workflow metadata **SHOULD** be carried as CloudEvents extension attributes: `traceid`, `correlationid`, and `causationid`. These names are lowercase because CloudEvents requires lowercase extension-attribute names; the same concepts use camelCase in GovStack-owned JSON bodies and transport/application headers. Transport/application headers **MAY** mirror these values where broker tooling requires header-level metadata, but the CloudEvent remains the normative event envelope. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key. For structured CloudEvents command messages, the key **MUST** be the CloudEvents extension attribute `idempotencykey`; for non-CloudEvents command messages, it **MUST** be the message header `idempotencyKey`." + open_questions: [] +- id: "17.9" + title: "Message localisation headers" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 179-message-localisation-headers + text: "Message headers used for localisation **MUST** be `acceptLanguage` on inbound command/request messages and `contentLanguage` on outbound localised messages. Stable fields such as identifiers, enum values, timestamps, currency codes, and error codes **MUST NOT** be translated." + open_questions: [] +- id: "17.10" + title: "Security schemes cover every operation" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1710-security-schemes-cover-every-operation + text: "AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Message signing (§16.5) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication." + open_questions: [] +- id: "17.11" + title: "Documented delivery guarantees" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1711-documented-delivery-guarantees + text: "Each operation **MUST** document its delivery guarantee: `atMostOnce`, `atLeastOnce`, or `effectivelyOnce`. `effectivelyOnce` **MUST** be backed by an idempotency contract, duplicate detection, or a documented resource-state invariant; it **MUST NOT** imply the transport literally delivers a message exactly once." + open_questions: [] +- id: "17.12" + title: "Documented ordering guarantees" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1712-documented-ordering-guarantees + text: "Each operation **MUST** document ordering guarantees, if any. If ordering is partitioned, keyed, or scoped, the key or scope **MUST** be declared. If no ordering is guaranteed, the spec **MUST** state that explicitly." + open_questions: [] +- id: "17.13" + title: "Declared delivery-management capabilities" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1713-declared-delivery-management-capabilities + text: "Each operation **MUST** declare which delivery-management capabilities the chosen transport contract exposes: redelivery, dead-letter handling, retention, and replay. The declaration **MUST** state whether each capability is supported, unsupported, or not applicable." + open_questions: [] +- id: "17.14" + title: "Portable capability contract" + class: R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1714-portable-capability-contract + text: "A reference specification **MUST** define the portable contract shape, defaults, and allowed bounds for supported delivery-management capabilities. Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings belong in implementation profiles." + open_questions: [] +- id: "17.15" + title: "Machine-readable delivery extensions" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1715-machine-readable-delivery-extensions + text: "Delivery, ordering, and delivery-management capability declarations **MUST** be machine-readable using GovStack specification extensions declared in `govstack-asyncapi-common.yaml` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay`) as well as human-readable in `description`. `[OPEN-15-G]`" + open_questions: ["OPEN-15-G"] +- id: "17.16" + title: "Async rejection error messages" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1716-async-rejection-error-messages + text: "Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using the common GovStack error envelope from §11. The error message **MUST** be correlated to the original message using the correlation metadata rules in §17.8 or an equivalent protocol binding." + open_questions: [] +- id: "17.17" + title: "Declared request-reply correlation" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1717-declared-request-reply-correlation + text: "Request-reply over messaging **MAY** be used where the protocol and use case support it. When used, the AsyncAPI operation **MUST** declare the reply channel or reply address pattern and the correlation mechanism. Fire-and-forget event publication **MUST NOT** pretend to be request-reply." + open_questions: [] +- id: "17.18" + title: "Correlated completion signals" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1718-correlated-completion-signals + text: "Long-running asynchronous work triggered by a message **MUST** expose completion through either an operation-status message based on the §15 Operation resource or an operation-completed domain event. The spec **MUST** document how clients correlate the completion signal to the initiating message." + open_questions: [] +- id: "17.19" + title: "Protocol bindings where relevant" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1719-protocol-bindings-where-relevant + text: "Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. `[OPEN-15-F]`" + open_questions: ["OPEN-15-F"] +- id: "17.20" + title: "Examples for every message" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1720-examples-for-every-message + text: "AsyncAPI documents **MUST** define examples for every message and **SHOULD** include at least one example showing headers plus payload for each common message family: command, event, error, and operation-completion where applicable." + open_questions: [] +- id: "18.1" + title: "SemVer versioning" + class: M + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 181-semver-versioning + text: "`info.version` **MUST** follow SemVer." + open_questions: [] +- id: "18.2" + title: "Major version in path or channel" + class: M + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 182-major-version-in-path-or-channel + text: "Major version increments **MUST** be reflected in the OpenAPI URL path (`/v2/`). AsyncAPI channels **MUST** include the major version in the channel address or an equivalent machine-readable version field documented in `govstack-asyncapi-common.yaml`." + open_questions: [] +- id: "18.3" + title: "Backward-compatible minor changes" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 183-backward-compatible-minor-changes + text: "Minor and patch increments **MUST** be backward-compatible. Adding optional fields, adding endpoints, adding enum values (for fields declared extensibly per §9.9), and relaxing constraints are non-breaking." + open_questions: [] +- id: "18.4" + title: "Breaking changes bump major version" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 184-breaking-changes-bump-major-version + text: "Breaking changes (removing endpoints, removing fields, narrowing types, narrowing enums, tightening required, changing semantic meaning) **MUST** be released as a new major version." + open_questions: [] +- id: "18.5" + title: "Deprecation and Sunset headers" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 185-deprecation-and-sunset-headers + text: "Deprecated endpoints **MUST** return a `Deprecation` header per RFC 9745 (a structured-field date carrying the deprecation timestamp, e.g. `Deprecation: @1735689600`) and a `Sunset` header per RFC 8594 indicating planned removal. The minimum deprecation window between announcement and sunset, and the maximum number of concurrent major versions a BB can keep in production, are operational policy and are proposed for the Lifecycle & Governance companion, not here." + open_questions: [] +- id: "18.6" + title: "Clients ignore unknown fields" + class: informative + strengths: [] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 186-clients-ignore-unknown-fields + text: "(informative) Conforming clients are expected to ignore unknown JSON fields and unknown enum values. This is what makes the additive changes in 18.3 non-breaking; the design constraint that enables it is in §9.8." + open_questions: [] +- id: "18.7" + title: "AsyncAPI deprecation metadata" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 187-asyncapi-deprecation-metadata + text: "AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. `[OPEN-16-A]`" + open_questions: ["OPEN-16-A"] +- id: "19.1" + title: "Honour the request language" + class: R + strengths: ["MUST"] + surface: Universal + page: part-e/19-localisation.md + anchor: 191-honour-the-request-language + text: "Localisable content (error `title`/`detail`, enum display labels, free-text status messages) **MUST** respect the request language header appropriate to the surface: `Accept-Language` for HTTP, `acceptLanguage` for AsyncAPI messages." + open_questions: [] +- id: "19.2" + title: "Never translate stable content" + class: R + strengths: ["MUST NOT"] + surface: Universal + page: part-e/19-localisation.md + anchor: 192-never-translate-stable-content + text: "Stable content (error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated." + open_questions: [] +- id: "19.3" + title: "English as default language" + class: R + strengths: ["MUST"] + surface: Universal + page: part-e/19-localisation.md + anchor: 193-english-as-default-language + text: "Default language **MUST** be English. `[OPEN-17-A]`" + open_questions: ["OPEN-17-A"] +- id: "19.4" + title: "Declare the response language" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-e/19-localisation.md + anchor: 194-declare-the-response-language + text: "Responses or messages with localised content **MUST** include the response language header appropriate to the surface: `Content-Language` for HTTP, `contentLanguage` for AsyncAPI messages." + open_questions: [] +- id: "20.1" + title: "Every file passes validation" + class: M + strengths: ["MUST"] + surface: Universal + page: part-e/20-conformance-and-validation.md + anchor: 201-every-file-passes-validation + text: "Every BB OpenAPI file **MUST** pass `openapi-spec-validator`. Every BB AsyncAPI file **MUST** pass an equivalent AsyncAPI parser/validator (e.g., `asyncapi/parser`)." + open_questions: [] +- id: "20.2" + title: "Passes the GovStack Spectral ruleset" + class: M + strengths: ["MUST"] + surface: Universal + page: part-e/20-conformance-and-validation.md + anchor: 202-passes-the-govstack-spectral-ruleset + text: "Every BB API spec **MUST** pass the GovStack Spectral ruleset for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of rules tagged `[M+R]` (§1.9). The v0.1 ruleset **MUST** include the OpenAPI rules, CloudEvents event rules, and AsyncAPI documentation rules from §3, §16, and §17. Future protocol profiles may add deeper Kafka, MQTT, AMQP, WebSocket, or SSE rules." + open_questions: [] +- id: "20.3" + title: "Declared guide conformance version" + class: M + strengths: ["MUST"] + surface: Universal + page: part-e/20-conformance-and-validation.md + anchor: 203-declared-guide-conformance-version + text: "Each canonical specification file **MUST** declare the guide version it conforms to via the `info`-level extension `x-govstack-api-guide`: an object with `version` (the guide version targeted, SemVer) and optional `exceptions` (a list of rule IDs, each with a reference to its approved exception record per §1.6). Validation tooling (§20.2) selects the matching ruleset version from this declaration. `[OPEN-20-A]`" + open_questions: ["OPEN-20-A"] diff --git a/api-design-guide/tools/build_rules_index.py b/api-design-guide/tools/build_rules_index.py new file mode 100644 index 0000000..72aecf8 --- /dev/null +++ b/api-design-guide/tools/build_rules_index.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +"""Build the machine-readable rules index for the GovStack Cross-BB API Design Guide. + +This script is part of the guide's machine layer. It scans every rule page under +`part-*/` and regenerates two artifacts at the book root: + + * `rules.yaml` - one structured entry per rule (id, class, strengths, + surface, page, anchor, rule text, open questions). + * `all-rules.md` - a human-facing "rules at a glance" page with one GFM + table per section. + +Both artifacts are GENERATED. Do not hand-edit them; edit the section pages and +re-run this script instead. Output is byte-reproducible: no timestamps, no +machine-specific paths, stable ordering. + +Modes: + (default) regenerate both files on disk and report what changed. + --check regenerate in memory and diff against disk; exit 1 if either file + is stale (a missing file counts as stale), 0 otherwise. + +The script fails loudly (non-zero exit, message on stderr) on any malformed +page: an anchor id that does not equal the computed slug, an href that does not +equal its id, a duplicate rule id, a rule id whose section does not match its +page filename, or an unrecognised surface. +""" + +import argparse +import difflib +import json +import re +import sys +from pathlib import Path + +GUIDE_NAME = "GovStack Cross-BB API Design Guide" +GUIDE_VERSION = "0.2.0-draft" + +# Regex for a heading line carrying an explicit anchor tag, e.g. +# ## 2.1 OpenAPI 3.1.0 required +ANCHORED_HEADING_RE = re.compile( + r'^## (?P.+?) \s*$' +) +# A rule heading's leading text is "
. ". +RULE_TEXT_RE = re.compile(r"^(?P\d+\.\d+) (?P.+)$") +# A line that looks like the start of a rule heading (used to detect a rule +# heading whose anchor is missing or malformed). +LOOKS_LIKE_RULE_RE = re.compile(r"^## \d+\.\d+(\s|$)") +# Enforcement-class badge at the very start of a rule body. +BADGE_RE = re.compile(r"^\*\*\[(?P<cls>M\+R|M|R)\]\*\* ") +# Open-question identifiers. +OPEN_QUESTION_RE = re.compile(r"OPEN-\d+-[A-Z]") + +# RFC 2119 strength keywords, in output order. Longer forms are listed before +# their prefixes so "MUST NOT" is considered before "MUST". +STRENGTH_TOKENS = [ + ("MUST NOT", "**MUST NOT**"), + ("MUST", "**MUST**"), + ("SHOULD NOT", "**SHOULD NOT**"), + ("SHOULD", "**SHOULD**"), + ("MAY", "**MAY**"), +] + +SLUG_KEEP = set("abcdefghijklmnopqrstuvwxyz0123456789-") + + +class BuildError(Exception): + """Raised when a page violates the format contract.""" + + +def slugify(heading_text): + """GitHub-style slug shared with check_links.py; the two MUST be identical. + + Lowercase the text, keep ASCII letters, digits and existing hyphens, turn + spaces into hyphens, drop every other character. Consecutive hyphens are NOT + collapsed. The caller passes the heading text without its trailing `<a>` tag + and with the trailing space before that tag already stripped. + """ + out = [] + for ch in heading_text.lower(): + if ch in SLUG_KEEP: + out.append(ch) + elif ch == " ": + out.append("-") + # every other character is dropped + return "".join(out) + + +def unwrap_links(text): + """Replace every markdown link `[text](target)` with its link text. + + The scanner tracks bracket and paren depth so link text that itself contains + brackets (for example ``[`[OPEN-4-B]`](...)``) is handled correctly. + """ + result = [] + i = 0 + n = len(text) + while i < n: + if text[i] == "[": + depth = 0 + j = i + close = -1 + while j < n: + if text[j] == "[": + depth += 1 + elif text[j] == "]": + depth -= 1 + if depth == 0: + close = j + break + j += 1 + if close != -1 and close + 1 < n and text[close + 1] == "(": + k = close + 2 + pdepth = 1 + while k < n: + if text[k] == "(": + pdepth += 1 + elif text[k] == ")": + pdepth -= 1 + if pdepth == 0: + break + k += 1 + if k < n and pdepth == 0: + link_text = text[i + 1 : close] + result.append(unwrap_links(link_text)) + i = k + 1 + continue + result.append(text[i]) + i += 1 + else: + result.append(text[i]) + i += 1 + return "".join(result) + + +def strip_example_blocks(body_lines): + """Drop each informative Example block: from a line starting with + `**Example (informative).**` through the end of its fenced code block.""" + kept = [] + in_example = False + fence_open = False + for line in body_lines: + if not in_example: + if line.startswith("**Example (informative).**"): + in_example = True + fence_open = False + continue + kept.append(line) + else: + if line.lstrip().startswith("```"): + if not fence_open: + fence_open = True + else: + in_example = False + fence_open = False + # every line inside the example is dropped + return kept + + +def group_paragraphs(lines): + """Collapse soft-wrapped lines into paragraphs; blank lines separate them.""" + paragraphs = [] + current = [] + for line in lines: + if line.strip() == "": + if current: + paragraphs.append(" ".join(current)) + current = [] + else: + current.append(line.strip()) + if current: + paragraphs.append(" ".join(current)) + return paragraphs + + +def extract_strengths(text): + """RFC 2119 keywords present in the rule text, deduplicated, in fixed order.""" + strengths = [] + for label, token in STRENGTH_TOKENS: + if token in text: + strengths.append(label) + return strengths + + +def strongest_strength(strengths): + """The single strongest keyword for the summary table.""" + if "MUST NOT" in strengths or "MUST" in strengths: + return "MUST" + if "SHOULD NOT" in strengths or "SHOULD" in strengths: + return "SHOULD" + if "MAY" in strengths: + return "MAY" + return "—" # em dash placeholder rendered as a single character + + +def extract_open_questions(text): + """OPEN-N-X ids referenced in the rule text, deduplicated, first-seen order.""" + seen = [] + for match in OPEN_QUESTION_RE.finditer(text): + oid = match.group(0) + if oid not in seen: + seen.append(oid) + return seen + + +def parse_body(body_lines): + """Return (class, strengths, text, open_questions) for one rule body.""" + kept = strip_example_blocks(body_lines) + paragraphs = [unwrap_links(p) for p in group_paragraphs(kept)] + rule_class = "informative" + if paragraphs: + match = BADGE_RE.match(paragraphs[0]) + if match: + rule_class = match.group("cls") + paragraphs[0] = paragraphs[0][match.end() :] + text = "\n".join(paragraphs) + return rule_class, extract_strengths(text), text, extract_open_questions(text) + + +def parse_section_number(filename): + match = re.match(r"^(\d+)-", filename) + if not match: + raise BuildError(f"cannot parse a section number from filename: {filename}") + return int(match.group(1)) + + +def extract_h1(lines, page_rel): + for line in lines: + match = re.match(r"^# (.+)$", line) + if match: + title = match.group(1).strip() + return re.sub(r"\s*<a href.*</a>\s*$", "", title) + raise BuildError(f"{page_rel}: no H1 heading found") + + +def compute_surface(lines, page_rel): + for line in lines: + stripped = line.strip() + if stripped.startswith("**Applies to:**"): + rest = stripped[len("**Applies to:**") :].strip() + if rest.startswith("OpenAPI surface"): + return "OpenAPI" + if rest.startswith("AsyncAPI surface"): + return "AsyncAPI" + if rest.startswith("Event-driven"): + return "Event-driven" + if rest.startswith("Universal"): + return "Universal" + raise BuildError( + f"{page_rel}: unrecognised surface in 'Applies to:' line: {rest!r}" + ) + raise BuildError(f"{page_rel}: no '**Applies to:**' line found in hint block") + + +def discover_rule_pages(book_root): + pages = list(book_root.glob("part-*/*.md")) + return sorted(pages, key=lambda p: (parse_section_number(p.name), p.name)) + + +def heading_indices(lines): + return [i for i, line in enumerate(lines) if line.startswith("## ")] + + +def collect_page(book_root, page, seen_ids): + """Parse one rule page into a page-info dict with its ordered rule list.""" + page_rel = page.relative_to(book_root).as_posix() + section_num = parse_section_number(page.name) + lines = page.read_text(encoding="utf-8").split("\n") + h1_title = extract_h1(lines, page_rel) + surface = compute_surface(lines, page_rel) + all_heads = heading_indices(lines) + + rules = [] + for idx, line in enumerate(lines): + if not line.startswith("## "): + continue + match = ANCHORED_HEADING_RE.match(line) + if not match: + if LOOKS_LIKE_RULE_RE.match(line): + raise BuildError( + f"{page_rel}:{idx + 1}: rule heading is missing a valid anchor tag: {line.strip()!r}" + ) + # A non-rule heading without an anchor: nothing to validate here. + continue + + heading_text = match.group("text") + href = match.group("href") + anchor_id = match.group("id") + expected = slugify(heading_text) + if anchor_id != expected: + raise BuildError( + f"{page_rel}:{idx + 1}: anchor id {anchor_id!r} != computed slug {expected!r} " + f"for heading {heading_text!r}" + ) + if href != anchor_id: + raise BuildError( + f"{page_rel}:{idx + 1}: href '#{href}' != id '{anchor_id}'" + ) + + rule_match = RULE_TEXT_RE.match(heading_text) + if not rule_match: + # A validated non-rule anchored heading (for example a "Note on ..." + # heading). It is not part of the rules index. + continue + + rule_id = rule_match.group("id") + rule_title = rule_match.group("title").strip() + if rule_id in seen_ids: + raise BuildError(f"{page_rel}:{idx + 1}: duplicate rule id {rule_id!r}") + seen_ids.add(rule_id) + if int(rule_id.split(".")[0]) != section_num: + raise BuildError( + f"{page_rel}:{idx + 1}: rule id {rule_id!r} does not match page " + f"section number {section_num}" + ) + + # Body runs from after this heading to the next '## ' heading or EOF. + next_heads = [h for h in all_heads if h > idx] + end = next_heads[0] if next_heads else len(lines) + body_lines = lines[idx + 1 : end] + rule_class, strengths, text, open_qs = parse_body(body_lines) + + rules.append( + { + "id": rule_id, + "title": rule_title, + "class": rule_class, + "strengths": strengths, + "surface": surface, + "page": page_rel, + "anchor": anchor_id, + "text": text, + "open_questions": open_qs, + } + ) + return {"page": page_rel, "h1_title": h1_title, "rules": rules} + + +def collect(book_root): + seen_ids = set() + page_infos = [] + for page in discover_rule_pages(book_root): + page_infos.append(collect_page(book_root, page, seen_ids)) + rules = [rule for info in page_infos for rule in info["rules"]] + return rules, page_infos + + +def js(value): + """A JSON string literal, which is also a valid YAML double-quoted scalar.""" + return json.dumps(value, ensure_ascii=False) + + +def inline_list(items): + if not items: + return "[]" + return "[" + ", ".join(js(item) for item in items) + "]" + + +def render_rules_yaml(rules): + lines = [ + "# GENERATED FILE. DO NOT HAND-EDIT.", + "# Regenerate with: python3 tools/build_rules_index.py", + "#", + "# Invariant: for each rule, `page` + `anchor` locate it in the book.", + "# The same `#anchor` fragment resolves on both GitHub and GitBook.", + f"guide: {GUIDE_NAME}", + f"version: {GUIDE_VERSION}", + f"rule_count: {len(rules)}", + "rules:", + ] + for rule in rules: + lines.append(f"- id: {js(rule['id'])}") + lines.append(f" title: {js(rule['title'])}") + lines.append(f" class: {rule['class']}") + lines.append(f" strengths: {inline_list(rule['strengths'])}") + lines.append(f" surface: {rule['surface']}") + lines.append(f" page: {rule['page']}") + lines.append(f" anchor: {rule['anchor']}") + lines.append(f" text: {js(rule['text'])}") + lines.append(f" open_questions: {inline_list(rule['open_questions'])}") + return "\n".join(lines) + "\n" + + +def escape_cell(text): + return text.replace("\\", "\\\\").replace("|", "\\|") + + +def render_all_rules_md(page_infos): + lines = [ + "---", + 'description: "Every rule in the guide: enforcement class, RFC 2119 strength, surface, and a link."', + "---", + "", + "# Rules at a glance", + "", + "This page is generated from the section pages by `tools/build_rules_index.py`; " + "do not edit it by hand. Class legend: `[M]` machine-checkable, `[R]` review, " + "`[M+R]` both; see [§1.9](1-introduction.md#19-rule-enforcement-classes).", + "", + ] + for info in page_infos: + if not info["rules"]: + continue + lines.append(f"## {info['h1_title']}") + lines.append("") + lines.append("| Rule | Class | Strength | Surface | Title |") + lines.append("| --- | --- | --- | --- | --- |") + for rule in info["rules"]: + rule_cell = f"[{rule['id']}]({rule['page']}#{rule['anchor']})" + class_cell = "—" if rule["class"] == "informative" else rule["class"] + strength_cell = strongest_strength(rule["strengths"]) + surface_cell = rule["surface"] + title_cell = escape_cell(rule["title"]) + lines.append( + f"| {rule_cell} | {class_cell} | {strength_cell} | {surface_cell} | {title_cell} |" + ) + lines.append("") + return "\n".join(lines) + "\n" + + +def write_lf(path, content): + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + + +def run_check(targets, rule_count): + mismatch = False + for path, content in targets: + if not path.exists(): + print( + f"{path.name}: MISSING (run: python3 tools/build_rules_index.py)", + file=sys.stderr, + ) + mismatch = True + continue + disk = path.read_text(encoding="utf-8") + if disk != content: + mismatch = True + diff = difflib.unified_diff( + disk.splitlines(), + content.splitlines(), + fromfile=f"{path.name} (on disk)", + tofile=f"{path.name} (regenerated)", + lineterm="", + ) + print("\n".join(diff), file=sys.stderr) + if mismatch: + print( + "check failed: generated artifacts are stale; run python3 tools/build_rules_index.py", + file=sys.stderr, + ) + return 1 + print(f"check passed: rules.yaml and all-rules.md are up to date ({rule_count} rules)") + return 0 + + +def run_write(targets, page_infos, rule_count): + for path, content in targets: + if not path.exists(): + status = "created" + elif path.read_text(encoding="utf-8") != content: + status = "updated" + else: + status = "unchanged" + write_lf(path, content) + print(f"{status}: {path.name}") + page_count = len([info for info in page_infos if info["rules"]]) + print(f"wrote {rule_count} rules across {page_count} pages") + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--check", + action="store_true", + help="regenerate in memory and fail (exit 1) if the on-disk files differ", + ) + args = parser.parse_args(argv) + + book_root = Path(__file__).resolve().parent.parent + try: + rules, page_infos = collect(book_root) + except BuildError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + targets = [ + (book_root / "rules.yaml", render_rules_yaml(rules)), + (book_root / "all-rules.md", render_all_rules_md(page_infos)), + ] + + if args.check: + return run_check(targets, len(rules)) + return run_write(targets, page_infos, len(rules)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api-design-guide/tools/check_links.py b/api-design-guide/tools/check_links.py new file mode 100644 index 0000000..ddeac8f --- /dev/null +++ b/api-design-guide/tools/check_links.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Validate the cross-reference integrity of the GovStack Cross-BB API Design Guide. + +This script is part of the guide's machine layer. It reads every `.md` file +under the book root (including `guides/`, `appendix/`, `SUMMARY.md` and +`README.md`) and enforces the book's internal-consistency contract: + + 1. Every relative markdown link resolves to a file that exists. + 2. Every link fragment `#x` matches an explicit anchor id in the target file. + 3. Every explicit `<a href="#x" id="x"></a>` anchor has href == id, id equal to + the slug of its heading, and no duplicate id within a page. + 4. SUMMARY.md lists every page exactly once (README.md first) and nothing that + is missing. + 5. No page is an orphan (unreachable from SUMMARY.md). + 6. Every OPEN-N-X id referenced in the book is defined exactly once as a row in + appendix/b-open-questions.md. + 7. Every page opens with `---`, carries a double-quoted `description:` line, and + closes its frontmatter. + +Exit status is 0 with a one-line summary when everything passes, or 1 with every +failure listed on stderr. There are no silent skips: if a check cannot run (for +example the open-questions appendix is missing) that itself is a failure. +""" + +import argparse +import os +import re +import sys +from pathlib import Path + +# Explicit anchor tag as emitted on heading lines. +ANCHOR_RE = re.compile(r'<a href="#(?P<href>[^"]*)" id="(?P<id>[^"]*)"></a>') +OPEN_QUESTION_RE = re.compile(r"OPEN-\d+-[A-Z]") +OPEN_QUESTION_EXACT_RE = re.compile(r"^OPEN-\d+-[A-Z]$") +DESCRIPTION_RE = re.compile(r'^description:\s*".*"\s*$') + +SKIP_LINK_PREFIXES = ("http://", "https://", "mailto:") +SLUG_KEEP = set("abcdefghijklmnopqrstuvwxyz0123456789-") + +APPENDIX_OPEN_QUESTIONS = "appendix/b-open-questions.md" + + +def slugify(heading_text): + """GitHub-style slug shared with build_rules_index.py; the two MUST be identical. + + Lowercase the text, keep ASCII letters, digits and existing hyphens, turn + spaces into hyphens, drop every other character. Consecutive hyphens are NOT + collapsed. The caller passes the heading text without its trailing `<a>` tag + and with the trailing space before that tag already stripped. + """ + out = [] + for ch in heading_text.lower(): + if ch in SLUG_KEEP: + out.append(ch) + elif ch == " ": + out.append("-") + # every other character is dropped + return "".join(out) + + +def iter_markdown_links(line): + """Yield (target, column) for every `[text](target)` link on a line. + + A bracket/paren depth scanner is used so link text that contains brackets + (for example ``[`[OPEN-4-B]`](...)``) is parsed correctly. + """ + i = 0 + n = len(line) + while i < n: + if line[i] == "[": + depth = 0 + j = i + close = -1 + while j < n: + if line[j] == "[": + depth += 1 + elif line[j] == "]": + depth -= 1 + if depth == 0: + close = j + break + j += 1 + if close != -1 and close + 1 < n and line[close + 1] == "(": + k = close + 2 + pdepth = 1 + while k < n: + if line[k] == "(": + pdepth += 1 + elif line[k] == ")": + pdepth -= 1 + if pdepth == 0: + break + k += 1 + if k < n and pdepth == 0: + yield line[close + 2 : k].strip(), i + i = k + 1 + continue + i += 1 + else: + i += 1 + + +class Checker: + def __init__(self, book_root): + self.book_root = book_root + self.failures = [] + self.md_files = sorted(book_root.rglob("*.md")) + # Per-file data keyed by absolute Path. + self.anchors = {} # path -> set of anchor ids + self.links = [] # (path, lineno, target) + self.open_mentions = [] # (path, lineno, open_id) + self.link_count = 0 + self.anchor_count = 0 + + def rel(self, path): + return path.relative_to(self.book_root).as_posix() + + def fail(self, path, lineno, message): + location = self.rel(path) + if lineno is not None: + location += f":{lineno}" + self.failures.append(f"{location}: {message}") + + # -- pass 1: read each file, validate anchors and frontmatter ------------- + + def scan_files(self): + for path in self.md_files: + lines = path.read_text(encoding="utf-8").split("\n") + is_summary = self.rel(path) == "SUMMARY.md" + if not is_summary: + self.check_frontmatter(path, lines) + self.collect_anchors(path, lines) + self.collect_links_and_mentions(path, lines) + + def check_frontmatter(self, path, lines): + if not lines or lines[0].strip() != "---": + self.fail(path, 1, "page does not start with '---' frontmatter") + return + close_idx = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + close_idx = i + break + if close_idx is None: + self.fail(path, 1, "frontmatter is never closed with '---'") + return + for i in range(1, close_idx): + if DESCRIPTION_RE.match(lines[i]): + return + self.fail( + path, + 1, + 'frontmatter has no double-quoted description: line (description: "...")', + ) + + def collect_anchors(self, path, lines): + ids = set() + for lineno, line in enumerate(lines, start=1): + for match in ANCHOR_RE.finditer(line): + self.anchor_count += 1 + href = match.group("href") + anchor_id = match.group("id") + pre = line[: match.start()] + heading_text = pre.lstrip("#").strip() + expected = slugify(heading_text) + if href != anchor_id: + self.fail( + path, + lineno, + f"anchor href '#{href}' != id '{anchor_id}'", + ) + if anchor_id != expected: + self.fail( + path, + lineno, + f"anchor id '{anchor_id}' != slug '{expected}' of heading " + f"{heading_text!r}", + ) + if anchor_id in ids: + self.fail(path, lineno, f"duplicate anchor id '{anchor_id}'") + ids.add(anchor_id) + self.anchors[path] = ids + + def collect_links_and_mentions(self, path, lines): + for lineno, line in enumerate(lines, start=1): + for target, _col in iter_markdown_links(line): + self.links.append((path, lineno, target)) + for match in OPEN_QUESTION_RE.finditer(line): + self.open_mentions.append((path, lineno, match.group(0))) + + # -- pass 2: validate links and fragments --------------------------------- + + def validate_links(self): + for path, lineno, target in self.links: + # Skip external links and empty targets. Bare "#fragment" self-links + # never match these and fall through to the fragment check below. + if target == "" or any(target.startswith(p) for p in SKIP_LINK_PREFIXES): + continue + self.link_count += 1 + if target.startswith("#"): + self.validate_fragment(path, lineno, path, target[1:]) + continue + path_part, _, fragment = target.partition("#") + if path_part == "": + self.validate_fragment(path, lineno, path, fragment) + continue + resolved = Path(os.path.normpath(path.parent / path_part)) + if not resolved.exists(): + self.fail( + path, + lineno, + f"link target does not exist: {target}", + ) + continue + if fragment: + self.validate_fragment(path, lineno, resolved, fragment) + + def validate_fragment(self, path, lineno, target_file, fragment): + if fragment == "": + return + if target_file.suffix != ".md": + self.fail( + path, + lineno, + f"fragment '#{fragment}' points at a non-markdown file " + f"{self.rel(target_file)}", + ) + return + ids = self.anchors.get(target_file) + if ids is None: + self.fail( + path, + lineno, + f"fragment '#{fragment}' points at an unreadable file " + f"{self.rel(target_file)}", + ) + return + if fragment not in ids: + self.fail( + path, + lineno, + f"fragment '#{fragment}' has no matching anchor in " + f"{self.rel(target_file)}", + ) + + # -- SUMMARY.md, orphans -------------------------------------------------- + + def check_summary(self): + summary = self.book_root / "SUMMARY.md" + if not summary.exists(): + self.failures.append("SUMMARY.md: file is missing (checks 4 and 5 cannot run)") + return + summary_targets = [] # ordered list of resolved absolute Paths + for lineno, line in enumerate(summary.read_text(encoding="utf-8").split("\n"), 1): + for target, _col in iter_markdown_links(line): + if any(target.startswith(p) for p in SKIP_LINK_PREFIXES): + continue + path_part = target.partition("#")[0] + if not path_part.endswith(".md"): + continue + resolved = Path(os.path.normpath(summary.parent / path_part)) + summary_targets.append((resolved, lineno)) + + # README.md must be the first listed page. + readme = Path(os.path.normpath(self.book_root / "README.md")) + if not summary_targets: + self.failures.append("SUMMARY.md: lists no pages") + elif summary_targets[0][0] != readme: + self.fail( + summary, + summary_targets[0][1], + f"first listed page must be README.md, found " + f"{self.rel(summary_targets[0][0])}", + ) + + # Missing files and duplicate listings. + seen = {} + for resolved, lineno in summary_targets: + if not resolved.exists(): + self.fail(summary, lineno, f"lists a missing file: {self.rel(resolved)}") + if resolved in seen: + self.fail( + summary, + lineno, + f"lists {self.rel(resolved)} more than once " + f"(first at line {seen[resolved]})", + ) + else: + seen[resolved] = lineno + + # Every page (except SUMMARY.md itself) must be listed exactly once. + universe = {p for p in self.md_files if self.rel(p) != "SUMMARY.md"} + listed = set(seen) + for page in sorted(universe - listed): + self.fail(page, None, "page is not listed in SUMMARY.md (orphan)") + for resolved in sorted(listed - universe): + # Already reported as missing above if it does not exist; otherwise it + # is a listed file outside the discovered .md set (should not happen). + if resolved.exists(): + self.fail( + self.book_root / "SUMMARY.md", + None, + f"lists a file outside the book's page set: {self.rel(resolved)}", + ) + + # -- OPEN-question integrity --------------------------------------------- + + def check_open_questions(self): + appendix = self.book_root / APPENDIX_OPEN_QUESTIONS + if not appendix.exists(): + self.failures.append( + f"{APPENDIX_OPEN_QUESTIONS}: file is missing (check 6 cannot run)" + ) + return + defined = {} # open_id -> first defining line number + for lineno, line in enumerate(appendix.read_text(encoding="utf-8").split("\n"), 1): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [c.strip() for c in stripped.strip("|").split("|")] + if not cells: + continue + first = cells[0] + match = OPEN_QUESTION_RE.search(first) + if not match: + continue + open_id = match.group(0) + if open_id in defined: + self.fail( + appendix, + lineno, + f"duplicate OPEN id definition '{open_id}' " + f"(first at line {defined[open_id]})", + ) + else: + defined[open_id] = lineno + + for path, lineno, open_id in self.open_mentions: + if open_id not in defined: + self.fail( + path, + lineno, + f"references undefined open question '{open_id}' " + f"(no row in {APPENDIX_OPEN_QUESTIONS})", + ) + + # -- driver --------------------------------------------------------------- + + def run(self): + if not self.md_files: + self.failures.append(f"no markdown files found under {self.book_root}") + return 1 + self.scan_files() + self.validate_links() + self.check_summary() + self.check_open_questions() + + if self.failures: + print( + f"check_links: {len(self.failures)} failure(s):", + file=sys.stderr, + ) + for failure in self.failures: + print(f" {failure}", file=sys.stderr) + return 1 + + print( + f"check_links: OK - {len(self.md_files)} files, {self.link_count} links, " + f"{self.anchor_count} anchors checked" + ) + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.parse_args(argv) + + book_root = Path(__file__).resolve().parent.parent + if not book_root.is_dir(): + print(f"error: book root is not a directory: {book_root}", file=sys.stderr) + return 2 + return Checker(book_root).run() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api-design-guide/version-history.md b/api-design-guide/version-history.md new file mode 100644 index 0000000..045e09e --- /dev/null +++ b/api-design-guide/version-history.md @@ -0,0 +1,38 @@ +--- +description: "What changed in each version of the GovStack Cross-BB API Design Guide." +--- + +# Version history + +## v0.2 (DRAFT, 2026-07-10) + +This edition supersedes the circulated v0.1 document. It is the same rulebook, restructured for publication as a GitBook and extended with the changes below. No normative wording changed other than what is listed here; a mechanical fidelity check of the v0.2 pages against the v0.1 text backs this list. + +**Structure and presentation** + +- Published as a GitBook: one page per section, one anchored heading per rule, so every rule is deep-linkable with a stable URL fragment that works on GitBook and GitHub alike. +- The enforcement-class tag (`[M]`, `[R]`, `[M+R]`, see [§1.9](1-introduction.md#19-rule-enforcement-classes)) now appears as a bold badge at the start of each rule body instead of after the rule number; [§1.9](1-introduction.md#19-rule-enforcement-classes)'s first sentence was updated to match. +- Sections renumbered to a continuous 1–20, removing the lettered sections (v0.1 §2A is now §3, §15A is now §17); every cross-reference was updated. The mapping table is on [How to use this guide](how-to-use-this-guide.md). The `OPEN-N-X` identifiers were deliberately **not** re-keyed (see the note in [Appendix B](appendix/b-open-questions.md)). +- Non-normative short titles were added to every rule heading (see [About the rule titles](how-to-use-this-guide.md#about-the-rule-titles)). +- Cross-references are now hyperlinks; each section's Intent / Applies to / Layer preamble is presented as an info callout; the "Rules:" list label was dropped; the note and carve-out paragraphs (casing note and carve-outs in [§9](part-c/9-json-conventions-and-naming.md), consent propagation in [§13](part-d/13-authentication-and-authorisation.md), retrofitting in [§18](part-d/18-compatibility-and-lifecycle.md), governance in [§20](part-e/20-conformance-and-validation.md)) received their own anchored headings, with the carve-outs' "(per §1.7)" qualifier moved to a line under the heading. +- [§13](part-d/13-authentication-and-authorisation.md)'s page title drops the "(spec declarations)" qualifier from the v0.1 heading; the section's scope statement is unchanged. +- The broken table markup in Appendices A and B was repaired. +- The executive summary now says "This draft" instead of "This v0.1 draft", and the Part D group label is spelled "Behaviour", matching the body text. + +**New content (normative)** + +- New [§1.10 Applicability and transition](1-introduction.md#110-applicability-and-transition): the guide binds new surfaces and new major versions, existing specs are not retroactively non-conformant, and the guide itself is versioned with SemVer. +- New rule [9.11](part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code): every namespace that embeds a `{bb-code}` uses the BB's single registered code, with a required syntax; new open question OPEN-9-A. One-sentence pointers to 9.11 were added to rules [11.5](part-c/11-errors.md#115-namespaced-stable-error-codes), [13.4](part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), [16.3](part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), and [17.2](part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses). +- New rule [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version): each canonical spec declares the guide version it targets via the `x-govstack-api-guide` extension; new open question OPEN-20-A. The extension was added to rule [9.10](part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix)'s example list. + +**New content (informative)** + +- Six examples: a `/health` response ([§5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)), an error envelope ([§11](part-c/11-errors.md)), cursor and offset pagination envelopes ([§12](part-c/12-pagination-filtering-sorting.md)), an Operation resource ([§15](part-d/15-asynchronous-operations.md)), and a structured CloudEvent ([§16](part-d/16-cloudevents-and-webhooks.md)). +- Two diagrams: which artifact documents which surface ([§1.2](1-introduction.md#12-scope)) and the layering model ([§1.8](1-introduction.md#18-layering-what-this-guide-constrains)). +- [Appendix B](appendix/b-open-questions.md) gained a **Blocks v1.0?** column marking the decisions that must precede ratification. +- A non-normative [Guides](guides/README.md) group: spec editor checklist, validation commands, AI-agent instructions, and maintenance notes. +- A machine layer: [Rules at a glance](all-rules.md) and `rules.yaml` (both generated from the pages by `tools/build_rules_index.py`), plus `tools/check_links.py` as a consistency guard. + +## v0.1 (DRAFT, 2026-05-31) + +Initial draft circulated to the GovStack committee for feedback: 164 numbered rules in sections 1–18 plus the lettered sections 2A and 15A, with three appendices (companion documents, open questions, normative references). Authored by Jeremi Joslin, drawing on the 2026 cross-BB audit of all 15 Building Blocks. From 6ad82fc11010f3e676c19850db71419c32b1cf70 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Fri, 10 Jul 2026 20:20:55 +0700 Subject: [PATCH 02/19] feat: add GovStack Spectral ruleset and API lint tooling Rule 20.2 mandates a GovStack Spectral ruleset; this ships its draft in-repo so BB spec editors and agents get mechanical verification instead of a checklist. 125 default rules plus 8 opt-in strict heuristics across OpenAPI 3.1 and AsyncAPI 3.0, a driver adding the 20.1 base validators, file-layout checks and 20.3 exception handling, per-rule coverage for all 166 guide rules in coverage.yaml (machine-checked), golden reference specs, and a composite GitHub Action with a guarded template workflow. Guide pages updated to point at the ruleset. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- .github/workflows/api-spec-lint.yml | 63 + .../appendix/a-companion-documents.md | 2 +- .../guides/maintaining-this-guide.md | 11 + .../guides/using-with-ai-agents.md | 3 + .../guides/validating-your-spec.md | 19 +- api-design-guide/linter/.gitignore | 1 + api-design-guide/linter/README.md | 124 + api-design-guide/linter/action.yml | 89 + api-design-guide/linter/cli.mjs | 759 ++++ api-design-guide/linter/coverage.yaml | 846 +++++ api-design-guide/linter/functions/README.md | 268 ++ .../linter/functions/envelopeShape.js | 103 + .../linter/functions/extensionShape.js | 90 + .../linter/functions/lib/casing.js | 36 + .../linter/functions/lib/schemaWalk.js | 90 + api-design-guide/linter/functions/lib/util.js | 73 + .../linter/functions/mediaTypeExpected.js | 56 + .../linter/functions/operationResponses.js | 83 + .../linter/functions/pathSegments.js | 82 + .../functions/responseHeaderRequired.js | 71 + .../linter/functions/s03-asyncOperation.js | 123 + .../linter/functions/s04-bodyExamplesEnums.js | 117 + .../functions/s04-duplicateDescriptions.js | 70 + .../linter/functions/s04-noPlaceholderText.js | 63 + .../linter/functions/s05-actionVerbs.js | 70 + .../linter/functions/s05-pluralNoun.js | 63 + .../functions/s06-bulkMutationSelection.js | 42 + .../functions/s07-noStoreOnProblemJson.js | 37 + .../linter/functions/s08-credentialsInUrl.js | 74 + .../linter/functions/s08-headerEcho.js | 61 + .../functions/s08-idempotencyKeyRequired.js | 38 + .../linter/functions/s08-noXHeaders.js | 89 + .../linter/functions/s08-personalDataInUrl.js | 69 + .../linter/functions/s08-rateLimitHeaders.js | 60 + .../functions/s08-requestIdCorrelation.js | 58 + .../linter/functions/s09-abbreviations.js | 76 + .../linter/functions/s09-bbCode.js | 109 + .../linter/functions/s09-booleanStrings.js | 67 + .../linter/functions/s09-closedEnum.js | 55 + .../linter/functions/s09-enumCasing.js | 71 + .../linter/functions/s09-extensionPrefix.js | 75 + .../functions/s12-collectionPagination.js | 111 + .../linter/functions/s12-sortParam.js | 44 + .../linter/functions/s13-apiKeyScope.js | 73 + .../linter/functions/s13-schemeExists.js | 58 + .../linter/functions/s13-scopeNames.js | 48 + .../linter/functions/s14-idempotencyKey.js | 57 + .../linter/functions/s15-cancelPath.js | 48 + .../linter/functions/s15-operationsPolling.js | 51 + .../linter/functions/s16-eventField.js | 113 + .../linter/functions/s16-signatureHeader.js | 49 + .../functions/s16-subscriptionEndpoints.js | 72 + .../linter/functions/s17-channelParameters.js | 57 + .../functions/s17-cloudEventsPayload.js | 71 + .../linter/functions/s17-opExtensions.js | 67 + .../linter/functions/s17-protocolBindings.js | 100 + .../linter/functions/s17-rejectionMessage.js | 97 + .../linter/functions/s17-requestReply.js | 65 + .../linter/functions/s17-securityCoverage.js | 64 + .../linter/functions/s18-deprecatedHeaders.js | 59 + .../functions/s18-versionMajorConsistency.js | 85 + .../functions/s19-localisationHeaders.js | 91 + .../linter/functions/schemaDescriptions.js | 70 + .../linter/functions/schemaFieldFormat.js | 75 + .../linter/functions/schemaPropertyNames.js | 46 + .../linter/functions/securityCoverage.js | 68 + .../linter/functions/valuePattern.js | 47 + api-design-guide/linter/package-lock.json | 3255 +++++++++++++++++ api-design-guide/linter/package.json | 22 + api-design-guide/linter/ruleset.yaml | 42 + api-design-guide/linter/rulesets/s02.yaml | 124 + api-design-guide/linter/rulesets/s03.yaml | 148 + .../linter/rulesets/s04-strict.yaml | 21 + api-design-guide/linter/rulesets/s04.yaml | 68 + .../linter/rulesets/s05-strict.yaml | 50 + api-design-guide/linter/rulesets/s05.yaml | 165 + api-design-guide/linter/rulesets/s06.yaml | 97 + api-design-guide/linter/rulesets/s07.yaml | 201 + .../linter/rulesets/s08-strict.yaml | 26 + api-design-guide/linter/rulesets/s08.yaml | 113 + .../linter/rulesets/s09-strict.yaml | 37 + api-design-guide/linter/rulesets/s09.yaml | 152 + api-design-guide/linter/rulesets/s10.yaml | 251 ++ api-design-guide/linter/rulesets/s11.yaml | 121 + api-design-guide/linter/rulesets/s12.yaml | 240 ++ api-design-guide/linter/rulesets/s13.yaml | 129 + api-design-guide/linter/rulesets/s14.yaml | 28 + api-design-guide/linter/rulesets/s15.yaml | 93 + api-design-guide/linter/rulesets/s16.yaml | 139 + .../linter/rulesets/s17-strict.yaml | 28 + api-design-guide/linter/rulesets/s17.yaml | 281 ++ api-design-guide/linter/rulesets/s18.yaml | 135 + api-design-guide/linter/rulesets/s19.yaml | 50 + api-design-guide/linter/rulesets/s20.yaml | 56 + api-design-guide/linter/strict.yaml | 22 + .../linter/tests/_lint-helper.mjs | 43 + .../linter/tests/coverage.test.mjs | 90 + .../tests/driver-fixtures/mini-ruleset.yaml | 22 + api-design-guide/linter/tests/driver.test.mjs | 320 ++ .../tests/fixtures/govstack-10.1/fail.yaml | 33 + .../tests/fixtures/govstack-10.1/pass.yaml | 34 + .../tests/fixtures/govstack-10.10/fail.yaml | 33 + .../tests/fixtures/govstack-10.10/pass.yaml | 34 + .../tests/fixtures/govstack-10.2/fail.yaml | 33 + .../tests/fixtures/govstack-10.2/pass.yaml | 34 + .../tests/fixtures/govstack-10.3/fail.yaml | 33 + .../tests/fixtures/govstack-10.3/pass.yaml | 34 + .../tests/fixtures/govstack-10.4/fail.yaml | 33 + .../tests/fixtures/govstack-10.4/pass.yaml | 40 + .../tests/fixtures/govstack-10.5/fail.yaml | 33 + .../tests/fixtures/govstack-10.5/pass.yaml | 34 + .../tests/fixtures/govstack-10.6/fail.yaml | 33 + .../tests/fixtures/govstack-10.6/pass.yaml | 34 + .../tests/fixtures/govstack-10.7/fail.yaml | 33 + .../tests/fixtures/govstack-10.7/pass.yaml | 35 + .../tests/fixtures/govstack-10.8/fail.yaml | 33 + .../tests/fixtures/govstack-10.8/pass.yaml | 34 + .../tests/fixtures/govstack-10.9/fail.yaml | 33 + .../tests/fixtures/govstack-10.9/pass.yaml | 34 + .../tests/fixtures/govstack-11.1/fail.yaml | 29 + .../tests/fixtures/govstack-11.1/pass.yaml | 38 + .../tests/fixtures/govstack-11.2/fail.yaml | 33 + .../tests/fixtures/govstack-11.2/pass.yaml | 38 + .../tests/fixtures/govstack-11.3/fail.yaml | 35 + .../tests/fixtures/govstack-11.3/pass.yaml | 38 + .../tests/fixtures/govstack-11.4/fail.yaml | 34 + .../tests/fixtures/govstack-11.4/pass.yaml | 43 + .../fixtures/govstack-11.5-enum/fail.yaml | 40 + .../fixtures/govstack-11.5-enum/pass.yaml | 40 + .../fixtures/govstack-11.5-example/fail.yaml | 40 + .../fixtures/govstack-11.5-example/pass.yaml | 40 + .../tests/fixtures/govstack-12.1/fail.yaml | 21 + .../tests/fixtures/govstack-12.1/pass.yaml | 50 + .../tests/fixtures/govstack-12.2/fail.yaml | 35 + .../tests/fixtures/govstack-12.2/pass.yaml | 50 + .../tests/fixtures/govstack-12.3/fail.yaml | 28 + .../tests/fixtures/govstack-12.3/pass.yaml | 50 + .../tests/fixtures/govstack-12.4/fail.yaml | 28 + .../tests/fixtures/govstack-12.4/pass.yaml | 41 + .../tests/fixtures/govstack-12.6/fail.yaml | 34 + .../tests/fixtures/govstack-12.6/pass.yaml | 35 + .../fixtures/govstack-12.7-grammar/fail.yaml | 28 + .../fixtures/govstack-12.7-grammar/pass.yaml | 28 + .../fixtures/govstack-12.7-name/fail.yaml | 28 + .../fixtures/govstack-12.7-name/pass.yaml | 28 + .../tests/fixtures/govstack-12.8/fail.yaml | 28 + .../tests/fixtures/govstack-12.8/pass.yaml | 28 + .../fixtures/govstack-12.9-body/fail.yaml | 39 + .../fixtures/govstack-12.9-body/pass.yaml | 43 + .../fixtures/govstack-12.9-response/fail.yaml | 33 + .../fixtures/govstack-12.9-response/pass.yaml | 43 + .../tests/fixtures/govstack-13.1/fail.yaml | 20 + .../tests/fixtures/govstack-13.1/pass.yaml | 21 + .../tests/fixtures/govstack-13.2/fail.yaml | 19 + .../tests/fixtures/govstack-13.2/pass.yaml | 18 + .../tests/fixtures/govstack-13.3/fail.yaml | 26 + .../tests/fixtures/govstack-13.3/pass.yaml | 20 + .../tests/fixtures/govstack-13.4/fail.yaml | 22 + .../tests/fixtures/govstack-13.4/pass.yaml | 23 + .../tests/fixtures/govstack-13.5/fail.yaml | 19 + .../tests/fixtures/govstack-13.5/pass.yaml | 19 + .../tests/fixtures/govstack-13.6/fail.yaml | 20 + .../tests/fixtures/govstack-13.6/pass.yaml | 32 + .../tests/fixtures/govstack-14.1/fail.yaml | 15 + .../tests/fixtures/govstack-14.1/pass.yaml | 21 + .../tests/fixtures/govstack-15.1/fail.yaml | 11 + .../tests/fixtures/govstack-15.1/pass.yaml | 15 + .../tests/fixtures/govstack-15.2/fail.yaml | 17 + .../tests/fixtures/govstack-15.2/pass.yaml | 30 + .../tests/fixtures/govstack-15.3/fail.yaml | 26 + .../tests/fixtures/govstack-15.3/pass.yaml | 27 + .../tests/fixtures/govstack-15.4/fail.yaml | 20 + .../tests/fixtures/govstack-15.4/pass.yaml | 30 + .../tests/fixtures/govstack-15.5/fail.yaml | 12 + .../tests/fixtures/govstack-15.5/pass.yaml | 17 + .../tests/fixtures/govstack-16.1/fail.yaml | 31 + .../tests/fixtures/govstack-16.1/pass.yaml | 30 + .../tests/fixtures/govstack-16.11/fail.yaml | 41 + .../tests/fixtures/govstack-16.11/pass.yaml | 56 + .../govstack-16.2-recommended/fail.yaml | 34 + .../govstack-16.2-recommended/pass.yaml | 40 + .../tests/fixtures/govstack-16.2/fail.yaml | 30 + .../tests/fixtures/govstack-16.2/pass.yaml | 34 + .../tests/fixtures/govstack-16.3/fail.yaml | 33 + .../tests/fixtures/govstack-16.3/pass.yaml | 33 + .../tests/fixtures/govstack-16.4/fail.yaml | 35 + .../tests/fixtures/govstack-16.4/pass.yaml | 34 + .../tests/fixtures/govstack-16.6/fail.yaml | 26 + .../tests/fixtures/govstack-16.6/pass.yaml | 27 + .../tests/fixtures/govstack-17.1/fail.yaml | 26 + .../tests/fixtures/govstack-17.1/pass.yaml | 25 + .../tests/fixtures/govstack-17.10/fail.yaml | 34 + .../tests/fixtures/govstack-17.10/pass.yaml | 35 + .../tests/fixtures/govstack-17.11/fail.yaml | 26 + .../tests/fixtures/govstack-17.11/pass.yaml | 26 + .../tests/fixtures/govstack-17.12/fail.yaml | 26 + .../tests/fixtures/govstack-17.12/pass.yaml | 28 + .../tests/fixtures/govstack-17.13/fail.yaml | 29 + .../tests/fixtures/govstack-17.13/pass.yaml | 29 + .../tests/fixtures/govstack-17.15/fail.yaml | 31 + .../tests/fixtures/govstack-17.15/pass.yaml | 33 + .../tests/fixtures/govstack-17.16/fail.yaml | 39 + .../tests/fixtures/govstack-17.16/pass.yaml | 59 + .../tests/fixtures/govstack-17.17/fail.yaml | 36 + .../tests/fixtures/govstack-17.17/pass.yaml | 37 + .../tests/fixtures/govstack-17.19/fail.yaml | 31 + .../tests/fixtures/govstack-17.19/pass.yaml | 33 + .../tests/fixtures/govstack-17.2/fail.yaml | 26 + .../tests/fixtures/govstack-17.2/pass.yaml | 25 + .../tests/fixtures/govstack-17.20/fail.yaml | 23 + .../tests/fixtures/govstack-17.20/pass.yaml | 27 + .../tests/fixtures/govstack-17.3/fail.yaml | 30 + .../tests/fixtures/govstack-17.3/pass.yaml | 30 + .../tests/fixtures/govstack-17.4/fail.yaml | 29 + .../tests/fixtures/govstack-17.4/pass.yaml | 29 + .../tests/fixtures/govstack-17.5/fail.yaml | 26 + .../tests/fixtures/govstack-17.5/pass.yaml | 25 + .../tests/fixtures/govstack-17.6/fail.yaml | 30 + .../tests/fixtures/govstack-17.6/pass.yaml | 46 + .../tests/fixtures/govstack-17.8/fail.yaml | 31 + .../tests/fixtures/govstack-17.8/pass.yaml | 32 + .../tests/fixtures/govstack-17.9/fail.yaml | 32 + .../tests/fixtures/govstack-17.9/pass.yaml | 32 + .../tests/fixtures/govstack-18.1/fail.yaml | 9 + .../tests/fixtures/govstack-18.1/pass.yaml | 9 + .../fixtures/govstack-18.2-asyncapi/fail.yaml | 45 + .../fixtures/govstack-18.2-asyncapi/pass.yaml | 45 + .../fixtures/govstack-18.2-openapi/fail.yaml | 18 + .../fixtures/govstack-18.2-openapi/pass.yaml | 18 + .../tests/fixtures/govstack-18.5/fail.yaml | 19 + .../tests/fixtures/govstack-18.5/pass.yaml | 28 + .../govstack-18.7-description/fail.yaml | 51 + .../govstack-18.7-description/pass.yaml | 50 + .../tests/fixtures/govstack-18.7/fail.yaml | 49 + .../tests/fixtures/govstack-18.7/pass.yaml | 50 + .../fixtures/govstack-19.4-asyncapi/fail.yaml | 40 + .../fixtures/govstack-19.4-asyncapi/pass.yaml | 53 + .../fixtures/govstack-19.4-openapi/fail.yaml | 24 + .../fixtures/govstack-19.4-openapi/pass.yaml | 29 + .../tests/fixtures/govstack-2.1/fail.yaml | 14 + .../tests/fixtures/govstack-2.1/pass.yaml | 14 + .../fixtures/govstack-2.5-semver/fail.yaml | 14 + .../fixtures/govstack-2.5-semver/pass.yaml | 14 + .../tests/fixtures/govstack-2.5/fail.yaml | 10 + .../tests/fixtures/govstack-2.5/pass.yaml | 14 + .../tests/fixtures/govstack-2.6/fail.yaml | 11 + .../tests/fixtures/govstack-2.6/pass.yaml | 15 + .../tests/fixtures/govstack-2.7/fail.yaml | 20 + .../tests/fixtures/govstack-2.7/pass.yaml | 24 + .../govstack-20.3-exceptions/fail.yaml | 14 + .../govstack-20.3-exceptions/pass.yaml | 16 + .../tests/fixtures/govstack-20.3/fail.yaml | 9 + .../tests/fixtures/govstack-20.3/pass.yaml | 11 + .../tests/fixtures/govstack-3.1/fail.yaml | 16 + .../tests/fixtures/govstack-3.1/pass.yaml | 48 + .../fixtures/govstack-3.5-semver/fail.yaml | 39 + .../fixtures/govstack-3.5-semver/pass.yaml | 48 + .../tests/fixtures/govstack-3.5/fail.yaml | 36 + .../tests/fixtures/govstack-3.5/pass.yaml | 48 + .../fixtures/govstack-3.6-host/fail.yaml | 39 + .../fixtures/govstack-3.6-host/pass.yaml | 48 + .../tests/fixtures/govstack-3.6/fail.yaml | 30 + .../tests/fixtures/govstack-3.6/pass.yaml | 48 + .../tests/fixtures/govstack-3.7/fail.yaml | 36 + .../tests/fixtures/govstack-3.7/pass.yaml | 48 + .../tests/fixtures/govstack-3.9/fail.yaml | 45 + .../tests/fixtures/govstack-3.9/pass.yaml | 48 + .../tests/fixtures/govstack-4.1/fail.yaml | 34 + .../tests/fixtures/govstack-4.1/pass.yaml | 36 + .../tests/fixtures/govstack-4.2/fail.yaml | 43 + .../tests/fixtures/govstack-4.2/pass.yaml | 48 + .../tests/fixtures/govstack-4.3/fail.yaml | 23 + .../tests/fixtures/govstack-4.3/pass.yaml | 23 + .../tests/fixtures/govstack-4.4/fail.yaml | 32 + .../tests/fixtures/govstack-4.4/pass.yaml | 32 + .../tests/fixtures/govstack-5.1/fail.yaml | 18 + .../tests/fixtures/govstack-5.1/pass.yaml | 29 + .../tests/fixtures/govstack-5.2/fail.yaml | 18 + .../tests/fixtures/govstack-5.2/pass.yaml | 18 + .../tests/fixtures/govstack-5.3/fail.yaml | 18 + .../tests/fixtures/govstack-5.3/pass.yaml | 18 + .../tests/fixtures/govstack-5.4/fail.yaml | 27 + .../tests/fixtures/govstack-5.4/pass.yaml | 40 + .../tests/fixtures/govstack-5.5/fail.yaml | 23 + .../tests/fixtures/govstack-5.5/pass.yaml | 23 + .../tests/fixtures/govstack-5.6/fail.yaml | 23 + .../tests/fixtures/govstack-5.6/pass.yaml | 23 + .../tests/fixtures/govstack-5.7/fail.yaml | 18 + .../tests/fixtures/govstack-5.7/pass.yaml | 18 + .../tests/fixtures/govstack-5.8/fail.yaml | 18 + .../tests/fixtures/govstack-5.8/pass.yaml | 23 + .../govstack-5.9-media-type/fail.yaml | 28 + .../govstack-5.9-media-type/pass.yaml | 41 + .../fixtures/govstack-5.9-no-auth/fail.yaml | 35 + .../fixtures/govstack-5.9-no-auth/pass.yaml | 28 + .../fixtures/govstack-5.9-presence/fail.yaml | 18 + .../fixtures/govstack-5.9-presence/pass.yaml | 28 + .../govstack-5.9-status-enum/fail.yaml | 28 + .../govstack-5.9-status-enum/pass.yaml | 42 + .../tests/fixtures/govstack-6.1/fail.yaml | 28 + .../tests/fixtures/govstack-6.1/pass.yaml | 23 + .../tests/fixtures/govstack-6.4/fail.yaml | 34 + .../tests/fixtures/govstack-6.4/pass.yaml | 34 + .../tests/fixtures/govstack-6.5/fail.yaml | 35 + .../tests/fixtures/govstack-6.5/pass.yaml | 29 + .../tests/fixtures/govstack-6.6/fail.yaml | 28 + .../tests/fixtures/govstack-6.6/pass.yaml | 28 + .../tests/fixtures/govstack-6.7/fail.yaml | 28 + .../tests/fixtures/govstack-6.7/pass.yaml | 34 + .../tests/fixtures/govstack-7.13/fail.yaml | 24 + .../tests/fixtures/govstack-7.13/pass.yaml | 26 + .../tests/fixtures/govstack-7.14/fail.yaml | 24 + .../tests/fixtures/govstack-7.14/pass.yaml | 26 + .../tests/fixtures/govstack-7.16/fail.yaml | 26 + .../tests/fixtures/govstack-7.16/pass.yaml | 46 + .../tests/fixtures/govstack-7.17/fail.yaml | 32 + .../tests/fixtures/govstack-7.17/pass.yaml | 41 + .../tests/fixtures/govstack-7.18/fail.yaml | 28 + .../tests/fixtures/govstack-7.18/pass.yaml | 33 + .../tests/fixtures/govstack-7.19/fail.yaml | 32 + .../tests/fixtures/govstack-7.19/pass.yaml | 34 + .../tests/fixtures/govstack-7.2/fail.yaml | 20 + .../tests/fixtures/govstack-7.2/pass.yaml | 26 + .../tests/fixtures/govstack-7.20/fail.yaml | 32 + .../tests/fixtures/govstack-7.20/pass.yaml | 37 + .../tests/fixtures/govstack-7.3/fail.yaml | 20 + .../tests/fixtures/govstack-7.3/pass.yaml | 26 + .../tests/fixtures/govstack-7.6/fail.yaml | 28 + .../tests/fixtures/govstack-7.6/pass.yaml | 33 + .../tests/fixtures/govstack-8.1/fail.yaml | 30 + .../tests/fixtures/govstack-8.1/pass.yaml | 30 + .../tests/fixtures/govstack-8.2/fail.yaml | 24 + .../tests/fixtures/govstack-8.2/pass.yaml | 28 + .../tests/fixtures/govstack-8.3/fail.yaml | 23 + .../tests/fixtures/govstack-8.3/pass.yaml | 29 + .../tests/fixtures/govstack-8.4/fail.yaml | 18 + .../tests/fixtures/govstack-8.4/pass.yaml | 28 + .../tests/fixtures/govstack-8.5/fail.yaml | 28 + .../tests/fixtures/govstack-8.5/pass.yaml | 33 + .../tests/fixtures/govstack-8.6/fail.yaml | 24 + .../tests/fixtures/govstack-8.6/pass.yaml | 24 + .../tests/fixtures/govstack-8.7/fail.yaml | 20 + .../tests/fixtures/govstack-8.7/pass.yaml | 43 + .../tests/fixtures/govstack-9.1/fail.yaml | 15 + .../tests/fixtures/govstack-9.1/pass.yaml | 15 + .../tests/fixtures/govstack-9.10/fail.yaml | 12 + .../tests/fixtures/govstack-9.10/pass.yaml | 12 + .../tests/fixtures/govstack-9.11/fail.yaml | 15 + .../tests/fixtures/govstack-9.11/pass.yaml | 15 + .../tests/fixtures/govstack-9.2/fail.yaml | 14 + .../tests/fixtures/govstack-9.2/pass.yaml | 14 + .../tests/fixtures/govstack-9.3/fail.yaml | 15 + .../tests/fixtures/govstack-9.3/pass.yaml | 14 + .../tests/fixtures/govstack-9.4/fail.yaml | 13 + .../tests/fixtures/govstack-9.4/pass.yaml | 12 + .../tests/fixtures/govstack-9.5/fail.yaml | 14 + .../tests/fixtures/govstack-9.5/pass.yaml | 14 + .../tests/fixtures/govstack-9.6/fail.yaml | 14 + .../tests/fixtures/govstack-9.6/pass.yaml | 14 + .../tests/fixtures/govstack-9.7/fail.yaml | 12 + .../tests/fixtures/govstack-9.7/pass.yaml | 12 + .../tests/fixtures/govstack-9.8/fail.yaml | 19 + .../tests/fixtures/govstack-9.8/pass.yaml | 18 + .../tests/fixtures/govstack-9.9/fail.yaml | 13 + .../tests/fixtures/govstack-9.9/pass.yaml | 14 + .../linter/tests/functions.test.mjs | 230 ++ api-design-guide/linter/tests/golden.test.mjs | 58 + .../linter/tests/golden/asyncapi-golden.yaml | 454 +++ .../linter/tests/golden/openapi-golden.yaml | 1411 +++++++ .../linter/tests/harness.test.mjs | 70 + .../linter/tests/run-fixtures.test.mjs | 77 + api-design-guide/tools/check_links.py | 12 +- api-design-guide/version-history.md | 1 + 373 files changed, 22655 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/api-spec-lint.yml create mode 100644 api-design-guide/linter/.gitignore create mode 100644 api-design-guide/linter/README.md create mode 100644 api-design-guide/linter/action.yml create mode 100755 api-design-guide/linter/cli.mjs create mode 100644 api-design-guide/linter/coverage.yaml create mode 100644 api-design-guide/linter/functions/README.md create mode 100644 api-design-guide/linter/functions/envelopeShape.js create mode 100644 api-design-guide/linter/functions/extensionShape.js create mode 100644 api-design-guide/linter/functions/lib/casing.js create mode 100644 api-design-guide/linter/functions/lib/schemaWalk.js create mode 100644 api-design-guide/linter/functions/lib/util.js create mode 100644 api-design-guide/linter/functions/mediaTypeExpected.js create mode 100644 api-design-guide/linter/functions/operationResponses.js create mode 100644 api-design-guide/linter/functions/pathSegments.js create mode 100644 api-design-guide/linter/functions/responseHeaderRequired.js create mode 100644 api-design-guide/linter/functions/s03-asyncOperation.js create mode 100644 api-design-guide/linter/functions/s04-bodyExamplesEnums.js create mode 100644 api-design-guide/linter/functions/s04-duplicateDescriptions.js create mode 100644 api-design-guide/linter/functions/s04-noPlaceholderText.js create mode 100644 api-design-guide/linter/functions/s05-actionVerbs.js create mode 100644 api-design-guide/linter/functions/s05-pluralNoun.js create mode 100644 api-design-guide/linter/functions/s06-bulkMutationSelection.js create mode 100644 api-design-guide/linter/functions/s07-noStoreOnProblemJson.js create mode 100644 api-design-guide/linter/functions/s08-credentialsInUrl.js create mode 100644 api-design-guide/linter/functions/s08-headerEcho.js create mode 100644 api-design-guide/linter/functions/s08-idempotencyKeyRequired.js create mode 100644 api-design-guide/linter/functions/s08-noXHeaders.js create mode 100644 api-design-guide/linter/functions/s08-personalDataInUrl.js create mode 100644 api-design-guide/linter/functions/s08-rateLimitHeaders.js create mode 100644 api-design-guide/linter/functions/s08-requestIdCorrelation.js create mode 100644 api-design-guide/linter/functions/s09-abbreviations.js create mode 100644 api-design-guide/linter/functions/s09-bbCode.js create mode 100644 api-design-guide/linter/functions/s09-booleanStrings.js create mode 100644 api-design-guide/linter/functions/s09-closedEnum.js create mode 100644 api-design-guide/linter/functions/s09-enumCasing.js create mode 100644 api-design-guide/linter/functions/s09-extensionPrefix.js create mode 100644 api-design-guide/linter/functions/s12-collectionPagination.js create mode 100644 api-design-guide/linter/functions/s12-sortParam.js create mode 100644 api-design-guide/linter/functions/s13-apiKeyScope.js create mode 100644 api-design-guide/linter/functions/s13-schemeExists.js create mode 100644 api-design-guide/linter/functions/s13-scopeNames.js create mode 100644 api-design-guide/linter/functions/s14-idempotencyKey.js create mode 100644 api-design-guide/linter/functions/s15-cancelPath.js create mode 100644 api-design-guide/linter/functions/s15-operationsPolling.js create mode 100644 api-design-guide/linter/functions/s16-eventField.js create mode 100644 api-design-guide/linter/functions/s16-signatureHeader.js create mode 100644 api-design-guide/linter/functions/s16-subscriptionEndpoints.js create mode 100644 api-design-guide/linter/functions/s17-channelParameters.js create mode 100644 api-design-guide/linter/functions/s17-cloudEventsPayload.js create mode 100644 api-design-guide/linter/functions/s17-opExtensions.js create mode 100644 api-design-guide/linter/functions/s17-protocolBindings.js create mode 100644 api-design-guide/linter/functions/s17-rejectionMessage.js create mode 100644 api-design-guide/linter/functions/s17-requestReply.js create mode 100644 api-design-guide/linter/functions/s17-securityCoverage.js create mode 100644 api-design-guide/linter/functions/s18-deprecatedHeaders.js create mode 100644 api-design-guide/linter/functions/s18-versionMajorConsistency.js create mode 100644 api-design-guide/linter/functions/s19-localisationHeaders.js create mode 100644 api-design-guide/linter/functions/schemaDescriptions.js create mode 100644 api-design-guide/linter/functions/schemaFieldFormat.js create mode 100644 api-design-guide/linter/functions/schemaPropertyNames.js create mode 100644 api-design-guide/linter/functions/securityCoverage.js create mode 100644 api-design-guide/linter/functions/valuePattern.js create mode 100644 api-design-guide/linter/package-lock.json create mode 100644 api-design-guide/linter/package.json create mode 100644 api-design-guide/linter/ruleset.yaml create mode 100644 api-design-guide/linter/rulesets/s02.yaml create mode 100644 api-design-guide/linter/rulesets/s03.yaml create mode 100644 api-design-guide/linter/rulesets/s04-strict.yaml create mode 100644 api-design-guide/linter/rulesets/s04.yaml create mode 100644 api-design-guide/linter/rulesets/s05-strict.yaml create mode 100644 api-design-guide/linter/rulesets/s05.yaml create mode 100644 api-design-guide/linter/rulesets/s06.yaml create mode 100644 api-design-guide/linter/rulesets/s07.yaml create mode 100644 api-design-guide/linter/rulesets/s08-strict.yaml create mode 100644 api-design-guide/linter/rulesets/s08.yaml create mode 100644 api-design-guide/linter/rulesets/s09-strict.yaml create mode 100644 api-design-guide/linter/rulesets/s09.yaml create mode 100644 api-design-guide/linter/rulesets/s10.yaml create mode 100644 api-design-guide/linter/rulesets/s11.yaml create mode 100644 api-design-guide/linter/rulesets/s12.yaml create mode 100644 api-design-guide/linter/rulesets/s13.yaml create mode 100644 api-design-guide/linter/rulesets/s14.yaml create mode 100644 api-design-guide/linter/rulesets/s15.yaml create mode 100644 api-design-guide/linter/rulesets/s16.yaml create mode 100644 api-design-guide/linter/rulesets/s17-strict.yaml create mode 100644 api-design-guide/linter/rulesets/s17.yaml create mode 100644 api-design-guide/linter/rulesets/s18.yaml create mode 100644 api-design-guide/linter/rulesets/s19.yaml create mode 100644 api-design-guide/linter/rulesets/s20.yaml create mode 100644 api-design-guide/linter/strict.yaml create mode 100644 api-design-guide/linter/tests/_lint-helper.mjs create mode 100644 api-design-guide/linter/tests/coverage.test.mjs create mode 100644 api-design-guide/linter/tests/driver-fixtures/mini-ruleset.yaml create mode 100644 api-design-guide/linter/tests/driver.test.mjs create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.10/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.10/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.8/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.8/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.9/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.9/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.5-enum/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.5-example/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.7-name/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.7-name/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.8/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.8/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.9-response/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-14.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-14.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.5-semver/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.5-semver/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-2.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-20.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-4.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.8/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.8/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-presence/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-6.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.13/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.13/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.14/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.14/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.16/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.16/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.17/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.17/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.18/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.18/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.19/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.19/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.20/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.20/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.1/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.1/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.11/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.11/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.2/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.2/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.3/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.3/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.4/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.4/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.5/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.5/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.6/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.6/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.8/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.8/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.9/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-9.9/pass.yaml create mode 100644 api-design-guide/linter/tests/functions.test.mjs create mode 100644 api-design-guide/linter/tests/golden.test.mjs create mode 100644 api-design-guide/linter/tests/golden/asyncapi-golden.yaml create mode 100644 api-design-guide/linter/tests/golden/openapi-golden.yaml create mode 100644 api-design-guide/linter/tests/harness.test.mjs create mode 100644 api-design-guide/linter/tests/run-fixtures.test.mjs diff --git a/.github/workflows/api-spec-lint.yml b/.github/workflows/api-spec-lint.yml new file mode 100644 index 0000000..b465e08 --- /dev/null +++ b/.github/workflows/api-spec-lint.yml @@ -0,0 +1,63 @@ +# API spec lint +# +# Lints this repo's OpenAPI/AsyncAPI spec against the GovStack Cross-BB API +# Design Guide, using the composite action in api-design-guide/linter/. This +# workflow is inherited as-is by Building Block (BB) repos instantiated from +# bb-template, so it must work with either the template's empty placeholder +# specs or a BB's real spec. +# +# Once a BB adds a real spec under api/, lint failures at or above the +# fail-on threshold (default: error) will fail this check. Whether/when to +# tighten that threshold, grant exceptions, or otherwise govern enforcement +# is left to each BB per the guide's own governance note (see +# api-design-guide/part-e/20-conformance-and-validation.md, "Note on +# governance") — this workflow just wires up the default. +# +# A BB repo may instead delete the inherited api-design-guide/linter/ folder +# and pin the action from this template's repo directly, e.g.: +# uses: GovStackWorkingGroup/bb-template/api-design-guide/linter@<ref> +name: API spec lint + +on: + pull_request: + paths: + - "api/**" + - "api-design-guide/linter/**" + - ".github/workflows/api-spec-lint.yml" + push: + branches: + - main + paths: + - "api/**" + - "api-design-guide/linter/**" + - ".github/workflows/api-spec-lint.yml" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # bb-template ships empty placeholder specs, so a fresh template clone + # (or a BB that hasn't added its spec yet) has nothing to lint. Skip + # the lint step in that case rather than failing the check. + - name: Check for API spec + id: guard + shell: bash + run: | + has_spec=false + for f in api/openapi.yaml api/asyncapi.yaml; do + if [[ -f "$f" ]] && [[ -n "$(tr -d '[:space:]' < "$f")" ]]; then + has_spec=true + fi + done + if [[ "$has_spec" == "true" ]]; then + echo "spec-present=true" >> "$GITHUB_OUTPUT" + else + echo "spec-present=false" >> "$GITHUB_OUTPUT" + echo "no API spec present; skipping lint (the bb-template ships empty placeholders)" + fi + + - name: Lint API spec + if: steps.guard.outputs.spec-present == 'true' + uses: ./api-design-guide/linter diff --git a/api-design-guide/appendix/a-companion-documents.md b/api-design-guide/appendix/a-companion-documents.md index 3d1d874..f1af040 100644 --- a/api-design-guide/appendix/a-companion-documents.md +++ b/api-design-guide/appendix/a-companion-documents.md @@ -12,6 +12,6 @@ description: "Companion documents and artifacts that pick up the topics this gui | **GovStack API Security & Operations** | Not yet drafted | Operational behaviour of a deployed BB: token validation, certificate trust, key rotation, replay enforcement, audit logging, log hygiene, alg allowlists, FAPI conformance. | | `govstack-openapi-common.yaml` | To be authored alongside v1.0 | Shared security scheme, RFC 9457 error schema, pagination envelope, common headers, Operation resource, common error catalogue ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)). | | `govstack-asyncapi-common.yaml` | To be authored alongside v1.0 | Shared CloudEvents envelope ([§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)), common message headers ([§17](../part-d/17-asyncapi-channel-rules.md)), common security schemes (OAuth 2.0, OpenID Connect, X.509/mTLS), signing metadata, delivery-semantics extensions, and common error messages referencing the [§11](../part-c/11-errors.md) error envelope. | -| **Spectral ruleset** | To be authored alongside v1.0 | Machine-enforceable subset of the guide's rules ([§20](../part-e/20-conformance-and-validation.md)). v0.1 covers OpenAPI, CloudEvents, and AsyncAPI documentation rules; protocol-profile rules may be added later. | +| **Spectral ruleset** | Draft ships in-repo at [`linter/`](../linter/README.md); formal v1.0 companion publication pending | Machine-enforceable subset of the guide's rules ([§20](../part-e/20-conformance-and-validation.md)). The draft covers OpenAPI, CloudEvents, and AsyncAPI documentation rules ([`linter/coverage.yaml`](../linter/coverage.yaml) records per-rule coverage); protocol-profile rules may be added later. | | **Conformance test pack** | Future companion artifact | Governance-defined contract tests beyond schema and Spectral validation. | | **Reference BB implementation** | Future companion artifact | Worked example applying the guide end-to-end to one BB. | diff --git a/api-design-guide/guides/maintaining-this-guide.md b/api-design-guide/guides/maintaining-this-guide.md index 03cc980..613c547 100644 --- a/api-design-guide/guides/maintaining-this-guide.md +++ b/api-design-guide/guides/maintaining-this-guide.md @@ -34,6 +34,17 @@ python3 tools/check_links.py Validates every internal link and anchor reference in the book, including `SUMMARY.md`. Run this alongside the index check whenever a page's headings or cross-references change. +## Keeping the linter in step + +The [GovStack Spectral ruleset](../linter/README.md) enforces this guide mechanically, so a rule edit is not finished until the linter agrees with it. Adding, removing, or substantively rewording a rule usually means updating the matching Spectral rule in `linter/rulesets/`, its fixture pair in `linter/tests/fixtures/`, and the rule's entry in `linter/coverage.yaml` (which records how, or why not, every rule is covered). Two checks make forgetting this loud: + +```bash +cd linter && npm ci && npm test +COVERAGE_ENFORCE=1 node --test tests/coverage.test.mjs +``` + +The second command fails if `rules.yaml` and `coverage.yaml` disagree about the set of rule ids, or if `coverage.yaml` and the shipped rulesets disagree about which Spectral rules exist. A new guide rule that was never triaged for linting is therefore a test failure, not a silent gap. Rule `documentationUrl`s in the ruleset embed each rule's page and anchor, which is one more reason anchors must stay frozen. + ## Adding an open question Append a row to [Appendix B](../appendix/b-open-questions.md) using an ID of the form `OPEN-{section}-{letter}`, keyed to the current section numbering. Never re-key an existing `OPEN-*` identifier: they are frozen once assigned, as noted on the Appendix B page itself, precisely so that a reference to `OPEN-15-A` in a discussion thread or a companion document keeps meaning the same thing over time. diff --git a/api-design-guide/guides/using-with-ai-agents.md b/api-design-guide/guides/using-with-ai-agents.md index 0b6d74e..29b7e17 100644 --- a/api-design-guide/guides/using-with-ai-agents.md +++ b/api-design-guide/guides/using-with-ai-agents.md @@ -24,6 +24,9 @@ This repository's API specifications must conform to the GovStack Cross-BB API D Before writing or reviewing OpenAPI/AsyncAPI content: - Read api-design-guide/rules.yaml and treat every MUST rule as blocking. - Cite rule IDs (for example 9.2, 11.1) when flagging or fixing violations. +- Lint the spec: `cd api-design-guide/linter && npm ci && node cli.mjs --repo-root ../..` + Every finding is prefixed with the guide rule id it enforces. Fix findings in the + spec; never edit the ruleset to silence them. - Run the validators in api-design-guide/guides/validating-your-spec.md before declaring spec work done. - Consult api-design-guide/guides/spec-editor-checklist.md for the review pass. diff --git a/api-design-guide/guides/validating-your-spec.md b/api-design-guide/guides/validating-your-spec.md index d29c979..07d75d5 100644 --- a/api-design-guide/guides/validating-your-spec.md +++ b/api-design-guide/guides/validating-your-spec.md @@ -23,13 +23,24 @@ npx @asyncapi/cli validate api/asyncapi.yaml This checks that the file is a structurally valid AsyncAPI 3.0 document. It is the mechanical check behind [3.4](../part-a/3-asyncapi-document-standards.md#34-passes-an-asyncapi-validator). -## Spectral +## The GovStack Spectral ruleset + +The ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) ships in this repository at [`linter/`](../linter/README.md), as a draft of the v1.0 companion artifact tracked in [Appendix A](../appendix/a-companion-documents.md). The recommended entrypoint is the driver, which also runs the base validators above, the file-layout checks, and the [20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling: + +```bash +cd api-design-guide/linter && npm ci +node cli.mjs --repo-root ../.. +``` + +Or run Spectral directly against the ruleset: ```bash -npx @stoplight/spectral-cli lint api/openapi.yaml +npx @stoplight/spectral-cli lint -r api-design-guide/linter/ruleset.yaml api/openapi.yaml ``` -Run without a GovStack-specific ruleset file, this applies Spectral's generic built-in OpenAPI rules only: it will catch general structural issues but knows nothing about this guide's rules (naming conventions, header requirements, error envelope shape, and so on). The GovStack Spectral ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) is a v1.0 companion artifact and does not exist yet; see [Appendix A](../appendix/a-companion-documents.md). Until it is published, treat a clean generic Spectral run as a weak signal, not conformance. +Every finding is prefixed with the guide rule id it enforces (for example `[7.13][M]`) and links to the rule's section. Findings for rule ids declared in `info.x-govstack-api-guide.exceptions` are reported as suppressed rather than counted. An opt-in `strict.yaml` adds noisier heuristics; [`linter/coverage.yaml`](../linter/coverage.yaml) records, for every rule in this guide, whether and how the linter covers it. In CI, the same checks run via the composite GitHub Action in `linter/action.yml`. + +Running `npx @stoplight/spectral-cli lint api/openapi.yaml` *without* `-r` applies only Spectral's generic built-in rules: useful as a quick structural check, but it knows nothing about this guide. ## The book's own machine layer @@ -37,4 +48,4 @@ Two scripts keep this book itself internally consistent rather than checking a B ## What "passing" actually means -Each rule in this guide carries an enforcement-class tag explained in [§1.9](../1-introduction.md#19-rule-enforcement-classes): `[M]` (machine-checkable), `[R]` (requires human review), or `[M+R]` (a linter can check the structural part, a reviewer must confirm the semantic part). The validators above, and the future GovStack Spectral ruleset, cover the `[M]` rules and the mechanical half of the `[M+R]` rules. Everything else, including every `[R]` rule and the judgement half of every `[M+R]` rule, is verified through specification review, not a command line. A clean validator run is necessary for conformance; it is not sufficient. +Each rule in this guide carries an enforcement-class tag explained in [§1.9](../1-introduction.md#19-rule-enforcement-classes): `[M]` (machine-checkable), `[R]` (requires human review), or `[M+R]` (a linter can check the structural part, a reviewer must confirm the semantic part). The validators above, and the GovStack Spectral ruleset, cover the `[M]` rules and the mechanical half of the `[M+R]` rules (to the extent recorded in [`linter/coverage.yaml`](../linter/coverage.yaml)). Everything else, including every `[R]` rule and the judgement half of every `[M+R]` rule, is verified through specification review, not a command line. A clean validator run is necessary for conformance; it is not sufficient. diff --git a/api-design-guide/linter/.gitignore b/api-design-guide/linter/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/api-design-guide/linter/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/api-design-guide/linter/README.md b/api-design-guide/linter/README.md new file mode 100644 index 0000000..a60c6d3 --- /dev/null +++ b/api-design-guide/linter/README.md @@ -0,0 +1,124 @@ +# GovStack API lint + +The GovStack Spectral ruleset and lint tooling for the +[Cross-BB API Design Guide](../README.md). This is the mechanical enforcement +behind rule [20.2](../part-e/20-conformance-and-validation.md) (draft; the +formal companion publication is tracked in +[Appendix A](../appendix/a-companion-documents.md)). It implements guide +version **0.2.0** (`guide_version` in [coverage.yaml](coverage.yaml)). + +## Quick start + +```bash +cd api-design-guide/linter +npm ci +node cli.mjs --repo-root ../.. # lints api/openapi.yaml + api/asyncapi.yaml +``` + +The driver runs everything rule 20 asks for: + +1. **File-tree checks** — canonical entrypoints `api/openapi.yaml` / + `api/asyncapi.yaml`, legacy `swagger.*` names, divergent spec copies + (guide 2.2/2.3/3.2/3.3). +2. **Base validators** (20.1) — `openapi-spec-validator` and + `@asyncapi/cli validate`, when installed (`--skip-validators` to skip). +3. **The Spectral ruleset** — 125 rules across both surfaces (OpenAPI 3.1, + AsyncAPI 3.0). Spectral auto-detects the document type. +4. **Declared exceptions** (20.3) — findings for rule ids listed in + `info.x-govstack-api-guide.exceptions` are reported as suppressed, not + dropped. File-tree findings cannot be suppressed. + +Flags: `--openapi <path>`, `--asyncapi <path>`, `--ruleset <file>`, `--strict`, +`--fail-on error|warn|info|never` (default `error`), `--format text|json`, +`--skip-validators`. Exit codes: `0` clean or below threshold, `1` findings at +or above `--fail-on`, `2` operational error. + +You can also run Spectral directly: + +```bash +npx spectral lint -r ruleset.yaml api/openapi.yaml +npx spectral lint -r strict.yaml api/openapi.yaml # opt-in noisy heuristics +``` + +## Rule naming and severities + +Spectral rules are named `govstack-<guide-rule-id>` (suffixed when one guide +rule needs several checks, e.g. `govstack-5.9-status-enum`). Every message +starts with `[<id>][<class>]` and every rule carries a `documentationUrl` +pointing at the rule's section of the guide. + +Severity policy (mechanical, no judgment): + +- Fully checkable rules inherit their RFC 2119 strength: MUST → `error`, + SHOULD → `warn`, MAY → `info`. +- Proxy checks (the linter verifies an automatable stand-in, not the whole + rule) run one notch lower, so an imperfect heuristic never blocks at + `error`. Each fragment comments on what its proxies do *not* verify. +- Noisy heuristics (pluralization, verb detection, personal-data term scans, + copy-paste detection) ship only in `strict.yaml` (8 rules, opt-in, `warn`). + +Two deliberate overlaps to be aware of: `govstack-8.3` and `govstack-14.1` +both require an `Idempotency-Key` header on creating POSTs (each cites its own +guide rule), and the §10 field-format rules can report a `$ref`'d schema once +per reference path. Fixing the schema clears all copies. + +## Coverage + +[coverage.yaml](coverage.yaml) maps **all 166 guide rules** to their +enforcement status — it is the scope contract, machine-checked by +`tests/coverage.test.mjs` in both directions (set `COVERAGE_ENFORCE=1`): + +| status | count | meaning | +| --- | --- | --- | +| `implemented` | 58 | fully checked by the listed Spectral rules | +| `partial-proxy` | 53 | an automated proxy is checked; the note says what is not | +| `driver` | 7 | checked by `cli.mjs` (file tree, base validators), not Spectral | +| `strict-only` | 8 | noisy heuristic, ships only in `strict.yaml` | +| `needs-context` | 7 | needs input that does not exist yet (common components YAML, BB-code registry) | +| `runtime` | 10 | constrains wire behaviour; test-harness territory | +| `human` | 20 | review/governance judgment | +| `informative` | 3 | non-normative guide entries | + +Cross-version breaking-change rules (18.3/18.4) are diff territory, not lint +territory: run [oasdiff](https://github.com/oasdiff/oasdiff) against the +previously published OpenAPI version. + +## Reference examples + +`tests/golden/openapi-golden.yaml` and `tests/golden/asyncapi-golden.yaml` are +complete specs that pass the entire default ruleset. When a rule's requirement +is unclear, they show a shape that satisfies it. + +## CI + +`action.yml` is a composite GitHub Action wrapping the driver. Minimal usage +in a BB repo: + +```yaml +- uses: actions/checkout@v4 +- uses: ./api-design-guide/linter + with: + fail-on: error +``` + +The template's own workflow (`.github/workflows/api-spec-lint.yml`) skips +cleanly while `api/` contains only empty placeholders. + +## Development + +```bash +npm test # fixtures, functions, driver, golden, harness +COVERAGE_ENFORCE=1 node --test tests/coverage.test.mjs # coverage drift checks +``` + +Every Spectral rule has `tests/fixtures/<rule-name>/{fail,pass}.yaml`: the +runner lints both with the full bundled ruleset and asserts the rule fires on +`fail.yaml` and not on `pass.yaml`. When a guide rule changes, update the rule, +its fixtures, and its `coverage.yaml` entry together — the drift checks fail +otherwise. Shared custom functions live in `functions/` (API reference in +[functions/README.md](functions/README.md)); section-specific ones are +prefixed `sNN-`. + +The ruleset is plain Spectral format, so it also runs under +[vacuum](https://github.com/daveshanley/vacuum) (v0.29+) for a faster CI +alternative; the driver only shells out to Spectral. diff --git a/api-design-guide/linter/action.yml b/api-design-guide/linter/action.yml new file mode 100644 index 0000000..7c7ef4a --- /dev/null +++ b/api-design-guide/linter/action.yml @@ -0,0 +1,89 @@ +# GovStack API spec lint — composite action +# +# Lints a Building Block's OpenAPI/AsyncAPI spec against the GovStack Cross-BB +# API Design Guide Spectral ruleset using the Node driver in this directory +# (cli.mjs). See ../guides/validating-your-spec.md for the ruleset itself. +# +# This action assumes the calling workflow has ALREADY checked out the +# repository (e.g. via actions/checkout) before this action runs — it does +# not check out anything itself. +name: "GovStack API spec lint" +description: "Lints a BB's OpenAPI/AsyncAPI spec against the GovStack Cross-BB API Design Guide ruleset." + +inputs: + openapi-path: + description: "Path (relative to the repo root) to the OpenAPI spec to lint." + required: false + default: "api/openapi.yaml" + asyncapi-path: + description: "Path (relative to the repo root) to the AsyncAPI spec to lint." + required: false + default: "api/asyncapi.yaml" + fail-on: + description: "Minimum severity that fails the check: error, warn, info, or never." + required: false + default: "error" + strict: + description: "Set to 'true' to also lint against the guide's strict ruleset." + required: false + default: "false" + node-version: + description: "Node.js version to set up for running the linter." + required: false + default: "22" + install-validators: + description: >- + Set to 'true' to install openapi-spec-validator (Python) and + @asyncapi/cli (npm) so the driver can additionally run schema + validation. Set to 'false' to skip installing them; the driver is then + called with --skip-validators. + required: false + default: "true" + +runs: + using: "composite" + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + # openapi-spec-validator is a Python tool the driver shells out to when + # present; it's optional, so only install it when asked. + - name: Set up Python (for openapi-spec-validator) + if: inputs.install-validators == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install openapi-spec-validator + if: inputs.install-validators == 'true' + shell: bash + run: pip install openapi-spec-validator + + - name: Install @asyncapi/cli + if: inputs.install-validators == 'true' + shell: bash + run: npm i -g @asyncapi/cli + + - name: Install linter dependencies + shell: bash + working-directory: ${{ github.action_path }} + run: npm ci + + - name: Run GovStack API spec lint + shell: bash + run: | + args=( + --repo-root "$GITHUB_WORKSPACE" + --openapi "${{ inputs.openapi-path }}" + --asyncapi "${{ inputs.asyncapi-path }}" + --fail-on "${{ inputs.fail-on }}" + ) + if [[ "${{ inputs.strict }}" == "true" ]]; then + args+=(--strict) + fi + if [[ "${{ inputs.install-validators }}" == "false" ]]; then + args+=(--skip-validators) + fi + node "${{ github.action_path }}/cli.mjs" "${args[@]}" diff --git a/api-design-guide/linter/cli.mjs b/api-design-guide/linter/cli.mjs new file mode 100755 index 0000000..ef6193c --- /dev/null +++ b/api-design-guide/linter/cli.mjs @@ -0,0 +1,759 @@ +#!/usr/bin/env node +// GovStack API lint driver. +// +// Orchestrates three layers of conformance checking for a Building Block repo: +// 1. File-tree checks the Spectral ruleset cannot see (guide §2.2/§2.3/§3.2/§3.3): +// canonical entrypoint location, legacy swagger.* files, divergent spec copies. +// 2. Base validators (§20.1): openapi-spec-validator and the AsyncAPI CLI. +// 3. The GovStack Spectral ruleset (§20.2), run programmatically. +// +// It also consumes each spec's info.x-govstack-api-guide declaration (§20.3) to compare +// the targeted guide version and to suppress findings covered by approved exceptions. +// +// The ruleset itself (ruleset.yaml / strict.yaml / rulesets/* / functions/*) is authored +// elsewhere; this driver only loads and runs it. + +import { parseArgs } from 'node:util'; +import * as fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import YAML from 'yaml'; +import spectralCore from '@stoplight/spectral-core'; +import Parsers from '@stoplight/spectral-parsers'; +import bundler from '@stoplight/spectral-ruleset-bundler/with-loader'; + +const { Spectral, Document } = spectralCore; +const { bundleAndLoadRuleset } = bundler; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +// Spectral severity numbers -> our names. 0=error 1=warn 2=info 3=hint. +const SEVERITY_NAME = ['error', 'warn', 'info', 'hint']; +const SEVERITY_NUM = { error: 0, warn: 1, info: 2, hint: 3 }; +// --fail-on threshold: a finding fails the run when its severity number <= threshold. +const FAIL_ON_THRESHOLD = { error: 0, warn: 1, info: 2, never: -1 }; + +// Directories never scanned for divergent spec copies (§2.3/§3.3). +const DIVERGENT_EXCLUDE_DIRS = new Set([ + '.git', + 'node_modules', + 'api-design-guide', + 'test', + 'examples', + 'spec', +]); + +// Operational failures (bad flags, unreadable/unparseable spec, ruleset load failure) -> exit 2. +class OperationalError extends Error {} + +// -------------------------------------------------------------------------------------- +// Argument parsing +// -------------------------------------------------------------------------------------- + +function parseCliArgs(argv) { + let values; + try { + ({ values } = parseArgs({ + args: argv, + options: { + 'repo-root': { type: 'string' }, + openapi: { type: 'string' }, + asyncapi: { type: 'string' }, + ruleset: { type: 'string' }, + strict: { type: 'boolean', default: false }, + 'fail-on': { type: 'string', default: 'error' }, + format: { type: 'string', default: 'text' }, + 'skip-validators': { type: 'boolean', default: false }, + }, + allowPositionals: false, + strict: true, + })); + } catch (err) { + throw new OperationalError(`Invalid arguments: ${err.message}`); + } + + const failOn = values['fail-on']; + if (!Object.prototype.hasOwnProperty.call(FAIL_ON_THRESHOLD, failOn)) { + throw new OperationalError( + `Invalid --fail-on value "${failOn}" (expected error|warn|info|never).`, + ); + } + const format = values.format; + if (format !== 'text' && format !== 'json') { + throw new OperationalError(`Invalid --format value "${format}" (expected text|json).`); + } + return values; +} + +// -------------------------------------------------------------------------------------- +// Path resolution +// -------------------------------------------------------------------------------------- + +// Nearest ancestor of `start` (inclusive) containing a .git entry, else `start`. +function findRepoRoot(start) { + let dir = path.resolve(start); + for (;;) { + if (fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return path.resolve(start); + dir = parent; + } +} + +function resolveConfig(values) { + const repoRoot = values['repo-root'] + ? path.resolve(values['repo-root']) + : findRepoRoot(process.cwd()); + + const openapiPath = path.resolve( + repoRoot, + values.openapi ?? path.join('api', 'openapi.yaml'), + ); + const asyncapiPath = path.resolve( + repoRoot, + values.asyncapi ?? path.join('api', 'asyncapi.yaml'), + ); + + let rulesetPath; + if (values.ruleset) { + rulesetPath = path.resolve(values.ruleset); + } else { + rulesetPath = path.join(HERE, values.strict ? 'strict.yaml' : 'ruleset.yaml'); + } + + return { + repoRoot, + openapiPath, + asyncapiPath, + rulesetPath, + failOn: values['fail-on'], + format: values.format, + skipValidators: values['skip-validators'], + }; +} + +// -------------------------------------------------------------------------------------- +// Spec file loading +// -------------------------------------------------------------------------------------- + +// Returns { present, empty, content, data }. `present` means the file exists and holds +// more than whitespace. An unreadable-but-existing file, or unparseable YAML, is fatal. +async function loadSpec(absPath, relDisplay) { + let content; + try { + content = await fsp.readFile(absPath, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return { present: false, empty: false }; + throw new OperationalError(`Cannot read spec file ${relDisplay}: ${err.message}`); + } + if (content.trim() === '') return { present: false, empty: true }; + + let data; + try { + data = YAML.parse(content); + } catch (err) { + throw new OperationalError(`Cannot parse spec file ${relDisplay}: ${err.message}`); + } + return { present: true, empty: false, content, data }; +} + +// -------------------------------------------------------------------------------------- +// File-tree checks (§2.2 / §2.3 / §3.2 / §3.3) +// -------------------------------------------------------------------------------------- + +// Legacy api/swagger.{yaml,json}. Non-empty -> file-canonical-name finding (§2.2, error). +// Empty placeholder -> notice only (the bb-template ships empty placeholders). +async function checkLegacySwagger(repoRoot, rel, findings, notices) { + let anyPresent = false; + for (const name of ['swagger.yaml', 'swagger.json']) { + const abs = path.join(repoRoot, 'api', name); + let content; + try { + content = await fsp.readFile(abs, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') continue; + throw new OperationalError(`Cannot read ${rel(abs)}: ${err.message}`); + } + anyPresent = true; + if (content.trim() === '') { + notices.push( + `Empty legacy placeholder ${rel(abs)}; no OpenAPI surface to lint. ` + + `Rename to api/openapi.yaml when you add one (guide §2.2).`, + ); + } else { + findings.push({ + file: rel(abs), + code: 'file-canonical-name', + guideRule: '2.2', + severity: 'error', + message: + `Legacy ${rel(abs)} must be renamed/converted to api/openapi.yaml; ` + + `the canonical OpenAPI entrypoint is api/openapi.yaml (guide §2.2).`, + jsonPath: [], + range: null, + documentationUrl: null, + }); + } + } + return anyPresent; +} + +async function readHead(absPath, n) { + const fh = await fsp.open(absPath, 'r'); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fh.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString('utf8'); + } finally { + await fh.close(); + } +} + +const SNIFF_RE = /(?:^|[\s"'{,])(openapi|asyncapi)["']?\s*:/im; +const SNIFF_BYTES = 2048; +const FULL_PARSE_MAX_BYTES = 5 * 1024 * 1024; + +// Cheap top-of-file sniff, then confirm by parse (only if not huge). Returns +// { kind } when the file looks like a top-level OpenAPI/AsyncAPI document, else null. +async function sniffSpec(absPath) { + let head; + try { + head = await readHead(absPath, SNIFF_BYTES); + } catch { + return null; + } + if (!SNIFF_RE.test(head)) return null; + + let size = 0; + try { + size = (await fsp.stat(absPath)).size; + } catch { + return null; + } + if (size > FULL_PARSE_MAX_BYTES) { + // Too large to parse cheaply; trust the sniff (heuristic). + return { kind: 'API' }; + } + let data; + try { + data = YAML.parse(await fsp.readFile(absPath, 'utf8')); + } catch { + return null; + } + if (data && typeof data === 'object' && !Array.isArray(data)) { + if (typeof data.openapi === 'string') return { kind: 'OpenAPI' }; + if (typeof data.asyncapi === 'string') return { kind: 'AsyncAPI' }; + } + return null; +} + +// Walk the repo tree for spec documents outside api/, excluding the dirs the guide's own +// fixtures and vendor trees live in. Heuristic; findings say so (§2.3/§3.3, warn). +async function scanDivergentCopies(repoRoot, skipAbs, rel, findings) { + const apiDir = path.resolve(repoRoot, 'api'); + + async function walk(dir) { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (DIVERGENT_EXCLUDE_DIRS.has(entry.name)) continue; + if (path.resolve(full) === apiDir) continue; + await walk(full); + } else if (entry.isFile()) { + if (!/\.(ya?ml|json)$/i.test(entry.name)) continue; + if (skipAbs.has(path.resolve(full))) continue; + const hit = await sniffSpec(full); + if (hit) { + const guideRule = hit.kind === 'AsyncAPI' ? '3.3' : '2.3'; + findings.push({ + file: rel(full), + code: 'file-divergent-copies', + guideRule, + severity: 'warn', + message: + `Heuristic: ${rel(full)} looks like an ${hit.kind} document outside api/. ` + + `Canonical specs must live under api/ with no divergent copies (guide §${guideRule}); ` + + `markdown snippets must $ref the canonical file. Verify this is not a stray copy.`, + jsonPath: [], + range: null, + documentationUrl: null, + }); + } + } + } + } + + await walk(repoRoot); +} + +// -------------------------------------------------------------------------------------- +// Base validators (§20.1) +// -------------------------------------------------------------------------------------- + +function runCommand(cmd, args) { + return new Promise((resolve) => { + let child; + try { + child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + resolve({ code: null, stdout: '', stderr: '', spawnError: err }); + return; + } + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => resolve({ code: null, stdout, stderr, spawnError: err })); + child.on('close', (code) => resolve({ code, stdout, stderr, spawnError: null })); + }); +} + +function trimOutput(s) { + const t = s.trim(); + const max = 800; + return t.length > max ? `${t.slice(0, max)} …[truncated]` : t; +} + +// Result shape: { ok } | { notice } | { finding: <message> }. +async function validateOpenapi(absPath) { + const r = await runCommand('openapi-spec-validator', [absPath]); + if (r.spawnError) { + return { + notice: + `openapi-spec-validator not found; skipping OpenAPI base validation (§20.1). ` + + `Install with: pip install openapi-spec-validator`, + }; + } + if (r.code === 0) return { ok: true }; + return { finding: `openapi-spec-validator: ${trimOutput(`${r.stdout}\n${r.stderr}`)}` }; +} + +// npx may report a missing package rather than a validation failure; those messages mean +// "fall back to a globally installed asyncapi CLI", not "the spec is invalid". +const NPX_UNAVAILABLE_RE = + /could not determine executable|npm error|not been installed|could not resolve|E404|command not found|no such file/i; + +async function validateAsyncapi(absPath) { + const npx = await runCommand('npx', ['--no-install', '@asyncapi/cli', 'validate', absPath]); + if (!npx.spawnError) { + if (npx.code === 0) return { ok: true }; + const out = `${npx.stdout}\n${npx.stderr}`; + if (!NPX_UNAVAILABLE_RE.test(out)) { + return { finding: `@asyncapi/cli: ${trimOutput(out)}` }; + } + // else: package unavailable via npx -> fall through to a global asyncapi binary. + } + + const global = await runCommand('asyncapi', ['validate', absPath]); + if (global.spawnError) { + return { + notice: + `AsyncAPI CLI not found; skipping AsyncAPI base validation (§20.1). ` + + `Install with: npm i -g @asyncapi/cli`, + }; + } + if (global.code === 0) return { ok: true }; + return { finding: `asyncapi validate: ${trimOutput(`${global.stdout}\n${global.stderr}`)}` }; +} + +async function runBaseValidator(kind, absPath, rel, findings, notices) { + const result = + kind === 'openapi' ? await validateOpenapi(absPath) : await validateAsyncapi(absPath); + if (result.notice) { + notices.push(result.notice); + } else if (result.finding) { + findings.push({ + file: rel(absPath), + code: 'base-validator', + guideRule: '20.1', + severity: 'error', + message: result.finding, + jsonPath: [], + range: null, + documentationUrl: null, + }); + } +} + +// -------------------------------------------------------------------------------------- +// Spectral +// -------------------------------------------------------------------------------------- + +async function loadRuleset(rulesetPath) { + try { + await fsp.access(rulesetPath); + } catch { + throw new OperationalError(`Ruleset not found: ${rulesetPath}`); + } + try { + return await bundleAndLoadRuleset(rulesetPath, { fs, fetch: globalThis.fetch }); + } catch (err) { + throw new OperationalError(`Failed to load ruleset ${rulesetPath}: ${err.message}`); + } +} + +// A guide rule id is the Spectral code with the `govstack-` prefix removed and any +// trailing `-suffix` after the numeric id dropped: govstack-2.5-contact -> 2.5. +function guideRuleFromCode(code) { + const s = String(code).replace(/^govstack-/, ''); + const m = s.match(/^(\d+(?:\.\d+)?)/); + return m ? m[1] : null; +} + +function mapSpectralResult(r, relPath) { + const severity = SEVERITY_NAME[r.severity] ?? 'info'; + return { + file: relPath, + code: String(r.code), + guideRule: guideRuleFromCode(r.code), + severity, + message: r.message, + jsonPath: Array.isArray(r.path) ? r.path : [], + range: r.range ?? null, + documentationUrl: r.documentationUrl ?? null, + }; +} + +// -------------------------------------------------------------------------------------- +// Guide-version declaration & exceptions (§20.3) +// -------------------------------------------------------------------------------------- + +// exceptions entries may be strings ("9.2") or objects ({rule, record}). Normalize to a +// Map of guideRuleId -> record (string|null). +function normalizeExceptions(raw) { + const map = new Map(); + if (!Array.isArray(raw)) return map; + for (const item of raw) { + if (typeof item === 'string') { + map.set(item.trim(), null); + } else if (item && typeof item === 'object') { + const rule = item.rule ?? item.id ?? item.ruleId; + if (typeof rule === 'string') { + map.set(rule.trim(), item.record ?? item.reference ?? item.ref ?? null); + } + } + } + return map; +} + +function majorMinor(version) { + const m = String(version).match(/^(\d+)\.(\d+)/); + return m ? `${m[1]}.${m[2]}` : null; +} + +let cachedGuideVersion; +function getGuideVersion() { + if (cachedGuideVersion !== undefined) return cachedGuideVersion; + cachedGuideVersion = null; + try { + const raw = fs.readFileSync(path.join(HERE, 'coverage.yaml'), 'utf8'); + const parsed = YAML.parse(raw); + if (parsed && typeof parsed.guide_version === 'string') { + cachedGuideVersion = parsed.guide_version; + } + } catch { + // coverage.yaml missing/unparseable: skip version comparison silently (it ships beside + // this CLI; its absence is not a spec error). + } + return cachedGuideVersion; +} + +// Reads info.x-govstack-api-guide. Returns { exceptions: Map }. Emits a version-mismatch +// notice when the declared major.minor differs from the linter's target guide version. +function consumeGuideDeclaration(specData, relPath, notices) { + const decl = specData?.info?.['x-govstack-api-guide']; + if (!decl || typeof decl !== 'object') return { exceptions: new Map() }; + + const guideVersion = getGuideVersion(); + if (typeof decl.version === 'string' && guideVersion) { + const declMM = majorMinor(decl.version); + const targetMM = majorMinor(guideVersion); + if (declMM && targetMM && declMM !== targetMM) { + notices.push( + `${relPath} declares guide version ${decl.version}, but this linter targets ` + + `${guideVersion} (major.minor mismatch); applied rules may differ (guide §20.3).`, + ); + } + } + return { exceptions: normalizeExceptions(decl.exceptions) }; +} + +// -------------------------------------------------------------------------------------- +// Reporting +// -------------------------------------------------------------------------------------- + +function locationOf(finding) { + if (finding.range && finding.range.start) { + return `${finding.range.start.line + 1}:${finding.range.start.character + 1}`; + } + return null; +} + +function groupFindings(findings) { + const groups = new Map(); + for (const f of findings) { + let g = groups.get(f.code); + if (!g) { + g = { + code: f.code, + guideRule: f.guideRule, + severity: f.severity, + severityNum: SEVERITY_NUM[f.severity] ?? 2, + message: f.message, + documentationUrl: f.documentationUrl, + locations: [], + count: 0, + }; + groups.set(f.code, g); + } + g.count += 1; + const loc = locationOf(f); + if (loc && g.locations.length < 5) g.locations.push(loc); + } + return [...groups.values()].sort( + (a, b) => a.severityNum - b.severityNum || a.code.localeCompare(b.code), + ); +} + +function renderText(report) { + const { files, notices, suppressed, summary, failOn, failed, noSpecBanner } = report; + const lines = []; + + if (noSpecBanner) { + lines.push('════════════════════════════════════════════════════════════════'); + lines.push(' NOTICE: no API spec files found to lint.'); + lines.push(' Expected api/openapi.yaml and/or api/asyncapi.yaml.'); + lines.push('════════════════════════════════════════════════════════════════'); + } + + for (const f of files) { + if (f.findings.length === 0) continue; + lines.push(''); + lines.push(f.path); + for (const g of groupFindings(f.findings)) { + const rule = g.guideRule ? ` (§${g.guideRule})` : ''; + lines.push(` [${g.severity}] ${g.code}${rule} ×${g.count}`); + lines.push(` ${g.message}`); + if (g.locations.length) { + const more = g.count > g.locations.length ? ', …' : ''; + lines.push(` at ${g.locations.join(', ')}${more}`); + } + if (g.documentationUrl) lines.push(` docs: ${g.documentationUrl}`); + } + } + + if (suppressed.length) { + lines.push(''); + lines.push(`Suppressed (${suppressed.length}) — excepted via info.x-govstack-api-guide:`); + for (const s of suppressed) { + const rule = s.guideRule ? ` (§${s.guideRule})` : ''; + const record = s.exceptionRecord ? `record: ${s.exceptionRecord}` : 'record: (none)'; + lines.push(` [${s.severity}] ${s.code}${rule} — ${s.file} — ${record}`); + } + } + + if (notices.length) { + lines.push(''); + lines.push(`Notices (${notices.length}):`); + for (const n of notices) lines.push(` - ${n}`); + } + + lines.push(''); + lines.push( + `Summary: ${summary.filesLinted} file(s) linted; ${summary.errors} error(s), ` + + `${summary.warnings} warning(s), ${summary.info} info; ${summary.suppressed} suppressed.`, + ); + lines.push(`Result: ${failed ? 'FAIL' : 'PASS'} (fail-on=${failOn}).`); + return lines.join('\n'); +} + +function renderJson(report) { + const { files, notices, summary, failOn, failed } = report; + return JSON.stringify( + { + files: files.map((f) => ({ + path: f.path, + findings: f.findings.map((x) => ({ + code: x.code, + guideRule: x.guideRule, + severity: x.severity, + message: x.message, + path: x.jsonPath, + range: x.range, + documentationUrl: x.documentationUrl, + })), + suppressed: f.suppressed.map((x) => ({ + code: x.code, + guideRule: x.guideRule, + severity: x.severity, + message: x.message, + path: x.jsonPath, + range: x.range, + documentationUrl: x.documentationUrl, + exceptionRecord: x.exceptionRecord, + })), + })), + notices, + summary, + failOn, + failed, + }, + null, + 2, + ); +} + +// -------------------------------------------------------------------------------------- +// Main +// -------------------------------------------------------------------------------------- + +async function main(argv) { + const values = parseCliArgs(argv); + const cfg = resolveConfig(values); + const rel = (p) => { + const r = path.relative(cfg.repoRoot, p); + return r === '' || r.startsWith('..') ? p : r; + }; + + const findings = []; + const suppressed = []; + const notices = []; + + // --- Load candidate spec files ------------------------------------------------------- + const openapi = await loadSpec(cfg.openapiPath, rel(cfg.openapiPath)); + const asyncapi = await loadSpec(cfg.asyncapiPath, rel(cfg.asyncapiPath)); + + const specs = []; + if (openapi.present) specs.push({ kind: 'openapi', abs: cfg.openapiPath, ...openapi }); + if (asyncapi.present) specs.push({ kind: 'asyncapi', abs: cfg.asyncapiPath, ...asyncapi }); + + // --- File-tree checks (§2.2/§2.3/§3.2/§3.3) ----------------------------------------- + const legacyPresent = await checkLegacySwagger(cfg.repoRoot, rel, findings, notices); + const skipAbs = new Set([cfg.openapiPath, cfg.asyncapiPath].map((p) => path.resolve(p))); + await scanDivergentCopies(cfg.repoRoot, skipAbs, rel, findings); + + const noSpec = specs.length === 0; + if (noSpec && !legacyPresent) { + notices.push( + `No API spec files found (looked for ${rel(cfg.openapiPath)} and ${rel(cfg.asyncapiPath)}). ` + + `An API surface is optional; nothing to lint.`, + ); + } + + // --- Spectral (§20.2) + base validators (§20.1) + guide declaration (§20.3) ---------- + let spectral; + for (const spec of specs) { + const relSpec = rel(spec.abs); + + // §20.3: version comparison + exception set for this spec. + const { exceptions } = consumeGuideDeclaration(spec.data, relSpec, notices); + + // §20.1 base validator. + if (!cfg.skipValidators) { + await runBaseValidator(spec.kind, spec.abs, rel, findings, notices); + } + + // §20.2 Spectral. + if (!spectral) { + const ruleset = await loadRuleset(cfg.rulesetPath); + spectral = new Spectral(); + spectral.setRuleset(ruleset); + } + const doc = new Document(spec.content, Parsers.Yaml, spec.abs); + let results; + try { + results = await spectral.run(doc); + } catch (err) { + throw new OperationalError(`Spectral failed on ${relSpec}: ${err.message}`); + } + + for (const r of results) { + const finding = mapSpectralResult(r, relSpec); + if (finding.guideRule && exceptions.has(finding.guideRule)) { + suppressed.push({ ...finding, exceptionRecord: exceptions.get(finding.guideRule) }); + } else { + findings.push(finding); + } + } + + // §20.1/§20.3: base-validator findings for this spec are also subject to its exceptions. + for (let i = findings.length - 1; i >= 0; i -= 1) { + const f = findings[i]; + if (f.code === 'base-validator' && f.file === relSpec && exceptions.has(f.guideRule)) { + findings.splice(i, 1); + suppressed.push({ ...f, exceptionRecord: exceptions.get(f.guideRule) }); + } + } + } + + // --- Assemble per-file report -------------------------------------------------------- + const fileMap = new Map(); + const ensureFile = (p) => { + let e = fileMap.get(p); + if (!e) { + e = { path: p, findings: [], suppressed: [] }; + fileMap.set(p, e); + } + return e; + }; + // Present specs appear first, in a deterministic order, even when clean. + for (const spec of specs) ensureFile(rel(spec.abs)); + for (const f of findings) ensureFile(f.file).findings.push(f); + for (const s of suppressed) ensureFile(s.file).suppressed.push(s); + const files = [...fileMap.values()]; + + // --- Summary + threshold verdict ----------------------------------------------------- + const threshold = FAIL_ON_THRESHOLD[cfg.failOn]; + let errors = 0; + let warnings = 0; + let info = 0; + let failed = false; + for (const f of findings) { + const n = SEVERITY_NUM[f.severity] ?? 2; + if (n === 0) errors += 1; + else if (n === 1) warnings += 1; + else if (n === 2) info += 1; + if (n <= threshold) failed = true; + } + + const report = { + files, + notices, + suppressed, + summary: { + filesLinted: specs.length, + errors, + warnings, + info, + suppressed: suppressed.length, + }, + failOn: cfg.failOn, + failed, + noSpecBanner: noSpec && !legacyPresent, + }; + + const output = cfg.format === 'json' ? renderJson(report) : renderText(report); + process.stdout.write(`${output}\n`); + + return failed ? 1 : 0; +} + +main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((err) => { + if (err instanceof OperationalError) { + process.stderr.write(`error: ${err.message}\n`); + } else { + process.stderr.write(`error: unexpected failure: ${err?.stack ?? err}\n`); + } + process.exit(2); + }); diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml new file mode 100644 index 0000000..9e5b3f9 --- /dev/null +++ b/api-design-guide/linter/coverage.yaml @@ -0,0 +1,846 @@ +# GovStack API lint coverage manifest: every guide rule and how (or why not) the linter covers it. +# Hand-maintained alongside the rulesets; machine-checked by tests/coverage.test.mjs against +# ../rules.yaml (all 166 ids present exactly once) and against the rule names actually defined +# in ruleset.yaml / strict.yaml (spectral_rules lists must match, both directions). +# +# status values: +# implemented - fully checked by the Spectral rules listed in spectral_rules +# driver - checked by cli.mjs (file-tree layout, base validators), not by Spectral +# partial-proxy - Spectral checks an automated proxy; note says what it does NOT verify +# strict-only - noisy heuristic, ships only in strict.yaml (opt-in) +# needs-context - not checkable from the document alone; note names the missing input +# runtime - constrains wire behaviour; conformance test-harness territory, not a linter's +# human - review/governance judgment; no useful automated check +# informative - non-normative guide entry; nothing to enforce +guide_version: "0.2.0" +rules: + - id: "2.1" + class: "M" + status: implemented + spectral_rules: [govstack-2.1] + note: "assert openapi == \"3.1.0\"" + - id: "2.2" + class: "M+R" + status: driver + spectral_rules: [] + note: "canonical file at api/openapi.yaml, single entrypoint, all $refs resolve - repo file tree" + - id: "2.3" + class: "R" + status: driver + spectral_rules: [] + note: "no divergent copies elsewhere; md snippets $ref canonical - repo file tree" + - id: "2.4" + class: "M" + status: driver + spectral_rules: [] + note: "run openapi-spec-validator against 3.1 schema" + - id: "2.5" + class: "M" + status: implemented + spectral_rules: [govstack-2.5, govstack-2.5-semver] + note: "info has title/version/description/contact; version matches SemVer regex" + - id: "2.6" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-2.6] + note: "proxy: servers non-empty and no localhost/127.0.0.1 URLs; \"meaningful\"/\"not fake\" unverifiable" + - id: "2.7" + class: "M+R" + status: implemented + spectral_rules: [govstack-2.7] + note: "each operation has operationId(camelCase)/summary/description/>=1 tag" + - id: "2.8" + class: "M" + status: needs-context + spectral_rules: [] + note: "shared components $ref pinned local govstack-openapi-common.yaml - needs vendored file" + - id: "3.1" + class: "M" + status: implemented + spectral_rules: [govstack-3.1] + note: "assert asyncapi == \"3.0.0\"" + - id: "3.2" + class: "M+R" + status: driver + spectral_rules: [] + note: "canonical api/asyncapi.yaml, one entrypoint - repo file tree" + - id: "3.3" + class: "R" + status: driver + spectral_rules: [] + note: "no divergent AsyncAPI copies - repo file tree" + - id: "3.4" + class: "M" + status: driver + spectral_rules: [] + note: "run AsyncAPI 3.0 parser/validator" + - id: "3.5" + class: "M" + status: implemented + spectral_rules: [govstack-3.5, govstack-3.5-semver] + note: "info has title/version(SemVer)/description/contact" + - id: "3.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-3.6, govstack-3.6-host] + note: "servers/channels/operations/components.messages non-empty; server host not localhost" + - id: "3.7" + class: "M+R" + status: implemented + spectral_rules: [govstack-3.7] + note: "each op has action(send/receive)/summary/description/>=1 tag/channel ref/>=1 msg ref; verify op msgs resolve to channel's messages" + - id: "3.8" + class: "M" + status: needs-context + spectral_rules: [] + note: "shared components $ref pinned local govstack-asyncapi-common.yaml - needs vendored file" + - id: "3.9" + class: "M" + status: partial-proxy + spectral_rules: [govstack-3.9] + note: "proxy: payload schemas parse as JSON Schema + apply §9/§10 checks to payload props" + - id: "4.1" + class: "M" + status: implemented + spectral_rules: [govstack-4.1] + note: "recursive traversal: every schema node has non-empty description" + - id: "4.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-4.2] + note: "body-example presence (strong); enum \"values documented\" proxied by description presence" + - id: "4.3" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-4.3] + note: "regex scan all text for TBD/Lorem ipsum/\"a, b, c\"; cross-BB-name leakage needs external list" + - id: "4.4" + class: "R" + status: strict-only + spectral_rules: [govstack-4.4] + note: "proxy: flag identical descriptions reused across operations" + - id: "5.1" + class: "M" + status: implemented + spectral_rules: [govstack-5.1] + note: "every path key matches ^/v\\d+/...; exempts the 5.9 unversioned operational endpoints /health and /ready" + - id: "5.2" + class: "M+R" + status: strict-only + spectral_rules: [govstack-5.2] + note: "proxy: resource segment plural heuristic" + - id: "5.3" + class: "M" + status: implemented + spectral_rules: [govstack-5.3] + note: "each non-param path segment kebab-case" + - id: "5.4" + class: "M" + status: implemented + spectral_rules: [govstack-5.4] + note: "<=2 non-param levels after /v{N}/; {param} segments do not count (per mandated 15.5/16.11 action paths)" + - id: "5.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-5.5] + note: "proxy: flag single-resource GET/DELETE identifying via query *id param" + - id: "5.6" + class: "M" + status: implemented + spectral_rules: [govstack-5.6] + note: "query-param names camelCase" + - id: "5.7" + class: "M+R" + status: strict-only + spectral_rules: [govstack-5.7] + note: "proxy: verb-like final path segments vs verb list" + - id: "5.8" + class: "R" + status: strict-only + spectral_rules: [govstack-5.8] + note: "proxy: action-style paths follow /{col}/{id}/{verb}" + - id: "5.9" + class: "M+R" + status: implemented + spectral_rules: [govstack-5.9-presence, govstack-5.9-media-type, govstack-5.9-status-enum, govstack-5.9-no-auth] + note: "/health present, unversioned, media application/health+json, status enum pass/fail/warn, no auth; media-type and status-enum checks scoped to the 200 response so the 11.1 problem+json error response is satisfiable" + - id: "6.1" + class: "M+R" + status: implemented + spectral_rules: [govstack-6.1] + note: "GET operations declare no requestBody" + - id: "6.2" + class: "R" + status: human + spectral_rules: [] + note: "descriptive, no normative keyword" + - id: "6.3" + class: "R" + status: runtime + spectral_rules: [] + note: "replace-entire-resource/idempotent is runtime" + - id: "6.4" + class: "M+R" + status: implemented + spectral_rules: [govstack-6.4] + note: "PATCH requestBody content includes application/merge-patch+json (only merge/json-patch allowed)" + - id: "6.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-6.5] + note: "proxy: DELETE responses in {204(no body),200,202}" + - id: "6.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-6.6] + note: "POST .../search returns 200 not 201" + - id: "6.7" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-6.7] + note: "proxy: collection-targeted PUT/PATCH/DELETE require >=1 param" + - id: "7.1" + class: "R" + status: human + spectral_rules: [] + note: "descriptive status mapping, no MUST" + - id: "7.2" + class: "M" + status: implemented + spectral_rules: [govstack-7.2] + note: "every 201 response declares a Location header" + - id: "7.3" + class: "M+R" + status: implemented + spectral_rules: [govstack-7.3] + note: "every 202 response declares Location" + - id: "7.4" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.5" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-7.6] + note: "every 401 response declares WWW-Authenticate" + - id: "7.7" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.8" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.9" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.10" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.11" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.12" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.13" + class: "M" + status: implemented + spectral_rules: [govstack-7.13] + note: "every operation declares a 500 response" + - id: "7.14" + class: "M" + status: partial-proxy + spectral_rules: [govstack-7.14] + note: "proxy: each operation declares >1 code / >=1 non-2xx" + - id: "7.15" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.16" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-7.16] + note: "proxy: resource GETs declare ETag header + 304 (advisory SHOULD); exempts the 5.9 operational endpoints /health and /ready (a liveness probe is not a cacheable resource)" + - id: "7.17" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-7.17] + note: "proxy: PUT/PATCH declare If-Match param + 412 response (advisory)" + - id: "7.18" + class: "M" + status: implemented + spectral_rules: [govstack-7.18] + note: "every 405 response declares Allow header" + - id: "7.19" + class: "M+R" + status: implemented + spectral_rules: [govstack-7.19] + note: "PATCH operations declare a 415 response" + - id: "7.20" + class: "M" + status: implemented + spectral_rules: [govstack-7.20] + note: "problem+json / operation-status responses declare Cache-Control (no-store)" + - id: "8.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.1] + note: "proxy: no apiKey scheme in query/cookie, no token-like query params" + - id: "8.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.2] + note: "proxy: ops taking Accept-Language declare Content-Language response header" + - id: "8.3" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.3] + note: "proxy: create-POSTs (201) declare Idempotency-Key header param" + - id: "8.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.4] + note: "proxy: request declares X-Request-Id param + response header" + - id: "8.5" + class: "M" + status: implemented + spectral_rules: [govstack-8.5] + note: "no header param/response header uses X- prefix except X-Request-Id" + - id: "8.6" + class: "R" + status: strict-only + spectral_rules: [govstack-8.6] + note: "proxy: flag param names matching personal-data terms" + - id: "8.7" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.7] + note: "proxy: 429 responses declare Retry-After; rate-limited ops declare RateLimit-* headers" + - id: "9.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-9.1] + note: "proxy: responses use application/json; binary/export exception unknowable" + - id: "9.2" + class: "M" + status: implemented + spectral_rules: [govstack-9.2] + note: "recursive: all schema property names camelCase. Walks components.schemas only; inline body schemas not walked." + - id: "9.3" + class: "M" + status: partial-proxy + spectral_rules: [govstack-9.3] + note: "proxy: flag boolean-looking props typed string / enum [\"true\",\"false\"]" + - id: "9.4" + class: "M" + status: partial-proxy + spectral_rules: [govstack-9.4] + note: "proxy: flag 3.0-style nullable:true (should be type:[...,\"null\"])" + - id: "9.5" + class: "M" + status: implemented + spectral_rules: [govstack-9.5] + note: "recursive: property names no spaces/non-ASCII. Walks components.schemas only; inline body schemas not walked." + - id: "9.6" + class: "R" + status: strict-only + spectral_rules: [govstack-9.6] + note: "proxy: property names vs abbreviation dictionary" + - id: "9.7" + class: "M" + status: partial-proxy + spectral_rules: [govstack-9.7] + note: "proxy: enum string values SCREAMING_SNAKE_CASE with known exceptions (ISO codes, health status)" + - id: "9.8" + class: "M" + status: implemented + spectral_rules: [govstack-9.8] + note: "top-level resource body schemas don't set additionalProperties:false" + - id: "9.9" + class: "R" + status: partial-proxy + spectral_rules: [govstack-9.9] + note: "proxy: flag closed enums lacking x-extensible-enum/UNKNOWN fallback" + - id: "9.10" + class: "M+R" + status: strict-only + spectral_rules: [govstack-9.10] + note: "proxy: GovStack-semantic extensions use x-govstack- prefix (weak)" + - id: "9.11" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-9.11] + note: "in-doc: extract bb-code from error codes/scopes/event types/channels, verify identical + regex ^[a-z][a-z0-9-]{1,30}$; ecosystem-uniqueness needs registry" + - id: "10.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.1] + note: "proxy: resource id fields string/uuid not integer" + - id: "10.2" + class: "M" + status: partial-proxy + spectral_rules: [govstack-10.2] + note: "proxy: timestamp-named fields declare format:date-time" + - id: "10.3" + class: "M" + status: partial-proxy + spectral_rules: [govstack-10.3] + note: "proxy: date-named fields declare format:date" + - id: "10.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.4] + note: "proxy: money fields are {amount:string,currency} object, not type:number" + - id: "10.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.5] + note: "proxy: phone fields string with E.164 pattern" + - id: "10.6" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.6] + note: "proxy: email fields declare format:email" + - id: "10.7" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.7] + note: "proxy: base64 fields declare contentEncoding:base64 + maxLength" + - id: "10.8" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.8] + note: "proxy: country fields match ^[A-Z]{2}$ / ISO enum" + - id: "10.9" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.9] + note: "proxy: language fields match BCP-47 pattern" + - id: "10.10" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.10] + note: "proxy: currency fields match ^[A-Z]{3}$" + - id: "11.1" + class: "M" + status: implemented + spectral_rules: [govstack-11.1] + note: "every 4xx/5xx response uses application/problem+json. OpenAPI surface only (problem+json/status-code mechanism has no AsyncAPI equivalent)." + - id: "11.2" + class: "M+R" + status: implemented + spectral_rules: [govstack-11.2] + note: "problem schema has type/title/status; detail/instance advisory; no-PII clause human. OpenAPI surface only." + - id: "11.3" + class: "M" + status: implemented + spectral_rules: [govstack-11.3] + note: "problem schema includes code/traceId/timestamp (may need resolved $ref). OpenAPI surface only." + - id: "11.4" + class: "M+R" + status: implemented + spectral_rules: [govstack-11.4] + note: "validation-error schema has errors[] with pointer/code/message. OpenAPI surface only; HTTP 400 stands in for 'attributable to specific fields'." + - id: "11.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-11.5-enum, govstack-11.5-example] + note: "proxy: where code enum/examples exist, match org.govstack.{bb-code}.{lowerCamel}. OpenAPI surface only." + - id: "11.6" + class: "R" + status: runtime + spectral_rules: [] + note: "code/type stable across localised responses - runtime" + - id: "11.7" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "common errors $ref'd from govstack-openapi-common.yaml - needs common file" + - id: "12.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.1] + note: "proxy: list endpoints (array response) declare pagination params/envelope; exempts the 5.9 operational endpoints /health and /ready (a liveness probe is not a collection)" + - id: "12.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.2] + note: "proxy: paginated lists declare pageSize+cursor; exempts the 5.9 operational endpoints /health and /ready" + - id: "12.3" + class: "M" + status: implemented + spectral_rules: [govstack-12.3] + note: "paginated response shape {items,pageInfo{nextCursor,hasMore,total?}}; exempts the 5.9 operational endpoints /health and /ready" + - id: "12.4" + class: "M+R" + status: implemented + spectral_rules: [govstack-12.4] + note: "pageSize param schema has default and maximum; exempts the 5.9 operational endpoints /health and /ready" + - id: "12.5" + class: "R" + status: human + spectral_rules: [] + note: "permissive MAY" + - id: "12.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-12.6] + note: "if offset param present, envelope {items,offset,limit,total} with total required" + - id: "12.7" + class: "M" + status: implemented + spectral_rules: [govstack-12.7-name, govstack-12.7-grammar] + note: "sort param named sort (flag orderBy/sortBy); value grammar field/-field" + - id: "12.8" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.8] + note: "proxy: flag operator-style filter params (field[gte], generic filter)" + - id: "12.9" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.9-body, govstack-12.9-response] + note: "proxy: .../search carries pageSize/cursor in body + §12.3 envelope" + - id: "12.10" + class: "informative" + status: informative + spectral_rules: [] + note: "out-of-scope statement (informative)" + - id: "13.1" + class: "M" + status: implemented + spectral_rules: [govstack-13.1] + note: "root security + securitySchemes present and every operation covered (or explicit override)" + - id: "13.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-13.2] + note: "proxy: an openIdConnect/oauth2 scheme exists" + - id: "13.3" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-13.3] + note: "proxy: an mTLS/client-credentials/X509 scheme exists" + - id: "13.4" + class: "M" + status: partial-proxy + spectral_rules: [govstack-13.4] + note: "proxy: scope strings match bb:{bb-code}:{resource}:{action}" + - id: "13.5" + class: "M+R" + status: implemented + spectral_rules: [govstack-13.5] + note: "no apiKey scheme in query/cookie; token schemes use Authorization header" + - id: "13.6" + class: "R" + status: partial-proxy + spectral_rules: [govstack-13.6] + note: "proxy: apiKey applied only to operational (/health-like) ops" + - id: "14.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-14.1] + note: "proxy: create-POSTs (201) accept Idempotency-Key" + - id: "14.2" + class: "R" + status: runtime + spectral_rules: [] + note: "key opacity/client-generation runtime" + - id: "14.3" + class: "R" + status: human + spectral_rules: [] + note: "\"document replay window\" prose" + - id: "14.4" + class: "R" + status: runtime + spectral_rules: [] + note: "replay returns original response - runtime" + - id: "14.5" + class: "R" + status: runtime + spectral_rules: [] + note: "422/409 on key-reuse/fingerprint - runtime" + - id: "14.6" + class: "R" + status: human + spectral_rules: [] + note: "\"document per-operation duplicate handling\" - human" + - id: "15.1" + class: "M+R" + status: implemented + spectral_rules: [govstack-15.1] + note: "every 202 declares Location" + - id: "15.2" + class: "M" + status: partial-proxy + spectral_rules: [govstack-15.2] + note: "Operation schema $ref from common; shape {id,status,result,error,createdAt,updatedAt,progress?}" + - id: "15.3" + class: "M" + status: partial-proxy + spectral_rules: [govstack-15.3] + note: "Operation status enum matches fixed set from common" + - id: "15.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-15.4] + note: "proxy: canonical GET /v1/operations/{operationId} present when Operations used. strengths empty in rules.yaml (class M+R); treated as MUST-flavored." + - id: "15.5" + class: "M+R" + status: implemented + spectral_rules: [govstack-15.5] + note: "cancellation path exactly POST /v1/operations/{operationId}/cancel" + - id: "15.6" + class: "R" + status: human + spectral_rules: [] + note: "SHOULD + \"long-running\" semantic - advisory" + - id: "15.7" + class: "R" + status: human + spectral_rules: [] + note: "\"document result retention\" prose" + - id: "16.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-16.1] + note: "proxy: OpenAPI push events live under top-level webhooks. Brokered-transport (AsyncAPI) clause not checked." + - id: "16.2" + class: "M" + status: implemented + spectral_rules: [govstack-16.2, govstack-16.2-recommended] + note: "event payload schema requires specversion(const \"1.0\")/id/source/type + data" + - id: "16.3" + class: "M" + status: implemented + spectral_rules: [govstack-16.3] + note: "event type const matches reverse-DNS org.govstack.{bb-code}.{resource}.{action}, no version" + - id: "16.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-16.4] + note: "proxy: flag source const/example containing host/env/pod tokens" + - id: "16.5" + class: "R" + status: runtime + spectral_rules: [] + note: "actual event signing runtime" + - id: "16.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-16.6] + note: "webhook ops / async messages declare GovStack-Signature header/field. OpenAPI webhooks half only; AsyncAPI clause not enforced (its binding-exception makes it non-mechanical)." + - id: "16.7" + class: "R" + status: runtime + spectral_rules: [] + note: "signed-material contents runtime" + - id: "16.8" + class: "R" + status: needs-context + spectral_rules: [] + note: "common files pin signed bytes/algorithm - needs common file" + - id: "16.9" + class: "informative" + status: informative + spectral_rules: [] + note: "out-of-scope statement (informative)" + - id: "16.10" + class: "R" + status: human + spectral_rules: [] + note: "\"document delivery-failure contract\" prose" + - id: "16.11" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-16.11] + note: "proxy: subscription create/list/rotate-secret/delete endpoints present" + - id: "17.1" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.1] + note: "every operation has action in {send,receive}" + - id: "17.2" + class: "M" + status: implemented + spectral_rules: [govstack-17.2] + note: "channel address keys match org.govstack.{bb-code}.v{major}.{resource}.{event}" + - id: "17.3" + class: "R" + status: strict-only + spectral_rules: [govstack-17.3] + note: "proxy: scan channel addresses/param names for personal-data terms" + - id: "17.4" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.4] + note: "every {param} in channel address declared under parameters with schema+description. AsyncAPI 3.0 parameters have no schema field; 'schema documented' proxied by required description." + - id: "17.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.5] + note: "proxy: flag dev/test/prod/broker tokens in channel addresses" + - id: "17.6" + class: "M" + status: implemented + spectral_rules: [govstack-17.6] + note: "message payloads CloudEvents-shaped with domain data under data" + - id: "17.7" + class: "M" + status: needs-context + spectral_rules: [] + note: "channel messages $ref shared CloudEvents schema from common - needs common file" + - id: "17.8" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.8] + note: "message headers camelCase/no X- (strong); command idempotency-key needs command identification" + - id: "17.9" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.9] + note: "proxy: localisation headers named acceptLanguage/contentLanguage" + - id: "17.10" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.10] + note: "every operation covered by a security scheme via servers/operations" + - id: "17.11" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.11] + note: "each op declares x-govstack-delivery in {atMostOnce,atLeastOnce,effectivelyOnce}" + - id: "17.12" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.12] + note: "each op declares x-govstack-ordering (key/scope or explicit none)" + - id: "17.13" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.13] + note: "each op declares redelivery/dead-letter/retention/replay as supported/unsupported/n-a. Extension key names (x-govstack-redelivery/-dead-letter/-retention/-replay) inferred pending govstack-asyncapi-common.yaml." + - id: "17.14" + class: "R" + status: human + spectral_rules: [] + note: "authoring portable contract shape/defaults - governance" + - id: "17.15" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.15] + note: "each op carries x-govstack-delivery/ordering/replay + description" + - id: "17.16" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.16] + note: "proxy: rejection message using §11 error envelope exists + correlation" + - id: "17.17" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.17] + note: "when reply present, declares reply channel + correlationId; else not request-reply" + - id: "17.18" + class: "M+R" + status: human + spectral_rules: [] + note: "\"long-running\" identification + correlation documentation - human" + - id: "17.19" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.19] + note: "proxy: per server protocol, channels/ops declare matching kafka/mqtt/amqp/ws bindings" + - id: "17.20" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.20] + note: "every components.messages entry has >=1 example" + - id: "18.1" + class: "M" + status: implemented + spectral_rules: [govstack-18.1] + note: "info.version matches SemVer regex" + - id: "18.2" + class: "M" + status: implemented + spectral_rules: [govstack-18.2-openapi, govstack-18.2-asyncapi] + note: "OpenAPI paths carry /v{N}; AsyncAPI channels carry major version; consistency with info.version major" + - id: "18.3" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "backward-compat of minor bump - needs PREVIOUS spec version (oasdiff, out of scope)" + - id: "18.4" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "breaking change => major bump - needs PREVIOUS spec version (oasdiff, out of scope)" + - id: "18.5" + class: "M+R" + status: implemented + spectral_rules: [govstack-18.5] + note: "deprecated operations declare Deprecation + Sunset response headers" + - id: "18.6" + class: "informative" + status: informative + spectral_rules: [] + note: "informative" + - id: "18.7" + class: "M+R" + status: implemented + spectral_rules: [govstack-18.7, govstack-18.7-description] + note: "validate x-govstack-deprecated shape (since/sunset/replacement/reason) + description" + - id: "19.1" + class: "R" + status: runtime + spectral_rules: [] + note: "honouring request language runtime" + - id: "19.2" + class: "R" + status: runtime + spectral_rules: [] + note: "not translating stable content runtime" + - id: "19.3" + class: "R" + status: runtime + spectral_rules: [] + note: "default response language English runtime" + - id: "19.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-19.4-openapi, govstack-19.4-asyncapi] + note: "proxy: localised responses declare Content-Language/contentLanguage header" + - id: "20.1" + class: "M" + status: driver + spectral_rules: [] + note: "run openapi-spec-validator / AsyncAPI parser" + - id: "20.2" + class: "M" + status: human + spectral_rules: [] + note: "meta/self-referential (the ruleset itself)" + - id: "20.3" + class: "M" + status: implemented + spectral_rules: [govstack-20.3, govstack-20.3-exceptions] + note: "info.x-govstack-api-guide has version(SemVer) + optional exceptions[]" diff --git a/api-design-guide/linter/functions/README.md b/api-design-guide/linter/functions/README.md new file mode 100644 index 0000000..eac9454 --- /dev/null +++ b/api-design-guide/linter/functions/README.md @@ -0,0 +1,268 @@ +# Shared custom-function library + +Generic, parameterised Spectral functions that the GovStack section rulesets +(`rulesets/sNN.yaml`) call via `functionOptions`. Section agents should code +against **this document** and not read the sources. Read it end to end before +writing rules: most guide checks are a matter of picking the right function and +options, not writing new JavaScript. + +If a shared function almost fits but not quite, do **not** edit it. Either add a +section-local function `functions/sNN-<purpose>.js` (and list it in your +fragment's `functions:` array) or flag the gap in your report. + +--- + +## How functions load (READ THIS — the load-bearing constraint) + +The ruleset is composed with `extends`: + +``` +strict.yaml ─extends→ ruleset.yaml ─extends→ rulesets/sNN.yaml +``` + +Each **fragment** (`rulesets/sNN.yaml`) that uses a custom function MUST declare, +at the top of the file: + +```yaml +functionsDir: "../functions" # relative to the fragment file +functions: + - valuePattern # EVERY custom function the fragment references + - schemaPropertyNames +``` + +This has been verified to resolve **both** ways: + +- programmatically, via `@stoplight/spectral-ruleset-bundler`'s + `bundleAndLoadRuleset` (what the test harness uses), and +- via the CLI: `npx spectral lint -r ruleset.yaml <doc>`. + +…and through the full two-level `extends` chain above. So `functionsDir` + +`functions:` **inside a fragment** is the supported pattern — you do not need to +touch `ruleset.yaml`. + +Rules to obey: + +- List **every** function name your fragment references in its `functions:` + array. A referenced-but-unlisted function is a load error. +- `functionsDir` is always `"../functions"` (fragments live in `rulesets/`). +- The `functions/lib/` directory holds shared internals (`schemaWalk.js`, + `casing.js`, `util.js`). These are **not** Spectral functions and must never + appear in a `functions:` array. Spectral only loads what you list; the bundler + resolves the `lib/` imports automatically. Do not add non-function `.js` files + to the top level of `functions/`. + +## Function contract + +Every function is an ESM default export with the Spectral signature: + +```js +export default function name(targetVal, options, context) { … } +``` + +- **`targetVal`** — the value selected by the rule's `given`. What each function + expects is stated per function below as *Given*. +- **`options`** — your rule's `functionOptions`. +- **`context`** — Spectral context; functions read `context.path` and prefix it + onto every finding's `path`, so findings point at the offending node. +- **Return** — `undefined` when clean, or an array of + `{ message, path }` findings. Functions are defensive: bad/`undefined`/ + non-object input returns `undefined` (never throws). Recursive walkers are + cycle-safe (resolved `$ref`s can be circular). + +Use `message: "[<id>][<class>] {{error}}"` in the rule and let the function +supply the precise text via `{{error}}`. + +--- + +## Function reference + +Options marked *(required)* must be present; everything else is optional. + +### `valuePattern` +Assert a single **string** matches / does not match a regex. +- **Given:** the string itself (`$.info.version`, `$.servers[*].url`, a channel + address). Spectral calls the function once per selected string. +- **Options:** `match` (regex the value MUST match), `notMatch` / + `forbidPattern` (regex it MUST NOT match), `flags` (e.g. `"i"`), `name` + (label used in the message, default `"value"`). Regex strings are compiled + with `new RegExp`; in YAML prefer single quotes so backslashes stay literal. +- **Example:** + ```yaml + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^\d+\.\d+\.\d+$' + ``` + +### `schemaPropertyNames` +Recursively assert every declared **property name** obeys a casing/pattern. +- **Given:** a JSON Schema (a body schema, `$.components.schemas[*]`). +- **Options:** `casing` (`camel|pascal|kebab|snake|screamingSnake|flat`), + `allowPattern` (regex a name must match), `forbidPattern` (regex a name must + not match, e.g. `'\s'` for spaces / `'[^\x00-\x7F]'` for non-ASCII), `flags`. +- **Example (9.2 camelCase property names):** + ```yaml + given: $.components.schemas[*] + then: { function: schemaPropertyNames, functionOptions: { casing: camel } } + ``` + +### `schemaDescriptions` +Recursively assert schema nodes carry a non-empty `description`. +- **Given:** a JSON Schema. +- **Options:** `mode` — `"properties"` (default: every declared property needs a + description) or `"all"` (every subschema node except pure combinator wrappers); + `includeRoot` (properties mode only: also require a description on the root). +- **Example (4.1):** + ```yaml + then: { function: schemaDescriptions, functionOptions: { mode: all } } + ``` + +### `responseHeaderRequired` +Assert responses whose status matches a pattern declare given header(s), and +optionally that a companion status exists. +- **Given:** an operation's `responses` object + (`$.paths[*][get,put,post,delete,patch].responses`). +- **Options:** `status` *(required)* (status matcher: `"201"`, `"2xx"`, + `"default"`, or a regex), `headers` *(required)* (name or array; matched + case-insensitively unless `caseInsensitive: false`), `requireStatus` (a + response matching `status` must exist), `alsoRequireStatus` (a companion + status matcher that must also be present). +- **Example (7.2 Location on 201):** + ```yaml + then: + function: responseHeaderRequired + functionOptions: { status: "201", headers: [Location] } + ``` + +### `operationResponses` +Assert an operation's response set satisfies presence/absence/count rules. +- **Given:** an operation (`$.paths[*][get,put,post,delete,patch]`). Reads + `.responses`. +- **Options:** `require` (each matcher must match ≥1 status), `requireOneOf` + (≥1 of the matchers), `forbid` (no status may match), `minCount` (≥N response + entries), `minNonSuccess` (≥N non-2xx responses). Matchers are status strings + as above. +- **Example (7.13 must declare 500; 6.6 search must be 200 not 201):** + ```yaml + functionOptions: { require: ["500"] } + functionOptions: { require: ["200"], forbid: ["201"] } + ``` + +### `pathSegments` +Structural checks over the `paths` object. One `check` per rule instance. +- **Given:** `$.paths`. +- **Options:** `check` *(required)* one of `versionPrefix` (keys start with + `/v{N}/`, 5.1), `segmentCasing` (non-version, non-`{param}` segments obey + `casing`, default kebab, 5.3), `maxDepthAfterVersion` (≤ `max` segments after + the version, default 2, 5.4); plus `casing` / `max` for those checks. +- **Example (5.4):** + ```yaml + given: $.paths + then: + function: pathSegments + functionOptions: { check: maxDepthAfterVersion, max: 2 } + ``` + +### `envelopeShape` +Assert a JSON Schema **declares** a required shape (required props, nested +object/array shapes, const/enum/type on leaves). Inspects the schema, does not +validate a data instance. One level of top-level `allOf` is merged. +- **Given:** the schema (a response schema, `$.components.schemas.Foo`). +- **Options (a recursive spec node):** `requiredProperties` (names that must be + in `required`), `properties` (`name -> child spec`; each named property must + be declared and is validated by its child), `type`, `const`, `enum` (schema's + `enum` must equal this as a set), `items` (child spec for array `items`). +- **Example (12.3 page envelope):** + ```yaml + functionOptions: + requiredProperties: [items, pageInfo] + properties: + items: { type: array } + pageInfo: { requiredProperties: [nextCursor, hasMore] } + ``` +- **Example (16.2 CloudEvents payload):** + ```yaml + functionOptions: + requiredProperties: [specversion, id, source, type, data] + properties: { specversion: { const: "1.0" } } + ``` + +### `securityCoverage` +Check operations are authenticated (or explicitly opted out). +- **Given / modes:** + - default `"covered"` — **Given:** `$`. Every operation must be covered by a + non-empty root `security` or an operation-level `security` (`security: []` + counts as an allowed explicit opt-out). Option `requireSchemes: true` also + requires `components.securitySchemes` to be non-empty. (13.1) + - `mode: "none"` — **Given:** a single operation. It MUST declare + `security: []` (unauthenticated), e.g. the /health GET. (5.9/13.x) +- **Example:** + ```yaml + given: $ + then: { function: securityCoverage, functionOptions: { requireSchemes: true } } + ``` + +### `schemaFieldFormat` +For every property whose **name** matches a pattern, require its schema to +declare type/format/pattern/contentEncoding constraints. Drives the §10 proxies. +- **Given:** a JSON Schema. +- **Options:** `namePattern` *(required)* (regex on the property name), + `nameFlags`, and `require`: `type`, `forbidType` (string or array), `format`, + `formatOneOf`, `contentEncoding`, `pattern` (exact match), `mustDeclare` + (array of keywords that must simply be present, e.g. `["maxLength"]`). +- **Example (10.2 timestamps; 10.4 money not a number):** + ```yaml + functionOptions: { namePattern: 'At$', require: { type: string, format: date-time } } + functionOptions: { namePattern: '(amount|price|balance)$', nameFlags: i, require: { forbidType: number } } + ``` + +### `extensionShape` +Validate presence and shape of an `x-govstack-*` extension on a container. +- **Given:** the container that carries the extension (an operation, `$.info`, + a message). +- **Options:** `extension` *(required)* (the key), `required` (default true), + `valueType` (`string|object|array`), `enum` (allowed string values), + `requiredKeys` (object keys that must be present), `semverKeys` (object keys + whose value must be SemVer), `keyEnums` (`{ key: [values] }`), `keyPatterns` + (`{ key: regexString }`). +- **Example (17.11 delivery enum; 20.3 guide metadata):** + ```yaml + functionOptions: { extension: x-govstack-delivery, enum: [atMostOnce, atLeastOnce, effectivelyOnce] } + functionOptions: { extension: x-govstack-api-guide, valueType: object, requiredKeys: [version], semverKeys: [version] } + ``` + +### `mediaTypeExpected` +Assert a `content` map declares / forbids media types. +- **Given:** a `content` object (`…requestBody.content`, a response's + `.content`). Patterns are regexes matched against the media-type keys, so + escape `+`: `application/problem\+json`. +- **Options:** `require` (each pattern must match ≥1 key), `requireOneOf`, + `forbid`. +- **Example (11.1 problem+json on errors; 6.4 PATCH merge-patch):** + ```yaml + functionOptions: { require: ['application/problem\+json'] } + functionOptions: { require: ['application/merge-patch\+json'], forbid: ['^application/json$'] } + ``` + (6.4 uses `require` + `forbid`, not `requireOneOf`: merge-patch is the MUST + baseline and json-patch only a MAY addition, so a json-patch-only body must + still fail.) + +--- + +## Internals (`functions/lib/`, not Spectral functions) + +- **`schemaWalk.js`** — `walkSchema(root, visit, opts)` visits every subschema + (Draft 2020-12 keywords: properties/patternProperties/additional·unevaluated, + items/prefixItems, allOf/anyOf/oneOf/not/if/then/else, $defs, contains, + propertyNames, dependentSchemas). Cycle-safe (WeakSet on node identity — each + distinct schema object is visited once). `forEachProperty(root, cb)` yields + every declared property with its path. +- **`casing.js`** — `matchesCasing(name, type)` and `CASING_TYPES`. +- **`util.js`** — `isObject`, `isNonEmptyString`, `asArray`, `toRegExp` + (returns `undefined` on invalid patterns), `statusMatcher` (expands + `2xx`/`default`/exact/regex), `SEMVER_PATTERN`. + +If you need to walk schemas in a section-local function, import +`./lib/schemaWalk.js` — do not re-implement cycle protection. diff --git a/api-design-guide/linter/functions/envelopeShape.js b/api-design-guide/linter/functions/envelopeShape.js new file mode 100644 index 0000000..ef26ec3 --- /dev/null +++ b/api-design-guide/linter/functions/envelopeShape.js @@ -0,0 +1,103 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * envelopeShape — assert that a JSON Schema *declares* a required shape: + * required properties, nested object/array shapes, and const/enum/type on + * leaves. It inspects the schema (properties/required/const/…), it does NOT + * validate a data instance. Reused for pagination envelopes, problem+json, + * CloudEvents payloads and the Operation resource. + * + * `given` should select the schema, e.g. a `200` response schema or + * `$.components.schemas.PageEnvelope`. + * + * The options object is a "spec" node. A spec node may contain: + * requiredProperties {string[]} names that must appear in schema.required. + * properties {object} map of name -> child spec node; each named property + * must be declared, and is validated by its child spec. + * type {string} schema.type must equal this (array-typed `type` ok). + * const {any} schema.const must deep-equal this. + * enum {any[]} schema.enum must equal this as a set. + * items {object} for arrays: schema.items validated by this child spec. + * + * One level of top-level `allOf` composition is merged when reading + * required/properties (documented limitation: deeper allOf nesting is not). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options - the root spec node. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function envelopeShape(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + matchNode(targetVal, options, base, results, 0); + return results.length ? results : undefined; +} + +/** Merge a schema's own required/properties with one level of allOf branches. */ +function effective(schema) { + const required = new Set(asArray(schema.required).filter((s) => typeof s === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + if (Array.isArray(schema.allOf)) { + for (const branch of schema.allOf) { + if (!isObject(branch)) continue; + for (const r of asArray(branch.required)) if (typeof r === 'string') required.add(r); + if (isObject(branch.properties)) { + for (const [k, v] of Object.entries(branch.properties)) if (!(k in properties)) properties[k] = v; + } + } + } + return { required, properties }; +} + +function typeMatches(schemaType, wanted) { + if (Array.isArray(schemaType)) return schemaType.includes(wanted); + return schemaType === wanted; +} + +function matchNode(schema, spec, path, results, depth) { + if (depth > 50 || !isObject(schema) || !isObject(spec)) return; + + if (spec.type !== undefined && !typeMatches(schema.type, spec.type)) { + results.push({ message: `schema must declare type "${spec.type}"`, path }); + } + if ('const' in spec && JSON.stringify(schema.const) !== JSON.stringify(spec.const)) { + results.push({ message: `schema must declare const ${JSON.stringify(spec.const)}`, path }); + } + if (spec.enum !== undefined) { + const have = new Set(asArray(schema.enum).map((v) => JSON.stringify(v))); + const want = new Set(asArray(spec.enum).map((v) => JSON.stringify(v))); + const equal = have.size === want.size && [...want].every((v) => have.has(v)); + if (!equal) { + results.push({ message: `schema enum must be exactly ${JSON.stringify(spec.enum)}`, path }); + } + } + + const eff = effective(schema); + + for (const name of asArray(spec.requiredProperties)) { + if (!eff.required.has(name)) { + results.push({ message: `schema must list "${name}" in required`, path: [...path, 'required'] }); + } + } + + if (isObject(spec.properties)) { + for (const [name, childSpec] of Object.entries(spec.properties)) { + const child = eff.properties[name]; + if (!isObject(child)) { + results.push({ message: `schema must declare property "${name}"`, path: [...path, 'properties'] }); + continue; + } + if (isObject(childSpec)) matchNode(child, childSpec, [...path, 'properties', name], results, depth + 1); + } + } + + if (isObject(spec.items)) { + if (!isObject(schema.items)) { + results.push({ message: 'schema must declare items', path: [...path, 'items'] }); + } else { + matchNode(schema.items, spec.items, [...path, 'items'], results, depth + 1); + } + } +} diff --git a/api-design-guide/linter/functions/extensionShape.js b/api-design-guide/linter/functions/extensionShape.js new file mode 100644 index 0000000..0ca2c44 --- /dev/null +++ b/api-design-guide/linter/functions/extensionShape.js @@ -0,0 +1,90 @@ +import { isObject, toRegExp, SEMVER_PATTERN } from './lib/util.js'; + +/** + * extensionShape — validate the presence and shape of an `x-govstack-*` + * extension on a container object (operation, info, message, …). Drives the + * §17 delivery/ordering trio, §18.7 deprecation metadata and §20.3 guide + * metadata. + * + * `given` should select the container that carries the extension (e.g. an + * operation, or `$.info`). + * + * options: + * extension {string} the extension key to validate, e.g. "x-govstack-delivery". + * required {boolean} default true — absent extension is a violation. + * valueType {"string"|"object"|"array"} expected type of the value. + * enum {any[]} for string values: allowed values. + * requiredKeys {string[]} for object values: keys that must be present. + * semverKeys {string[]} for object values: keys whose value must be SemVer. + * keyEnums {object} { key: [allowed values] } for object values. + * keyPatterns {object} { key: regexString } for object values. + * + * @param {unknown} targetVal - the container object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function extensionShape(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const key = options.extension; + if (typeof key !== 'string' || !key) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const here = [...base, key]; + const results = []; + const present = Object.prototype.hasOwnProperty.call(targetVal, key); + + if (!present) { + if (options.required !== false) results.push({ message: `must declare "${key}"`, path: [...base] }); + return results.length ? results : undefined; + } + + const value = targetVal[key]; + + if (options.valueType !== undefined) { + const actual = Array.isArray(value) ? 'array' : typeof value; + const wanted = options.valueType === 'object' && Array.isArray(value) ? 'array' : options.valueType; + const ok = + (wanted === 'object' && isObject(value)) || + (wanted === 'array' && Array.isArray(value)) || + (wanted !== 'object' && wanted !== 'array' && actual === wanted); + if (!ok) { + results.push({ message: `"${key}" must be of type ${options.valueType}`, path: here }); + return results; // shape checks below assume the right type + } + } + + if (Array.isArray(options.enum) && !options.enum.includes(value)) { + results.push({ message: `"${key}" must be one of ${JSON.stringify(options.enum)}`, path: here }); + } + + if (isObject(value)) { + for (const k of Array.isArray(options.requiredKeys) ? options.requiredKeys : []) { + if (!Object.prototype.hasOwnProperty.call(value, k)) { + results.push({ message: `"${key}" must declare "${k}"`, path: here }); + } + } + const semverRe = toRegExp(SEMVER_PATTERN); + for (const k of Array.isArray(options.semverKeys) ? options.semverKeys : []) { + if (k in value && !(typeof value[k] === 'string' && semverRe.test(value[k]))) { + results.push({ message: `"${key}.${k}" must be a SemVer string`, path: [...here, k] }); + } + } + if (isObject(options.keyEnums)) { + for (const [k, allowed] of Object.entries(options.keyEnums)) { + if (k in value && Array.isArray(allowed) && !allowed.includes(value[k])) { + results.push({ message: `"${key}.${k}" must be one of ${JSON.stringify(allowed)}`, path: [...here, k] }); + } + } + } + if (isObject(options.keyPatterns)) { + for (const [k, pat] of Object.entries(options.keyPatterns)) { + const re = toRegExp(pat); + if (re && k in value && !(typeof value[k] === 'string' && re.test(value[k]))) { + results.push({ message: `"${key}.${k}" must match ${re.toString()}`, path: [...here, k] }); + } + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/lib/casing.js b/api-design-guide/linter/functions/lib/casing.js new file mode 100644 index 0000000..97e6bf2 --- /dev/null +++ b/api-design-guide/linter/functions/lib/casing.js @@ -0,0 +1,36 @@ +/** + * Casing predicates shared by the GovStack Spectral functions. + * + * Not a Spectral function; imported by function modules only. + */ + +const PATTERNS = { + // getThing, listUsers, pageSize (lower camel; digits allowed after first char) + camel: /^[a-z][a-zA-Z0-9]*$/, + // GetThing, ProblemDetails + pascal: /^[A-Z][a-zA-Z0-9]*$/, + // account-holders, merge-patch (lower kebab; digits allowed) + kebab: /^[a-z0-9]+(?:-[a-z0-9]+)*$/, + // account_holder + snake: /^[a-z0-9]+(?:_[a-z0-9]+)*$/, + // ACCOUNT_CLOSED, PENDING + screamingSnake: /^[A-Z0-9]+(?:_[A-Z0-9]+)*$/, + // alllowercase + flat: /^[a-z][a-z0-9]*$/, +}; + +/** The casing type names this module understands. */ +export const CASING_TYPES = Object.keys(PATTERNS); + +/** + * Does `name` conform to the given casing type? + * Unknown types return false (fail closed); non-string input returns false. + * @param {string} name + * @param {keyof typeof PATTERNS} type + * @returns {boolean} + */ +export function matchesCasing(name, type) { + if (typeof name !== 'string') return false; + const re = PATTERNS[type]; + return re ? re.test(name) : false; +} diff --git a/api-design-guide/linter/functions/lib/schemaWalk.js b/api-design-guide/linter/functions/lib/schemaWalk.js new file mode 100644 index 0000000..4546e39 --- /dev/null +++ b/api-design-guide/linter/functions/lib/schemaWalk.js @@ -0,0 +1,90 @@ +/** + * Cycle-safe recursive JSON Schema walker (Draft 2020-12 aware, tolerant of + * older drafts). Shared internal for the schema-oriented GovStack functions. + * + * Not a Spectral function; imported by function modules only. + * + * Spectral resolves `$ref`s before a rule runs (unless `resolved: false`), so a + * recursive schema (`Person.friend -> Person`) becomes a genuine circular JS + * object. The walker guards against this with a WeakSet keyed on node identity: + * every distinct schema object is visited at most once, at the first path it is + * reached by. That also de-duplicates schemas shared across the document. + */ + +import { isObject } from './util.js'; + +// keyword -> single subschema +const SUBSCHEMA_KEYS = [ + 'additionalProperties', + 'unevaluatedProperties', + 'additionalItems', + 'unevaluatedItems', + 'contains', + 'not', + 'if', + 'then', + 'else', + 'propertyNames', +]; +// keyword -> { name: subschema } +const MAP_KEYS = ['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions']; +// keyword -> [ subschema ] +const ARRAY_KEYS = ['allOf', 'anyOf', 'oneOf', 'prefixItems']; + +/** + * Visit every subschema of `root`, including `root` itself. + * @param {unknown} root - a JSON Schema object (anything else is a no-op). + * @param {(node: object, path: (string|number)[]) => void} visit + * Called once per schema node. `path` is relative to `root`. + * @param {{ maxDepth?: number }} [opts] + */ +export function walkSchema(root, visit, opts = {}) { + if (!isObject(root) || typeof visit !== 'function') return; + const seen = new WeakSet(); + const maxDepth = Number.isInteger(opts.maxDepth) ? opts.maxDepth : 200; + + const recur = (node, path, depth) => { + if (!isObject(node) || seen.has(node) || depth > maxDepth) return; + seen.add(node); + + visit(node, path); + + for (const key of MAP_KEYS) { + const m = node[key]; + if (isObject(m)) { + for (const name of Object.keys(m)) recur(m[name], [...path, key, name], depth + 1); + } + } + for (const key of ARRAY_KEYS) { + const a = node[key]; + if (Array.isArray(a)) a.forEach((sub, i) => recur(sub, [...path, key, i], depth + 1)); + } + for (const key of SUBSCHEMA_KEYS) { + if (isObject(node[key])) recur(node[key], [...path, key], depth + 1); + } + const items = node.items; + if (Array.isArray(items)) { + items.forEach((sub, i) => recur(sub, [...path, 'items', i], depth + 1)); + } else if (isObject(items)) { + recur(items, [...path, 'items'], depth + 1); + } + }; + + recur(root, [], 0); +} + +/** + * Visit every declared property across all subschemas of `root`. + * @param {unknown} root + * @param {(name: string, propSchema: unknown, path: (string|number)[]) => void} cb + * `path` points at the property schema (…/properties/<name>). + */ +export function forEachProperty(root, cb) { + if (typeof cb !== 'function') return; + walkSchema(root, (node, path) => { + const props = node.properties; + if (isObject(props)) { + for (const name of Object.keys(props)) cb(name, props[name], [...path, 'properties', name]); + } + }); +} diff --git a/api-design-guide/linter/functions/lib/util.js b/api-design-guide/linter/functions/lib/util.js new file mode 100644 index 0000000..679ff60 --- /dev/null +++ b/api-design-guide/linter/functions/lib/util.js @@ -0,0 +1,73 @@ +/** + * Small shared helpers for the GovStack Spectral custom functions. + * + * These live in functions/lib/ and are NOT Spectral functions themselves. + * Spectral only loads the modules named in a ruleset's `functions:` array, so + * files in lib/ are safe to keep next to the function modules: they are only + * ever pulled in via relative `import` from a function module, and the bundler + * resolves them. + */ + +/** True for a plain (non-null, non-array) object. */ +export function isObject(v) { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** True for a non-empty string. */ +export function isNonEmptyString(v) { + return typeof v === 'string' && v.length > 0; +} + +/** Coerce to array: array stays, undefined/null -> [], scalar -> [scalar]. */ +export function asArray(v) { + if (Array.isArray(v)) return v; + if (v === undefined || v === null) return []; + return [v]; +} + +/** + * Build a RegExp from a string pattern (or return an existing RegExp). + * Returns undefined on invalid input rather than throwing, so callers can + * bail defensively. + * @param {string|RegExp} pattern + * @param {string} [flags] + * @returns {RegExp|undefined} + */ +export function toRegExp(pattern, flags) { + if (pattern instanceof RegExp) return pattern; + if (typeof pattern !== 'string' || pattern.length === 0) return undefined; + try { + return new RegExp(pattern, flags); + } catch { + return undefined; + } +} + +/** + * Expand an HTTP status matcher into a predicate over status-code keys. + * Accepts: + * - "2xx" / "4XX" / "1xx" -> class match on the leading digit + * - "default" -> matches the literal "default" response key + * - an exact code "201" -> exact match + * - any other string -> treated as an anchored regex + * @param {string} matcher + * @returns {(statusKey: string) => boolean} + */ +export function statusMatcher(matcher) { + if (typeof matcher !== 'string' || matcher.length === 0) return () => false; + const m = matcher.toLowerCase(); + if (/^[1-5]xx$/.test(m)) { + const cls = m[0]; + return (key) => typeof key === 'string' && key[0] === cls && /^\d{3}$/.test(key); + } + if (m === 'default') return (key) => key === 'default'; + if (/^\d{3}$/.test(m)) return (key) => key === m; + const re = toRegExp(`^(?:${matcher})$`); + return re ? (key) => typeof key === 'string' && re.test(key) : () => false; +} + +/** SemVer 2.0.0 core + optional pre-release/build, as used across the guide. */ +export const SEMVER_PATTERN = + '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)' + + '(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?' + + '(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$'; diff --git a/api-design-guide/linter/functions/mediaTypeExpected.js b/api-design-guide/linter/functions/mediaTypeExpected.js new file mode 100644 index 0000000..e35cf9b --- /dev/null +++ b/api-design-guide/linter/functions/mediaTypeExpected.js @@ -0,0 +1,56 @@ +import { isObject, asArray, toRegExp } from './lib/util.js'; + +/** + * mediaTypeExpected — assert a `content` map declares / forbids media types. + * Drives §6.4 (PATCH -> merge-patch+json), §9.1 (json responses) and §11.1 + * (problem+json on errors). + * + * `given` should select a `content` object, e.g. + * `$.paths[*][*].requestBody.content` or a specific response's `content`. + * + * Media types are matched as regexes against the content-map keys, so + * `application/problem+json` works verbatim (the `+` is escaped for you only if + * you pass a plain string with no regex metacharacters — otherwise pass a valid + * regex). Prefer anchored, escaped patterns. + * + * options: + * require {string|string[]} each pattern must match >=1 declared media type. + * requireOneOf {string|string[]} at least one pattern must match. + * forbid {string|string[]} no declared media type may match any pattern. + * + * @param {unknown} targetVal - a content object (map mediaType -> mediaTypeObject). + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function mediaTypeExpected(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const keys = Object.keys(targetVal); + const results = []; + + const test = (pattern, key) => { + const re = toRegExp(pattern); + return re ? re.test(key) : false; + }; + + for (const pattern of asArray(opts.require)) { + if (!keys.some((k) => test(pattern, k))) { + results.push({ message: `content must declare a media type matching "${pattern}"`, path: [...base] }); + } + } + + const oneOf = asArray(opts.requireOneOf); + if (oneOf.length && !oneOf.some((p) => keys.some((k) => test(p, k)))) { + results.push({ message: `content must declare one of these media types: ${oneOf.join(', ')}`, path: [...base] }); + } + + for (const pattern of asArray(opts.forbid)) { + for (const key of keys.filter((k) => test(pattern, k))) { + results.push({ message: `content must not declare media type "${key}"`, path: [...base, key] }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/operationResponses.js b/api-design-guide/linter/functions/operationResponses.js new file mode 100644 index 0000000..871547d --- /dev/null +++ b/api-design-guide/linter/functions/operationResponses.js @@ -0,0 +1,83 @@ +import { isObject, asArray, statusMatcher } from './lib/util.js'; + +const isSuccess = (key) => typeof key === 'string' && key[0] === '2' && /^\d{3}$/.test(key); + +/** + * operationResponses — assert an operation's declared response set satisfies + * presence / absence / count constraints. + * + * `given` should select an operation object, e.g. + * `$.paths[*][get,put,post,delete,patch]`. The function reads `.responses`. + * + * options (all optional; combine as needed): + * require {string|string[]} each matcher MUST match >=1 declared status. + * requireOneOf {string|string[]} at least one matcher must match. + * forbid {string|string[]} no declared status may match any matcher. + * minCount {number} at least this many response entries. + * minNonSuccess {number} at least this many non-2xx responses. + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function operationResponses(targetVal, options, context) { + if (!isObject(targetVal)) return; + const responses = targetVal.responses; + if (!isObject(responses)) { + // No responses object at all is itself a violation of any presence check. + if (options && (options.require || options.requireOneOf || options.minCount)) { + const base = context && Array.isArray(context.path) ? context.path : []; + return [{ message: 'operation declares no responses', path: [...base] }]; + } + return; + } + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const keys = Object.keys(responses); + const results = []; + + for (const pattern of asArray(opts.require)) { + const m = statusMatcher(pattern); + if (!keys.some(m)) { + results.push({ message: `operation must declare a "${pattern}" response`, path: [...base, 'responses'] }); + } + } + + const oneOf = asArray(opts.requireOneOf); + if (oneOf.length) { + const ok = oneOf.some((pattern) => keys.some(statusMatcher(pattern))); + if (!ok) { + results.push({ + message: `operation must declare at least one of these responses: ${oneOf.join(', ')}`, + path: [...base, 'responses'], + }); + } + } + + for (const pattern of asArray(opts.forbid)) { + const m = statusMatcher(pattern); + for (const key of keys.filter(m)) { + results.push({ message: `operation must not declare a "${key}" response`, path: [...base, 'responses', key] }); + } + } + + if (Number.isInteger(opts.minCount) && keys.length < opts.minCount) { + results.push({ + message: `operation must declare at least ${opts.minCount} responses (found ${keys.length})`, + path: [...base, 'responses'], + }); + } + + if (Number.isInteger(opts.minNonSuccess)) { + const nonSuccess = keys.filter((k) => !isSuccess(k)).length; + if (nonSuccess < opts.minNonSuccess) { + results.push({ + message: `operation must declare at least ${opts.minNonSuccess} non-2xx response(s) (found ${nonSuccess})`, + path: [...base, 'responses'], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/pathSegments.js b/api-design-guide/linter/functions/pathSegments.js new file mode 100644 index 0000000..12e4e7e --- /dev/null +++ b/api-design-guide/linter/functions/pathSegments.js @@ -0,0 +1,82 @@ +import { isObject } from './lib/util.js'; +import { matchesCasing } from './lib/casing.js'; + +const VERSION_SEG = /^v\d+$/; +const isParam = (seg) => seg.startsWith('{') && seg.endsWith('}'); +const split = (key) => key.split('/').filter((s) => s.length > 0); + +/** + * pathSegments — structural checks over the OpenAPI `paths` object. One check + * per rule instance (selected by `check`), so each guide rule maps to one + * clearly-named Spectral rule. + * + * `given` should be `$.paths`. + * + * options: + * check {string} required, one of: + * "versionPrefix" every path key starts with /v{N}/ (guide 5.1) + * "segmentCasing" every non-version, non-param segment obeys `casing` + * (default kebab) (guide 5.3) + * "maxDepthAfterVersion" at most `max` NON-PARAM levels follow the version + * prefix; `{param}` segments do NOT count as levels + * (default max 2) (guide 5.4) + * casing {string} for "segmentCasing" (default "kebab"). + * max {number} for "maxDepthAfterVersion" (default 2). + * exemptPaths {string[]} for "versionPrefix": path keys matched EXACTLY that + * are exempt from the version-prefix requirement (the + * guide 5.9 unversioned operational endpoints). + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function pathSegments(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const check = opts.check; + const results = []; + + for (const key of Object.keys(targetVal)) { + if (typeof key !== 'string' || !key.startsWith('/')) continue; + const segs = split(key); + const here = [...base, key]; + + if (check === 'versionPrefix') { + // Guide 5.9 mandates UNVERSIONED operational liveness endpoints (/health, + // and optionally /ready); exempt those exact path keys from the /v{N}/ + // requirement so 5.1 and 5.9 do not contradict each other. + const exemptPaths = Array.isArray(opts.exemptPaths) ? opts.exemptPaths : []; + if (exemptPaths.includes(key)) continue; + if (segs.length === 0 || !VERSION_SEG.test(segs[0])) { + results.push({ message: `path "${key}" must start with a version prefix (/v{N}/…)`, path: here }); + } + } else if (check === 'segmentCasing') { + const casing = typeof opts.casing === 'string' ? opts.casing : 'kebab'; + for (const seg of segs) { + if (VERSION_SEG.test(seg) || isParam(seg)) continue; + if (!matchesCasing(seg, casing)) { + results.push({ message: `path "${key}" segment "${seg}" must be ${casing} case`, path: here }); + } + } + } else if (check === 'maxDepthAfterVersion') { + const max = Number.isInteger(opts.max) ? opts.max : 2; + const afterVersion = VERSION_SEG.test(segs[0]) ? segs.slice(1) : segs; + // "Levels of nesting" counts resource/action segments only: a `{param}` + // path parameter is NOT a level. Guide 5.8/15.5/16.11 mandate paths like + // /v1/operations/{operationId}/cancel and + // /v1/subscriptions/{subscriptionId}/rotate-secret — three raw segments, + // but only two non-param levels — so params must not count toward depth. + const levels = afterVersion.filter((seg) => !isParam(seg)); + if (levels.length > max) { + results.push({ + message: `path "${key}" has ${levels.length} levels of nesting after the version prefix (max ${max}; path parameters do not count)`, + path: here, + }); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/responseHeaderRequired.js b/api-design-guide/linter/functions/responseHeaderRequired.js new file mode 100644 index 0000000..bdfb52f --- /dev/null +++ b/api-design-guide/linter/functions/responseHeaderRequired.js @@ -0,0 +1,71 @@ +import { isObject, asArray, statusMatcher } from './lib/util.js'; + +/** + * responseHeaderRequired — assert that responses whose status matches a pattern + * declare one or more headers, and (optionally) that a companion status exists. + * + * `given` should select an operation's `responses` object, e.g. + * `$.paths[*][get,put,post,delete,patch].responses`. + * + * options: + * status {string} status matcher: "201" | "2xx" | "default" | regex. + * headers {string|string[]} header name(s) each matching response MUST + * declare (compared case-insensitively by default). + * caseInsensitive{boolean} default true. + * requireStatus {boolean} if true, at least one response must match `status` + * (otherwise the rule only constrains matches that exist). + * alsoRequireStatus {string} a companion status matcher that MUST also be + * present in the responses object (e.g. a 304 alongside ETag). + * + * @param {unknown} targetVal - a responses object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function responseHeaderRequired(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const wanted = asArray(opts.headers).filter((h) => typeof h === 'string' && h.length); + const matchStatus = statusMatcher(opts.status); + const ci = opts.caseInsensitive !== false; + const results = []; + + const statusKeys = Object.keys(targetVal); + const matching = statusKeys.filter(matchStatus); + + if (opts.requireStatus === true && matching.length === 0) { + results.push({ + message: `no response declared for status "${opts.status}"`, + path: [...base], + }); + } + + for (const key of matching) { + const response = targetVal[key]; + if (!isObject(response)) continue; + const declared = isObject(response.headers) ? Object.keys(response.headers) : []; + const declaredCmp = ci ? declared.map((h) => h.toLowerCase()) : declared; + for (const header of wanted) { + const needle = ci ? header.toLowerCase() : header; + if (!declaredCmp.includes(needle)) { + results.push({ + message: `${key} response must declare a "${header}" header`, + path: [...base, key], + }); + } + } + } + + if (opts.alsoRequireStatus !== undefined) { + const companion = statusMatcher(opts.alsoRequireStatus); + if (!statusKeys.some(companion)) { + results.push({ + message: `responses must also declare a "${opts.alsoRequireStatus}" response`, + path: [...base], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s03-asyncOperation.js b/api-design-guide/linter/functions/s03-asyncOperation.js new file mode 100644 index 0000000..845cde1 --- /dev/null +++ b/api-design-guide/linter/functions/s03-asyncOperation.js @@ -0,0 +1,123 @@ +import { isObject, isNonEmptyString } from './lib/util.js'; + +const ACTIONS = new Set(['send', 'receive']); + +/** The $ref string of a Reference Object, or undefined for anything else. */ +function refString(node) { + return isObject(node) && typeof node.$ref === 'string' ? node.$ref : undefined; +} + +/** + * Resolve a local JSON Pointer ("#/channels/foo") against the document root. + * Returns undefined for non-local refs or unresolvable pointers. + */ +function resolveLocalRef(root, ref) { + if (typeof ref !== 'string' || !ref.startsWith('#/')) return undefined; + const parts = ref + .slice(2) + .split('/') + .map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~')); + let cur = root; + for (const p of parts) { + if (cur === null || typeof cur !== 'object') return undefined; + cur = cur[p]; + if (cur === undefined) return undefined; + } + return cur; +} + +/** + * s03-asyncOperation — §3.7 AsyncAPI 3.0 operation-metadata completeness. + * + * Given: the whole UNRESOLVED AsyncAPI 3.0 document (`$`, with `resolved: false` + * on the rule) so that operation `channel` / `messages` $ref pointers are still + * visible as strings. + * + * For every entry under `operations`, asserts it declares: + * - `action` == "send" or "receive" + * - a non-empty `summary` + * - a non-empty `description` + * - at least one `tag` + * - a `channel` Reference Object ({$ref: <string>}) + * - a non-empty `messages` array of Reference Objects + * - each `messages[i]` $ref points into the operation's referenced channel's + * messages (ref begins "<channelRef>/messages/"); when the channel ref is a + * local pointer, the referenced message key must exist on that channel. + * + * The CloudEvents-ness of the referenced message is a §17.6 concern and is not + * checked here; this only verifies presence and channel-membership. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s03AsyncOperation(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.operations)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const findings = []; + + for (const [opId, op] of Object.entries(root.operations)) { + const at = (...seg) => [...base, 'operations', opId, ...seg]; + if (!isObject(op)) { + findings.push({ message: `operation "${opId}" must be an object`, path: at() }); + continue; + } + + if (!ACTIONS.has(op.action)) { + findings.push({ message: `operation "${opId}" must declare action "send" or "receive"`, path: at('action') }); + } + if (!isNonEmptyString(op.summary)) { + findings.push({ message: `operation "${opId}" must declare a non-empty summary`, path: at('summary') }); + } + if (!isNonEmptyString(op.description)) { + findings.push({ message: `operation "${opId}" must declare a non-empty description`, path: at('description') }); + } + if (!Array.isArray(op.tags) || op.tags.length < 1) { + findings.push({ message: `operation "${opId}" must declare at least one tag`, path: at('tags') }); + } + + const channelRef = refString(op.channel); + if (!channelRef) { + findings.push({ message: `operation "${opId}" must reference a channel via $ref`, path: at('channel') }); + } + + const messages = op.messages; + if (!Array.isArray(messages) || messages.length < 1) { + findings.push({ message: `operation "${opId}" must reference at least one channel message via $ref`, path: at('messages') }); + continue; + } + + messages.forEach((m, i) => { + const mRef = refString(m); + if (!mRef) { + findings.push({ message: `operation "${opId}" message ${i} must be a $ref to a channel message`, path: at('messages', i) }); + return; + } + if (!channelRef) return; // channel error already reported; can't cross-check + const prefix = `${channelRef}/messages/`; + if (!mRef.startsWith(prefix)) { + findings.push({ + message: `operation "${opId}" message ${i} ($ref "${mRef}") must reference a message on the operation's channel "${channelRef}"`, + path: at('messages', i), + }); + return; + } + // For local channel refs, confirm the message key is actually declared. + if (channelRef.startsWith('#/')) { + const channel = resolveLocalRef(root, channelRef); + const key = mRef.slice(prefix.length); + if (isObject(channel) && (!isObject(channel.messages) || !(key in channel.messages))) { + findings.push({ + message: `operation "${opId}" message ${i} references "${key}" which is not defined on channel "${channelRef}"`, + path: at('messages', i), + }); + } + } + }); + } + + return findings.length ? findings : undefined; +} diff --git a/api-design-guide/linter/functions/s04-bodyExamplesEnums.js b/api-design-guide/linter/functions/s04-bodyExamplesEnums.js new file mode 100644 index 0000000..634775f --- /dev/null +++ b/api-design-guide/linter/functions/s04-bodyExamplesEnums.js @@ -0,0 +1,117 @@ +import { isObject } from './lib/util.js'; +import { walkSchema } from './lib/schemaWalk.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** True if a media-type object (or its schema) carries at least one example. */ +function hasExample(mediaTypeObj) { + if (!isObject(mediaTypeObj)) return false; + if ('example' in mediaTypeObj) return true; + if (isObject(mediaTypeObj.examples) && Object.keys(mediaTypeObj.examples).length > 0) return true; + const schema = mediaTypeObj.schema; + if (isObject(schema)) { + if ('example' in schema) return true; + if (Array.isArray(schema.examples) && schema.examples.length > 0) return true; + } + return false; +} + +/** Push a finding if none of a `content` map's media types carries an example. */ +function checkContent(content, path, label, results) { + if (!isObject(content)) return; + const mediaTypes = Object.keys(content); + if (mediaTypes.length === 0) return; + if (!mediaTypes.some((mt) => hasExample(content[mt]))) { + results.push({ message: `${label} must have at least one example`, path }); + } +} + +/** + * Push a finding for every `enum` schema node lacking a `description`. PROXY: + * "has a description" substitutes for "documents what the values mean" - a + * description that doesn't actually explain the enum values still passes. + */ +function checkEnums(schema, path, results) { + if (!isObject(schema)) return; + walkSchema(schema, (node, subPath) => { + if (Array.isArray(node.enum) && node.enum.length > 0 && typeof node.description !== 'string') { + results.push({ + message: `enum at ${[...path, ...subPath].join('/') || '(root)'} must document what its values mean (add a description)`, + path: [...path, ...subPath], + }); + } + }); +} + +/** + * bodyExamplesAndEnums — guide 4.2 (proxy): every request/response body (and + * AsyncAPI message) MUST have at least one example; every `enum` MUST + * document what its values mean. + * + * PROXY: the enum check substitutes "has a non-empty description" for + * "documents what the values mean" per the guide's own carve-out ("an + * example alone is insufficient when the values are not self-explanatory") - + * it cannot verify the description actually explains the values. + * + * `given` should be `$` (the root document); OpenAPI paths and AsyncAPI + * components.messages are both scanned so one rule covers either surface. + * + * @param {unknown} targetVal - the root document. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function bodyExamplesAndEnums(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + if (isObject(targetVal.paths)) { + for (const [pathKey, pathItem] of Object.entries(targetVal.paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!isObject(op)) continue; + const opPath = [...base, 'paths', pathKey, method]; + + if (isObject(op.requestBody)) { + const reqContent = op.requestBody.content; + const reqPath = [...opPath, 'requestBody', 'content']; + checkContent(reqContent, reqPath, `${method.toUpperCase()} ${pathKey} request body`, results); + if (isObject(reqContent)) { + for (const mt of Object.keys(reqContent)) { + if (isObject(reqContent[mt])) checkEnums(reqContent[mt].schema, [...reqPath, mt, 'schema'], results); + } + } + } + + if (isObject(op.responses)) { + for (const [status, response] of Object.entries(op.responses)) { + if (!isObject(response)) continue; + const respPath = [...opPath, 'responses', status, 'content']; + checkContent(response.content, respPath, `${method.toUpperCase()} ${pathKey} ${status} response body`, results); + if (isObject(response.content)) { + for (const mt of Object.keys(response.content)) { + if (isObject(response.content[mt])) checkEnums(response.content[mt].schema, [...respPath, mt, 'schema'], results); + } + } + } + } + } + } + } + + if (isObject(targetVal.components) && isObject(targetVal.components.messages)) { + for (const [name, message] of Object.entries(targetVal.components.messages)) { + if (!isObject(message)) continue; + const msgPath = [...base, 'components', 'messages', name]; + const examples = message.examples; + if (!Array.isArray(examples) || examples.length === 0) { + results.push({ message: `message "${name}" must have at least one example`, path: msgPath }); + } + checkEnums(message.payload, [...msgPath, 'payload'], results); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s04-duplicateDescriptions.js b/api-design-guide/linter/functions/s04-duplicateDescriptions.js new file mode 100644 index 0000000..bfb6165 --- /dev/null +++ b/api-design-guide/linter/functions/s04-duplicateDescriptions.js @@ -0,0 +1,70 @@ +import { isObject } from './lib/util.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** + * duplicateOperationDescriptions — STRICT-ONLY heuristic proxy for guide 4.4 + * ("operation `description` MUST describe what the operation actually + * does"). Flags operation `description` strings that are byte-identical + * (after trimming) across two or more distinct operations: a strong signal + * of copy-paste that was never edited to match the operation it landed in + * (the guide cites this exact failure mode). Does NOT verify that a + * non-duplicated description is actually accurate for its operation - that + * requires reading and understanding the operation's real behaviour. + * + * `given` should be `$` (the root document); both OpenAPI `paths` operations + * and AsyncAPI `operations` are scanned so one rule covers either surface. + * + * @param {unknown} targetVal - the root document. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function duplicateOperationDescriptions(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const seen = new Map(); // trimmed description text -> [{ path, label }] + + const record = (description, path, label) => { + if (typeof description !== 'string' || description.trim().length === 0) return; + const key = description.trim(); + if (!seen.has(key)) seen.set(key, []); + seen.get(key).push({ path, label }); + }; + + if (isObject(targetVal.paths)) { + for (const [pathKey, pathItem] of Object.entries(targetVal.paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (isObject(op)) { + record(op.description, [...base, 'paths', pathKey, method, 'description'], `${method.toUpperCase()} ${pathKey}`); + } + } + } + } + + if (isObject(targetVal.operations)) { + for (const [opKey, op] of Object.entries(targetVal.operations)) { + if (isObject(op)) record(op.description, [...base, 'operations', opKey, 'description'], opKey); + } + } + + const results = []; + for (const [text, occurrences] of seen) { + if (occurrences.length < 2) continue; + for (const occ of occurrences) { + const others = occurrences + .filter((o) => o !== occ) + .map((o) => o.label) + .join(', '); + const preview = text.length > 60 ? `${text.slice(0, 60)}…` : text; + results.push({ + message: `operation description is identical (copy-paste?) to ${others}: "${preview}"`, + path: occ.path, + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s04-noPlaceholderText.js b/api-design-guide/linter/functions/s04-noPlaceholderText.js new file mode 100644 index 0000000..44c7c65 --- /dev/null +++ b/api-design-guide/linter/functions/s04-noPlaceholderText.js @@ -0,0 +1,63 @@ +import { isObject } from './lib/util.js'; + +// guide 4.3: "TBD", "Lorem ipsum", and the literal placeholder list "a, b, c" +// are the three mechanically-scannable forms of placeholder text. Cross-BB +// name leakage and placeholder test-plan steps are NOT scanned: they need an +// external list of BB names / test-plan conventions this function doesn't have. +const DEFAULT_PATTERNS = [ + { name: 'TBD', regex: /\bTBD\b/ }, + { name: 'Lorem ipsum', regex: /lorem\s+ipsum/i }, + { name: 'placeholder list "a, b, c"', regex: /\ba\s*,\s*b\s*,\s*c\b/i }, +]; + +/** + * noPlaceholderText — guide 4.3 (proxy): recursively scans every string value + * in the given node for placeholder text (TBD / Lorem ipsum / "a, b, c"). + * Does NOT check for content copy-pasted from another BB with that BB's name + * still present, or test plans with literal placeholder steps - both need + * context this function doesn't have access to. + * + * `given` should be `$` (the whole document): placeholder text can appear in + * any string (descriptions, summaries, examples, titles, ...), not just + * schemas, so this walks generically rather than via the schema-keyword-aware + * walker. + * + * @param {unknown} targetVal - any value; the root document in practice. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function noPlaceholderText(targetVal, options, context) { + if (targetVal === undefined || targetVal === null) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const seen = new WeakSet(); + + const visit = (node, path) => { + if (typeof node === 'string') { + for (const { name, regex } of DEFAULT_PATTERNS) { + if (regex.test(node)) { + results.push({ + message: `placeholder text (${name}) found: remove or replace with real content`, + path: [...base, ...path], + }); + } + } + return; + } + if (Array.isArray(node)) { + if (seen.has(node)) return; + seen.add(node); + node.forEach((child, i) => visit(child, [...path, i])); + return; + } + if (isObject(node)) { + if (seen.has(node)) return; + seen.add(node); + for (const key of Object.keys(node)) visit(node[key], [...path, key]); + } + }; + + visit(targetVal, []); + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s05-actionVerbs.js b/api-design-guide/linter/functions/s05-actionVerbs.js new file mode 100644 index 0000000..4b1c520 --- /dev/null +++ b/api-design-guide/linter/functions/s05-actionVerbs.js @@ -0,0 +1,70 @@ +import { isObject } from './lib/util.js'; + +const VERSION_SEG = /^v\d+$/; +const isParam = (seg) => seg.startsWith('{') && seg.endsWith('}'); +const split = (key) => key.split('/').filter((s) => s.length > 0); + +// Lexical dictionary of common CRUD-mirroring / domain action verbs. No +// morphology: this both under- and over-fires (see JSDoc below). +const DEFAULT_VERBS = [ + 'get', 'create', 'new', 'add', 'update', 'edit', 'modify', 'delete', 'remove', + 'fetch', 'retrieve', 'list', 'find', 'set', 'cancel', 'approve', 'reject', + 'submit', 'publish', 'activate', 'deactivate', 'archive', 'restore', 'confirm', + 'verify', 'validate', 'process', 'execute', 'start', 'stop', 'pause', 'resume', + 'complete', 'close', 'reopen', 'send', 'search', +]; + +/** + * actionVerbSegment — STRICT-ONLY heuristic: flags a path whose final, + * non-parameter segment is a verb (from a fixed lexical list) that is NOT + * immediately preceded by a `{param}` segment. + * + * Drives both guide 5.7 ("verbs MUST NOT appear in CRUD paths") and 5.8 + * ("non-CRUD actions MUST be sub-resources, i.e. /{collection}/{id}/{verb}") + * — the guide states the same shape requirement from two directions + * (prohibition vs. required positive shape), so both rules share this one + * detector and differ only in id/message/severity/docs. + * + * Purely lexical: it does not know which paths are "CRUD operations" vs. + * legitimate collection names that happen to end in a dictionary verb (e.g. + * a segment literally named "process" as a noun), so it both over- and + * under-fires. `verbs` lets callers extend the default list. + * + * Given: $.paths + * Options: verbs {string[]} additional verbs merged into the default list. + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function actionVerbSegment(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const verbs = new Set([ + ...DEFAULT_VERBS, + ...(Array.isArray(opts.verbs) ? opts.verbs.map((v) => String(v).toLowerCase()) : []), + ]); + const results = []; + + for (const key of Object.keys(targetVal)) { + if (typeof key !== 'string' || !key.startsWith('/')) continue; + const segs = split(key); + if (segs.length === 0) continue; + const last = segs[segs.length - 1]; + if (isParam(last) || VERSION_SEG.test(last)) continue; + const words = last.split('-'); + const verbLike = words.some((w) => verbs.has(w.toLowerCase())); + if (!verbLike) continue; + const prev = segs.length >= 2 ? segs[segs.length - 2] : undefined; + if (!isParam(prev)) { + results.push({ + message: `path "${key}" ends in a verb-like segment "${last}" without a preceding {id} sub-resource`, + path: [...base, key], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s05-pluralNoun.js b/api-design-guide/linter/functions/s05-pluralNoun.js new file mode 100644 index 0000000..3922315 --- /dev/null +++ b/api-design-guide/linter/functions/s05-pluralNoun.js @@ -0,0 +1,63 @@ +import { isObject } from './lib/util.js'; + +const VERSION_SEG = /^v\d+$/; +const isParam = (seg) => seg.startsWith('{') && seg.endsWith('}'); +const split = (key) => key.split('/').filter((s) => s.length > 0); + +/** + * pluralSegment — STRICT-ONLY heuristic: every non-version, non-parameter + * path segment should "look plural" (guide 5.2). Purely lexical: a segment + * looks plural if its last kebab-case word ends in "s", or the whole segment + * is listed in `irregularPlurals`. `exceptions` allow-lists literal segment + * names that other guide rules already sanction as singular (e.g. + * "health"/"ready" per 5.9, action-verb leaves per 5.8) so callers can quiet + * expected non-noise. + * + * This has no morphological understanding of English: it accepts non-plural + * words that happen to end in "s" (e.g. "status") and flags legitimate + * singular segments not covered by `exceptions`/`irregularPlurals`. + * + * Given: $.paths + * Options: + * exceptions {string[]} literal segment names (case-insensitive) allowed + * to stay singular. + * irregularPlurals {string[]} extra accepted plural forms not ending in + * "s" (e.g. "children", "people"). + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function pluralSegment(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const exceptions = new Set( + (Array.isArray(opts.exceptions) ? opts.exceptions : []).map((s) => String(s).toLowerCase()), + ); + const irregular = new Set( + (Array.isArray(opts.irregularPlurals) ? opts.irregularPlurals : []).map((s) => String(s).toLowerCase()), + ); + const results = []; + + for (const key of Object.keys(targetVal)) { + if (typeof key !== 'string' || !key.startsWith('/')) continue; + const segs = split(key); + for (const seg of segs) { + if (isParam(seg) || VERSION_SEG.test(seg)) continue; + const lower = seg.toLowerCase(); + if (exceptions.has(lower) || irregular.has(lower)) continue; + const words = seg.split('-'); + const lastWord = words[words.length - 1]; + if (!lastWord.toLowerCase().endsWith('s')) { + results.push({ + message: `path "${key}" segment "${seg}" should be a plural noun`, + path: [...base, key], + }); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s06-bulkMutationSelection.js b/api-design-guide/linter/functions/s06-bulkMutationSelection.js new file mode 100644 index 0000000..f98b6ae --- /dev/null +++ b/api-design-guide/linter/functions/s06-bulkMutationSelection.js @@ -0,0 +1,42 @@ +import { isObject, asArray } from './lib/util.js'; + +const MUTATING_METHODS = ['put', 'patch', 'delete']; + +/** + * bulkMutationSelection — guide 6.7 (proxy): a collection-targeted PUT, + * PATCH, or DELETE (a path with no `{param}` segment) MUST require at least + * one explicit selection parameter. PROXY: only checks that >=1 `query` + * parameter is declared (path-item-level or operation-level); it cannot + * verify the parameter actually scopes/limits which records are mutated, nor + * does it check the append-only-resource carve-out (that needs external + * knowledge of which resources are designated append-only). + * + * `given` should select path-item objects already filtered to collection-only + * keys, e.g. `$.paths[?(!@property.includes('{'))]`. + * + * @param {unknown} targetVal - a path-item object. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function bulkMutationSelection(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const pathItemParams = asArray(targetVal.parameters); + const results = []; + + for (const method of MUTATING_METHODS) { + const op = targetVal[method]; + if (!isObject(op)) continue; + const params = [...pathItemParams, ...asArray(op.parameters)]; + const hasQueryParam = params.some((p) => isObject(p) && p.in === 'query'); + if (!hasQueryParam) { + results.push({ + message: `bulk ${method.toUpperCase()} on a collection must declare at least one query parameter for explicit selection`, + path: [...base, method], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s07-noStoreOnProblemJson.js b/api-design-guide/linter/functions/s07-noStoreOnProblemJson.js new file mode 100644 index 0000000..e6fc70d --- /dev/null +++ b/api-design-guide/linter/functions/s07-noStoreOnProblemJson.js @@ -0,0 +1,37 @@ +import { isObject } from './lib/util.js'; + +/** + * s07-noStoreOnProblemJson — assert that a response using + * `application/problem+json` content declares a `Cache-Control` header + * (guide 7.20). Presence-only: does not check that the header's value is + * actually `no-store`, and does not attempt to identify "Operation-status + * responses" (§15), which have no content-type signature to key off. + * + * `given` should select individual response objects, e.g. + * `$.paths[*][get,put,post,delete,patch].responses[*]`. + * + * @param {unknown} targetVal - a response object. + * @param {object} [options] - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s07NoStoreOnProblemJson(targetVal, options, context) { + if (!isObject(targetVal)) return; + const content = targetVal.content; + if (!isObject(content)) return; + + const isProblemJson = Object.keys(content).some((key) => /^application\/problem\+json/i.test(key)); + if (!isProblemJson) return; + + const declared = isObject(targetVal.headers) ? Object.keys(targetVal.headers) : []; + const hasCacheControl = declared.some((h) => h.toLowerCase() === 'cache-control'); + if (hasCacheControl) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + return [ + { + message: 'application/problem+json response must declare a "Cache-Control" header', + path: [...base], + }, + ]; +} diff --git a/api-design-guide/linter/functions/s08-credentialsInUrl.js b/api-design-guide/linter/functions/s08-credentialsInUrl.js new file mode 100644 index 0000000..bae3483 --- /dev/null +++ b/api-design-guide/linter/functions/s08-credentialsInUrl.js @@ -0,0 +1,74 @@ +import { isObject } from './lib/util.js'; + +const OPS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +// Names that look like a credential travelling somewhere other than the +// Authorization header (query/path parameters, or an apiKey security scheme +// placed in query/cookie). +const SUSPECT_NAME = /(access[_-]?)?token|api[_-]?key|apikey|secret|passwd|password|credential|auth(?:orization)?$/i; + +/** + * s08 credentialsInUrl — proxy for guide 8.1: credentials must travel in the + * Authorization header, never in query parameters, fragments, or URL paths. + * + * Checks two mechanically-visible signals: + * - no `apiKey` security scheme is placed `in: query` or `in: cookie` + * - no path/query parameter has a credential-looking name (token, apiKey, + * secret, password, credential, ...) + * + * Does NOT verify: that credentials actually travel via the Authorization + * header at runtime, arbitrary/synonym parameter names, URL fragments (not + * representable in OpenAPI), or header VALUES that might embed a credential. + * + * `given` should be the document root (`$`). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function credentialsInUrl(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const schemes = isObject(targetVal.components) ? targetVal.components.securitySchemes : undefined; + if (isObject(schemes)) { + for (const [name, scheme] of Object.entries(schemes)) { + if (!isObject(scheme)) continue; + if (scheme.type === 'apiKey' && (scheme.in === 'query' || scheme.in === 'cookie')) { + results.push({ + message: `securityScheme "${name}" is apiKey in "${scheme.in}"; credentials must travel in the Authorization header, not query/cookie`, + path: [...base, 'components', 'securitySchemes', name], + }); + } + } + } + + const scanParams = (params, path) => { + if (!Array.isArray(params)) return; + params.forEach((p, idx) => { + if (!isObject(p)) return; + if ((p.in === 'query' || p.in === 'path') && typeof p.name === 'string' && SUSPECT_NAME.test(p.name)) { + results.push({ + message: `parameter "${p.name}" (in: ${p.in}) looks like a credential; credentials must travel in the Authorization header, not query/path`, + path: [...path, idx, 'name'], + }); + } + }); + }; + + const paths = targetVal.paths; + if (isObject(paths)) { + for (const [route, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + scanParams(pathItem.parameters, [...base, 'paths', route, 'parameters']); + for (const op of OPS) { + const operation = pathItem[op]; + if (isObject(operation)) scanParams(operation.parameters, [...base, 'paths', route, op, 'parameters']); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-headerEcho.js b/api-design-guide/linter/functions/s08-headerEcho.js new file mode 100644 index 0000000..1a5196c --- /dev/null +++ b/api-design-guide/linter/functions/s08-headerEcho.js @@ -0,0 +1,61 @@ +import { isObject, statusMatcher } from './lib/util.js'; + +/** + * s08 headerEcho — proxy: IF an operation declares a given request header + * parameter, THEN its matching (default 2xx) responses must declare a given + * response header. Drives guide 8.2 (Accept-Language -> Content-Language): + * a "localisation request" is proxied as one that carries Accept-Language. + * + * Does NOT verify: that the echoed value is actually derived from the + * request (only that the response declares the header), nor requests that + * omit Accept-Language entirely (that MUST is about requests that ARE + * localising, which this proxy can only detect via the header's presence). + * + * `given` should select an operation, e.g. + * `$.paths[*][get,put,post,delete,patch]`. + * + * options: + * requestHeader {string} (required) request header parameter name (in: + * header) whose presence triggers the check. + * responseHeader {string} (required) header name each matching response + * must declare. + * status {string} status matcher for responses to check (default + * "2xx"). + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function headerEcho(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + if (typeof opts.requestHeader !== 'string' || typeof opts.responseHeader !== 'string') return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const params = Array.isArray(targetVal.parameters) ? targetVal.parameters : []; + const reqName = opts.requestHeader.toLowerCase(); + const hasRequestHeader = params.some( + (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === reqName, + ); + if (!hasRequestHeader) return; + + const responses = targetVal.responses; + if (!isObject(responses)) return; + const matchStatus = statusMatcher(opts.status || '2xx'); + const respName = opts.responseHeader.toLowerCase(); + const results = []; + + for (const [status, response] of Object.entries(responses)) { + if (!matchStatus(status) || !isObject(response)) continue; + const declared = isObject(response.headers) ? Object.keys(response.headers).map((h) => h.toLowerCase()) : []; + if (!declared.includes(respName)) { + results.push({ + message: `operation accepts "${opts.requestHeader}" but ${status} response does not declare "${opts.responseHeader}"`, + path: [...base, 'responses', status], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-idempotencyKeyRequired.js b/api-design-guide/linter/functions/s08-idempotencyKeyRequired.js new file mode 100644 index 0000000..824ac06 --- /dev/null +++ b/api-design-guide/linter/functions/s08-idempotencyKeyRequired.js @@ -0,0 +1,38 @@ +import { isObject } from './lib/util.js'; + +/** + * s08 idempotencyKeyRequired — proxy for guide 8.3: POST endpoints that + * require idempotency under §14 MUST accept an Idempotency-Key header, + * unless §14.6 applies. + * + * Proxy signal: a POST operation that declares a 201 response is treated as + * a "create" endpoint that requires idempotency. Does NOT verify the actual + * §14 idempotency-requirement determination, and does NOT check the §14.6 + * opt-out. + * + * `given` should select POST operations, e.g. `$.paths[*].post`. + * + * @param {unknown} targetVal - a POST operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function idempotencyKeyRequired(targetVal, options, context) { + if (!isObject(targetVal)) return; + const responses = targetVal.responses; + if (!isObject(responses) || !('201' in responses)) return; + + const params = Array.isArray(targetVal.parameters) ? targetVal.parameters : []; + const hasHeader = params.some( + (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === 'idempotency-key', + ); + if (hasHeader) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + return [ + { + message: 'POST operation returning 201 must accept an Idempotency-Key header parameter (unless §14.6 applies)', + path: [...base, 'parameters'], + }, + ]; +} diff --git a/api-design-guide/linter/functions/s08-noXHeaders.js b/api-design-guide/linter/functions/s08-noXHeaders.js new file mode 100644 index 0000000..e952839 --- /dev/null +++ b/api-design-guide/linter/functions/s08-noXHeaders.js @@ -0,0 +1,89 @@ +import { isObject } from './lib/util.js'; + +const OPS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; +const ALLOWED_X_HEADER = 'x-request-id'; + +/** + * s08 noXHeaders — guide 8.5: new custom headers MUST NOT use the `X-` + * prefix (RFC 6648), except the legacy X-Request-Id correlation header + * (§8.4, pending [OPEN-7-A]). + * + * Scans every header-carrying location in the document: operation and + * path-item parameters with `in: header`, response `headers` maps (inline + * on operations and under `components.responses`), and + * `components.parameters` entries with `in: header`. + * + * `given` should be the document root (`$`). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function noXHeaders(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const flagName = (name, path) => { + if (typeof name !== 'string') return; + if (/^x-/i.test(name) && name.toLowerCase() !== ALLOWED_X_HEADER) { + results.push({ + message: `header "${name}" uses the reserved "X-" prefix (RFC 6648); rename without the X- prefix`, + path, + }); + } + }; + + const scanParams = (params, path) => { + if (!Array.isArray(params)) return; + params.forEach((p, idx) => { + if (isObject(p) && p.in === 'header') flagName(p.name, [...path, idx, 'name']); + }); + }; + + const scanResponses = (responses, path) => { + if (!isObject(responses)) return; + for (const [status, response] of Object.entries(responses)) { + if (!isObject(response) || !isObject(response.headers)) continue; + for (const headerName of Object.keys(response.headers)) { + flagName(headerName, [...path, status, 'headers', headerName]); + } + } + }; + + const paths = targetVal.paths; + if (isObject(paths)) { + for (const [route, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + scanParams(pathItem.parameters, [...base, 'paths', route, 'parameters']); + for (const op of OPS) { + const operation = pathItem[op]; + if (!isObject(operation)) continue; + scanParams(operation.parameters, [...base, 'paths', route, op, 'parameters']); + scanResponses(operation.responses, [...base, 'paths', route, op, 'responses']); + } + } + } + + const components = targetVal.components; + if (isObject(components)) { + const compParams = components.parameters; + if (isObject(compParams)) { + for (const [key, p] of Object.entries(compParams)) { + if (isObject(p) && p.in === 'header') flagName(p.name, [...base, 'components', 'parameters', key, 'name']); + } + } + const compResponses = components.responses; + if (isObject(compResponses)) { + for (const [key, response] of Object.entries(compResponses)) { + if (!isObject(response) || !isObject(response.headers)) continue; + for (const headerName of Object.keys(response.headers)) { + flagName(headerName, [...base, 'components', 'responses', key, 'headers', headerName]); + } + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-personalDataInUrl.js b/api-design-guide/linter/functions/s08-personalDataInUrl.js new file mode 100644 index 0000000..c984c47 --- /dev/null +++ b/api-design-guide/linter/functions/s08-personalDataInUrl.js @@ -0,0 +1,69 @@ +import { isObject } from './lib/util.js'; + +const OPS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +// Heuristic dictionary of personal-data term fragments. Deliberately broad +// (STRICT-ONLY): flags plausible personal-data parameter names, not a +// definitive determination. +const PERSONAL_DATA_TERMS = + /national[-_]?id|\bssn\b|social[-_]?security|passport|phone|mobile|e[-_]?mail|full[-_]?name|first[-_]?name|last[-_]?name|surname|date[-_]?of[-_]?birth|\bdob\b|birth[-_]?date|home[-_]?address|street[-_]?address/i; + +/** + * s08 personalDataInUrl — STRICT-ONLY heuristic for guide 8.6: personal data + * MUST NOT appear in path segments, query parameters, or header values; + * opaque server-generated IDs MUST be used to refer to citizen records in + * URLs. + * + * Flags path/query/header parameters whose NAME matches a dictionary of + * personal-data term fragments (e.g. `email`, `phone`, `nationalId`, + * `dateOfBirth`). Deliberately noisy: a parameter literally named `email` + * trips it even when it is e.g. an admin lookup field, hence STRICT-ONLY. + * + * Does NOT verify: header or query VALUES (only names are visible + * statically), synonyms outside the dictionary, or whether an ID is + * actually opaque/server-generated vs. a natural key that happens not to + * match the dictionary. + * + * `given` should be the document root (`$`). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function personalDataInUrl(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const scanParams = (params, path) => { + if (!Array.isArray(params)) return; + params.forEach((p, idx) => { + if (!isObject(p)) return; + if ( + (p.in === 'path' || p.in === 'query' || p.in === 'header') && + typeof p.name === 'string' && + PERSONAL_DATA_TERMS.test(p.name) + ) { + results.push({ + message: `parameter "${p.name}" (in: ${p.in}) looks like personal data; use an opaque server-generated identifier instead`, + path: [...path, idx, 'name'], + }); + } + }); + }; + + const paths = targetVal.paths; + if (isObject(paths)) { + for (const [route, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + scanParams(pathItem.parameters, [...base, 'paths', route, 'parameters']); + for (const op of OPS) { + const operation = pathItem[op]; + if (isObject(operation)) scanParams(operation.parameters, [...base, 'paths', route, op, 'parameters']); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-rateLimitHeaders.js b/api-design-guide/linter/functions/s08-rateLimitHeaders.js new file mode 100644 index 0000000..6ec96f6 --- /dev/null +++ b/api-design-guide/linter/functions/s08-rateLimitHeaders.js @@ -0,0 +1,60 @@ +import { isObject } from './lib/util.js'; + +const RATE_LIMIT_HEADERS = ['ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']; +const shouldCheck = (status) => /^2\d\d$/.test(status) || status === '429'; + +/** + * s08 rateLimitHeaders — proxy for guide 8.7: endpoints rate-limited by the + * BB itself MUST declare rate-limit response headers (the v0.1 three-header + * form: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset), and 429 + * responses MUST additionally declare Retry-After. + * + * Proxy signal: an operation that declares a 429 response is treated as + * "rate-limited by the BB itself" (the delegated-to-gateway exception, + * which requires only a prose statement, is not mechanically checkable). + * The RateLimit-* trio is then required on that operation's 2xx and 429 + * responses. + * + * Does NOT verify: that rate limiting is actually implemented, the + * delegated-to-gateway prose exception, or the newer structured-field draft + * form (only the three-header v0.1 form is checked). + * + * `given` should select an operation's `responses`, e.g. + * `$.paths[*][get,put,post,delete,patch].responses`. + * + * @param {unknown} targetVal - a responses object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function rateLimitHeaders(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const statuses = Object.keys(targetVal); + + if (!statuses.includes('429')) return; + + const response429 = targetVal['429']; + const declared429 = isObject(response429) && isObject(response429.headers) + ? Object.keys(response429.headers).map((h) => h.toLowerCase()) + : []; + if (!declared429.includes('retry-after')) { + results.push({ message: '429 response must declare a "Retry-After" header', path: [...base, '429'] }); + } + + for (const status of statuses.filter(shouldCheck)) { + const response = targetVal[status]; + if (!isObject(response)) continue; + const declared = isObject(response.headers) ? Object.keys(response.headers).map((h) => h.toLowerCase()) : []; + const missing = RATE_LIMIT_HEADERS.filter((h) => !declared.includes(h)); + if (missing.length) { + results.push({ + message: `${status} response must declare rate-limit headers: ${missing.join(', ')}`, + path: [...base, status], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-requestIdCorrelation.js b/api-design-guide/linter/functions/s08-requestIdCorrelation.js new file mode 100644 index 0000000..e6dfb61 --- /dev/null +++ b/api-design-guide/linter/functions/s08-requestIdCorrelation.js @@ -0,0 +1,58 @@ +import { isObject } from './lib/util.js'; + +/** + * s08 requestIdCorrelation — proxy for guide 8.4: a request SHOULD carry an + * X-Request-Id header, and the server MUST echo it in the response (or + * generate one if absent from the request). + * + * Checks, per operation: + * - it SHOULD declare an X-Request-Id header parameter (in: header); + * - every 2xx response MUST declare an X-Request-Id response header + * (mechanically, presence in the spec stands in for "echoed or + * generated" — the runtime behaviour is out of scope). + * + * Does NOT verify: actual runtime echo/generate behaviour, distinctness + * from the error-envelope `traceId` (§11.3, explicitly a BB's choice), or + * path-item-level (shared) parameters — only operation-level parameters are + * inspected. + * + * `given` should select an operation, e.g. + * `$.paths[*][get,put,post,delete,patch]`. + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function requestIdCorrelation(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const params = Array.isArray(targetVal.parameters) ? targetVal.parameters : []; + const hasParam = params.some( + (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === 'x-request-id', + ); + if (!hasParam) { + results.push({ + message: 'operation should declare an X-Request-Id header parameter for correlation', + path: [...base, 'parameters'], + }); + } + + const responses = targetVal.responses; + if (isObject(responses)) { + for (const [status, response] of Object.entries(responses)) { + if (status[0] !== '2' || !isObject(response)) continue; + const declared = isObject(response.headers) ? Object.keys(response.headers).map((h) => h.toLowerCase()) : []; + if (!declared.includes('x-request-id')) { + results.push({ + message: `${status} response must declare an X-Request-Id header (echoed or server-generated)`, + path: [...base, 'responses', status], + }); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-abbreviations.js b/api-design-guide/linter/functions/s09-abbreviations.js new file mode 100644 index 0000000..20cf2be --- /dev/null +++ b/api-design-guide/linter/functions/s09-abbreviations.js @@ -0,0 +1,76 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { isObject } from './lib/util.js'; + +/** + * s09-abbreviations — STRICT proxy for §9.6 (avoid abbreviations). Splits each + * declared property name into words (camelCase / snake / kebab boundaries) and + * flags any word that appears in an abbreviation dictionary, suggesting the + * expansion. Deliberately noisy — ships only in the strict profile. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * abbreviations {object} map of abbreviation -> preferred word (lowercase + * keys). Replaces the default dictionary when given. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_ABBREVIATIONS = { + qty: 'quantity', + amt: 'amount', + num: 'number', + nbr: 'number', + addr: 'address', + msg: 'message', + desc: 'description', + cfg: 'configuration', + dob: 'dateOfBirth', + tmp: 'temporary', + txn: 'transaction', + acct: 'account', + cust: 'customer', + dept: 'department', + mgr: 'manager', + pwd: 'password', + usr: 'user', + cnt: 'count', + idx: 'index', + fname: 'firstName', + lname: 'lastName', + pct: 'percent', + attr: 'attribute', + err: 'error', +}; + +function splitWords(name) { + return String(name) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[\s_-]+/) + .filter(Boolean); +} + +export default function s09Abbreviations(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const dict = isObject(opts.abbreviations) ? opts.abbreviations : DEFAULT_ABBREVIATIONS; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + forEachProperty(targetVal, (name, _schema, path) => { + for (const word of splitWords(name)) { + const preferred = dict[word.toLowerCase()]; + if (preferred) { + results.push({ + message: `field "${name}" uses abbreviation "${word}"; prefer "${preferred}" (§9.6)`, + path: [...base, ...path], + }); + } + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-bbCode.js b/api-design-guide/linter/functions/s09-bbCode.js new file mode 100644 index 0000000..64e70e1 --- /dev/null +++ b/api-design-guide/linter/functions/s09-bbCode.js @@ -0,0 +1,109 @@ +import { isObject } from './lib/util.js'; + +/** + * s09-bbCode — PROXY for §9.11 (single registered BB code), IN-DOCUMENT scope + * only. Extracts BB-code segments from the identifier namespaces the guide + * defines and checks two things within one document: + * + * 1. every extracted BB code matches `^[a-z][a-z0-9-]{1,30}$`; + * 2. all extracted BB codes are identical (the same BB uses one code across + * OAuth scopes, error codes, event types and channel addresses). + * + * Extraction runs over every object key and string value (skipping free-text + * prose keys) using the two documented shapes: + * - OAuth scope: `bb:{bb-code}:{resource}:{action}` + * - reverse-DNS: `org.govstack.{bb-code}....` (error codes, event types, + * channel addresses, problem-type URIs) + * The segment `common` is reserved (§11.7) and excluded from the identity check. + * + * It does NOT verify ecosystem-wide uniqueness of the BB code — that needs the + * cross-repo BB-code register (Appendix A), which is out of scope here. + * + * `given` should be `$` (the whole document). Cycle-safe. + * + * options: + * skipKeys {string[]} object keys whose string values are prose and skipped + * (default description/summary/title/externalDocs). + * + * @param {unknown} targetVal - the document root. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const SCOPE_RE = /^bb:([^:\s]+):/; +const RDNS_RE = /org\.govstack\.([^.\s]+)\./gi; +const BB_CODE_RE = /^[a-z][a-z0-9-]{1,30}$/; +const RESERVED = 'common'; +const DEFAULT_SKIP_KEYS = ['description', 'summary', 'title', 'externalDocs']; + +function truncate(s) { + return s.length > 60 ? `${s.slice(0, 57)}...` : s; +} + +export default function s09BbCode(targetVal, options, context) { + if (!isObject(targetVal) && !Array.isArray(targetVal)) return; + const opts = isObject(options) ? options : {}; + const skipKeys = new Set( + Array.isArray(opts.skipKeys) ? opts.skipKeys.map(String) : DEFAULT_SKIP_KEYS, + ); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const seen = new WeakSet(); + const codes = new Map(); // valid, non-reserved code -> first path seen + + const consider = (str, path) => { + if (typeof str !== 'string' || str.length === 0) return; + const raw = []; + const scope = SCOPE_RE.exec(str); + if (scope) raw.push(scope[1]); + RDNS_RE.lastIndex = 0; + let m; + while ((m = RDNS_RE.exec(str)) !== null) raw.push(m[1]); + + for (const code of raw) { + if (!BB_CODE_RE.test(code)) { + results.push({ + message: `BB code "${code}" in "${truncate(str)}" must match ^[a-z][a-z0-9-]{1,30}$ (§9.11)`, + path, + }); + continue; + } + if (code === RESERVED) continue; + if (!codes.has(code)) codes.set(code, path); + } + }; + + const walk = (node, path) => { + if (typeof node === 'string') { + consider(node, path); + return; + } + if (Array.isArray(node)) { + if (seen.has(node)) return; + seen.add(node); + node.forEach((item, i) => walk(item, [...path, i])); + return; + } + if (!isObject(node) || seen.has(node)) return; + seen.add(node); + for (const key of Object.keys(node)) { + consider(key, [...path, key]); // scope strings / addresses can be map keys + if (skipKeys.has(key)) continue; // skip prose values + walk(node[key], [...path, key]); + } + }; + + walk(targetVal, base); + + if (codes.size > 1) { + const list = [...codes.keys()]; + results.push({ + message: + `document uses ${codes.size} distinct BB codes (${list.join(', ')}); a BB must use its single ` + + `registered code identically across error codes, scopes, event types and channel addresses (§9.11)`, + path: base, + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-booleanStrings.js b/api-design-guide/linter/functions/s09-booleanStrings.js new file mode 100644 index 0000000..b599f98 --- /dev/null +++ b/api-design-guide/linter/functions/s09-booleanStrings.js @@ -0,0 +1,67 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { isObject, toRegExp } from './lib/util.js'; + +/** + * s09-booleanStrings — PROXY for §9.3 (real JSON booleans). Flags properties + * that look boolean but are modelled as strings. Two heuristics, each a finding: + * + * 1. a property whose NAME matches a boolean-ish pattern (is/has/can prefixes) + * whose schema `type` includes "string"; + * 2. a property whose schema `enum` is made up solely of the string literals + * "true"/"false" (any case), regardless of name. + * + * It does NOT catch boolean-ish fields typed as integer 0/1, nor boolean values + * that are typed string without a boolean-ish name — those are not mechanically + * distinguishable from legitimate strings. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * namePattern {string} regex a property NAME must match for heuristic 1. + * nameFlags {string} regex flags for namePattern. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_NAME_PATTERN = + '^(is|has|can|should|must|will|allow|allows|enable|enables|require|requires|use|uses|include|includes)[A-Z0-9]'; + +export default function s09BooleanStrings(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const nameRe = toRegExp(opts.namePattern || DEFAULT_NAME_PATTERN, opts.nameFlags); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + forEachProperty(targetVal, (name, schema, path) => { + if (!isObject(schema)) return; + const full = [...base, ...path]; + const type = schema.type; + const typeList = Array.isArray(type) ? type : type === undefined ? [] : [type]; + + // Heuristic 1: boolean-looking name modelled as a string. + if (nameRe && nameRe.test(name) && typeList.includes('string')) { + results.push({ + message: `boolean-looking field "${name}" is typed string; use a real JSON boolean (true/false)`, + path: full, + }); + } + + // Heuristic 2: enum of the string literals "true"/"false". + const en = schema.enum; + if ( + Array.isArray(en) && + en.length > 0 && + en.every((v) => typeof v === 'string' && ['true', 'false'].includes(v.toLowerCase())) + ) { + results.push({ + message: `field "${name}" enumerates string booleans (${JSON.stringify(en)}); use a real JSON boolean`, + path: full, + }); + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-closedEnum.js b/api-design-guide/linter/functions/s09-closedEnum.js new file mode 100644 index 0000000..1d93857 --- /dev/null +++ b/api-design-guide/linter/functions/s09-closedEnum.js @@ -0,0 +1,55 @@ +import { walkSchema } from './lib/schemaWalk.js'; +import { isObject } from './lib/util.js'; + +/** + * s09-closedEnum — PROXY for §9.9 (no closed enums for growing sets). Flags + * every closed string `enum` that offers no forward-compatibility escape hatch, + * i.e. that has neither an `x-extensible-enum` annotation nor a catch-all + * fallback member (UNKNOWN / OTHER / UNSPECIFIED). + * + * It CANNOT tell whether a value set is "expected to grow", so it also flags + * truly-fixed enums (ISO codes, etc.) that §9.9 explicitly permits — hence this + * is an info-level proxy the reviewer must judge, not a hard error. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * fallbackMembers {string[]} enum members that count as a fallback + * (default UNKNOWN/OTHER/UNSPECIFIED, case-insensitive). + * extensionKey {string} annotation that opts an enum out (default + * "x-extensible-enum"). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_FALLBACKS = ['UNKNOWN', 'OTHER', 'UNSPECIFIED']; + +export default function s09ClosedEnum(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const fallbacks = new Set( + (Array.isArray(opts.fallbackMembers) ? opts.fallbackMembers : DEFAULT_FALLBACKS).map((s) => + String(s).toLowerCase(), + ), + ); + const extKey = typeof opts.extensionKey === 'string' ? opts.extensionKey : 'x-extensible-enum'; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + walkSchema(targetVal, (node, path) => { + if (!Array.isArray(node.enum) || node.enum.length === 0) return; + if (!node.enum.every((v) => typeof v === 'string')) return; // only string enums grow + if (node[extKey] !== undefined) return; // annotated open enum + if (node.enum.some((v) => fallbacks.has(v.toLowerCase()))) return; // has a fallback member + results.push({ + message: + `closed enum ${JSON.stringify(node.enum)} has no x-extensible-enum annotation and no ` + + `UNKNOWN/OTHER fallback member; if this value set may grow, open it (§9.9). Truly-fixed sets may ignore.`, + path: [...base, ...path, 'enum'], + }); + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-enumCasing.js b/api-design-guide/linter/functions/s09-enumCasing.js new file mode 100644 index 0000000..8dc3a0b --- /dev/null +++ b/api-design-guide/linter/functions/s09-enumCasing.js @@ -0,0 +1,71 @@ +import { walkSchema } from './lib/schemaWalk.js'; +import { isObject, toRegExp } from './lib/util.js'; + +/** + * s09-enumCasing — PROXY for §9.7 (SCREAMING_SNAKE_CASE enum values). Flags + * string `enum` members that are not SCREAMING_SNAKE_CASE, with documented + * exceptions for values that idiomatically stay lowercase: + * + * - ISO-style short codes / locales (language `en`, `fra`, locale `en-US`), + * matched by `allowPattern`; + * - health / status vocab (`pass`, `fail`, `warn`, `up`, `down`, ...), listed + * in `allowValues`. + * + * Because those exceptions are pattern-based, a lowercase enum that merely looks + * ISO-like (any 2-3 letter token) is not flagged: the proxy trades a few misses + * for far fewer false positives. Non-string enum members are ignored. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * pattern {string} regex an enum value MUST match (default SCREAMING_SNAKE). + * allowPattern {string} regex of always-allowed values (default ISO-ish codes). + * allowValues {string[]} literal always-allowed values (default health vocab). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const SCREAMING = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/; +const DEFAULT_ALLOW_PATTERN = '^[a-z]{2,3}([-_][A-Za-z0-9]{2,4})?$'; +const DEFAULT_ALLOW_VALUES = [ + 'pass', + 'fail', + 'warn', + 'ok', + 'up', + 'down', + 'healthy', + 'unhealthy', + 'degraded', +]; + +export default function s09EnumCasing(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const pattern = (opts.pattern && toRegExp(opts.pattern)) || SCREAMING; + const allowRe = + opts.allowPattern !== undefined ? toRegExp(opts.allowPattern) : toRegExp(DEFAULT_ALLOW_PATTERN); + const allowValues = new Set( + (Array.isArray(opts.allowValues) ? opts.allowValues : DEFAULT_ALLOW_VALUES).map(String), + ); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + walkSchema(targetVal, (node, path) => { + if (!Array.isArray(node.enum)) return; + node.enum.forEach((value, i) => { + if (typeof value !== 'string' || value.length === 0) return; + if (pattern.test(value)) return; + if (allowValues.has(value)) return; + if (allowRe && allowRe.test(value)) return; + results.push({ + message: `enum value "${value}" must be SCREAMING_SNAKE_CASE (e.g. ACTIVE, PENDING_REVIEW)`, + path: [...base, ...path, 'enum', i], + }); + }); + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-extensionPrefix.js b/api-design-guide/linter/functions/s09-extensionPrefix.js new file mode 100644 index 0000000..b8386b0 --- /dev/null +++ b/api-design-guide/linter/functions/s09-extensionPrefix.js @@ -0,0 +1,75 @@ +import { isObject } from './lib/util.js'; + +/** + * s09-extensionPrefix — STRICT proxy for §9.10 (GovStack extension prefix). + * Walks the whole document and flags specification extensions (`x-*` keys) that + * look GovStack-defined but are not prefixed exactly `x-govstack-`: + * + * 1. an `x-<token>` whose token is a known GovStack extension concept + * (delivery, ordering, replay, deprecated, api-guide) -> should be + * `x-govstack-<token>`; + * 2. any `x-*` key that mentions "govstack" but is not prefixed + * `x-govstack-` (typos / wrong casing / wrong separator). + * + * It is weak: it cannot know the full set of GovStack-defined extensions, so it + * misses GovStack extensions with unknown names, and cannot tell a legitimate + * third-party `x-` extension from a GovStack one. Ships only in strict. + * + * `given` should be `$` (the whole document). Cycle-safe. + * + * options: + * knownExtensions {string[]} known GovStack extension tokens (suffix after + * `x-govstack-`), replacing the default list. + * + * @param {unknown} targetVal - the document root. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_KNOWN = ['delivery', 'ordering', 'replay', 'deprecated', 'api-guide']; + +export default function s09ExtensionPrefix(targetVal, options, context) { + if (!isObject(targetVal) && !Array.isArray(targetVal)) return; + const opts = isObject(options) ? options : {}; + const known = new Set( + (Array.isArray(opts.knownExtensions) ? opts.knownExtensions : DEFAULT_KNOWN).map(String), + ); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const seen = new WeakSet(); + + const walk = (node, path) => { + if (Array.isArray(node)) { + if (seen.has(node)) return; + seen.add(node); + node.forEach((item, i) => { + if (isObject(item) || Array.isArray(item)) walk(item, [...path, i]); + }); + return; + } + if (!isObject(node) || seen.has(node)) return; + seen.add(node); + for (const key of Object.keys(node)) { + if (key.startsWith('x-')) { + const isGovstack = /^x-govstack-/.test(key); + const token = key.slice(2); // strip leading "x-" + if (!isGovstack && known.has(token)) { + results.push({ + message: `extension "${key}" names a GovStack-defined concept but lacks the prefix; use "x-govstack-${token}"`, + path: [...base, ...path, key], + }); + } else if (!isGovstack && /govstack/i.test(key)) { + results.push({ + message: `extension "${key}" references GovStack but is not prefixed exactly "x-govstack-"`, + path: [...base, ...path, key], + }); + } + } + const child = node[key]; + if (isObject(child) || Array.isArray(child)) walk(child, [...path, key]); + } + }; + + walk(targetVal, []); + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s12-collectionPagination.js b/api-design-guide/linter/functions/s12-collectionPagination.js new file mode 100644 index 0000000..977130f --- /dev/null +++ b/api-design-guide/linter/functions/s12-collectionPagination.js @@ -0,0 +1,111 @@ +import { isObject, asArray } from './lib/util.js'; +import envelopeShape from './envelopeShape.js'; + +/** + * s12-collectionPagination — pagination checks for a "collection" GET + * operation that need to branch on whether the operation opted into + * offset-based pagination (guide §12.6) instead of the cursor-based default + * (guide §12.2/§12.3). Reuses `envelopeShape` for the actual shape check so + * the envelope rules (cursor vs. flat/offset) stay in one place. + * + * `given` should select the operation object itself, e.g. + * `$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]` (a "collection" path: + * one that does not end in a `{param}` segment). + * + * An operation is considered to have opted into offset pagination when its + * `parameters` array declares a parameter named `offset`. + * + * The operational liveness endpoints `/health` and `/ready` (guide §5.9) are + * NOT collections and are exempted by exact path-key match (see EXEMPT_PATHS). + * + * options: + * mode {'cursorParams'|'cursorEnvelope'|'offsetEnvelope'} (required) + * - cursorParams: operation MUST declare both `pageSize` and `cursor` + * query parameters. No-op when the operation has an + * `offset` parameter (that operation is covered by the + * `offsetEnvelope` mode / §12.6 instead). + * - cursorEnvelope: the 200 response body schema MUST declare the §12.3 + * envelope `{ items, pageInfo: { nextCursor, hasMore } }`. + * No-op when the operation has an `offset` parameter. + * - offsetEnvelope: the 200 response body schema MUST declare the §12.6 + * flat envelope `{ items, offset, limit, total }`. + * No-op when the operation does NOT have an `offset` + * parameter (nothing to check: it isn't using offset + * pagination). + * mediaType {string} content-type key to inspect on the 200 response, + * default "application/json". + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const EXEMPT_PATHS = new Set(['/health', '/ready']); + +export default function collectionPagination(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const mediaType = typeof opts.mediaType === 'string' ? opts.mediaType : 'application/json'; + + // Guide §12.1 covers "endpoints returning collections". The operational + // liveness probes /health and /ready (guide §5.9) are not collections, so + // exempt them by exact path-key match. `given` selects the GET operation, so + // context.path is ['paths', '<pathKey>', 'get'] — the key sits before 'get'. + const pathKey = base.length >= 2 ? base[base.length - 2] : undefined; + if (typeof pathKey === 'string' && EXEMPT_PATHS.has(pathKey)) return undefined; + + const params = asArray(targetVal.parameters).filter(isObject); + const offsetMode = params.some((p) => p.name === 'offset'); + + if (opts.mode === 'cursorParams') { + if (offsetMode) return undefined; + const names = new Set(params.map((p) => p.name)); + const results = []; + if (!names.has('pageSize')) { + results.push({ + message: 'collection endpoint must declare a "pageSize" query parameter (guide 12.2)', + path: [...base, 'parameters'], + }); + } + if (!names.has('cursor')) { + results.push({ + message: 'collection endpoint must declare a "cursor" query parameter; default pagination is cursor-based (guide 12.2)', + path: [...base, 'parameters'], + }); + } + return results.length ? results : undefined; + } + + if (opts.mode !== 'cursorEnvelope' && opts.mode !== 'offsetEnvelope') return undefined; + + const schema = targetVal.responses?.['200']?.content?.[mediaType]?.schema; + if (!isObject(schema)) return undefined; + const schemaPath = [...base, 'responses', '200', 'content', mediaType, 'schema']; + + if (opts.mode === 'cursorEnvelope') { + if (offsetMode) return undefined; + return envelopeShape( + schema, + { + requiredProperties: ['items', 'pageInfo'], + properties: { + items: { type: 'array' }, + pageInfo: { requiredProperties: ['nextCursor', 'hasMore'] }, + }, + }, + { path: schemaPath }, + ); + } + + // mode === 'offsetEnvelope' + if (!offsetMode) return undefined; + return envelopeShape( + schema, + { + requiredProperties: ['items', 'offset', 'limit', 'total'], + properties: { items: { type: 'array' } }, + }, + { path: schemaPath }, + ); +} diff --git a/api-design-guide/linter/functions/s12-sortParam.js b/api-design-guide/linter/functions/s12-sortParam.js new file mode 100644 index 0000000..f05dd22 --- /dev/null +++ b/api-design-guide/linter/functions/s12-sortParam.js @@ -0,0 +1,44 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s12-sortParam — for an operation that declares a `sort` query parameter, + * assert its schema is a string carrying a `pattern` (guide 12.7's + * `field` / `-field`, comma-separated grammar). A no-op when the operation + * declares no `sort` parameter at all: sorting support is optional per + * operation, only its shape is mandated once offered. + * + * Does NOT verify that the declared `pattern` actually encodes the exact + * field/-field/comma grammar, only that a pattern is present; validating an + * arbitrary author-supplied regex's semantics is not mechanically decidable. + * + * `given` should select an operation object, e.g. `$.paths[*][get]`. + * + * @param {unknown} targetVal - an operation object. + * @param {object} options - unused, present for signature consistency. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function sortParamShape(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const params = asArray(targetVal.parameters).filter(isObject); + const idx = params.findIndex((p) => p.name === 'sort'); + if (idx === -1) return undefined; + + const param = params[idx]; + const schema = isObject(param.schema) ? param.schema : {}; + const path = [...base, 'parameters', idx, 'schema']; + const results = []; + + if (schema.type !== 'string') { + results.push({ message: '"sort" parameter schema must declare type "string"', path }); + } + if (typeof schema.pattern !== 'string' || schema.pattern.length === 0) { + results.push({ + message: '"sort" parameter schema must declare a "pattern" encoding the field/-field, comma-separated grammar (guide 12.7)', + path, + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s13-apiKeyScope.js b/api-design-guide/linter/functions/s13-apiKeyScope.js new file mode 100644 index 0000000..031a82c --- /dev/null +++ b/api-design-guide/linter/functions/s13-apiKeyScope.js @@ -0,0 +1,73 @@ +import { isObject } from './lib/util.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +// Path segments that identify an operational (health-like) endpoint per §5.9. +const OPERATIONAL = new Set([ + 'health', 'healthz', 'live', 'livez', 'ready', 'readyz', + 'metrics', 'ping', 'status', 'startup', 'startupz', +]); + +/** + * apiKeyScope — proxy for §13.6. API keys may protect operational endpoints + * (`/health` and similar) but MUST NOT protect operations that read or write + * personal data. This flags every operation that applies an `apiKey` security + * scheme yet is not an operational endpoint. + * + * It is a proxy: it cannot decide "reads or writes personal data", so it uses a + * path heuristic (§5.9 operational names) and flags apiKey use on everything + * else. It also cannot see credentials applied outside declared security + * requirements. + * + * Given: the document root ($). + * + * @param {unknown} targetVal - the document root. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function apiKeyScope(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const components = isObject(targetVal.components) ? targetVal.components : {}; + const schemes = isObject(components.securitySchemes) ? components.securitySchemes : {}; + const apiKeyNames = new Set( + Object.entries(schemes) + .filter(([, v]) => isObject(v) && v.type === 'apiKey') + .map(([k]) => k), + ); + if (apiKeyNames.size === 0) return; + + const rootSecurity = Array.isArray(targetVal.security) ? targetVal.security : undefined; + + const usesApiKey = (requirements) => + Array.isArray(requirements) && + requirements.some((req) => isObject(req) && Object.keys(req).some((n) => apiKeyNames.has(n))); + + const isOperational = (pathKey) => + pathKey + .split('/') + .filter((seg) => seg && !seg.startsWith('{')) + .some((seg) => OPERATIONAL.has(seg.toLowerCase())); + + const results = []; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!isObject(op)) continue; + const effective = op.security !== undefined ? op.security : rootSecurity; + if (usesApiKey(effective) && !isOperational(pathKey)) { + results.push({ + message: + `operation ${method.toUpperCase()} ${pathKey} applies an apiKey scheme but is not an ` + + `operational (health-like) endpoint; API keys must not protect data operations`, + path: [...base, 'paths', pathKey, method], + }); + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s13-schemeExists.js b/api-design-guide/linter/functions/s13-schemeExists.js new file mode 100644 index 0000000..edbeeea --- /dev/null +++ b/api-design-guide/linter/functions/s13-schemeExists.js @@ -0,0 +1,58 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * schemeExists — assert the document declares at least one security scheme + * matching a required profile. Drives the §13.2 and §13.3 proxies: + * - §13.2: an OAuth 2.0 / OpenID Connect scheme exists. + * - §13.3: a service-to-service scheme exists (mTLS, or OAuth + * client-credentials). + * + * It only checks that such a scheme is *declared* under + * `components.securitySchemes`; it does NOT verify that the scheme is applied to + * the right (citizen-facing vs inter-BB) operations, which the guide leaves to + * human review. + * + * Given: the document root ($). Reads `components.securitySchemes`. + * + * options: + * types {string[]} scheme `type` values accepted unconditionally + * (e.g. ["openIdConnect","oauth2"] or ["mutualTLS"]). + * oauthFlows {string[]} if present, a `type: oauth2` scheme also matches when + * it declares at least one of these flow names under + * `flows` (e.g. ["clientCredentials"]). + * label {string} human label used in the message. + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemeExists(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const types = asArray(opts.types).filter((t) => typeof t === 'string'); + const oauthFlows = asArray(opts.oauthFlows).filter((f) => typeof f === 'string'); + const label = typeof opts.label === 'string' ? opts.label : 'a matching'; + + const components = isObject(targetVal.components) ? targetVal.components : {}; + const schemes = isObject(components.securitySchemes) ? components.securitySchemes : {}; + + const matches = (scheme) => { + if (!isObject(scheme)) return false; + const t = scheme.type; + if (typeof t === 'string' && types.includes(t)) return true; + if (t === 'oauth2' && oauthFlows.length && isObject(scheme.flows)) { + return oauthFlows.some((f) => isObject(scheme.flows[f])); + } + return false; + }; + + const found = Object.values(schemes).some(matches); + if (found) return; + + const target = isObject(components.securitySchemes) + ? [...base, 'components', 'securitySchemes'] + : [...base]; + return [{ message: `no ${label} security scheme is declared under components.securitySchemes`, path: target }]; +} diff --git a/api-design-guide/linter/functions/s13-scopeNames.js b/api-design-guide/linter/functions/s13-scopeNames.js new file mode 100644 index 0000000..5d9f848 --- /dev/null +++ b/api-design-guide/linter/functions/s13-scopeNames.js @@ -0,0 +1,48 @@ +import { isObject, toRegExp } from './lib/util.js'; + +/** + * scopeNames — assert every OAuth scope string obeys the GovStack scope naming + * convention (§13.4). Scopes are the *keys* of an oauth2 scheme's + * `flows.<flow>.scopes` object. + * + * This is a proxy: it validates the *shape* of each scope string against the + * default `bb:{bb-code}:{resource}:{action}` and its two documented + * alternatives (reverse-DNS `org.govstack.{bb-code}.{resource}.{action}` and + * `resource.action`). It does NOT verify that `{bb-code}` is the BB's actual + * registered code (§9.11), that scopes are unique ecosystem-wide, that a single + * convention is used consistently, or that every operation documents its scopes. + * + * Given: a `scopes` object (`$.components.securitySchemes[*].flows[*].scopes`). + * + * options: + * patterns {string[]} regexes; each scope key must match at least one. + * flags {string} regex flags applied to every pattern. + * label {string} convention name used in the message. + * + * @param {unknown} targetVal - a scopes object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function scopeNames(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const label = typeof opts.label === 'string' ? opts.label : 'the GovStack scope naming convention'; + + const regexes = (Array.isArray(opts.patterns) ? opts.patterns : []) + .map((p) => toRegExp(p, typeof opts.flags === 'string' ? opts.flags : undefined)) + .filter((r) => r instanceof RegExp); + if (regexes.length === 0) return; + + const results = []; + for (const key of Object.keys(targetVal)) { + if (!regexes.some((r) => r.test(key))) { + results.push({ + message: `scope "${key}" does not match ${label} (bb:{bb-code}:{resource}:{action})`, + path: [...base, key], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s14-idempotencyKey.js b/api-design-guide/linter/functions/s14-idempotencyKey.js new file mode 100644 index 0000000..1e36a47 --- /dev/null +++ b/api-design-guide/linter/functions/s14-idempotencyKey.js @@ -0,0 +1,57 @@ +import { isObject, asArray, statusMatcher } from './lib/util.js'; + +/** + * idempotencyKey — proxy for §14.1. A POST that creates a resource (declares a + * 201 response) MUST accept an `Idempotency-Key` request header. This checks the + * create-POST case only. + * + * It is a proxy: it cannot decide the other MUST triggers (moves value, submits + * an irreversible request, sends a message, starts a long-running job, etc.) nor + * the SHOULD/MAY tiers, so it keys off the mechanically visible signal of a POST + * declaring a 201 response. + * + * Given: a path item (`$.paths[*]`). Reads its `post` operation plus both + * path-level and operation-level `parameters`. + * + * options: + * header {string} required header name (default "Idempotency-Key"). + * status {string} status matcher marking a create (default "201"). + * method {string} operation key to inspect (default "post"). + * + * @param {unknown} targetVal - a path item object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function idempotencyKey(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const header = typeof opts.header === 'string' ? opts.header : 'Idempotency-Key'; + const method = typeof opts.method === 'string' ? opts.method : 'post'; + const matchStatus = statusMatcher(typeof opts.status === 'string' ? opts.status : '201'); + + const op = targetVal[method]; + if (!isObject(op) || !isObject(op.responses)) return; + + const created = Object.keys(op.responses).filter(matchStatus); + if (created.length === 0) return; + + const params = [...asArray(targetVal.parameters), ...asArray(op.parameters)]; + const hasHeader = params.some( + (p) => + isObject(p) && + p.in === 'header' && + typeof p.name === 'string' && + p.name.toLowerCase() === header.toLowerCase(), + ); + if (hasHeader) return; + + const pathKey = base.length ? base[base.length - 1] : ''; + return [{ + message: + `${method.toUpperCase()} ${pathKey} declares a ${created[0]} response (a create) but does not ` + + `accept an "${header}" request header`, + path: [...base, method], + }]; +} diff --git a/api-design-guide/linter/functions/s15-cancelPath.js b/api-design-guide/linter/functions/s15-cancelPath.js new file mode 100644 index 0000000..5ed0c68 --- /dev/null +++ b/api-design-guide/linter/functions/s15-cancelPath.js @@ -0,0 +1,48 @@ +import { isObject } from './lib/util.js'; + +const CANONICAL = /^\/v\d+\/operations\/\{[^}]+\}\/cancel$/; + +/** + * cancelPath — §15.5. Cancellation of an asynchronous Operation, when + * supported, MUST be `POST /v{N}/operations/{operationId}/cancel`. + * + * Scope: only Operation-cancellation paths are checked — a path whose last + * segment is `cancel` and that contains an `operations` segment. Domain-level + * cancel actions (e.g. `/v1/orders/{id}/cancel`) are not Operation + * cancellations and are out of scope. A matching path must be the canonical + * shape and must declare a `post` operation. + * + * Given: the paths object ($.paths). + * + * @param {unknown} targetVal - the paths object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function cancelPath(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const [key, item] of Object.entries(targetVal)) { + if (typeof key !== 'string') continue; + const segments = key.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + if (last !== 'cancel' || !segments.includes('operations')) continue; + + if (!CANONICAL.test(key)) { + results.push({ + message: `cancellation path "${key}" must be POST /v{N}/operations/{operationId}/cancel`, + path: [...base, key], + }); + continue; + } + if (!isObject(item) || !isObject(item.post)) { + results.push({ + message: `cancellation at "${key}" must be declared as a POST operation`, + path: [...base, key], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s15-operationsPolling.js b/api-design-guide/linter/functions/s15-operationsPolling.js new file mode 100644 index 0000000..0f62296 --- /dev/null +++ b/api-design-guide/linter/functions/s15-operationsPolling.js @@ -0,0 +1,51 @@ +import { isObject } from './lib/util.js'; + +const POLL_PATH = /^\/v\d+\/operations\/\{[^}]+\}$/; + +/** + * operationsPolling — proxy for §15.4. When a spec uses the asynchronous + * Operations pattern, clients poll via `GET /v{N}/operations/{operationId}`. + * This flags a spec that uses Operations but does not declare that canonical + * poll endpoint. + * + * "Uses Operations" is detected structurally: the document declares a + * `components.schemas.Operation` schema, or has any path under `/operations/`. + * + * It is a proxy: it cannot decide whether asynchronous behaviour is actually + * needed, and it accepts any version major and any path-parameter name. + * + * Given: the document root ($). + * + * @param {unknown} targetVal - the document root. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function operationsPolling(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const schemas = + isObject(targetVal.components) && isObject(targetVal.components.schemas) + ? targetVal.components.schemas + : {}; + + const pathKeys = Object.keys(paths); + const usesOperations = + isObject(schemas.Operation) || + pathKeys.some((k) => k.includes('/operations/') || k.endsWith('/operations')); + if (!usesOperations) return; + + const hasPoll = Object.entries(paths).some( + ([k, v]) => POLL_PATH.test(k) && isObject(v) && isObject(v.get), + ); + if (hasPoll) return; + + return [{ + message: + 'the Operations pattern is used but no canonical poll endpoint ' + + 'GET /v{N}/operations/{operationId} is declared', + path: [...base, 'paths'], + }]; +} diff --git a/api-design-guide/linter/functions/s16-eventField.js b/api-design-guide/linter/functions/s16-eventField.js new file mode 100644 index 0000000..bbc0d64 --- /dev/null +++ b/api-design-guide/linter/functions/s16-eventField.js @@ -0,0 +1,113 @@ +import { isObject, toRegExp } from './lib/util.js'; + +/** + * s16-eventField — inspect a named property of a CloudEvents envelope schema + * and assert its *pinned literal value(s)* satisfy / violate a pattern. + * + * Drives the §16 event-type (16.3) and stable-source (16.4) checks. The event + * `type` and `source` are ordinarily pinned on the envelope schema via `const`, + * `enum`, `default`, `example` or `examples`; this function locates the named + * property (merging one level of top-level `allOf`, the same limitation as + * envelopeShape), gathers those pinned string values, and tests each one. + * + * A free-form property (no pinned value) cannot be checked here and is left + * clean — presence of the property itself is enforced by §16.2 (envelopeShape). + * + * `given` should select the envelope schema, e.g. + * $.webhooks[*][post].requestBody.content[*].schema + * + * Options: + * property {string} (required) property name to inspect ("type", "source"). + * match {string} regex string; every pinned value MUST match it. + * forbidPattern {string} regex string; no pinned value may match it. + * forbidSegments {string} regex string; no dot-separated segment may match it + * (used to reject a version segment in an event type). + * sources {string[]} value keywords to gather (default ["const","enum"]). + * flags {string} regex flags applied to match/forbid patterns. + * name {string} label used in messages (default = property). + * expected {string} human description of the expected/forbidden state. + * + * @param {unknown} targetVal - a JSON Schema (the envelope schema). + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function eventField(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const property = options.property; + if (typeof property !== 'string' || property.length === 0) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + const name = typeof options.name === 'string' ? options.name : property; + const expected = typeof options.expected === 'string' ? options.expected : undefined; + + const prop = findProperty(targetVal, property); + if (!isObject(prop)) return; // property not declared — §16.2 covers presence + + const values = gatherValues(prop, options.sources); + if (values.length === 0) return; // no pinned value to check + + const matchRe = toRegExp(options.match, options.flags); + const forbidRe = toRegExp(options.forbidPattern, options.flags); + const forbidSegRe = toRegExp(options.forbidSegments, options.flags); + + const path = [...base, 'properties', property]; + const results = []; + for (const value of values) { + if (matchRe && !matchRe.test(value)) { + results.push({ + message: `${name} "${value}" is not valid; expected ${expected || `it to match ${options.match}`}.`, + path, + }); + } + if (forbidRe && forbidRe.test(value)) { + results.push({ + message: `${name} "${value}" is not stable; ${expected || 'it must not contain a forbidden token'}.`, + path, + }); + } + if (forbidSegRe) { + for (const seg of value.split('.')) { + if (forbidSegRe.test(seg)) { + results.push({ + message: `${name} "${value}" must not contain a version segment ("${seg}"); ${expected || 'the version belongs in the channel address, not the event type'}.`, + path, + }); + break; + } + } + } + } + return results.length ? results : undefined; +} + +/** Locate a property in a schema, merging one level of top-level allOf. */ +function findProperty(schema, name) { + if (isObject(schema.properties) && isObject(schema.properties[name])) { + return schema.properties[name]; + } + if (Array.isArray(schema.allOf)) { + for (const branch of schema.allOf) { + if (isObject(branch) && isObject(branch.properties) && isObject(branch.properties[name])) { + return branch.properties[name]; + } + } + } + return undefined; +} + +/** Collect pinned string values from a property schema's value keywords. */ +function gatherValues(prop, sources) { + const keys = Array.isArray(sources) && sources.length ? sources : ['const', 'enum']; + const out = []; + for (const key of keys) { + const v = prop[key]; + if (v === undefined) continue; + if (key === 'enum' || key === 'examples') { + if (Array.isArray(v)) for (const item of v) if (typeof item === 'string') out.push(item); + } else if (typeof v === 'string') { + out.push(v); + } + } + return out; +} diff --git a/api-design-guide/linter/functions/s16-signatureHeader.js b/api-design-guide/linter/functions/s16-signatureHeader.js new file mode 100644 index 0000000..eb778c1 --- /dev/null +++ b/api-design-guide/linter/functions/s16-signatureHeader.js @@ -0,0 +1,49 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +/** + * s16-signatureHeader — guide 16.6 (OpenAPI/webhooks surface): the event + * signature MUST travel in a single ecosystem-wide HTTP header named + * `GovStack-Signature`. Every webhook delivery therefore has to declare that + * header as a request parameter. + * + * `given` should select each webhook path-item object, i.e. `$.webhooks[*]`. + * Header parameters may be declared at the path-item level (shared) or on the + * individual operations; this function accepts the header found at either + * level. Matching is case-insensitive because HTTP header names are. + * + * Options: + * field {string} header name to require (default "GovStack-Signature"). + * + * @param {unknown} targetVal - a webhook Path Item Object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function signatureHeader(targetVal, options, context) { + if (!isObject(targetVal)) return; + const field = (isObject(options) && typeof options.field === 'string' ? options.field : 'GovStack-Signature'); + const wanted = field.toLowerCase(); + const base = context && Array.isArray(context.path) ? context.path : []; + const label = base.length ? String(base[base.length - 1]) : 'webhook'; + + const hasHeader = (params) => + Array.isArray(params) && + params.some( + (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === wanted, + ); + + if (hasHeader(targetVal.parameters)) return; + for (const method of METHODS) { + const op = targetVal[method]; + if (isObject(op) && hasHeader(op.parameters)) return; + } + + return [ + { + message: `webhook "${label}" must declare a "${field}" header parameter so receivers can verify the event signature (§16.6).`, + path: base, + }, + ]; +} diff --git a/api-design-guide/linter/functions/s16-subscriptionEndpoints.js b/api-design-guide/linter/functions/s16-subscriptionEndpoints.js new file mode 100644 index 0000000..b5ae414 --- /dev/null +++ b/api-design-guide/linter/functions/s16-subscriptionEndpoints.js @@ -0,0 +1,72 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +/** + * s16-subscriptionEndpoints — guide 16.11 (proxy): subscription management MUST + * expose interfaces to create, list, rotate the signing secret, and delete a + * subscription. This is a proxy: it can only reason about paths that are + * *named* as subscription resources. When a document exposes any subscription + * path, the four capabilities are required; a document with no subscription + * path is left clean (absence of a subscription surface cannot be judged here, + * nor can the AsyncAPI message-command control plane the guide also permits). + * + * Capabilities are inferred from path shape and HTTP method: + * create POST on a collection path ending in `/subscriptions` + * list GET on that collection path + * delete DELETE on an item path `/subscriptions/{id}` + * rotate-secret POST/PUT on a path whose last segment mentions rotate/secret + * + * `given` should be `$.paths`. + * + * @param {unknown} targetVal - the OpenAPI `paths` object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function subscriptionEndpoints(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const collection = new Set(); + const item = new Set(); + const rotate = new Set(); + let sawSubscription = false; + + for (const [route, pathItem] of Object.entries(targetVal)) { + if (!isObject(pathItem)) continue; + const segments = route.split('/').filter((s) => s.length > 0); + if (!segments.some((s) => /^subscriptions?$/i.test(s))) continue; + sawSubscription = true; + + const methods = METHODS.filter((m) => isObject(pathItem[m])); + const last = segments[segments.length - 1] || ''; + + if (/rotate|secret/i.test(last)) { + methods.forEach((m) => rotate.add(m)); + } else if (/^subscriptions?$/i.test(last)) { + methods.forEach((m) => collection.add(m)); + } else if (/^\{.+\}$/.test(last)) { + methods.forEach((m) => item.add(m)); + } + } + + if (!sawSubscription) return; // no subscription surface to judge + + const missing = []; + if (!collection.has('post')) missing.push('create (POST /…/subscriptions)'); + if (!collection.has('get')) missing.push('list (GET /…/subscriptions)'); + if (!(rotate.has('post') || rotate.has('put'))) { + missing.push('rotate-secret (POST /…/subscriptions/{id}/rotate-secret)'); + } + if (!item.has('delete')) missing.push('delete (DELETE /…/subscriptions/{id})'); + + if (missing.length === 0) return; + + return [ + { + message: `subscription management is exposed but missing: ${missing.join(', ')}. §16.11 requires create, list, rotate-secret, and delete interfaces.`, + path: base, + }, + ]; +} diff --git a/api-design-guide/linter/functions/s17-channelParameters.js b/api-design-guide/linter/functions/s17-channelParameters.js new file mode 100644 index 0000000..7281661 --- /dev/null +++ b/api-design-guide/linter/functions/s17-channelParameters.js @@ -0,0 +1,57 @@ +import { isObject, isNonEmptyString } from './lib/util.js'; + +/** + * s17-channelParameters — §17.4 declared channel parameters. + * + * Given: a single AsyncAPI 3.0 channel object (`$.channels[*]`). + * + * For every `{param}` token that appears in the channel `address`, asserts: + * - the parameter is declared under the channel `parameters` object, and + * - the declared parameter documents its routing semantics via a non-empty + * `description`. + * + * Note on "schema": the AsyncAPI 3.0 Parameter Object has no `schema` field + * (unlike AsyncAPI 2.x); a parameter's allowed values are expressed with `enum`. + * The guide's "its schema ... MUST be documented" is therefore proxied here by + * requiring a non-empty `description`; the value grammar is not further checked. + * + * options: none. + * @param {unknown} targetVal - a channel object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17ChannelParameters(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const address = targetVal.address; + if (!isNonEmptyString(address)) return; // no address to parse + const base = context && Array.isArray(context.path) ? context.path : []; + const params = isObject(targetVal.parameters) ? targetVal.parameters : undefined; + const results = []; + + const seen = new Set(); + const re = /\{([^}]+)\}/g; + let m; + while ((m = re.exec(address)) !== null) { + const name = m[1]; + if (seen.has(name)) continue; + seen.add(name); + + if (!params || !Object.prototype.hasOwnProperty.call(params, name)) { + results.push({ + message: `channel parameter "${name}" used in address "${address}" must be declared under the channel "parameters" object`, + path: [...base, 'parameters'], + }); + continue; + } + const def = params[name]; + if (!isObject(def) || !isNonEmptyString(def.description)) { + results.push({ + message: `channel parameter "${name}" must document its schema and routing semantics via a non-empty "description"`, + path: [...base, 'parameters', name], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s17-cloudEventsPayload.js b/api-design-guide/linter/functions/s17-cloudEventsPayload.js new file mode 100644 index 0000000..2640601 --- /dev/null +++ b/api-design-guide/linter/functions/s17-cloudEventsPayload.js @@ -0,0 +1,71 @@ +import { isObject, asArray } from './lib/util.js'; + +const CE_REQUIRED = ['specversion', 'id', 'source', 'type', 'data']; + +/** + * s17-cloudEventsPayload — §17.6 structured CloudEvents JSON payloads. + * + * Given: a single AsyncAPI 3.0 message object (`$.components.messages[*]`). + * + * For domain-event messages, asserts the payload declares the structured + * CloudEvents shape: `specversion` (const "1.0"), `id`, `source`, `type`, and + * `data` (the GovStack-owned domain data). One level of top-level `allOf` is + * merged so a message that composes the shared CloudEvents schema with a + * specialised `data` still satisfies the check. + * + * Bare §11 error/rejection envelopes (payload declares problem fields + * `title`+`status`, or `code`+`traceId`, and no `specversion`) are OUT of scope + * here — they are governed by §17.16 — so they are skipped to avoid false + * positives. + * + * options: none. + * @param {unknown} targetVal - a message object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17CloudEventsPayload(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const payload = targetVal.payload; + if (!isObject(payload)) return; // no inline payload schema to inspect + const base = context && Array.isArray(context.path) ? context.path : []; + const { required, properties } = effective(payload); + const declared = (n) => required.has(n) || properties[n] !== undefined; + + // Skip bare §11 error envelopes: they are not CloudEvents-shaped. + const looksLikeError = + !declared('specversion') && + ((declared('title') && declared('status')) || + (declared('code') && (declared('traceId') || declared('traceid')))); + if (looksLikeError) return; + + const results = []; + const at = [...base, 'payload']; + for (const name of CE_REQUIRED) { + if (!required.has(name)) { + results.push({ message: `CloudEvents payload must list "${name}" in required`, path: [...at, 'required'] }); + } + } + const sv = properties.specversion; + if (isObject(sv) && 'const' in sv && sv.const !== '1.0') { + results.push({ + message: 'CloudEvents payload "specversion" must be declared with const "1.0"', + path: [...at, 'properties', 'specversion', 'const'], + }); + } + return results.length ? results : undefined; +} + +/** Merge a schema's own required/properties with one level of allOf branches. */ +function effective(schema) { + const required = new Set(asArray(schema.required).filter((s) => typeof s === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + for (const branch of asArray(schema.allOf)) { + if (!isObject(branch)) continue; + for (const r of asArray(branch.required)) if (typeof r === 'string') required.add(r); + if (isObject(branch.properties)) { + for (const [k, v] of Object.entries(branch.properties)) if (!(k in properties)) properties[k] = v; + } + } + return { required, properties }; +} diff --git a/api-design-guide/linter/functions/s17-opExtensions.js b/api-design-guide/linter/functions/s17-opExtensions.js new file mode 100644 index 0000000..6e1e353 --- /dev/null +++ b/api-design-guide/linter/functions/s17-opExtensions.js @@ -0,0 +1,67 @@ +import { isObject, isNonEmptyString } from './lib/util.js'; + +/** + * s17-opExtensions — presence (and optional enum) of the machine-readable + * GovStack delivery-semantics extensions on an AsyncAPI operation, plus an + * optional human-readable `description` requirement. + * + * Drives: + * §17.13 — the four delivery-management capabilities, each stated as + * supported / unsupported / notApplicable: + * require: [x-govstack-redelivery, x-govstack-dead-letter, + * x-govstack-retention, x-govstack-replay] + * enumEach: [supported, unsupported, notApplicable] + * §17.15 — the headline machine-readable extensions plus a description: + * require: [x-govstack-delivery, x-govstack-ordering, x-govstack-replay] + * requireDescription: true + * + * Given: a single AsyncAPI 3.0 operation object (`$.operations[*]`). + * + * A required extension's value satisfies `enumEach` when it is a string in the + * enum, or an object whose `status` (or `support`) field is in the enum — so the + * exact common-file value shape (bare string vs `{status: ...}`) is tolerated. + * + * options: + * require {string[]} extension keys that must be present. (required) + * enumEach {any[]} allowed value/status for each required extension. + * requireDescription {boolean} operation must carry a non-empty `description`. + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17OpExtensions(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const require = Array.isArray(options.require) ? options.require : []; + const enumEach = Array.isArray(options.enumEach) ? options.enumEach : undefined; + const results = []; + + for (const key of require) { + if (typeof key !== 'string' || !key) continue; + if (!Object.prototype.hasOwnProperty.call(targetVal, key)) { + results.push({ message: `operation must declare "${key}"`, path: [...base] }); + continue; + } + if (enumEach) { + const raw = targetVal[key]; + const effective = isObject(raw) ? (raw.status !== undefined ? raw.status : raw.support) : raw; + if (!enumEach.includes(effective)) { + results.push({ + message: `operation "${key}" must be one of ${JSON.stringify(enumEach)}`, + path: [...base, key], + }); + } + } + } + + if (options.requireDescription === true && !isNonEmptyString(targetVal.description)) { + results.push({ + message: 'operation must also document delivery/ordering/replay semantics in a non-empty "description"', + path: [...base, 'description'], + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s17-protocolBindings.js b/api-design-guide/linter/functions/s17-protocolBindings.js new file mode 100644 index 0000000..bd6241f --- /dev/null +++ b/api-design-guide/linter/functions/s17-protocolBindings.js @@ -0,0 +1,100 @@ +import { isObject } from './lib/util.js'; + +/** + * s17-protocolBindings — §17.19 protocol bindings where relevant (PROXY). + * + * Given: the whole RESOLVED AsyncAPI 3.0 document (`$`). + * + * Proxy check. Collects the transport protocols declared on `servers` and, for + * each channel, asserts that a matching protocol binding is declared either on + * the channel itself or on any operation bound to it. Protocol names are + * normalised (wss->ws, amqps->amqp, mqtts->mqtt, kafka-secure->kafka, https-> + * http, …) before comparison. + * + * Does NOT verify the specific fields inside the bindings (Kafka topic/key, MQTT + * QoS/retained, AMQP exchange/queue/routing-key, WebSocket/SSE framing), nor + * whether protocol-specific fields genuinely affect interoperability. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17ProtocolBindings(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.channels)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const serverProtocols = new Set(); + if (isObject(root.servers)) { + for (const srv of Object.values(root.servers)) { + if (isObject(srv) && typeof srv.protocol === 'string') { + const p = normaliseProtocol(srv.protocol); + if (p && BINDING_RELEVANT.has(p)) serverProtocols.add(p); + } + } + } + if (serverProtocols.size === 0) return; // no binding-relevant protocol to check against + + // address -> set of normalised binding keys contributed by channel + its ops + const byAddress = new Map(); + const bindingKeysOf = (node) => { + if (!isObject(node) || !isObject(node.bindings)) return []; + return Object.keys(node.bindings).map(normaliseProtocol).filter(Boolean); + }; + + for (const ch of Object.values(root.channels)) { + if (isObject(ch) && typeof ch.address === 'string') { + const set = byAddress.get(ch.address) || new Set(); + for (const k of bindingKeysOf(ch)) set.add(k); + byAddress.set(ch.address, set); + } + } + if (isObject(root.operations)) { + for (const op of Object.values(root.operations)) { + const ch = isObject(op) ? op.channel : undefined; + if (isObject(ch) && typeof ch.address === 'string') { + const set = byAddress.get(ch.address) || new Set(); + for (const k of bindingKeysOf(op)) set.add(k); + byAddress.set(ch.address, set); + } + } + } + + const results = []; + for (const [key, ch] of Object.entries(root.channels)) { + if (!isObject(ch)) continue; + const declared = new Set(bindingKeysOf(ch)); + if (typeof ch.address === 'string') for (const k of byAddress.get(ch.address) || []) declared.add(k); + const matches = [...serverProtocols].some((p) => declared.has(p)); + if (!matches) { + results.push({ + message: `channel "${key}" (or an operation bound to it) should declare protocol bindings for the server protocol(s) ${JSON.stringify([...serverProtocols])}`, + path: [...base, 'channels', key, 'bindings'], + }); + } + } + + return results.length ? results : undefined; +} + +const BINDING_RELEVANT = new Set([ + 'kafka', 'mqtt', 'amqp', 'amqp1', 'ws', 'http', 'sqs', 'sns', 'googlepubsub', + 'nats', 'stomp', 'redis', 'jms', 'pulsar', 'solace', 'ibmmq', 'anypointmq', +]); + +function normaliseProtocol(p) { + if (typeof p !== 'string') return undefined; + const s = p.toLowerCase(); + const map = { + wss: 'ws', + websocket: 'ws', + amqps: 'amqp', + mqtts: 'mqtt', + 'secure-mqtt': 'mqtt', + 'kafka-secure': 'kafka', + https: 'http', + }; + return map[s] || s; +} diff --git a/api-design-guide/linter/functions/s17-rejectionMessage.js b/api-design-guide/linter/functions/s17-rejectionMessage.js new file mode 100644 index 0000000..9a5751d --- /dev/null +++ b/api-design-guide/linter/functions/s17-rejectionMessage.js @@ -0,0 +1,97 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s17-rejectionMessage — §17.16 async rejection/failure messages (PROXY). + * + * Given: the whole RESOLVED AsyncAPI 3.0 document (`$`). + * + * Proxy check. The guide requires that command-like messages which can be + * rejected asynchronously define a rejection/failure message using the common + * §11 error envelope, correlated to the original message. This function CANNOT + * identify which messages are "command-like", so, when the document declares any + * operations, it asserts: + * - at least one `components.messages` entry looks like a §11 error envelope + * (payload declares problem-style fields: `type`+`title`+`status`, or + * `code`+`traceId`/`traceid`), and + * - at least one such error message declares a correlation mechanism (a + * message-level `correlationId`, or a header/payload property whose name + * contains "correlation"). + * + * Does NOT verify: which specific operations need a rejection message, nor that + * the correlation actually points at the initiating message. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17RejectionMessage(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.operations) || Object.keys(root.operations).length === 0) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const messages = isObject(root.components) ? root.components.messages : undefined; + const entries = isObject(messages) ? Object.entries(messages) : []; + + const errorMessages = entries.filter(([, msg]) => isErrorEnvelope(msg)); + + if (errorMessages.length === 0) { + return [ + { + message: + 'no async rejection/failure message using the §11 error envelope was found under components.messages; command-like messages that can be rejected asynchronously must define one', + path: [...base, 'components', 'messages'], + }, + ]; + } + + const correlated = errorMessages.some(([, msg]) => hasCorrelation(msg)); + if (!correlated) { + const [name] = errorMessages[0]; + return [ + { + message: `async rejection message "${name}" must declare correlation metadata (a correlationId or a correlation header/attribute) linking it to the original message`, + path: [...base, 'components', 'messages', name], + }, + ]; + } + + return undefined; +} + +/** Property names declared on a schema, merging one level of allOf. */ +function propNames(schema) { + const names = new Set(); + if (!isObject(schema)) return names; + if (isObject(schema.properties)) for (const n of Object.keys(schema.properties)) names.add(n); + for (const branch of asArray(schema.allOf)) { + if (isObject(branch) && isObject(branch.properties)) for (const n of Object.keys(branch.properties)) names.add(n); + } + return names; +} + +/** True when a set of property names looks like the §11 problem envelope. */ +function problemShaped(names) { + const has = (n) => names.has(n); + if (has('title') && has('status')) return true; + if (has('code') && (has('traceId') || has('traceid'))) return true; + return false; +} + +function isErrorEnvelope(msg) { + if (!isObject(msg)) return false; + const payload = msg.payload; + // Bare §11 envelope, or CloudEvents-wrapped with the problem under `data`. + if (problemShaped(propNames(payload))) return true; + const data = isObject(payload) && isObject(payload.properties) ? payload.properties.data : undefined; + if (problemShaped(propNames(data))) return true; + return false; +} + +function hasCorrelation(msg) { + if (!isObject(msg)) return false; + if (isObject(msg.correlationId)) return true; + const names = [...propNames(msg.payload), ...propNames(msg.headers)]; + return names.some((n) => /correlation/i.test(n)); +} diff --git a/api-design-guide/linter/functions/s17-requestReply.js b/api-design-guide/linter/functions/s17-requestReply.js new file mode 100644 index 0000000..3bac52f --- /dev/null +++ b/api-design-guide/linter/functions/s17-requestReply.js @@ -0,0 +1,65 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s17-requestReply — §17.17 declared request-reply correlation. + * + * Given: a single RESOLVED AsyncAPI 3.0 operation object (`$.operations[*]`). + * Resolution turns `reply.channel` / message `$ref`s into objects, which this + * walk relies on to find declared correlation. + * + * The rule only bites when the operation declares a `reply` (request-reply). For + * such an operation it asserts: + * - a reply target is declared: `reply.channel` or `reply.address`, and + * - a correlation mechanism is declared: some reachable message (the reply's + * messages, the reply channel's messages, or the operation's own messages) + * declares a `correlationId`, or `reply.address` carries a `location`. + * + * The inverse guidance ("fire-and-forget MUST NOT pretend to be request-reply") + * is not mechanically decidable and is not enforced here. + * + * options: none. + * @param {unknown} targetVal - an operation object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17RequestReply(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const reply = targetVal.reply; + if (reply === undefined) return; // not request-reply — nothing to check + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + if (!isObject(reply)) { + return [{ message: 'operation "reply" must be an object declaring a reply channel/address and correlation', path: [...base, 'reply'] }]; + } + + const hasTarget = isObject(reply.channel) || isObject(reply.address); + if (!hasTarget) { + results.push({ + message: 'request-reply operation must declare the reply channel or reply address pattern under "reply"', + path: [...base, 'reply'], + }); + } + + const candidateMessages = []; + const collect = (v) => { + for (const m of asArray(v)) if (isObject(m)) candidateMessages.push(m); + }; + collect(reply.messages); + if (isObject(reply.channel) && isObject(reply.channel.messages)) collect(Object.values(reply.channel.messages)); + collect(targetVal.messages); + if (isObject(targetVal.channel) && isObject(targetVal.channel.messages)) collect(Object.values(targetVal.channel.messages)); + + const addressCorrelated = isObject(reply.address) && reply.address.location !== undefined; + const messageCorrelated = candidateMessages.some((m) => isObject(m.correlationId)); + + if (!addressCorrelated && !messageCorrelated) { + results.push({ + message: 'request-reply operation must declare a correlation mechanism (a message "correlationId" or a "reply.address.location")', + path: [...base, 'reply'], + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s17-securityCoverage.js b/api-design-guide/linter/functions/s17-securityCoverage.js new file mode 100644 index 0000000..185d39d --- /dev/null +++ b/api-design-guide/linter/functions/s17-securityCoverage.js @@ -0,0 +1,64 @@ +import { isObject } from './lib/util.js'; + +/** + * s17-securityCoverage — §17.10 AsyncAPI security covers every operation. + * + * Given: the whole RESOLVED AsyncAPI 3.0 document (`$`). Resolution turns each + * operation's `channel` `$ref` into the channel object and each server/operation + * `security` `$ref` into the scheme object, which this walk relies on. + * + * Asserts: + * - `components.securitySchemes` declares at least one scheme, and + * - every operation is covered: it declares a non-empty operation-level + * `security`, OR every server applicable to its channel declares a non-empty + * `security`. In AsyncAPI 3.0 `security` is a list of security scheme + * objects/references; a non-empty list counts as "secured". + * + * Applicable servers = the channel's `servers` list when present, otherwise all + * document servers. Message signing (§16.5) is deliberately NOT treated as a + * substitute here: only `servers`/`operations` security counts. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17SecurityCoverage(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.operations)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const schemes = isObject(root.components) ? root.components.securitySchemes : undefined; + if (!isObject(schemes) || Object.keys(schemes).length === 0) { + results.push({ + message: 'AsyncAPI security schemes must be declared under components.securitySchemes', + path: [...base, 'components', 'securitySchemes'], + }); + } + + const allServers = isObject(root.servers) ? Object.values(root.servers) : []; + const secured = (srv) => isObject(srv) && Array.isArray(srv.security) && srv.security.length > 0; + + for (const [opId, op] of Object.entries(root.operations)) { + if (!isObject(op)) continue; + if (Array.isArray(op.security) && op.security.length > 0) continue; // operation-level security + + const channel = op.channel; + const channelServers = + isObject(channel) && Array.isArray(channel.servers) && channel.servers.length > 0 + ? channel.servers + : allServers; + + const covered = channelServers.length > 0 && channelServers.every(secured); + if (!covered) { + results.push({ + message: `operation "${opId}" is not covered by a security scheme: it declares no operation-level security and its applicable server(s) declare none`, + path: [...base, 'operations', opId], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s18-deprecatedHeaders.js b/api-design-guide/linter/functions/s18-deprecatedHeaders.js new file mode 100644 index 0000000..cc691b2 --- /dev/null +++ b/api-design-guide/linter/functions/s18-deprecatedHeaders.js @@ -0,0 +1,59 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; +const SUCCESS_STATUS = /^2\d{2}$/; + +/** + * s18-deprecatedHeaders — guide 18.5: operations marked `deprecated: true` + * MUST return a Deprecation header (RFC 9745) and a Sunset header (RFC 8594) + * on their 2xx responses. + * + * `given` should be `$.paths`. Walks operations directly (rather than + * filtering via a `given` JSONPath filter chained after a bracketed method + * list) because that chained-filter form does not select reliably in this + * Spectral/nimma version - see the comment on govstack-18.5 in s18.yaml. + * + * Only checks header PRESENCE; the RFC 9745/8594 structured-field VALUE + * syntax is not checkable from a static header declaration. + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function deprecatedHeaders(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const [pathKey, pathItem] of Object.entries(targetVal)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation) || operation.deprecated !== true) continue; + const responses = operation.responses; + if (!isObject(responses)) continue; + for (const [status, response] of Object.entries(responses)) { + if (!SUCCESS_STATUS.test(status) || !isObject(response)) continue; + const headers = isObject(response.headers) + ? Object.keys(response.headers).map((h) => h.toLowerCase()) + : []; + const here = [...base, pathKey, method, 'responses', status]; + if (!headers.includes('deprecation')) { + results.push({ + message: `${status} response of deprecated operation "${method.toUpperCase()} ${pathKey}" must declare a "Deprecation" header`, + path: here, + }); + } + if (!headers.includes('sunset')) { + results.push({ + message: `${status} response of deprecated operation "${method.toUpperCase()} ${pathKey}" must declare a "Sunset" header`, + path: here, + }); + } + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s18-versionMajorConsistency.js b/api-design-guide/linter/functions/s18-versionMajorConsistency.js new file mode 100644 index 0000000..76cf072 --- /dev/null +++ b/api-design-guide/linter/functions/s18-versionMajorConsistency.js @@ -0,0 +1,85 @@ +import { isObject } from './lib/util.js'; + +const OPENAPI_VERSION_SEG = /^\/v(\d+)(?:\/|$)/; +const ASYNCAPI_VERSION_SEG = /(?:^|\.)v(\d+)(?:\.|$)/; + +/** + * s18-versionMajorConsistency — guide 18.2: a major version increment MUST be + * reflected in the OpenAPI URL path (`/v2/`) or the AsyncAPI channel address, + * and that reflected major MUST match `info.version`'s major segment. + * + * `given` should be `$` (the whole document), so the function can read + * `info.version` alongside `paths`/`channels` in one pass. + * + * options: + * surface {string} required, "openapi" | "asyncapi". + * "openapi" - every `paths` key that already carries a `/v{N}/` prefix + * must have N == info.version's major. Paths with no version + * prefix at all are guide 5.1's concern, not this rule's. + * "asyncapi" - every `channels` entry's address must embed a `.v{N}.` + * segment matching info.version's major. Does NOT verify the + * text's alternative "equivalent machine-readable version + * field documented in govstack-asyncapi-common.yaml" (that + * needs the vendored common file). + * + * Non-object input, or an info.version that isn't a plain SemVer-shaped + * string, returns undefined (guide 18.1 owns validating info.version itself). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function versionMajorConsistency(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + + const version = isObject(targetVal.info) ? targetVal.info.version : undefined; + if (typeof version !== 'string') return; + const versionMajorMatch = version.match(/^(\d+)\./); + if (!versionMajorMatch) return; + const infoMajor = versionMajorMatch[1]; + + const results = []; + + if (opts.surface === 'openapi') { + const paths = targetVal.paths; + if (!isObject(paths)) return; + for (const key of Object.keys(paths)) { + const m = typeof key === 'string' ? key.match(OPENAPI_VERSION_SEG) : null; + if (!m) continue; + if (m[1] !== infoMajor) { + results.push({ + message: `path "${key}" declares version v${m[1]} but info.version is "${version}" (major ${infoMajor}); the path's major version must match info.version's major`, + path: [...base, 'paths', key], + }); + } + } + } else if (opts.surface === 'asyncapi') { + const channels = targetVal.channels; + if (!isObject(channels)) return; + for (const [key, channel] of Object.entries(channels)) { + if (!isObject(channel)) continue; + const address = typeof channel.address === 'string' ? channel.address : key; + const m = address.match(ASYNCAPI_VERSION_SEG); + if (!m) { + results.push({ + message: `channel "${key}" address "${address}" does not include a major version segment (e.g. ".v${infoMajor}."); AsyncAPI channels must include the major version in the channel address`, + path: [...base, 'channels', key], + }); + continue; + } + if (m[1] !== infoMajor) { + results.push({ + message: `channel "${key}" address "${address}" declares version v${m[1]} but info.version is "${version}" (major ${infoMajor}); the channel's major version must match info.version's major`, + path: [...base, 'channels', key], + }); + } + } + } else { + return; + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s19-localisationHeaders.js b/api-design-guide/linter/functions/s19-localisationHeaders.js new file mode 100644 index 0000000..117b0e1 --- /dev/null +++ b/api-design-guide/linter/functions/s19-localisationHeaders.js @@ -0,0 +1,91 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s19-localisationHeaders — guide 19.4 proxy: "Responses or messages with + * localised content MUST include the response language header appropriate to + * the surface". Whether a response/message actually carries localised + * content is not decidable from a static document, so this checks the + * checkable inverse: wherever the localisation *input* signal is present + * (an `Accept-Language` header parameter, or an `acceptLanguage` message + * header), the paired *output* language header MUST also be present + * somewhere it can be. + * + * options: + * surface {string} required, "openapi" | "asyncapi". + * "openapi" - `given` is one operation object + * (`$.paths[*][get,put,post,delete,patch,...]`). If the + * operation declares an `Accept-Language` header parameter, + * at least one response must declare a `Content-Language` + * header. + * "asyncapi" - `given` is one channel object (`$.channels[*]`). Pairing + * is done at the channel level, not per-message: if any + * message on the channel declares an `acceptLanguage` + * header, some message on the same channel must declare + * `contentLanguage`. A per-message check would be wrong, + * since guide 17.9 puts these on *different* messages + * (inbound vs outbound) by design. + * + * Does NOT verify: that a response/message lacking the input signal is + * nonetheless free of localised content — only the converse. + * + * @param {unknown} targetVal - an operation (openapi) or a channel (asyncapi). + * @param {object} options + * @returns {{message:string}[]|undefined} + */ +export default function localisationHeaders(targetVal, options) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + + if (opts.surface === 'openapi') { + const params = asArray(targetVal.parameters); + const hasAcceptLanguage = params.some( + (p) => + isObject(p) && + p.in === 'header' && + typeof p.name === 'string' && + p.name.toLowerCase() === 'accept-language' + ); + if (!hasAcceptLanguage) return; + + const responses = targetVal.responses; + if (!isObject(responses)) return; + const hasContentLanguage = Object.values(responses).some( + (resp) => isObject(resp) && isObject(resp.headers) && headerNames(resp.headers).includes('content-language') + ); + if (hasContentLanguage) return; + return [ + { + message: + 'operation declares an Accept-Language parameter but no response declares a Content-Language header', + }, + ]; + } + + if (opts.surface === 'asyncapi') { + const messages = targetVal.messages; + if (!isObject(messages)) return; + const messageList = Object.values(messages).filter(isObject); + const hasAcceptLanguage = messageList.some((m) => messageHeaderNames(m).includes('acceptlanguage')); + if (!hasAcceptLanguage) return; + const hasContentLanguage = messageList.some((m) => messageHeaderNames(m).includes('contentlanguage')); + if (hasContentLanguage) return; + return [ + { + message: + 'channel has a message declaring acceptLanguage but no message on the channel declares contentLanguage', + }, + ]; + } + + return undefined; +} + +function headerNames(headersObj) { + return Object.keys(headersObj).map((h) => h.toLowerCase()); +} + +function messageHeaderNames(message) { + const schema = isObject(message.headers) ? message.headers : undefined; + const props = schema && isObject(schema.properties) ? schema.properties : {}; + return Object.keys(props).map((h) => h.toLowerCase()); +} diff --git a/api-design-guide/linter/functions/schemaDescriptions.js b/api-design-guide/linter/functions/schemaDescriptions.js new file mode 100644 index 0000000..31d4f3c --- /dev/null +++ b/api-design-guide/linter/functions/schemaDescriptions.js @@ -0,0 +1,70 @@ +import { walkSchema, forEachProperty } from './lib/schemaWalk.js'; +import { isObject, isNonEmptyString } from './lib/util.js'; + +// Nodes whose ONLY keywords are structural combinators carry no description of +// their own; requiring one there produces noise. They are skipped in mode "all". +const COMBINATOR_ONLY = new Set([ + 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', '$ref', 'description', 'title', +]); + +function isCombinatorWrapper(node) { + const keys = Object.keys(node); + return keys.length > 0 && keys.every((k) => COMBINATOR_ONLY.has(k)); +} + +/** + * schemaDescriptions — assert schema nodes carry a non-empty `description`. + * + * `given` should select a schema. The walk is cycle-safe. + * + * options: + * mode {"properties"|"all"} default "properties". + * "properties" — every declared property schema (recursively) needs a + * non-empty description. Good default: documents each field. + * "all" — every subschema node needs one, except pure combinator + * wrappers (a node whose only keywords are allOf/anyOf/…). + * includeRoot {boolean} in "properties" mode, also require the root schema to + * have a description. Default false. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemaDescriptions(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const mode = opts.mode === 'all' ? 'all' : 'properties'; + const results = []; + + if (mode === 'all') { + walkSchema(targetVal, (node, path) => { + if (isCombinatorWrapper(node)) return; + if (!isNonEmptyString(node.description)) { + results.push({ + message: + path.length === 0 + ? 'schema must have a non-empty description' + : `schema at ${path.join('/')} must have a non-empty description`, + path: [...base, ...path], + }); + } + }); + } else { + if (opts.includeRoot === true && !isNonEmptyString(targetVal.description)) { + results.push({ message: 'schema must have a non-empty description', path: [...base] }); + } + forEachProperty(targetVal, (name, schema, path) => { + const desc = isObject(schema) ? schema.description : undefined; + if (!isNonEmptyString(desc)) { + results.push({ + message: `property "${name}" must have a non-empty description`, + path: [...base, ...path], + }); + } + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/schemaFieldFormat.js b/api-design-guide/linter/functions/schemaFieldFormat.js new file mode 100644 index 0000000..323a6d4 --- /dev/null +++ b/api-design-guide/linter/functions/schemaFieldFormat.js @@ -0,0 +1,75 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { isObject, toRegExp } from './lib/util.js'; + +/** + * schemaFieldFormat — for every property whose NAME matches a pattern, require + * its schema to declare given type/format/pattern/contentEncoding constraints. + * Drives the §10 data-type proxies (timestamps -> date-time, money -> object, + * base64 -> contentEncoding + maxLength, …). Cycle-safe walk. + * + * `given` should select a schema (e.g. a request/response body or + * `$.components.schemas[*]`). + * + * options: + * namePattern {string} regex a property NAME must match to be checked (req'd). + * nameFlags {string} regex flags for namePattern (e.g. "i"). + * require {object} constraints the matching property schema must declare: + * type {string} schema.type must equal (array type ok). + * format {string} schema.format must equal. + * formatOneOf {string[]} schema.format must be one of. + * contentEncoding {string} schema.contentEncoding must equal. + * pattern {string} schema.pattern must equal exactly. + * mustDeclare {string[]} these keywords must simply be present + * (e.g. ["maxLength"]). + * forbidType {string|string[]} schema.type must NOT be any of these + * (e.g. money field must not be "number"). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemaFieldFormat(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const nameRe = toRegExp(options.namePattern, options.nameFlags); + if (!nameRe) return; + const req = isObject(options.require) ? options.require : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + forEachProperty(targetVal, (name, schema, path) => { + if (!nameRe.test(name)) return; + if (!isObject(schema)) return; + const full = [...base, ...path]; + const type = schema.type; + const typeList = Array.isArray(type) ? type : [type]; + + if (req.type !== undefined && !typeList.includes(req.type)) { + results.push({ message: `property "${name}" must declare type "${req.type}"`, path: full }); + } + if (req.forbidType !== undefined) { + const forbidden = Array.isArray(req.forbidType) ? req.forbidType : [req.forbidType]; + const hit = forbidden.find((t) => typeList.includes(t)); + if (hit) results.push({ message: `property "${name}" must not declare type "${hit}"`, path: full }); + } + if (req.format !== undefined && schema.format !== req.format) { + results.push({ message: `property "${name}" must declare format "${req.format}"`, path: full }); + } + if (Array.isArray(req.formatOneOf) && !req.formatOneOf.includes(schema.format)) { + results.push({ message: `property "${name}" format must be one of ${req.formatOneOf.join(', ')}`, path: full }); + } + if (req.contentEncoding !== undefined && schema.contentEncoding !== req.contentEncoding) { + results.push({ message: `property "${name}" must declare contentEncoding "${req.contentEncoding}"`, path: full }); + } + if (req.pattern !== undefined && schema.pattern !== req.pattern) { + results.push({ message: `property "${name}" must declare pattern ${JSON.stringify(req.pattern)}`, path: full }); + } + for (const kw of Array.isArray(req.mustDeclare) ? req.mustDeclare : []) { + if (!(kw in schema)) { + results.push({ message: `property "${name}" must declare "${kw}"`, path: full }); + } + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/schemaPropertyNames.js b/api-design-guide/linter/functions/schemaPropertyNames.js new file mode 100644 index 0000000..f0389a1 --- /dev/null +++ b/api-design-guide/linter/functions/schemaPropertyNames.js @@ -0,0 +1,46 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { matchesCasing } from './lib/casing.js'; +import { toRegExp, isObject } from './lib/util.js'; + +/** + * schemaPropertyNames — assert every declared property name (recursively, + * across all subschemas) obeys a casing convention and/or a pattern. + * + * `given` should select a schema (e.g. a response/request body schema, or + * `$.components.schemas[*]`). The walk is cycle-safe. + * + * options (apply in combination; a name violating any active check is flagged): + * casing {string} one of camel|pascal|kebab|snake|screamingSnake|flat. + * allowPattern {string} regex a name MUST match. + * forbidPattern {string} regex a name MUST NOT match (e.g. spaces / non-ASCII). + * flags {string} regex flags for allow/forbid. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemaPropertyNames(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const allow = opts.allowPattern !== undefined ? toRegExp(opts.allowPattern, opts.flags) : undefined; + const forbid = opts.forbidPattern !== undefined ? toRegExp(opts.forbidPattern, opts.flags) : undefined; + const casing = typeof opts.casing === 'string' ? opts.casing : undefined; + const results = []; + + forEachProperty(targetVal, (name, _schema, path) => { + const problems = []; + if (casing && !matchesCasing(name, casing)) problems.push(`must be ${casing} case`); + if (allow && !allow.test(name)) problems.push(`must match ${allow.toString()}`); + if (forbid && forbid.test(name)) problems.push(`must not match ${forbid.toString()}`); + if (problems.length) { + results.push({ + message: `property name "${name}" ${problems.join('; ')}`, + path: [...base, ...path], + }); + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/securityCoverage.js b/api-design-guide/linter/functions/securityCoverage.js new file mode 100644 index 0000000..ab0547b --- /dev/null +++ b/api-design-guide/linter/functions/securityCoverage.js @@ -0,0 +1,68 @@ +import { isObject } from './lib/util.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** + * securityCoverage — check that operations are authenticated (or explicitly + * opted out). + * + * Two modes: + * + * mode "covered" (default) — `given` is the document root (`$`). Every operation + * must be covered by a security requirement: either a non-empty root-level + * `security`, or an operation-level `security`. An operation-level + * `security: []` counts as an explicit, allowed opt-out. Optionally requires + * `components.securitySchemes` to be present. + * options: { requireSchemes?: boolean } + * + * mode "none" — `given` is a single operation (e.g. the /health GET). The + * operation MUST declare `security: []` (explicitly unauthenticated). + * options: { mode: "none" } + * + * @param {unknown} targetVal + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function securityCoverage(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + + if (opts.mode === 'none') { + const sec = targetVal.security; + if (!Array.isArray(sec) || sec.length !== 0) { + return [{ message: 'operation must be unauthenticated (declare security: [])', path: [...base] }]; + } + return; + } + + // mode "covered": targetVal is the document root. + const results = []; + const rootSecurity = Array.isArray(targetVal.security) && targetVal.security.length > 0; + const components = isObject(targetVal.components) ? targetVal.components : {}; + const schemes = isObject(components.securitySchemes) ? Object.keys(components.securitySchemes) : []; + + if (opts.requireSchemes === true && schemes.length === 0) { + results.push({ message: 'components.securitySchemes must declare at least one scheme', path: [...base, 'components', 'securitySchemes'] }); + } + + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!isObject(op)) continue; + const opSecurity = op.security; + const covered = Array.isArray(opSecurity) || rootSecurity; + if (!covered) { + results.push({ + message: `operation ${method.toUpperCase()} ${pathKey} is not covered by any security requirement`, + path: [...base, 'paths', pathKey, method], + }); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/valuePattern.js b/api-design-guide/linter/functions/valuePattern.js new file mode 100644 index 0000000..dfa7db6 --- /dev/null +++ b/api-design-guide/linter/functions/valuePattern.js @@ -0,0 +1,47 @@ +import { toRegExp } from './lib/util.js'; + +/** + * valuePattern — assert a single string value matches / does not match a regex. + * + * `given` must select the string itself (e.g. `$.info.version`, + * `$.servers[*].url`, a channel address key). Spectral invokes the function + * once per selected value, so `targetVal` is one string. + * + * options: + * match {string} value MUST match this regex (anchored as written). + * notMatch {string} value MUST NOT match this regex. + * forbidPattern{string} alias of notMatch (reads better for deny-lists). + * flags {string} regex flags applied to both (e.g. "i"). + * name {string} human label for the value in messages (default "value"). + * + * Non-string input returns undefined (no error), so it composes safely with + * broad `given` selectors. + * + * @param {unknown} targetVal + * @param {object} options + * @returns {{message:string}[]|undefined} + */ +export default function valuePattern(targetVal, options) { + if (typeof targetVal !== 'string') return; + const opts = options && typeof options === 'object' ? options : {}; + const label = typeof opts.name === 'string' && opts.name ? opts.name : 'value'; + const flags = typeof opts.flags === 'string' ? opts.flags : undefined; + const results = []; + + if (opts.match !== undefined) { + const re = toRegExp(opts.match, flags); + if (re && !re.test(targetVal)) { + results.push({ message: `${label} "${targetVal}" must match ${re.toString()}` }); + } + } + + const deny = opts.notMatch !== undefined ? opts.notMatch : opts.forbidPattern; + if (deny !== undefined) { + const re = toRegExp(deny, flags); + if (re && re.test(targetVal)) { + results.push({ message: `${label} "${targetVal}" must not match ${re.toString()}` }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/package-lock.json b/api-design-guide/linter/package-lock.json new file mode 100644 index 0000000..816eabc --- /dev/null +++ b/api-design-guide/linter/package-lock.json @@ -0,0 +1,3255 @@ +{ + "name": "govstack-api-lint", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "govstack-api-lint", + "version": "0.1.0", + "dependencies": { + "@stoplight/spectral-cli": "^6.16.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-functions": "^1.10.1", + "@stoplight/spectral-parsers": "^1.0.5", + "@stoplight/spectral-ruleset-bundler": "^1.6.2", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@asyncapi/specs": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", + "integrity": "sha512-A3WBLqAKGoJ2+6FWFtpjBlCQ1oFCcs4GxF7zsIGvNqp/klGUHjlA3aAcZ9XMMpLGE8zPeYDz2x9FmO6DSuKraQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.11" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/ternary": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/ternary/-/ternary-1.1.4.tgz", + "integrity": "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", + "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "commondir": "^1.0.1", + "estree-walker": "^2.0.1", + "glob": "^7.1.6", + "is-reference": "^1.2.1", + "magic-string": "^0.25.7", + "resolve": "^1.17.0" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "license": "MIT" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/better-ajv-errors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", + "integrity": "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==", + "license": "Apache-2.0", + "dependencies": { + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@stoplight/json": { + "version": "3.21.7", + "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.7.tgz", + "integrity": "sha512-xcJXgKFqv/uCEgtGlPxy3tPA+4I+ZI4vAuMJ885+ThkTHFVkC+0Fm58lA9NlsyjnkpxFh4YiQWpH+KefHdbA0A==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.3", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "jsonc-parser": "~2.2.1", + "lodash": "^4.17.21", + "safe-stable-stringify": "^1.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-readers/-/json-ref-readers-1.2.2.tgz", + "integrity": "sha512-nty0tHUq2f1IKuFYsLM4CXLZGHdMn+X/IwEUIpeSOXt0QjMUbL0Em57iJUDzz+2MkWG83smIigNZ3fauGjqgdQ==", + "license": "Apache-2.0", + "dependencies": { + "node-fetch": "^2.6.0", + "tslib": "^1.14.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@stoplight/json-ref-resolver": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.6.tgz", + "integrity": "sha512-YNcWv3R3n3U6iQYBsFOiWSuRGE5su1tJSiX6pAPRVk7dP0L7lqCteXGzuVRQ0gMZqUl8v1P0+fAKxF6PLo9B5A==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.21.0", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^12.3.0 || ^13.0.0", + "@types/urijs": "^1.19.19", + "dependency-graph": "~0.11.0", + "fast-memoize": "^2.5.2", + "immer": "^9.0.6", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "urijs": "^1.19.11" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/path": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@stoplight/path/-/path-1.3.2.tgz", + "integrity": "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/spectral-cli": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.16.1.tgz", + "integrity": "sha512-6DdYx94d+BNVTdgJRkb1KJIcqYQsOxstAZtH7Kh63SDUsXFRkdfsBKsL7l6csxLRYYh5Qm6CSBF7AUFYBFJ23w==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": "^1.19.5", + "@stoplight/spectral-formatters": "^1.4.1", + "@stoplight/spectral-parsers": "^1.0.4", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-bundler": "^1.6.0", + "@stoplight/spectral-ruleset-migrator": "^1.11.0", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "chalk": "4.1.2", + "fast-glob": "~3.2.12", + "hpagent": "~1.2.0", + "lodash": "^4.18.1", + "pony-cause": "^1.1.1", + "stacktracey": "^2.1.8", + "tslib": "^2.8.1", + "yargs": "~17.7.2" + }, + "bin": { + "spectral": "dist/index.js" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.23.1.tgz", + "integrity": "sha512-VLC8OhpO/pMJKb6IHhurxJjXO1qB56Ng1unIb8b+hNxdw0+SEcASvmR+RpjfHYX/jv/DfSaA1x8QhFBJBmqBOQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-parsers": "^1.0.0", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "~13.6.0", + "@types/es-aggregate-error": "^1.0.2", + "@types/json-schema": "^7.0.11", + "ajv": "^8.18.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "es-aggregate-error": "^1.0.7", + "expr-eval-fork": "^3.0.1", + "jsonpath-plus": "^10.3.0", + "lodash": "^4.18.1", + "lodash.topath": "^4.5.2", + "minimatch": "^3.1.4", + "nimma": "0.2.3", + "pony-cause": "^1.1.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/@stoplight/types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", + "integrity": "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-formats": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formats/-/spectral-formats-1.8.5.tgz", + "integrity": "sha512-xaC0rCH0p7/bzNJsz+JgLSj+Cp6uwYGWpePQxdLkF2G6a8Zyp3OyS7umkGYNiimEwKrOjvCNNTFJpeuiENZSBA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.23.0", + "@types/json-schema": "^7.0.7", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-formatters": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.5.1.tgz", + "integrity": "sha512-mGXaiIrPglPokSnbFqbkWN3DoozIbwrZAA6OgqSIl+djeD5+e6PMELg0g6r3ot3ZzntO+6/GXaDnxEQ/p9M/EQ==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/path": "^1.3.2", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.15.0", + "@types/markdown-escape": "^1.1.3", + "chalk": "4.1.2", + "cliui": "7.0.4", + "lodash": "^4.18.1", + "markdown-escape": "^2.0.0", + "node-sarif-builder": "^2.0.3", + "strip-ansi": "6.0", + "text-table": "^0.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-functions": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.5.tgz", + "integrity": "sha512-vDCd0NJ93715bcUpZZ5vNHiyxd4cgHF6tuXsDiXOXKAByg+I1fR5/dMijEo6Ce1Lz95a+RZ22JKYhF1YuzVvuA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-runtime": "^1.1.2", + "ajv": "^8.18.0", + "ajv-draft-04": "~1.0.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-parsers/-/spectral-parsers-1.0.5.tgz", + "integrity": "sha512-ANDTp2IHWGvsQDAY85/jQi9ZrF4mRrA5bciNHX+PUxPr4DwS6iv4h+FVWJMVwcEYdpyoIdyL+SRmHdJfQEPmwQ==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml": "~4.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-ref-resolver": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ref-resolver/-/spectral-ref-resolver-1.0.5.tgz", + "integrity": "sha512-gj3TieX5a9zMW29z3mBlAtDOCgN3GEc1VgZnCVlr5irmR4Qi5LuECuFItAq4pTn5Zu+sW5bqutsCH7D4PkpyAA==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json-ref-readers": "1.2.2", + "@stoplight/json-ref-resolver": "~3.1.6", + "@stoplight/spectral-runtime": "^1.1.2", + "dependency-graph": "0.11.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-bundler": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.7.0.tgz", + "integrity": "sha512-PpIdj5Wje0T7ktxY8EUzBWLU0+mGGQHznT8nlQxTMnRhWLNYsm6HvSZDXLtMi+86yqvTuf7loJy6JvLBDzHGAA==", + "license": "Apache-2.0", + "dependencies": { + "@rollup/plugin-commonjs": "~22.0.2", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": ">=1", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": ">=1", + "@stoplight/spectral-parsers": ">=1", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/node": "*", + "pony-cause": "1.1.1", + "rollup": "~2.80.0", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.12.1.tgz", + "integrity": "sha512-IUEbDmmTro0oF6VoAtrUySRV/b6bvYmV7wV6lB99f0Ym5lF9M2DXcgPLo7VMbKTPjCOQcaBzWRnIMXAyLjIRMA==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/ordered-object-literal": "~1.0.4", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@stoplight/yaml": "~4.2.3", + "@types/node": "*", + "ajv": "^8.18.0", + "ast-types": "0.14.2", + "astring": "^1.9.0", + "reserved": "0.1.2", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", + "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.1", + "@stoplight/types": "^13.0.0", + "@stoplight/yaml-ast-parser": "0.0.48", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", + "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==", + "license": "Apache-2.0" + }, + "node_modules/@stoplight/spectral-rulesets": { + "version": "1.22.6", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.6.tgz", + "integrity": "sha512-xBwrb2zjx+7AzGS3aX7aOtddChRRw8aoQMu8ZT5AmTfEr0VAj4ydGC6Pl7lIvAIomcO7hw+P4I2oylTOOCkUVw==", + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^6.8.0", + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/json-schema": "^7.0.7", + "ajv": "^8.18.0", + "ajv-formats": "~2.1.1", + "json-schema-traverse": "^1.0.0", + "leven": "3.1.0", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.6.tgz", + "integrity": "sha512-Y8rEDyMN4bSMJCrDs2shdcVHYyCnH3FvXRP4dBhha4Z8iJv+JPp7KqOV/hwVB/hWFC209upiwj2oDmLfR0qCDg==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.20.1", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "lodash": "^4.18.1", + "node-fetch": "^2.7.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/types": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", + "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.3.0.tgz", + "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.5", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml-ast-parser": "0.0.50", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.50.tgz", + "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==", + "license": "Apache-2.0" + }, + "node_modules/@stoplight/yaml/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@types/es-aggregate-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", + "integrity": "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/markdown-escape": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/markdown-escape/-/markdown-escape-1.1.3.tgz", + "integrity": "sha512-JIc1+s3y5ujKnt/+N+wq6s/QdL2qZ11fP79MijrVXsAAnzSxCbT2j/3prHRouJdZ2yFLN3vkP0HytfnoCczjOw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "license": "MIT" + }, + "node_modules/@types/urijs": { + "version": "1.19.26", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz", + "integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/ast-types": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", + "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-aggregate-error": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/es-aggregate-error/-/es-aggregate-error-1.0.14.tgz", + "integrity": "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expr-eval-fork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.3.tgz", + "integrity": "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", + "integrity": "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/markdown-escape": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-escape/-/markdown-escape-2.0.0.tgz", + "integrity": "sha512-Trz4v0+XWlwy68LJIyw3bLbsJiC8XAbRCKF9DbEtZjyndKOGVx6n+wNB0VfoRmY2LKboQLeniap3xrb6LGSJ8A==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nimma": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", + "integrity": "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==", + "license": "Apache-2.0", + "dependencies": { + "@jsep-plugin/regex": "^1.0.1", + "@jsep-plugin/ternary": "^1.0.2", + "astring": "^1.8.1", + "jsep": "^1.2.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + }, + "optionalDependencies": { + "jsonpath-plus": "^6.0.1 || ^10.1.0", + "lodash.topath": "^4.5.2" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-sarif-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", + "integrity": "sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg==", + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.4", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "license": "Unlicense" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reserved": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved/-/reserved-0.1.2.tgz", + "integrity": "sha512-/qO54MWj5L8WCBP9/UNe2iefJc+L9yETbH32xO/ft/EYPOTCR5k+azvDUgdCOKwZH8hXwPd0b8XBL78Nn2U69g==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", + "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", + "license": "ISC", + "dependencies": { + "builtins": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + } + } +} diff --git a/api-design-guide/linter/package.json b/api-design-guide/linter/package.json new file mode 100644 index 0000000..92921eb --- /dev/null +++ b/api-design-guide/linter/package.json @@ -0,0 +1,22 @@ +{ + "name": "govstack-api-lint", + "version": "0.1.0", + "private": true, + "description": "GovStack Spectral ruleset and lint driver for the Cross-BB API Design Guide", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "lint": "node cli.mjs", + "test": "node --test \"tests/*.test.mjs\"" + }, + "dependencies": { + "@stoplight/spectral-cli": "^6.16.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-functions": "^1.10.1", + "@stoplight/spectral-parsers": "^1.0.5", + "@stoplight/spectral-ruleset-bundler": "^1.6.2", + "yaml": "^2.8.0" + } +} diff --git a/api-design-guide/linter/ruleset.yaml b/api-design-guide/linter/ruleset.yaml new file mode 100644 index 0000000..0f00567 --- /dev/null +++ b/api-design-guide/linter/ruleset.yaml @@ -0,0 +1,42 @@ +# GovStack API Design Guide — Spectral ruleset (entry point) +# ============================================================ +# This ruleset mechanically enforces the GovStack Cross-BB API Design Guide. +# Guide version implemented: 0.2.0 +# Rule catalogue (source of truth): ../rules.yaml +# Coverage contract (rule -> status -> spectral rules): ./coverage.yaml +# +# It is composed of one fragment per guide section (rulesets/sNN.yaml). Each +# fragment is a standalone Spectral ruleset that declares its own +# `functionsDir: "../functions"` and `functions:` list; this has been verified +# to resolve through the `extends` chain both via the ruleset bundler +# (programmatic, used by the tests) and via the Spectral CLI. +# +# How to run: +# npx spectral lint -r ruleset.yaml path/to/openapi.yaml # normative rules +# npx spectral lint -r strict.yaml path/to/openapi.yaml # + opt-in strict heuristics +# npm test # fixtures + unit + harness +# +# Fragment status: s02 is implemented (proof-of-concept). s03–s20 are valid +# empty skeletons, populated by the per-section work. ALL fragments are listed +# below already so the bundle loads today and section agents never edit this +# file. +extends: + - ./rulesets/s02.yaml + - ./rulesets/s03.yaml + - ./rulesets/s04.yaml + - ./rulesets/s05.yaml + - ./rulesets/s06.yaml + - ./rulesets/s07.yaml + - ./rulesets/s08.yaml + - ./rulesets/s09.yaml + - ./rulesets/s10.yaml + - ./rulesets/s11.yaml + - ./rulesets/s12.yaml + - ./rulesets/s13.yaml + - ./rulesets/s14.yaml + - ./rulesets/s15.yaml + - ./rulesets/s16.yaml + - ./rulesets/s17.yaml + - ./rulesets/s18.yaml + - ./rulesets/s19.yaml + - ./rulesets/s20.yaml diff --git a/api-design-guide/linter/rulesets/s02.yaml b/api-design-guide/linter/rulesets/s02.yaml new file mode 100644 index 0000000..3c2b651 --- /dev/null +++ b/api-design-guide/linter/rulesets/s02.yaml @@ -0,0 +1,124 @@ +# Rules for §2 OpenAPI document standards — proof-of-concept section. +# +# Implements guide rules 2.1, 2.5, 2.6, 2.7. Source text: ../../rules.yaml. +# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# +# Custom functions: this fragment declares functionsDir + functions so the +# bundler (programmatic) and the CLI both resolve valuePattern from +# ../functions. This has been verified to work through an `extends` chain. +functionsDir: "../functions" +functions: + - valuePattern +rules: + # 2.1 [M] — the spec MUST declare openapi: 3.1.0; earlier versions MUST NOT. + # formats [oas2, oas3] so a 2.x/3.0.x document is still told to move to 3.1.0. + govstack-2.1: + description: "OpenAPI version must be exactly 3.1.0 (guide 2.1, [M])." + message: "[2.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#21-openapi-310-required + severity: error + formats: [oas2, oas3] + given: $ + then: + function: schema + functionOptions: + schema: + type: object + required: [openapi] + properties: + openapi: + const: "3.1.0" + + # 2.5 [M] — info MUST include title, version, description, contact. + # Split per the documented suffix convention: presence here, SemVer below. + # formats [oas3] so the info block is checked even on a wrong-version doc. + govstack-2.5: + description: "info block must include title, version, description, contact (guide 2.5, [M])." + message: "[2.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#25-complete-info-block + severity: error + formats: [oas3] + given: $.info + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [title, version, description, contact] + properties: + title: { type: string, minLength: 1 } + description: { type: string, minLength: 1 } + contact: { type: object } + + # 2.5 [M] — info.version MUST be SemVer. Uses the shared valuePattern function. + govstack-2.5-semver: + description: "info.version must be a SemVer string (guide 2.5, [M])." + message: "[2.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#25-complete-info-block + severity: error + formats: [oas3] + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$' + + # 2.6 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: servers is non-empty AND no server URL points at localhost/loopback. + # Does NOT verify: that URLs are "meaningful"/not fake production domains, the + # SHOULD to use parameterised template URLs, or the MAY to label example.org + # defaults as non-production. Those are not mechanically decidable. + govstack-2.6: + description: "servers must be non-empty and not point at localhost/loopback (guide 2.6, [M+R], proxy)." + message: "[2.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#26-meaningful-servers-block + severity: warn + formats: [oas3_1] + given: $ + then: + function: schema + functionOptions: + schema: + type: object + required: [servers] + properties: + servers: + type: array + minItems: 1 + items: + type: object + properties: + url: + type: string + not: + pattern: 'localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?' + + # 2.7 [M+R] — every operation MUST include operationId (camelCase), summary, + # description, and >=1 tag. The verb-noun convention for operationId is not + # mechanically verified (only camelCase is). + govstack-2.7: + description: "every operation must declare operationId(camelCase), summary, description, >=1 tag (guide 2.7, [M+R])." + message: "[2.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#27-complete-operation-metadata + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [operationId, summary, description, tags] + properties: + operationId: + type: string + minLength: 1 + pattern: '^[a-z][a-zA-Z0-9]*$' + summary: { type: string, minLength: 1 } + description: { type: string, minLength: 1 } + tags: + type: array + minItems: 1 diff --git a/api-design-guide/linter/rulesets/s03.yaml b/api-design-guide/linter/rulesets/s03.yaml new file mode 100644 index 0000000..3e27c07 --- /dev/null +++ b/api-design-guide/linter/rulesets/s03.yaml @@ -0,0 +1,148 @@ +# Rules for §3 AsyncAPI document standards — generated from the guide; see coverage.yaml. +# +# Implements guide rules 3.1, 3.5, 3.6, 3.7 (implemented) and 3.9 (partial-proxy). +# Source text: ../../rules.yaml. Severity + formats policy: <SCRATCHPAD>/linter-conventions.md. +# 3.2/3.3/3.4 are driver-status (file-tree/validator) and 3.8 is needs-context — not here. +# +# Custom functions: this fragment declares functionsDir + functions so both the +# bundler (programmatic) and the CLI resolve them from ../functions. +functionsDir: "../functions" +functions: + - valuePattern + - schemaPropertyNames + - s03-asyncOperation +rules: + # 3.1 [M] — the spec MUST declare asyncapi: 3.0.0; earlier versions MUST NOT. + # formats [aas2, aas3] so a 2.x document is still told to move to 3.0.0. + govstack-3.1: + description: "AsyncAPI version must be exactly 3.0.0 (guide 3.1, [M])." + message: "[3.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required + severity: error + formats: [aas2, aas3] + given: $ + then: + function: schema + functionOptions: + schema: + type: object + required: [asyncapi] + properties: + asyncapi: + const: "3.0.0" + + # 3.5 [M] — info MUST include title, version, description, contact. + # Split per the suffix convention: presence here, SemVer below. + # formats [aas2, aas3] so the info block is checked even on a wrong-version doc. + govstack-3.5: + description: "AsyncAPI info block must include title, version, description, contact (guide 3.5, [M])." + message: "[3.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block + severity: error + formats: [aas2, aas3] + given: $.info + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [title, version, description, contact] + properties: + title: { type: string, minLength: 1 } + description: { type: string, minLength: 1 } + contact: { type: object } + + # 3.5 [M] — info.version MUST be SemVer. Shared valuePattern function. + govstack-3.5-semver: + description: "AsyncAPI info.version must be a SemVer string (guide 3.5, [M])." + message: "[3.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block + severity: error + formats: [aas2, aas3] + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$' + + # 3.6 [M+R] — the file MUST declare non-empty servers, channels, operations and + # components.messages. The SHOULD (parameterised hosts) and MAY (reserved doc + # domains) sentences are advisory and not mechanically verified here. + govstack-3.6: + description: "servers, channels, operations and components.messages must all be non-empty (guide 3.6, [M+R])." + message: "[3.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages + severity: error + formats: [aas3] + given: $ + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [servers, channels, operations, components] + properties: + servers: { type: object, minProperties: 1 } + channels: { type: object, minProperties: 1 } + operations: { type: object, minProperties: 1 } + components: + type: object + required: [messages] + properties: + messages: { type: object, minProperties: 1 } + + # 3.6 [M+R] — server definitions MUST NOT point at localhost/loopback. The + # broader "no personal machines / undocumented placeholders / fake production + # brokers" prohibition is not mechanically decidable and is not verified. + govstack-3.6-host: + description: "AsyncAPI server host must not be localhost/loopback (guide 3.6, [M+R])." + message: "[3.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages + severity: error + formats: [aas3] + given: $.servers[*].host + then: + function: valuePattern + functionOptions: + name: server.host + notMatch: 'localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?' + + # 3.7 [M+R] — every operation MUST declare action(send/receive), summary, + # description, >=1 tag, a referenced channel, and >=1 referenced message that + # resolves to a message on that channel. resolved:false so the raw $ref + # pointers are visible for the channel-membership cross-check. The + # CloudEvents-ness of the message (a §17.6 concern) and the MAY about channel + # messages re-referencing components.messages are not verified here. + govstack-3.7: + description: "every AsyncAPI operation must declare action/summary/description/>=1 tag/channel/>=1 channel message (guide 3.7, [M+R])." + message: "[3.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata + severity: error + formats: [aas3] + resolved: false + given: $ + then: + function: s03-asyncOperation + + # 3.9 [M] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: message payload schema property names are camelCase (a concrete + # §9.2 check applied to payload fields). Does NOT verify: that payloads parse + # as valid JSON Schema (that is 3.4 / the AsyncAPI validator, a driver check), + # the full §10 field-format battery, or payload conventions on non-JSON-Schema + # payload formats. + govstack-3.9: + description: "AsyncAPI message payload property names must be camelCase (guide 3.9, [M], proxy for §9/§10)." + message: "[3.9][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#39-json-schema-payload-conventions + severity: warn + formats: [aas3] + given: + - $.components.messages[*].payload + - $.channels[*].messages[*].payload + then: + function: schemaPropertyNames + functionOptions: + casing: camel diff --git a/api-design-guide/linter/rulesets/s04-strict.yaml b/api-design-guide/linter/rulesets/s04-strict.yaml new file mode 100644 index 0000000..3b3f9a5 --- /dev/null +++ b/api-design-guide/linter/rulesets/s04-strict.yaml @@ -0,0 +1,21 @@ +# STRICT-only rules for §4 — ships only via ../strict.yaml (opt-in). +# +# 4.4 [R] — STRICT-ONLY heuristic proxy, severity warn per strict-only policy. +# Flags operation descriptions that are byte-identical across >=2 distinct +# operations: a strong copy-paste signal (the guide cites this exact audit +# finding). Does NOT verify that a non-duplicated description is actually +# accurate for its operation - that needs reading the operation's real +# behaviour, which is not mechanically checkable. +functionsDir: "../functions" +functions: + - s04-duplicateDescriptions +rules: + govstack-4.4: + description: "operation descriptions must not be copy-pasted verbatim across operations (guide 4.4, [R], strict proxy)." + message: "[4.4][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#44-accurate-operation-descriptions + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s04-duplicateDescriptions diff --git a/api-design-guide/linter/rulesets/s04.yaml b/api-design-guide/linter/rulesets/s04.yaml new file mode 100644 index 0000000..a0fdcae --- /dev/null +++ b/api-design-guide/linter/rulesets/s04.yaml @@ -0,0 +1,68 @@ +# Rules for §4 documentation and descriptions — generated from the guide; see coverage.yaml +# +# Implements guide rules 4.1, 4.2, 4.3. Source text: ../../rules.yaml. +# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# 4.4 is STRICT-ONLY and lives in s04-strict.yaml instead. +# +# §4 surface is "Universal": every rule here fires on both OpenAPI and +# AsyncAPI documents. Given targets combine OpenAPI-shaped and AsyncAPI-shaped +# JSONPaths / document branches; whichever doesn't apply to the current +# document format simply selects/finds nothing. +functionsDir: "../functions" +functions: + - schemaDescriptions + - s04-bodyExamplesEnums + - s04-noPlaceholderText +rules: + # 4.1 [M] — every schema MUST have a description. Recursive: every subschema + # node (not just declared properties) needs one, via schemaDescriptions' + # mode "all". Scope: components.schemas plus inline request/response body + # schemas (OpenAPI) and message payload schemas (AsyncAPI). Parameter/header + # schemas are out of scope (not swept). + govstack-4.1: + description: "every schema must have a description (guide 4.1, [M])." + message: "[4.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#41-every-schema-described + severity: error + formats: [oas3_1, aas3] + given: + - $.components.schemas[*] + - $.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema + - $.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema + - $.components.messages[*].payload + then: + function: schemaDescriptions + functionOptions: { mode: all } + + # 4.2 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: every request/response body (and AsyncAPI message) declares + # >=1 example; every `enum` schema node carries a description. + # Does NOT verify: that an enum's description actually explains what each + # value means (only that some description is present) - the guide's own + # text acknowledges an example alone can be insufficient, but a vacuous + # description would still pass here. + govstack-4.2: + description: "request/response bodies need an example; enums need documented values (guide 4.2, [M+R], proxy)." + message: "[4.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s04-bodyExamplesEnums + + # 4.3 [M+R] — PROXY (bucket B), notched one step: MUST NOT -> warn. + # Verifies: no string anywhere in the document matches TBD / Lorem ipsum / + # the literal placeholder list "a, b, c". + # Does NOT verify: content copy-pasted from another BB with that BB's name + # still present (needs an external list of BB names), or test plans with + # literal placeholder steps (needs test-plan-structure context). + govstack-4.3: + description: "the spec must not contain placeholder text (guide 4.3, [M+R], proxy)." + message: "[4.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#43-no-placeholder-text + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s04-noPlaceholderText diff --git a/api-design-guide/linter/rulesets/s05-strict.yaml b/api-design-guide/linter/rulesets/s05-strict.yaml new file mode 100644 index 0000000..0cce2d0 --- /dev/null +++ b/api-design-guide/linter/rulesets/s05-strict.yaml @@ -0,0 +1,50 @@ +# STRICT-only rules for §5 — noisy lexical heuristics, opt-in via +# ../strict.yaml. All at severity `warn` per +# <SCRATCHPAD>/linter-conventions.md, regardless of the guide rule's own +# strength. Source text: ../../rules.yaml. +functionsDir: "../functions" +functions: + - s05-pluralNoun + - s05-actionVerbs +rules: + # 5.2 [M+R] MUST — resource paths use plural nouns. Lexical "last word ends + # in s" heuristic; see functions/s05-pluralNoun.js for known false + # positives (e.g. "status") and false negatives (irregular plurals not + # listed). + govstack-5.2: + description: "Resource path segments should look like plural nouns (guide 5.2, [M+R], strict heuristic)." + message: "[5.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#52-plural-noun-resources + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s05-pluralNoun + functionOptions: + exceptions: [health, ready] + + # 5.7 [M+R] MUST NOT — verbs in CRUD paths. Lexical dictionary heuristic; + # see functions/s05-actionVerbs.js. Shares its detector with 5.8 below (the + # guide states the same shape requirement as both a prohibition and a + # required positive shape). + govstack-5.7: + description: "Verbs must not appear in paths for CRUD operations (guide 5.7, [M+R], strict heuristic)." + message: "[5.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#57-no-verbs-in-crud-paths + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s05-actionVerbs + + # 5.8 [R] MUST — non-CRUD actions expressed as /{collection}/{id}/{verb} + # sub-resources. + govstack-5.8: + description: "Non-CRUD actions must be expressed as /{collection}/{id}/{verb} sub-resources (guide 5.8, [R], strict heuristic)." + message: "[5.8][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s05-actionVerbs diff --git a/api-design-guide/linter/rulesets/s05.yaml b/api-design-guide/linter/rulesets/s05.yaml new file mode 100644 index 0000000..cf01c19 --- /dev/null +++ b/api-design-guide/linter/rulesets/s05.yaml @@ -0,0 +1,165 @@ +# Rules for §5 URL structure and versioning. Source text: ../../rules.yaml. +# +# Implements guide rules 5.1, 5.3, 5.4, 5.6, 5.9 (implemented) and 5.5 +# (partial-proxy). Strict-only siblings 5.2, 5.7, 5.8 live in +# rulesets/s05-strict.yaml. Severity policy and formats follow +# <SCRATCHPAD>/linter-conventions.md. +functionsDir: "../functions" +functions: + - pathSegments + - valuePattern + - mediaTypeExpected + - envelopeShape + - securityCoverage +rules: + # 5.1 [M] MUST — major version prefix /v{N}/... on every path. + # exemptPaths carves out the guide 5.9 UNVERSIONED operational endpoints: + # "Each BB MUST expose an UNVERSIONED operational liveness endpoint at /health + # ... A separate /ready endpoint MAY be exposed." Those exact path keys cannot + # carry a /v{N}/ prefix, so 5.1 must not fire on them. + govstack-5.1: + description: "Every path must start with a major version prefix /v{N}/ (guide 5.1, [M])." + message: "[5.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path + severity: error + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: versionPrefix, exemptPaths: ['/health', '/ready'] } + + # 5.3 [M] MUST — multi-word path segments must be kebab-case. + govstack-5.3: + description: "Path segments must be kebab-case (guide 5.3, [M])." + message: "[5.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#53-kebab-case-path-segments + severity: error + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: segmentCasing, casing: kebab } + + # 5.4 [M] SHOULD — path hierarchy shallow: at most two levels of nesting + # after /v{N}/. "Levels of nesting" counts resource/action segments only: + # `{param}` path parameters do NOT count. Guide 5.8/15.5 mandate + # POST /v1/operations/{operationId}/cancel and 16.11 mandates + # .../subscriptions/{subscriptionId}/rotate-secret (three raw segments, two + # non-param levels), so params must be skipped when counting depth. + govstack-5.4: + description: "Path hierarchy should have at most two levels of nesting after /v{N}/ (guide 5.4, [M])." + message: "[5.4][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: maxDepthAfterVersion, max: 2 } + + # 5.5 [M+R] MUST — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: GET/DELETE operations do not declare a query parameter whose + # NAME looks identifier-shaped (exactly "id", camelCase "...Id", or + # snake_case "..._id"). + # Does NOT verify: the converse (that every identifier which SHOULD be a + # path parameter actually is one), identifier query params under other + # HTTP methods, or identifier-shaped names outside this lexical pattern. + # Pure name-shape heuristic; false positives are possible on legitimate + # non-identifying filter params that happen to end in "Id". + govstack-5.5: + description: "GET/DELETE must not identify a resource via an id-shaped query parameter (guide 5.5, [M+R], proxy)." + message: "[5.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#55-identifiers-as-path-parameters + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,delete].parameters[?(@.in=='query')].name" + then: + function: valuePattern + functionOptions: + name: "query parameter" + forbidPattern: '(^id$)|([a-z0-9]Id$)|(_id$)' + + # 5.6 [M] MUST — query parameter names follow the §9 JSON naming + # convention (camelCase). Covers both path-item-level (shared) and + # operation-level query parameters. + govstack-5.6: + description: "Query parameter names must be camelCase per §9 (guide 5.6, [M])." + message: "[5.6][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#56-query-parameter-naming + severity: error + formats: [oas3_1] + given: + - "$.paths[*].parameters[?(@.in=='query')].name" + - "$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[?(@.in=='query')].name" + then: + function: valuePattern + functionOptions: + name: "query parameter" + match: '^[a-z][a-zA-Z0-9]*$' + + # 5.9 [M+R] — mixed MUST NOT / MUST / MAY, split per sentence group (each + # its own severity per the mixed-strength convention; all checkable clauses + # here are MUST-level so all four are `error`). + # NOT checked: the MAY clause (a separate /ready endpoint is optional, so + # there is nothing to assert if absent) and "MUST NOT expose + # system-internal detail" (not mechanically decidable from the spec alone). + govstack-5.9-presence: + description: "An unversioned /health liveness endpoint must be exposed (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: $.paths + then: + function: schema + functionOptions: + schema: + type: object + required: ["/health"] + + # Scoped to the SUCCESS (200) response only. Guide 5.9's health+json media + # type describes the health PAYLOAD; guide 11.1 separately REQUIRES 4xx/5xx to + # be application/problem+json, so a responses[*] scope would make /health's + # error responses unsatisfiable (health+json vs problem+json conflict). + govstack-5.9-media-type: + description: "/health 200 response must declare media type application/health+json (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: "$.paths['/health'].get.responses['200'].content" + then: + function: mediaTypeExpected + functionOptions: + require: ['application/health\+json'] + + # Scoped to the SUCCESS (200) response only — same 5.9-vs-11.1 interplay as + # 5.9-media-type above: the pass|fail|warn health enum belongs to the + # health+json 200 payload, not to the problem+json error responses. + govstack-5.9-status-enum: + description: "/health 200 response body must declare status with enum pass|fail|warn (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: "$.paths['/health'].get.responses['200'].content[*].schema" + then: + function: envelopeShape + functionOptions: + requiredProperties: [status] + properties: + status: + enum: ["pass", "fail", "warn"] + + govstack-5.9-no-auth: + description: "/health (and /ready, if present) must not require citizen authentication (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: + - "$.paths['/health'].get" + - "$.paths['/ready'].get" + then: + function: securityCoverage + functionOptions: { mode: none } diff --git a/api-design-guide/linter/rulesets/s06.yaml b/api-design-guide/linter/rulesets/s06.yaml new file mode 100644 index 0000000..7bb4a67 --- /dev/null +++ b/api-design-guide/linter/rulesets/s06.yaml @@ -0,0 +1,97 @@ +# Rules for §6 HTTP methods — generated from the guide; see coverage.yaml +# +# Implements guide rules 6.1, 6.4, 6.5, 6.6, 6.7. Source text: ../../rules.yaml. +# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# surface: OpenAPI for all of §6 (these are HTTP-method semantics; AsyncAPI +# has no GET/PUT/PATCH/DELETE/POST), so every rule uses formats: [oas3_1]. +functionsDir: "../functions" +functions: + - mediaTypeExpected + - operationResponses + - s06-bulkMutationSelection +rules: + # 6.1 [M+R] — GET requests MUST NOT carry a body. (The "safe and + # idempotent" behavioural half of this rule is not mechanically checkable + # from a static document; only the request-body absence is enforced here.) + govstack-6.1: + description: "GET operations must not declare a requestBody (guide 6.1, [M+R])." + message: "[6.1][M+R] GET operations must not declare a requestBody; move any selection/filter criteria to query parameters." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#61-get-is-safe-and-idempotent + severity: error + formats: [oas3_1] + given: $.paths[*].get + then: + field: requestBody + function: undefined + + # 6.4 [M+R] — PATCH request bodies MUST use application/merge-patch+json; + # the RFC 6902 application/json-patch+json media type MAY additionally be + # present. Plain application/json (a common mistake, treating PATCH like + # PUT) is explicitly forbidden. Other/exotic media types are not + # enumerated here (not decidable which ones are "wrong" in general), so + # this forbid list is illustrative, not exhaustive. + govstack-6.4: + description: "PATCH request bodies must use application/merge-patch+json (guide 6.4, [M+R])." + message: "[6.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#64-patch-uses-json-merge-patch + severity: error + formats: [oas3_1] + given: $.paths[*].patch.requestBody.content + then: + function: mediaTypeExpected + functionOptions: + require: ['application/merge-patch\+json'] + forbid: ['^application/json$'] + + # 6.5 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: DELETE only declares success statuses from {200, 202, 204} + # (forbids the common alternatives among the standard 2xx range), and that + # a 204 response (if declared) has no content/body. + # Does NOT verify: that a 200/202 response body actually "describes the + # resulting state" or is a genuine §15 Operation, nor which delete strategy + # (sync hard / soft / async) an implementation actually uses. + govstack-6.5: + description: "DELETE responses must be 204(no body)/200/202 (guide 6.5, [M+R], proxy)." + message: "[6.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#65-delete-response-semantics + severity: warn + formats: [oas3_1] + given: $.paths[*].delete + then: + - function: operationResponses + functionOptions: + forbid: ['201', '203', '205', '206', '207', '208', '226'] + - field: responses.204.content + function: undefined + + # 6.6 [M+R] — POST .../search MUST return 200, not 201. The given filters + # to path keys ending in "/search" via a JSONPath property filter (verified + # to resolve through both the bundler and the CLI). + govstack-6.6: + description: "POST .../search must return 200, not 201 (guide 6.6, [M+R])." + message: "[6.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#66-post-search-for-complex-queries + severity: error + formats: [oas3_1] + given: "$.paths[?(@property.endsWith('/search'))].post" + then: + function: operationResponses + functionOptions: + require: ['200'] + forbid: ['201'] + + # 6.7 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: a collection-targeted (no "{param}" segment) PUT/PATCH/DELETE + # declares >=1 query parameter (path-item- or operation-level). + # Does NOT verify: that the parameter actually scopes/limits which records + # get mutated, nor the append-only-resource carve-out (needs external + # knowledge of which resources are designated append-only). + govstack-6.7: + description: "collection-targeted PUT/PATCH/DELETE must declare an explicit selection parameter (guide 6.7, [M+R], proxy)." + message: "[6.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection + severity: warn + formats: [oas3_1] + given: "$.paths[?(!@property.includes('{'))]" + then: + function: s06-bulkMutationSelection diff --git a/api-design-guide/linter/rulesets/s07.yaml b/api-design-guide/linter/rulesets/s07.yaml new file mode 100644 index 0000000..a4f7d2b --- /dev/null +++ b/api-design-guide/linter/rulesets/s07.yaml @@ -0,0 +1,201 @@ +# Rules for §7 HTTP methods and status codes — generated from the guide; see +# coverage.yaml. +# +# Implements guide rules 7.2, 7.3, 7.6, 7.13, 7.18, 7.19, 7.20 (mechanical +# checks) and 7.14, 7.16, 7.17 as partial-proxy (bucket B: severity notched +# one step below the rule's strongest normative keyword; each carries a +# comment on what it does NOT verify). Source text: ../../rules.yaml. +# Severity/formats policy: <SCRATCHPAD>/linter-conventions.md. +functionsDir: "../functions" +functions: + - responseHeaderRequired + - operationResponses + - s07-noStoreOnProblemJson +rules: + # 7.2 [M] — every 201 response MUST include a Location header pointing at + # the created resource. Presence-only: does not verify the header's value + # actually resolves to the created resource. + govstack-7.2: + description: "every 201 response must declare a Location header (guide 7.2, [M])." + message: "[7.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#72-201-created-with-location + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "201" + headers: [Location] + + # 7.3 [M+R] — every 202 response MUST include Location pointing to an + # Operation resource (§15). Presence-only: does not verify the URL actually + # resolves to an Operation resource shape (govstack-15.x owns that). + govstack-7.3: + description: "every 202 response must declare a Location header (guide 7.3, [M+R])." + message: "[7.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#73-202-accepted-for-async-operations + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "202" + headers: [Location] + + # 7.6 [M+R] — every 401 response MUST include WWW-Authenticate (RFC 9110). + # Implements the MUST clause only; the SHOULD clause (RFC 6750 challenge + # with error=invalid_token for OAuth2 bearer schemes, §13.2) constrains the + # header's runtime *value*, not anything declared in the spec document. + govstack-7.6: + description: "every 401 response must declare a WWW-Authenticate header (guide 7.6, [M+R])." + message: "[7.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#76-401-with-www-authenticate + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "401" + headers: [WWW-Authenticate] + + # 7.13 [M] — specs MUST document a 500 response at minimum (502/503/504 are + # descriptive extras, not separately mandated). + govstack-7.13: + description: "every operation must declare a 500 response (guide 7.13, [M])." + message: "[7.13][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#713-server-errors-documented + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch]" + then: + function: operationResponses + functionOptions: + require: ["500"] + + # 7.14 [M] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: operation declares >=2 response codes AND >=1 non-2xx response, + # as evidence it does not declare "only 200". Does NOT verify that the + # declared codes are the actual set the operation can return, or that every + # relevant status is covered — "MUST declare the status codes it can + # return" is a claim about runtime behaviour a static document can't fully + # settle. + govstack-7.14: + description: "every operation must declare more than one status code, including a non-2xx (guide 7.14, [M], proxy)." + message: "[7.14][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#714-all-status-codes-declared + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch]" + then: + function: operationResponses + functionOptions: + minCount: 2 + minNonSuccess: 1 + + # 7.16 [M+R] — PROXY (bucket B), notched one step: SHOULD -> info. + # Verifies: every GET operation's 200 response declares an ETag header AND + # the operation also declares a 304 response. Does NOT distinguish + # single-resource GETs from collection-listing GETs (applies to every GET + # operation's 200 response), does not verify the ETag is actually derived + # from resource state, and does not verify client If-None-Match support + # (client-side behaviour, invisible to a server spec). + # The given excludes the guide 5.9 operational endpoints /health and /ready: + # 7.16 applies to "endpoints that return resources", and caching a liveness + # probe is an anti-pattern (no ETag/304 to advertise). + govstack-7.16: + description: "GET operations should declare an ETag header on 200 and a 304 response (guide 7.16, [M+R], proxy)." + message: "[7.16][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#716-etag-and-if-none-match + severity: info + formats: [oas3_1] + given: "$.paths[?(@property != '/health' && @property != '/ready')].get.responses" + then: + function: responseHeaderRequired + functionOptions: + status: "200" + headers: [ETag] + alsoRequireStatus: "304" + + # 7.17 [M+R] — PROXY (bucket B), notched one step: SHOULD -> info. + # Verifies: PUT/PATCH operations declare an If-Match header parameter (at + # the operation level — a parameter shared via the enclosing path item is + # not seen here) AND a 412 response. Does NOT verify the server actually + # implements optimistic concurrency, nor that 409 is reserved for + # non-precondition conflicts (7.9's territory). + govstack-7.17: + description: "PUT/PATCH operations should declare an If-Match header parameter and a 412 response (guide 7.17, [M+R], proxy)." + message: "[7.17][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match + severity: info + formats: [oas3_1] + given: "$.paths[*][put,patch]" + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [parameters, responses] + properties: + parameters: + type: array + contains: + type: object + properties: + name: { const: If-Match } + in: { const: header } + responses: + type: object + required: ["412"] + + # 7.18 [M] — every 405 response MUST include Allow listing the supported + # methods (RFC 9110). Presence-only: does not verify the Allow header's + # value actually lists the correct methods. + govstack-7.18: + description: "every 405 response must declare an Allow header (guide 7.18, [M])." + message: "[7.18][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#718-405-with-allow-header + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "405" + headers: [Allow] + + # 7.19 [M+R] — PATCH endpoints MUST return 415 for an unsupported patch + # media type (§6.4). The accompanying MAY clause (406 for Accept-header + # negotiation failures) is permissive and not tied to any specific + # operation type, so it is not checked. + govstack-7.19: + description: "every PATCH operation must declare a 415 response (guide 7.19, [M+R])." + message: "[7.19][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#719-415-for-unsupported-media-types + severity: error + formats: [oas3_1] + given: "$.paths[*].patch" + then: + function: operationResponses + functionOptions: + require: ["415"] + + # 7.20 [M] — SHOULD Cache-Control: no-store on error/Operation-status + # responses. Implements the mechanically clear half: any response whose + # content includes application/problem+json MUST declare a Cache-Control + # header (presence-only, not value). Does NOT independently identify + # "Operation-status responses" (§15) — those have no content-type + # signature to key off and require the Operation resource shape, which + # this section does not own. + govstack-7.20: + description: "problem+json responses should declare a Cache-Control header (guide 7.20, [M])." + message: "[7.20][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#720-no-store-on-error-responses + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses[*]" + then: + function: s07-noStoreOnProblemJson diff --git a/api-design-guide/linter/rulesets/s08-strict.yaml b/api-design-guide/linter/rulesets/s08-strict.yaml new file mode 100644 index 0000000..106576b --- /dev/null +++ b/api-design-guide/linter/rulesets/s08-strict.yaml @@ -0,0 +1,26 @@ +# STRICT-only rules for §8 — implements guide rule 8.6. +# +# Ships only via ../strict.yaml (opt-in). See ../coverage.yaml and the +# fragment format in <SCRATCHPAD>/linter-conventions.md. +functionsDir: "../functions" +functions: + - s08-personalDataInUrl +rules: + # 8.6 [R] — STRICT-ONLY heuristic, severity warn (per convention, always + # warn regardless of the guide's own MUST/MUST NOT strength). + # Verifies: path/query/header parameters whose NAME matches a dictionary + # of personal-data term fragments (email, phone, nationalId, dateOfBirth, + # ...). Deliberately noisy: flags plausible names, not a definitive + # determination, hence STRICT-ONLY rather than shipped in the main ruleset. + # Does NOT verify: header/query VALUES (only names are visible + # statically), synonyms outside the dictionary, or whether an ID is + # actually opaque/server-generated. + govstack-8.6: + description: "path/query/header parameter names must not look like personal data (guide 8.6, [R], strict heuristic)." + message: "[8.6][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#86-no-personal-data-in-addressable-locations + severity: warn + formats: [oas3_1] + given: $ + then: + function: s08-personalDataInUrl diff --git a/api-design-guide/linter/rulesets/s08.yaml b/api-design-guide/linter/rulesets/s08.yaml new file mode 100644 index 0000000..a5c59dc --- /dev/null +++ b/api-design-guide/linter/rulesets/s08.yaml @@ -0,0 +1,113 @@ +# Rules for §8 HTTP headers — implements guide rules 8.1-8.5, 8.7. +# +# 8.6 is STRICT-ONLY; see ./s08-strict.yaml. Source text: ../../rules.yaml. +# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# +# Custom functions: none of the shared library functions (functions/README.md) +# combine "operation parameters" with "responses" or scan header names across +# the whole document, so this fragment adds section-local functions for those +# cross-cutting checks. Each is documented inline in its file. +functionsDir: "../functions" +functions: + - s08-credentialsInUrl + - s08-headerEcho + - s08-idempotencyKeyRequired + - s08-requestIdCorrelation + - s08-noXHeaders + - s08-rateLimitHeaders +rules: + # 8.1 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: no apiKey security scheme is placed in query/cookie, and no + # path/query parameter has a credential-looking name (token, apiKey, + # secret, password, credential, ...). + # Does NOT verify: that credentials actually travel via the Authorization + # header at runtime, arbitrary/synonym parameter names, URL fragments (not + # representable in OpenAPI), or header VALUES that might embed a credential. + govstack-8.1: + description: "credentials must not travel in query/path parameters or an apiKey query/cookie scheme (guide 8.1, [M+R], proxy)." + message: "[8.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#81-credentials-in-authorization-header + severity: warn + formats: [oas3_1] + given: $ + then: + function: s08-credentialsInUrl + + # 8.2 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: IF an operation declares an Accept-Language header parameter + # (a "localisation request"), its 2xx responses declare Content-Language. + # Does NOT verify: requests that omit Accept-Language, or that the echoed + # value actually reflects the negotiated language. + govstack-8.2: + description: "operations accepting Accept-Language must echo Content-Language on success responses (guide 8.2, [M+R], proxy)." + message: "[8.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#82-accept-language-and-content-language + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch]" + then: + function: s08-headerEcho + functionOptions: + requestHeader: Accept-Language + responseHeader: Content-Language + + # 8.3 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: a POST operation declaring a 201 response (proxy for "requires + # idempotency under §14") accepts an Idempotency-Key header parameter. + # Does NOT verify: the actual §14 idempotency-requirement determination, + # nor the §14.6 opt-out. + govstack-8.3: + description: "create-POSTs (201) must accept an Idempotency-Key header (guide 8.3, [M+R], proxy)." + message: "[8.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#83-idempotency-key-header-accepted + severity: warn + formats: [oas3_1] + given: "$.paths[*].post" + then: + function: s08-idempotencyKeyRequired + + # 8.4 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: an operation SHOULD declare an X-Request-Id header parameter, + # and its 2xx responses MUST declare an X-Request-Id response header. + # Does NOT verify: actual runtime echo/generate behaviour, distinctness + # from the error-envelope traceId (§11.3, a BB's choice), or path-item- + # level (shared) parameters. + govstack-8.4: + description: "requests should carry X-Request-Id and responses must echo it (guide 8.4, [M+R], proxy)." + message: "[8.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#84-x-request-id-correlation + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch]" + then: + function: s08-requestIdCorrelation + + # 8.5 [M] — implemented (bucket A). New custom headers (parameters and + # response headers) must not use the X- prefix, except the legacy + # X-Request-Id (§8.4, pending [OPEN-7-A]). + govstack-8.5: + description: "new custom headers must not use the X- prefix, except X-Request-Id (guide 8.5, [M])." + message: "[8.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#85-no-new-x--prefixed-headers + severity: error + formats: [oas3_1] + given: $ + then: + function: s08-noXHeaders + + # 8.7 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: an operation declaring a 429 response (proxy for "rate-limited + # by the BB itself") declares Retry-After on 429 and the RateLimit-Limit / + # RateLimit-Remaining / RateLimit-Reset trio on its 2xx and 429 responses. + # Does NOT verify: that rate limiting is actually implemented, the + # delegated-to-gateway prose exception, or the newer structured-field + # draft form (only the three-header v0.1 form is checked). + govstack-8.7: + description: "rate-limited endpoints must declare RateLimit-* and 429 responses must declare Retry-After (guide 8.7, [M+R], proxy)." + message: "[8.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#87-rate-limit-headers-declared + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: s08-rateLimitHeaders diff --git a/api-design-guide/linter/rulesets/s09-strict.yaml b/api-design-guide/linter/rulesets/s09-strict.yaml new file mode 100644 index 0000000..7a6791f --- /dev/null +++ b/api-design-guide/linter/rulesets/s09-strict.yaml @@ -0,0 +1,37 @@ +# STRICT-only rules for §9 — ships only via ../strict.yaml (opt-in). +# +# Holds the deliberately-noisy §9 heuristics (9.6 abbreviation dictionary, 9.10 +# GovStack extension-prefix guess). Both run at severity warn. Source text: +# ../../rules.yaml. +functionsDir: "../functions" +functions: + - s09-abbreviations + - s09-extensionPrefix +rules: + # 9.6 [R] — STRICT proxy. Flags property-name words found in an abbreviation + # dictionary (qty -> quantity, addr -> address, …). Noisy by design and + # dictionary-bounded: misses abbreviations not in the list, may flag words + # that legitimately look like an abbreviation. + govstack-9.6: + description: "Abbreviations should not be used in field names (guide 9.6, [R], strict proxy)." + message: "[9.6][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#96-avoid-abbreviations + severity: warn + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-abbreviations + + # 9.10 [M+R] — STRICT proxy. Flags x-* extensions that look GovStack-defined + # but are not prefixed exactly x-govstack- (a known concept like x-delivery, + # or any key mentioning govstack). Weak: cannot know the full GovStack + # extension set, cannot tell third-party x- extensions apart. + govstack-9.10: + description: "GovStack-defined extensions must use the x-govstack- prefix (guide 9.10, [M+R], strict proxy)." + message: "[9.10][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s09-extensionPrefix diff --git a/api-design-guide/linter/rulesets/s09.yaml b/api-design-guide/linter/rulesets/s09.yaml new file mode 100644 index 0000000..efa619f --- /dev/null +++ b/api-design-guide/linter/rulesets/s09.yaml @@ -0,0 +1,152 @@ +# Rules for §9 JSON conventions and naming — generated from the guide. +# +# Implements guide rules 9.1-9.5, 9.7-9.9, 9.11 (9.6 and 9.10 are STRICT-only, +# in rulesets/s09-strict.yaml). Source text: ../../rules.yaml. Severity and +# formats follow <SCRATCHPAD>/linter-conventions.md. +# +# Bucket-B proxies (9.1, 9.3, 9.4, 9.7, 9.9, 9.11) run one severity notch below +# the rule's strength and each carry a comment saying what they do NOT verify. +functionsDir: "../functions" +functions: + - mediaTypeExpected + - schemaPropertyNames + - s09-booleanStrings + - s09-enumCasing + - s09-closedEnum + - s09-bbCode +rules: + # 9.1 [M+R] — PROXY (bucket B), MUST -> warn. Every response body should offer + # a JSON media type (application/json or a +json family type, e.g. + # problem+json). Does NOT verify the "unless binary or a document export" + # exception, so a legitimately binary response (application/pdf, image/*) is + # flagged; treat as a prompt to confirm the exception applies. + govstack-9.1: + description: "Response bodies should offer a JSON media type (guide 9.1, [M+R], proxy)." + message: "[9.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#91-json-as-default-media-type + severity: warn + formats: [oas3_1] + given: $.paths[*][get,put,post,delete,patch,head,options].responses[*].content + then: + function: mediaTypeExpected + functionOptions: + requireOneOf: ['json'] + + # 9.2 [M] — every declared property name (recursively) MUST be camelCase. + govstack-9.2: + description: "JSON field names must be camelCase (guide 9.2, [M])." + message: "[9.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#92-camelcase-field-names + severity: error + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: schemaPropertyNames + functionOptions: + casing: camel + + # 9.3 [M] — PROXY (bucket B), MUST -> warn. Flags boolean-looking fields typed + # string, and enums of the string literals "true"/"false". Does NOT catch + # boolean values typed as integer 0/1, nor boolean-valued strings with a + # non-boolean-ish name (indistinguishable from real strings). + govstack-9.3: + description: "Boolean fields must be real JSON booleans, not strings (guide 9.3, [M], proxy)." + message: "[9.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#93-real-json-booleans + severity: warn + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-booleanStrings + + # 9.4 [M] — PROXY (bucket B), MUST -> warn. Flags OpenAPI 3.0-style + # `nullable`, which is not a 3.1 keyword; nullability must be expressed as + # `type: [..., "null"]`. Restricted to 3.1 docs (formats: oas3_1). Does NOT + # verify that every genuinely-nullable field declares the null type. + govstack-9.4: + description: "Nullability must be explicit via type: [..., null], not 3.0 nullable (guide 9.4, [M], proxy)." + message: "[9.4][M] Do not use OpenAPI 3.0 'nullable'; express nullability as type: [..., \"null\"] (§9.4)." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#94-explicit-nullability + severity: warn + formats: [oas3_1] + given: $..nullable + then: + function: undefined + + # 9.5 [M] — field names MUST NOT contain spaces or non-ASCII characters. + govstack-9.5: + description: "Field names must not contain spaces or non-ASCII characters (guide 9.5, [M])." + message: "[9.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names + severity: error + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: schemaPropertyNames + functionOptions: + forbidPattern: '\s|[^\x00-\x7F]' + + # 9.7 [M] — PROXY (bucket B), MUST -> warn. Flags string enum members that are + # not SCREAMING_SNAKE_CASE. Does NOT flag ISO-style short lowercase codes + # (language/locale) or health-status vocab, which are exempted; a few genuine + # violations that happen to look ISO-like are therefore missed. + govstack-9.7: + description: "Enum values must be SCREAMING_SNAKE_CASE (guide 9.7, [M], proxy)." + message: "[9.7][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values + severity: warn + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-enumCasing + + # 9.8 [M] — resource body schemas MUST NOT set additionalProperties: false at + # the top level (it blocks forward-compatible field additions). Checks the + # top level of request/response body schemas; nested objects may still close. + govstack-9.8: + description: "Resource body schemas must not set additionalProperties: false at the top level (guide 9.8, [M])." + message: "[9.8][M] Resource body schema must not set additionalProperties: false at the top level (§9.8: forward-compatibility)." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas + severity: error + formats: [oas3_1] + given: + - $.paths[*][get,put,post,delete,patch].requestBody.content[*].schema + - $.paths[*][get,put,post,delete,patch].responses[*].content[*].schema + then: + function: schema + functionOptions: + schema: + type: object + properties: + additionalProperties: + not: + const: false + + # 9.9 [R] — PROXY (bucket B), SHOULD -> info. Flags closed string enums with + # no x-extensible-enum annotation and no UNKNOWN/OTHER fallback member. CANNOT + # tell whether a set is "expected to grow", so truly-fixed enums that §9.9 + # permits are also flagged; reviewer judgement required (hence info). + govstack-9.9: + description: "Growing-set fields should not be closed enums without an escape hatch (guide 9.9, [R], proxy)." + message: "[9.9][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets + severity: info + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-closedEnum + + # 9.11 [M+R] — PROXY (bucket B), MUST -> warn. IN-DOCUMENT scope only: checks + # that BB codes embedded in OAuth scopes / reverse-DNS identifiers match the + # regex and are used identically within this document. Does NOT verify + # ecosystem-wide uniqueness (needs the cross-repo BB-code register, out of + # scope). Runs on OpenAPI and AsyncAPI documents. + govstack-9.11: + description: "BB codes must match the regex and be used identically in-document (guide 9.11, [M+R], proxy)." + message: "[9.11][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s09-bbCode diff --git a/api-design-guide/linter/rulesets/s10.yaml b/api-design-guide/linter/rulesets/s10.yaml new file mode 100644 index 0000000..49741f7 --- /dev/null +++ b/api-design-guide/linter/rulesets/s10.yaml @@ -0,0 +1,251 @@ +# Rules for §10 data types and formats — generated from the guide; see coverage.yaml +# +# Implements guide rules 10.1-10.10. Source text: ../../rules.yaml. +# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# +# All ten rules here are bucket-B PROXIES: the guide's `surface` for every 10.x +# rule is "Universal" (OpenAPI request/response bodies AND AsyncAPI message +# payloads), but a linter can only see property NAMES and their declared JSON +# Schema keywords, not what the field actually means or what data flows through +# it at runtime. Each rule below picks a name heuristic (e.g. properties ending +# in "Id", "At", "Date") and asserts the matching property's schema declares +# the mechanically-checkable part of the guide's MUST. Per the severity policy, +# every proxy here runs one notch below its class's strength: all ten rules are +# MUST-strength in rules.yaml, so all run at `warn` (not `error`). +# +# Shared `given`: every rule scans the same set of schema-bearing locations, +# via the `DataSchemas` alias below — components.schemas / inline OpenAPI +# request+response body schemas for oas3_1 docs, components.messages/channel +# message payloads for aas3 docs. schemaFieldFormat's underlying walker +# recurses into nested properties/allOf/items/$defs, so declaring the schema +# root is enough to reach nested fields too. +functionsDir: "../functions" +functions: + - schemaFieldFormat +aliases: + DataSchemas: + description: "Every JSON Schema node that may declare data-type fields: OpenAPI reusable/inline body schemas, or AsyncAPI message payloads." + targets: + - formats: [oas3_1] + given: + - $.components.schemas[*] + - "$.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema" + - "$.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema" + - formats: [aas3] + given: + - $.components.messages[*].payload + - $.channels[*].messages[*].payload +rules: + # 10.1 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: resource identifiers MUST be opaque, URL-safe, server-generated, + # globally-unique strings; UUID v4 SHOULD be the default; ULID/KSUID/other + # opaque ids MAY be used; statutory identifiers MAY be exempted from the + # opacity requirement subject to §8.6. This proxy only checks that + # properties named "id" or ending in "Id" declare type: string. It does NOT + # verify opacity, URL-safety, global uniqueness, server-generation, that a + # UUID (if used) is v4, or the statutory-identifier §8.6 exception. + govstack-10.1: + description: "Properties named id/*Id must declare type: string, not an integer (guide 10.1, [M+R], proxy)." + message: "[10.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(^id$|Id$)' + require: { type: string } + + # 10.2 [M] — PROXY (bucket B), MUST -> warn. + # Guide: timestamps MUST be RFC 3339 with timezone, declared as + # format: date-time. Proxy: properties ending in "At" or "Timestamp" + # (createdAt, updatedAt, eventTimestamp) must declare type: string, + # format: date-time. Does NOT verify the value itself is RFC 3339 with an + # explicit timezone offset (that's a data-instance check, out of scope for a + # schema-shape linter) or catch timestamp fields using other naming + # conventions. + govstack-10.2: + description: "Properties ending in At/Timestamp must declare type: string, format: date-time (guide 10.2, [M], proxy)." + message: "[10.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(At|Timestamp)$' + require: { type: string, format: date-time } + + # 10.3 [M] — PROXY (bucket B), MUST -> warn. + # Guide: dates without time MUST be RFC 3339 calendar dates, declared as + # format: date. Proxy: properties ending in "Date" (birthDate, dueDate) must + # declare type: string, format: date. Does NOT verify the value itself is a + # valid RFC 3339 calendar date, or catch date fields under other naming + # conventions. + govstack-10.3: + description: "Properties ending in Date must declare type: string, format: date (guide 10.3, [M], proxy)." + message: "[10.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#103-rfc-3339-calendar-dates + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'Date$' + require: { type: string, format: date } + + # 10.4 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: monetary amounts MUST use the object { amount: string (decimal), + # currency: string (ISO 4217) }; floats MUST NOT be used for money. Proxy: + # properties ending in "amount"/"price"/"balance" (case-insensitive) must + # not declare type: number or type: integer. This flags a money field typed + # as a bare number (float or minor-units integer) but does NOT verify the + # replacement is actually the { amount, currency } object shape, that + # `amount` is a decimal string, or that `currency` is present/ISO 4217 + # (that last part is 10.10's job on the "currency"-named sibling). + govstack-10.4: + description: "Properties ending in amount/price/balance must not declare type: number or integer (guide 10.4, [M+R], proxy)." + message: "[10.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#104-decimal-string-monetary-amounts + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(amount|price|balance)$' + nameFlags: i + require: { forbidType: [number, integer] } + + # 10.5 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: phone numbers MUST be E.164 strings. Proxy: properties whose name + # contains "phone" (case-insensitive) must declare type: string and an + # E.164-shaped pattern (^\+[1-9]\d{1,14}$). Does NOT verify the number is a + # real, dialable E.164 number, only that the schema's `pattern` keyword is + # exactly this regex. + govstack-10.5: + description: "Properties matching *phone* must declare type: string with an E.164 pattern (guide 10.5, [M+R], proxy)." + message: "[10.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#105-e164-phone-numbers + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'phone' + nameFlags: i + require: { type: string, pattern: '^\+[1-9]\d{1,14}$' } + + # 10.6 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: email addresses MUST be RFC 5322 strings, declared as + # format: email. Proxy: properties whose name contains "email" + # (case-insensitive) must declare type: string, format: email. Does NOT + # verify the value is actually a deliverable/valid RFC 5322 address. + govstack-10.6: + description: "Properties matching *email* must declare type: string, format: email (guide 10.6, [M+R], proxy)." + message: "[10.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#106-rfc-5322-email-addresses + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'email' + nameFlags: i + require: { type: string, format: email } + + # 10.7 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: large binary uploads MUST use multipart/form-data or a dedicated + # binary endpoint; small inline payloads (signatures, certificates, QR + # codes, attestations) MAY be base64-encoded in JSON bodies, but if so the + # field MUST declare contentEncoding: base64 and a documented size limit. + # This proxy only checks the base64-in-JSON half: properties named after + # the guide's own examples (signature, certificate, qrCode, attestation) + # must declare type: string, contentEncoding: base64, and a maxLength. It + # does NOT verify the large-binary-upload MUST (multipart/dedicated + # endpoint), does not catch base64 fields under other names, and treats + # "a documented size limit" as satisfied by any maxLength value rather than + # checking it is a reasonable bound. + govstack-10.7: + description: "Base64 fields (signature/certificate/qrCode/attestation) must declare contentEncoding: base64 and maxLength (guide 10.7, [M+R], proxy)." + message: "[10.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#107-binary-uploads-and-base64-payloads + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(signature|certificate|qrCode|qr_code|attestation)' + nameFlags: i + require: { type: string, contentEncoding: base64, mustDeclare: [maxLength] } + + # 10.8 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: country codes MUST be ISO 3166-1 alpha-2. Proxy: properties whose + # name contains "country" (case-insensitive) must declare type: string and + # the alpha-2 shape pattern ^[A-Z]{2}$. Does NOT verify the two letters are + # an actual assigned ISO 3166-1 code (vs. any two uppercase letters), and a + # schema that instead constrains the value via an `enum` of valid codes + # (with no `pattern` keyword) will be flagged even though it is compliant — + # a known false-positive of this proxy. + govstack-10.8: + description: "Properties matching *country* must declare type: string with an ISO 3166-1 alpha-2 pattern (guide 10.8, [M+R], proxy)." + message: "[10.8][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#108-iso-3166-1-country-codes + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'country' + nameFlags: i + require: { type: string, pattern: '^[A-Z]{2}$' } + + # 10.9 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: language codes MUST be BCP 47. Proxy: properties whose name + # contains "language" or "locale" (case-insensitive) must declare + # type: string and a common-case BCP 47 shape pattern (primary subtag, + # optional script, optional region/UN M49 area). Does NOT implement the + # full BCP 47 grammar (extended language subtags, variants, extensions, + # private-use subtags), only the common language[-script][-region] shape. + govstack-10.9: + description: "Properties matching *language*/*locale* must declare type: string with a BCP 47 shape pattern (guide 10.9, [M+R], proxy)." + message: "[10.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#109-bcp-47-language-codes + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(language|locale)' + nameFlags: i + require: { type: string, pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' } + + # 10.10 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: currency codes MUST be ISO 4217. Proxy: properties named "currency" + # or ending in the camelCase word "Currency" (e.g. baseCurrency, + # currencyCode) must declare type: string and the alpha-3 shape pattern + # ^[A-Z]{3}$. Deliberately NOT a plain unanchored "contains currency" + # match: that would also catch unrelated fields like a "concurrencyToken" + # optimistic-locking field. Does NOT verify the three letters are an + # actually-assigned ISO 4217 code, and (like 10.8) a schema using an `enum` + # of valid codes instead of `pattern` is a known false-positive. + govstack-10.10: + description: "Properties matching currency/*Currency* must declare type: string with an ISO 4217 alpha-3 pattern (guide 10.10, [M+R], proxy)." + message: "[10.10][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(^currency|Currency)' + require: { type: string, pattern: '^[A-Z]{3}$' } diff --git a/api-design-guide/linter/rulesets/s11.yaml b/api-design-guide/linter/rulesets/s11.yaml new file mode 100644 index 0000000..d78e470 --- /dev/null +++ b/api-design-guide/linter/rulesets/s11.yaml @@ -0,0 +1,121 @@ +# Rules for §11 error handling — generated from the guide; see coverage.yaml. +# +# Implements guide rules 11.1-11.4 (implemented) and 11.5 (partial-proxy). +# 11.6 (runtime, localisation stability) and 11.7 (needs the not-yet-existing +# govstack-openapi-common.yaml) are out of scope per coverage.yaml. +# +# Surface for 11.1-11.5 is "Universal" in rules.yaml, but the mechanism below +# (HTTP status codes, `content` media types) only maps onto the OpenAPI +# surface: AsyncAPI has no equivalent request/response status-code model, so +# these rules are implemented for oas3_1 only. +functionsDir: "../functions" +functions: + - mediaTypeExpected + - envelopeShape + - valuePattern +rules: + # 11.1 [M] — error responses (4xx/5xx) MUST use application/problem+json. + # "Error responses" is read as any response declared under a 4xx or 5xx + # status key; a `default` response is not assumed to be an error and is not + # checked here. + govstack-11.1: + description: "4xx/5xx responses must use application/problem+json (guide 11.1, [M])." + message: "[11.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#111-rfc-9457-problem-details + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content" + then: + function: mediaTypeExpected + functionOptions: + require: ['application/problem\+json'] + + # 11.2 [M+R] — the RFC 9457 fields type/title/status MUST be present on the + # problem+json schema. `detail`/`instance` are SHOULD ("when they add + # diagnostic value") and the no-PII declaration is a documentation/human + # concern (D-bucket); neither is mechanically checked here. + govstack-11.2: + description: "problem+json schema must declare type, title, status (guide 11.2, [M+R])." + message: "[11.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#112-standard-problem-fields-present + severity: error + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' + then: + function: envelopeShape + functionOptions: + requiredProperties: [type, title, status] + + # 11.3 [M] — GovStack extension fields code/traceId/timestamp MUST be + # present on the problem+json schema. `resolved` defaults to true so a + # shared Problem schema referenced via $ref is inspected post-resolution. + govstack-11.3: + description: "problem+json schema must declare code, traceId, timestamp (guide 11.3, [M])." + message: "[11.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#113-govstack-error-extension-fields + severity: error + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' + then: + function: envelopeShape + functionOptions: + requiredProperties: [code, traceId, timestamp] + + # 11.4 [M+R] — field-level validation errors MUST appear in an `errors` + # array with pointer/code/message per entry. The guide conditions this on + # "where a failure is attributable to specific request fields"; the 400 + # (Bad Request) status is used as the mechanical stand-in for that + # condition, so this only fires on operations that declare a 400 response. + govstack-11.4: + description: "400 problem+json schema must declare an errors[] array with pointer/code/message (guide 11.4, [M+R])." + message: "[11.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#114-field-level-errors-array + severity: error + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses["400"].content["application/problem+json"].schema' + then: + function: envelopeShape + functionOptions: + requiredProperties: [errors] + properties: + errors: + type: array + items: + requiredProperties: [pointer, code, message] + + # 11.5 [M+R] — PROXY (bucket B, MUST -> warn). Error codes MUST be + # namespaced reverse-DNS `org.govstack.{bb-code}.{error-name}`, bb-code + # matching the §9.11 pattern `^[a-z][a-z0-9-]{1,30}$`, error-name either + # lowerCamelCase or (per the guide's numeric-catalogue MAY) a plain integer. + # Only checks literal `code` values that appear as a schema `enum` member or + # an `example`; a `code` typed as a bare string with no enum/example is + # invisible to this check. Does NOT verify: that {bb-code} is actually the + # BB's single REGISTERED code (the register does not exist yet, OPEN-10-A), + # nor codes declared only via OpenAPI-level `examples` maps, nor codes + # nested inside an `allOf` branch (only the schema's own direct + # properties.code are inspected). + govstack-11.5-enum: + description: "problem+json code enum values must match org.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." + message: "[11.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#115-namespaced-stable-error-codes + severity: warn + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema.properties.code.enum[*]' + then: + function: valuePattern + functionOptions: + name: "code" + match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' + + govstack-11.5-example: + description: "problem+json code example must match org.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." + message: "[11.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#115-namespaced-stable-error-codes + severity: warn + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema.properties.code.example' + then: + function: valuePattern + functionOptions: + name: "code" + match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' diff --git a/api-design-guide/linter/rulesets/s12.yaml b/api-design-guide/linter/rulesets/s12.yaml new file mode 100644 index 0000000..8d0cf75 --- /dev/null +++ b/api-design-guide/linter/rulesets/s12.yaml @@ -0,0 +1,240 @@ +# Rules for §12 pagination, filtering and sorting — generated from the guide; +# see coverage.yaml. +# +# Implements 12.3, 12.4, 12.6, 12.7 (implemented) and 12.1, 12.2, 12.8, 12.9 +# (partial-proxy). 12.5 ("total MAY be omitted") is a permissive MAY and 12.10 +# is an informative out-of-scope statement; neither is in this fragment, per +# coverage.yaml. +# +# Shared heuristic used throughout: a "collection" (list) GET operation is one +# whose path does NOT end in a `{param}` segment, e.g. `/v1/foos` (collection) +# vs. `/v1/foos/{id}` (single item). This is itself a proxy for "this endpoint +# returns a collection" — flagged explicitly wherever it drives a +# partial-proxy rule; where it drives an "implemented" rule (12.3/12.4/12.6/ +# 12.7), it is used only to scope an otherwise-exact shape check to the +# endpoints the shape applies to. +# +# Operational-endpoint carve-out: the guide 5.9 liveness probes `/health` and +# `/ready` are NOT collections, so they are exempt from the collection-GET +# rules. Function-driven rules (12.2/12.3/12.6) exempt them inside +# `s12-collectionPagination` (exact path-key match); the two inline-schema +# rules (12.1/12.4) exempt them directly in their `given`. +functionsDir: "../functions" +functions: + - s12-collectionPagination + - s12-sortParam + - envelopeShape +rules: + # 12.1 [M+R] — PROXY (bucket B, MUST -> warn). "Collections must paginate." + # Identifies list endpoints via the path heuristic above and only checks + # that SOME page-size-like query parameter (`pageSize` or `offset`) is + # declared. Does NOT verify the response body is actually bounded/paginated + # in shape (see 12.3/12.6 for the envelope shape checks) or that every + # true collection endpoint matches the path heuristic. + govstack-12.1: + description: "collection GET endpoints must declare a pagination parameter (guide 12.1, [M+R], proxy)." + message: "[12.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate + severity: warn + formats: [oas3_1] + # Inline-schema rule: exempt /health and /ready (guide 5.9 operational + # endpoints, not collections) directly in the given. + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/) && @property != "/health" && @property != "/ready")][get]' + then: + function: schema + functionOptions: + schema: + type: object + required: [parameters] + properties: + parameters: + type: array + contains: + type: object + required: [name] + properties: + name: { enum: [pageSize, offset] } + + # 12.2 [M+R] — PROXY (bucket B, MUST -> warn). Default pagination MUST be + # cursor-based with `pageSize` + `cursor` query parameters. No-op (via + # `s12-collectionPagination`, mode cursorParams) on operations that declare + # an `offset` parameter, since those opted into the §12.6 alternative. Does + # NOT verify the cursor is opaque/server-encoded (a runtime property) or + # that clients don't parse it. The /health and /ready operational endpoints + # (guide 5.9) are exempted inside `s12-collectionPagination` (exact path-key). + govstack-12.2: + description: "collection GET endpoints must declare pageSize + cursor query parameters by default (guide 12.2, [M+R], proxy)." + message: "[12.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default + severity: warn + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: cursorParams + + # 12.3 [M] — cursor pagination envelope MUST be { items, pageInfo: + # { nextCursor, hasMore } } (total is optional per 12.5, not checked here). + # No-op on operations that declared an `offset` parameter: those use the + # distinct §12.6 flat envelope instead. The /health and /ready operational + # endpoints (guide 5.9) are exempted inside `s12-collectionPagination`. + govstack-12.3: + description: "collection GET 200 response must use the cursor envelope {items, pageInfo{nextCursor, hasMore}} (guide 12.3, [M])." + message: "[12.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope + severity: error + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: cursorEnvelope + + # 12.4 [M+R] — pageSize MUST have a documented default and maximum. Fires + # both when pageSize is missing entirely and when it is present without + # both keywords (both cases mean "not documented"). + # Inline-schema rule: exempt /health and /ready (guide 5.9 operational + # endpoints, not collections) directly in the given, same as 12.1. + govstack-12.4: + description: "pageSize parameter must declare a default and a maximum (guide 12.4, [M+R])." + message: "[12.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#124-documented-pagesize-bounds + severity: error + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/) && @property != "/health" && @property != "/ready")][get]' + then: + function: schema + functionOptions: + schema: + type: object + required: [parameters] + properties: + parameters: + type: array + contains: + type: object + required: [name, schema] + properties: + name: { const: pageSize } + schema: + type: object + required: [default, maximum] + + # 12.6 [M+R] — offset pagination, when opted into (an `offset` parameter is + # declared), MUST use the flat envelope { items, offset, limit, total }, + # with `total` required (unlike the optional `total` in 12.3). No-op on + # operations without an `offset` parameter. + govstack-12.6: + description: "collection GET 200 response must use the flat envelope {items, offset, limit, total} when offset pagination is used (guide 12.6, [M+R])." + message: "[12.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#126-offset-pagination-envelope + severity: error + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: offsetEnvelope + + # 12.7 [M] — the sort parameter MUST be named `sort` (not `orderBy`/ + # `sortBy`), values `field`/`-field`, comma-separated. Split into a naming + # check (any operation, not just collection GETs: the wrong name is wrong + # wherever it appears) and a shape check (only fires when a `sort` + # parameter exists; does not require every collection to support sorting). + govstack-12.7-name: + description: "sort parameter must be named 'sort', not 'orderBy'/'sortBy' (guide 12.7, [M])." + message: "[12.7][M] parameter name must be \"sort\", not \"orderBy\"/\"sortBy\" (guide 12.7)" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: schema + functionOptions: + schema: + type: object + properties: + parameters: + type: array + items: + type: object + properties: + name: + not: + enum: [orderBy, sortBy] + + govstack-12.7-grammar: + description: "sort parameter schema must be a string with a field/-field, comma-separated pattern (guide 12.7, [M])." + message: "[12.7][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: s12-sortParam + + # 12.8 [M+R] — PROXY (bucket B, MUST -> warn). Simple filtering MUST use one + # query parameter per field, equality only. Flags the anti-patterns: + # operator-style bracket suffixes (`field[gte]`, `field[lt]`, ...) and a + # generic catch-all `filter`/`filters` parameter. Does NOT verify that + # non-suspicious parameter names are actually equality-only (a parameter + # named plainly `status` could still carry operator syntax in its value + # grammar; that isn't visible in the schema). + govstack-12.8: + description: "filter parameters must be one-per-field equality, not operator-style or a generic filter param (guide 12.8, [M+R], proxy)." + message: "[12.8][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#128-simple-equality-filtering + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: schema + functionOptions: + schema: + type: object + properties: + parameters: + type: array + items: + type: object + properties: + name: + not: + pattern: '(\[(gte|lte|gt|lt|ne|neq|in|nin|like|contains|regex)\]$)|(^(filter|filters)$)' + + # 12.9 [M+R] — PROXY (bucket B, MUST -> warn). Complex filtering MUST use + # POST /v1/{collection}/search, carrying pageSize/cursor in the request + # body and responding with the §12.3 envelope. Identifies the search + # endpoint via a `/search`-suffixed path heuristic (proxy for "this is the + # §6.6/§12.9 complex-filtering endpoint") and does NOT verify that the path + # actually sits under a collection resource, nor the filtering semantics of + # the request body itself. + govstack-12.9-body: + description: "POST .../search request body must carry pageSize and cursor (guide 12.9, [M+R], proxy)." + message: "[12.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#129-complex-filtering-via-search + severity: warn + formats: [oas3_1] + given: '$.paths[?(@property.match(/\/search$/))].post.requestBody.content["application/json"].schema' + then: + function: envelopeShape + functionOptions: + properties: + pageSize: {} + cursor: {} + + govstack-12.9-response: + description: "POST .../search response must use the §12.3 cursor envelope (guide 12.9, [M+R], proxy)." + message: "[12.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#129-complex-filtering-via-search + severity: warn + formats: [oas3_1] + given: '$.paths[?(@property.match(/\/search$/))].post.responses["200"].content["application/json"].schema' + then: + function: envelopeShape + functionOptions: + requiredProperties: [items, pageInfo] + properties: + items: { type: array } + pageInfo: { requiredProperties: [nextCursor, hasMore] } diff --git a/api-design-guide/linter/rulesets/s13.yaml b/api-design-guide/linter/rulesets/s13.yaml new file mode 100644 index 0000000..a462489 --- /dev/null +++ b/api-design-guide/linter/rulesets/s13.yaml @@ -0,0 +1,129 @@ +# Rules for §13 authentication and authorisation — generated from the guide. +# +# Implements 13.1, 13.5 (implemented) and 13.2, 13.3, 13.4, 13.6 (partial-proxy). +# Source text: ../../rules.yaml. Severity/formats per <SCRATCHPAD>/linter-conventions.md. +# +# Surface note: §13 rules are "Universal", but the mechanical checks here target +# the OpenAPI surface (formats [oas3_1]). AsyncAPI security coverage/schemes are +# enforced by §17 (17.10 and related); the AsyncAPI parts of §13 are not +# re-checked here. +functionsDir: "../functions" +functions: + - securityCoverage + - s13-schemeExists + - s13-scopeNames + - s13-apiKeyScope +rules: + # 13.1 [M] — declare a security scheme block and apply it by default to every + # operation; per-operation overrides must be explicit. On OpenAPI: root-level + # `security` + `components.securitySchemes`, covering every operation (an + # operation-level `security` — including `security: []` — is an explicit + # override). AsyncAPI coverage is handled by §17.10. + govstack-13.1: + description: "every operation must be covered by a declared security scheme (guide 13.1, [M])." + message: "[13.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation + severity: error + formats: [oas3_1] + given: $ + then: + function: securityCoverage + functionOptions: + requireSchemes: true + + # 13.2 [M+R] — PROXY (bucket B), MUST notched to warn. + # Verifies: an openIdConnect or oauth2 scheme is declared. + # Does NOT verify: which operations are citizen-facing, that citizen-facing + # operations actually apply this scheme, or discovery-URL validity. + govstack-13.2: + description: "an OAuth 2.0 / OpenID Connect security scheme must be declared (guide 13.2, [M+R], proxy)." + message: "[13.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations + severity: warn + formats: [oas3_1] + given: $ + then: + function: s13-schemeExists + functionOptions: + types: [openIdConnect, oauth2] + label: "OAuth 2.0 / OpenID Connect" + + # 13.3 [M+R] — PROXY (bucket B), MUST notched to warn. + # Verifies: a service-to-service scheme is declared (mutualTLS, or oauth2 with + # a clientCredentials flow). + # Does NOT verify: which operations cross a BB-to-BB trust boundary, that they + # apply this scheme, or the AsyncAPI X509/SASL variants (that surface is §17). + govstack-13.3: + description: "a service-to-service (mTLS / OAuth client-credentials) scheme must be declared (guide 13.3, [M+R], proxy)." + message: "[13.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#133-distinct-scheme-for-bb-to-bb-calls + severity: warn + formats: [oas3_1] + given: $ + then: + function: s13-schemeExists + functionOptions: + types: [mutualTLS] + oauthFlows: [clientCredentials] + label: "service-to-service (mTLS / OAuth client-credentials)" + + # 13.4 [M] — PROXY (bucket B), MUST notched to warn. + # Verifies: every declared OAuth scope string matches one of the documented + # shapes (default bb:{bb-code}:{resource}:{action}, reverse-DNS, or + # resource.action). + # Does NOT verify: that {bb-code} is the BB's registered code (§9.11), scope + # ecosystem-uniqueness, single-convention consistency, or per-operation + # scope documentation. + govstack-13.4: + description: "OAuth scope strings must follow the ecosystem-wide naming convention (guide 13.4, [M], proxy)." + message: "[13.4][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes + severity: warn + formats: [oas3_1] + given: $.components.securitySchemes[*].flows[*].scopes + then: + function: s13-scopeNames + functionOptions: + label: "the GovStack scope naming convention" + patterns: + - '^bb:[a-z][a-z0-9-]*:[A-Za-z0-9-]+:[A-Za-z0-9-]+$' + - '^org\.govstack\.[a-z][a-z0-9-]*\.[A-Za-z0-9-]+\.[A-Za-z0-9-]+$' + - '^[A-Za-z0-9-]+\.[A-Za-z0-9-]+$' + + # 13.5 [M+R] — credentials MUST NOT be declared in query parameters or + # cookies; the Authorization header is the only declared credential channel. + # Mechanically: an apiKey scheme MUST NOT use `in: query` or `in: cookie` + # (path/fragment credentials cannot be expressed as OpenAPI security schemes). + govstack-13.5: + description: "apiKey security schemes must not use in: query or in: cookie (guide 13.5, [M+R])." + message: "[13.5][M+R] Credentials must not be declared in query parameters or cookies; an apiKey scheme must not set in: query or in: cookie. Use the Authorization header." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#135-authorization-is-the-credential-channel + severity: error + formats: [oas3_1] + given: $.components.securitySchemes[*] + then: + function: schema + functionOptions: + schema: + not: + allOf: + - properties: { type: { const: apiKey } } + required: [type] + - properties: { in: { enum: [query, cookie] } } + required: [in] + + # 13.6 [R] — PROXY (bucket B), MUST NOT notched to warn. + # Verifies: no apiKey scheme is applied (via root or operation `security`) to a + # non-operational (non-health-like) operation. + # Does NOT verify: which operations "read or write personal data" (uses a §5.9 + # operational-path heuristic instead), nor credentials applied outside declared + # security requirements. + govstack-13.6: + description: "apiKey schemes must be applied only to operational (health-like) endpoints (guide 13.6, [R], proxy)." + message: "[13.6][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#136-api-keys-only-for-operational-endpoints + severity: warn + formats: [oas3_1] + given: $ + then: + function: s13-apiKeyScope diff --git a/api-design-guide/linter/rulesets/s14.yaml b/api-design-guide/linter/rulesets/s14.yaml new file mode 100644 index 0000000..07f5f2d --- /dev/null +++ b/api-design-guide/linter/rulesets/s14.yaml @@ -0,0 +1,28 @@ +# Rules for §14 idempotency — generated from the guide. +# +# Implements 14.1 (partial-proxy). 14.2/14.4/14.5 are runtime and 14.3/14.6 are +# human review (see coverage.yaml). Source text: ../../rules.yaml. +functionsDir: "../functions" +functions: + - s14-idempotencyKey +rules: + # 14.1 [M+R] — PROXY (bucket B), MUST notched to warn. + # Verifies: a POST that declares a 201 response (a create) accepts an + # `Idempotency-Key` request header (path- or operation-level). + # Does NOT verify: the other MUST triggers that are not mechanically visible + # (moves value, irreversible request, sends a message, creates a subscription, + # starts a long-running job), the SHOULD tier for other mutating POSTs, the + # MAY tier for read-like POSTs, or the §14.6 naturally-idempotent exemption. + govstack-14.1: + description: "create-POSTs (201) must accept an Idempotency-Key header (guide 14.1, [M+R], proxy)." + message: "[14.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts + severity: warn + formats: [oas3_1] + given: $.paths[*] + then: + function: s14-idempotencyKey + functionOptions: + header: Idempotency-Key + status: "201" + method: post diff --git a/api-design-guide/linter/rulesets/s15.yaml b/api-design-guide/linter/rulesets/s15.yaml new file mode 100644 index 0000000..6307e08 --- /dev/null +++ b/api-design-guide/linter/rulesets/s15.yaml @@ -0,0 +1,93 @@ +# Rules for §15 asynchronous operations — generated from the guide. +# +# Implements 15.1, 15.5 (implemented) and 15.2, 15.3, 15.4 (partial-proxy). +# Source text: ../../rules.yaml. Severity/formats per <SCRATCHPAD>/linter-conventions.md. +functionsDir: "../functions" +functions: + - responseHeaderRequired + - envelopeShape + - s15-operationsPolling + - s15-cancelPath +rules: + # 15.1 [M+R] — operations that cannot complete synchronously MUST return 202 + # with a Location header. Mechanically: every 202 response declares a Location + # header. That the Location "points to an Operation resource" is a runtime + # value and is not verified here. + govstack-15.1: + description: "every 202 response must declare a Location header (guide 15.1, [M+R])." + message: "[15.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#151-202-with-operation-location + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "202" + headers: [Location] + + # 15.2 [M] — PROXY (bucket B), MUST notched to warn. + # SCOPE LIMIT: checks the in-document `components.schemas.Operation` shape + # ({ id, status, result, error, createdAt, updatedAt } required; progress + # optional). Does NOT verify that the Operation resource is declared once in + # govstack-openapi-common.yaml and $ref'd (needs-context: the common file does + # not yet exist), nor the AIP-151 `done`/`metadata` alternative. + govstack-15.2: + description: "the Operation resource must declare the shared shape (guide 15.2, [M], proxy)." + message: "[15.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape + severity: warn + formats: [oas3_1] + given: $.components.schemas.Operation + then: + function: envelopeShape + functionOptions: + requiredProperties: [id, status, result, error, createdAt, updatedAt] + + # 15.3 [M] — PROXY (bucket B), MUST notched to warn. + # SCOPE LIMIT: checks the in-document Operation `status` enum equals the fixed + # set. Does NOT verify the enum is declared in the common file (needs-context), + # nor the AIP-151 boolean-`done` alternative (which has no `status` property + # and so is not flagged). + govstack-15.3: + description: "Operation status must use the fixed enum (guide 15.3, [M], proxy)." + message: "[15.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum + severity: warn + formats: [oas3_1] + given: $.components.schemas.Operation.properties.status + then: + function: envelopeShape + functionOptions: + enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] + + # 15.4 [M+R] — PROXY (bucket B), notched to warn. + # Verifies: when the Operations pattern is used (an Operation schema or an + # /operations/ path exists), a canonical GET /v{N}/operations/{operationId} + # poll endpoint is declared. + # Does NOT verify: whether asynchronous behaviour is actually needed, or the + # response shape of the poll endpoint (that is §15.2/§15.3). + govstack-15.4: + description: "a canonical GET /v{N}/operations/{operationId} poll endpoint must exist when Operations are used (guide 15.4, [M+R], proxy)." + message: "[15.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#154-polling-the-operation-resource + severity: warn + formats: [oas3_1] + given: $ + then: + function: s15-operationsPolling + + # 15.5 [M+R] — cancellation, when supported, MUST be + # POST /v{N}/operations/{operationId}/cancel. Any Operation-cancellation path + # (a `.../operations/.../cancel` path) that is not the canonical shape, or that + # does not declare a POST, is flagged. Domain-level cancels not under + # /operations/ are out of scope. + govstack-15.5: + description: "Operation cancellation must be POST /v{N}/operations/{operationId}/cancel (guide 15.5, [M+R])." + message: "[15.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#155-cancellation-via-cancel-sub-resource + severity: error + formats: [oas3_1] + given: $.paths + then: + function: s15-cancelPath diff --git a/api-design-guide/linter/rulesets/s16.yaml b/api-design-guide/linter/rulesets/s16.yaml new file mode 100644 index 0000000..4c6dc40 --- /dev/null +++ b/api-design-guide/linter/rulesets/s16.yaml @@ -0,0 +1,139 @@ +# Rules for §16 CloudEvents and webhooks (OpenAPI/webhooks surface). +# +# Implements guide rules 16.1, 16.2, 16.3, 16.4, 16.6, 16.11. Source text: +# ../../rules.yaml. Severity policy and formats follow +# <SCRATCHPAD>/linter-conventions.md. +# +# Scope note: §16 spans both the OpenAPI/webhooks and the AsyncAPI surfaces. +# These rules target the OpenAPI document (formats: [oas3_1]); the AsyncAPI +# clauses of 16.1 (brokered transports use AsyncAPI 3.0) and 16.6 (signature in +# the message-metadata channel) are not enforced here — see the report Flags. +functionsDir: "../functions" +functions: + - envelopeShape + - s16-eventField + - s16-signatureHeader + - s16-subscriptionEndpoints +rules: + # 16.1 [M+R] — PROXY (bucket B), notched MUST -> warn. + # Verifies: HTTP push events are NOT modelled with operation `callbacks`; the + # GovStack push mechanism is OpenAPI 3.1 top-level `webhooks`. + # Does NOT verify: that a BB with events documents them at all (an absent + # event surface is not detectable), nor the AsyncAPI-3.0 clause for brokered + # transports (MQTT/AMQP/Kafka/WebSockets/SSE). + govstack-16.1: + description: "HTTP push events must use top-level webhooks, not operation callbacks (guide 16.1, [M+R], proxy)." + message: "[16.1][M+R] HTTP push events MUST use OpenAPI 3.1 top-level `webhooks`; operation `callbacks` are not the GovStack push mechanism." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented + severity: warn + formats: [oas3_1] + given: $.paths[*][get,put,post,delete,patch].callbacks + then: + function: undefined + + # 16.2 [M] — the CloudEvents envelope MUST declare specversion (const "1.0"), + # id, source, type and data (GovStack domain payload lives under `data`). + govstack-16.2: + description: "webhook event payload must be a CloudEvents v1.0 envelope: specversion(const \"1.0\")/id/source/type/data (guide 16.2, [M])." + message: "[16.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required + severity: error + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: envelopeShape + functionOptions: + requiredProperties: [specversion, id, source, type, data] + properties: + specversion: { const: "1.0" } + + # 16.2 [M] SHOULD sentence — split per the mixed-strength convention: `time` + # and `datacontenttype` SHOULD be included (SHOULD -> warn). + govstack-16.2-recommended: + description: "CloudEvents envelope should include time and datacontenttype (guide 16.2, [M], SHOULD)." + message: "[16.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required + severity: warn + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: envelopeShape + functionOptions: + properties: + time: {} + datacontenttype: {} + + # 16.3 [M] — the pinned event `type` value MUST follow reverse-DNS + # org.govstack.{bb-code}.{resource}.{action} and MUST NOT carry a version + # segment. Only pinned const/enum values are checked (a free-form `type` is a + # 16.2 presence concern, not verifiable here). + govstack-16.3: + description: "event type must be reverse-DNS org.govstack.{bb-code}.{resource}.{action} with no version (guide 16.3, [M])." + message: "[16.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types + severity: error + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: s16-eventField + functionOptions: + property: type + name: event type + expected: "reverse-DNS org.govstack.{bb-code}.{resource}.{action} with no major-version segment" + match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}(?:\.[a-z][a-zA-Z0-9]*){2,}$' + forbidSegments: '^v[0-9]+$' + + # 16.4 [M+R] — PROXY (bucket B), notched MUST/MUST NOT -> warn. + # Verifies: the pinned CloudEvents `source` value contains no deployment-host / + # environment / pod / broker / queue token (localhost, IPs, ports, .internal, + # .svc, .cluster.local, dev/test/staging/prod/... segments). + # Does NOT verify: that the source positively identifies the publishing BB in + # a "stable" way — only known-unstable tokens are flagged. + govstack-16.4: + description: "CloudEvents source must be stable, not a deployment host/env/pod/broker/queue (guide 16.4, [M+R], proxy)." + message: "[16.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#164-stable-cloudevents-source + severity: warn + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: s16-eventField + functionOptions: + property: source + name: event source + sources: [const, enum, default, example, examples] + flags: i + expected: "it must not identify a specific deployment host, pod, broker, queue, or environment" + forbidPattern: '(?:^|[^a-z0-9])(?:dev|test|staging|stage|qa|uat|prod|production|preprod|sandbox|pod|broker|queue)(?:[^a-z0-9]|$)|localhost|\.svc(?:[./]|$)|\.cluster\.local|\.internal(?:[:/]|$)|(?:\d{1,3}\.){3}\d{1,3}|:\d{2,5}(?:/|$)' + + # 16.6 [M+R] — on the OpenAPI/webhooks surface the signature MUST travel in an + # HTTP header named `GovStack-Signature`. Every webhook delivery must declare + # that header parameter (path-item or operation level). The AsyncAPI clause of + # 16.6 (signature in the message-metadata channel) is out of scope here. + govstack-16.6: + description: "every webhook must declare the GovStack-Signature header parameter (guide 16.6, [M+R])." + message: "[16.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header + severity: error + formats: [oas3_1] + given: $.webhooks[*] + then: + function: s16-signatureHeader + functionOptions: + field: GovStack-Signature + + # 16.11 [M+R] — PROXY (bucket B), notched MUST -> warn. + # Verifies: when a subscription surface exists (paths named /subscriptions), + # it exposes create/list/rotate-secret/delete interfaces. + # Does NOT verify: that a BB which should offer subscriptions actually does + # (an absent subscription surface is not flagged), nor the AsyncAPI + # message-command control plane the guide also permits. + govstack-16.11: + description: "subscription management must expose create/list/rotate-secret/delete (guide 16.11, [M+R], proxy)." + message: "[16.11][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s16-subscriptionEndpoints diff --git a/api-design-guide/linter/rulesets/s17-strict.yaml b/api-design-guide/linter/rulesets/s17-strict.yaml new file mode 100644 index 0000000..19c809a --- /dev/null +++ b/api-design-guide/linter/rulesets/s17-strict.yaml @@ -0,0 +1,28 @@ +# STRICT-only rules for §17 — ships only via ../strict.yaml (opt-in), at warn. +# +# Source text: ../../rules.yaml (§17.3). See <SCRATCHPAD>/linter-conventions.md. +functionsDir: "../functions" +functions: + - valuePattern +rules: + # 17.3 [R] STRICT-ONLY (warn). Heuristic scan of channel addresses and channel + # parameter names for personal-data / identifier tokens. This is a keyword + # proxy: it cannot prove a token carries personal data, and deliberately omits + # broad words (bare "name"/"address") except as bounded segments to limit + # false positives. Topic/queue/routing-key names not expressed in the AsyncAPI + # document are out of reach. + govstack-17.3: + description: "Channel addresses/parameter names must not contain personal-data tokens (guide 17.3, [R], strict)." + message: "[17.3][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels + severity: warn + formats: [aas3] + given: + - $.channels[*].address + - $.channels[*].parameters.*~ + then: + function: valuePattern + functionOptions: + name: channel address/parameter + flags: i + forbidPattern: 'phone|msisdn|email|e-mail|passport|national-?id|first-?name|last-?name|sur-?name|given-?name|full-?name|maiden-?name|date-?of-?birth|birth-?date|(^|[.\-_])(ssn|dob|name)([.\-_]|$)' diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml new file mode 100644 index 0000000..202a2d4 --- /dev/null +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -0,0 +1,281 @@ +# Rules for §17 event-driven APIs (AsyncAPI channel documentation). +# +# Source text: ../../rules.yaml (§17). Severity policy, formats (aas3) and the +# proxy/notch conventions follow <SCRATCHPAD>/linter-conventions.md and +# ../coverage.yaml. AsyncAPI-surface rules gate on `formats: [aas3]`. +# +# Custom functions live in ../functions; every function referenced below is +# listed so the bundler and the CLI resolve them through the extends chain. +functionsDir: "../functions" +functions: + - valuePattern + - schemaPropertyNames + - extensionShape + - s17-channelParameters + - s17-cloudEventsPayload + - s17-securityCoverage + - s17-opExtensions + - s17-rejectionMessage + - s17-requestReply + - s17-protocolBindings +rules: + # 17.1 [M+R] — every operation MUST declare action send|receive (the BB's + # publish/consume perspective). + govstack-17.1: + description: "Every AsyncAPI operation must declare action send or receive (guide 17.1, [M+R])." + message: "[17.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#171-send-and-receive-perspective + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: schema + functionOptions: + schema: + type: object + required: [action] + properties: + action: + enum: [send, receive] + + # 17.2 [M] — channel addresses MUST follow reverse-DNS + # org.govstack.{bb-code}.v{major}.{resource}.{event}. bb-code per §9.11 + # (^[a-z][a-z0-9-]{1,30}$). Variable segments may themselves be {param} tokens. + govstack-17.2: + description: "Channel addresses must follow reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event} (guide 17.2, [M])." + message: "[17.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses + severity: error + formats: [aas3] + given: $.channels[*].address + then: + function: valuePattern + functionOptions: + name: channel address + match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.v[0-9]+(?:\.(?:[a-z][a-zA-Z0-9-]*|\{[a-zA-Z0-9_]+\})){2,}$' + + # 17.4 [M+R] — every {param} in a channel address MUST be declared under the + # channel parameters object and documented (non-empty description). AsyncAPI + # 3.0 Parameter Objects have no `schema` field, so the guide's "schema ... MUST + # be documented" is proxied by requiring a description (see the function). + govstack-17.4: + description: "Every {param} in a channel address must be declared under parameters and documented (guide 17.4, [M+R])." + message: "[17.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#174-declared-channel-parameters + severity: error + formats: [aas3] + given: $.channels[*] + then: + function: s17-channelParameters + + # 17.5 [M+R] — PROXY (bucket B). SHOULD NOT -> info. Flags env/broker tokens + # (dev/test/prod/staging/kafka/mqtt/amqp/…) as address segments. Does NOT + # verify arbitrary broker implementation names or deployment prefixes, and a + # token list can miss/over-match legitimate resource names. + govstack-17.5: + description: "Channel addresses should not contain environment/broker names (guide 17.5, [M+R], proxy)." + message: "[17.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#175-no-environment-names-in-addresses + severity: info + formats: [aas3] + given: $.channels[*].address + then: + function: valuePattern + functionOptions: + name: channel address + flags: i + forbidPattern: '(^|[.\-_])(dev|test|prod|staging|uat|qa|sandbox|preprod|nonprod|kafka|rabbitmq|mqtt|amqp|broker)([.\-_]|$)' + + # 17.6 [M] — domain-event message payloads MUST be structured CloudEvents JSON + # with GovStack domain data under `data`. Bare §11 error envelopes are skipped + # (governed by 17.16). Does not resolve payloads $ref'd to external files. + govstack-17.6: + description: "Domain-event message payloads must be structured CloudEvents JSON with data under `data` (guide 17.6, [M])." + message: "[17.6][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads + severity: error + formats: [aas3] + given: $.components.messages[*] + then: + function: s17-cloudEventsPayload + + # 17.8 [M+R] — PROXY (bucket B), MUST -> warn. GovStack-owned message headers + # MUST be camelCase and MUST NOT use the X- prefix (camelCase subsumes the X- + # ban). Does NOT verify the command idempotency-key requirement (needs command + # identification) nor CloudEvents trace extension attributes. + govstack-17.8: + description: "Message header names must be camelCase / not X- prefixed (guide 17.8, [M+R], proxy)." + message: "[17.8][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata + severity: warn + formats: [aas3] + given: $.components.messages[*].headers + then: + function: schemaPropertyNames + functionOptions: + casing: camel + + # 17.9 [M+R] — PROXY (bucket B), MUST -> warn. Localisation headers MUST be + # exactly acceptLanguage / contentLanguage. Flags near-miss variants + # (Accept-Language, content_language, language, locale, lang, …). Does NOT + # verify inbound-vs-outbound direction, nor the "stable fields MUST NOT be + # translated" clause. + govstack-17.9: + description: "Localisation message headers must be named acceptLanguage/contentLanguage (guide 17.9, [M+R], proxy)." + message: "[17.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#179-message-localisation-headers + severity: warn + formats: [aas3] + given: $.components.messages[*].headers + then: + function: schemaPropertyNames + functionOptions: + forbidPattern: '^(?:[Aa]ccept[-_ ][Ll]anguage|ACCEPT[-_ ]LANGUAGE|[Cc]ontent[-_ ][Ll]anguage|CONTENT[-_ ]LANGUAGE|language|Language|LANGUAGE|locale|Locale|lang|Lang)$' + + # 17.10 [M+R] — security schemes MUST be declared under components and applied + # on servers/operations so every operation is covered. Message signing (§16.5) + # is not accepted as a substitute (only servers/operations security counts). + govstack-17.10: + description: "AsyncAPI security schemes must cover every operation via servers/operations (guide 17.10, [M+R])." + message: "[17.10][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1710-security-schemes-cover-every-operation + severity: error + formats: [aas3] + given: $ + then: + function: s17-securityCoverage + + # 17.11 [M+R] — each operation MUST document its delivery guarantee as + # x-govstack-delivery in {atMostOnce, atLeastOnce, effectivelyOnce}. The + # "effectivelyOnce MUST be backed by an idempotency contract" clause is not + # mechanically verifiable. + govstack-17.11: + description: "Each operation must declare x-govstack-delivery in {atMostOnce,atLeastOnce,effectivelyOnce} (guide 17.11, [M+R])." + message: "[17.11][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: extensionShape + functionOptions: + extension: x-govstack-delivery + enum: [atMostOnce, atLeastOnce, effectivelyOnce] + + # 17.12 [M+R] — each operation MUST document ordering via x-govstack-ordering + # (an explicit "none", or the partition key/scope). The key/scope contents are + # defined in govstack-asyncapi-common.yaml and not further validated here. + govstack-17.12: + description: "Each operation must declare x-govstack-ordering (key/scope or explicit none) (guide 17.12, [M+R])." + message: "[17.12][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1712-documented-ordering-guarantees + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: extensionShape + functionOptions: + extension: x-govstack-ordering + + # 17.13 [M+R] — each operation MUST declare the four delivery-management + # capabilities, each stated supported/unsupported/notApplicable. Encoded as the + # per-capability extensions x-govstack-redelivery / -dead-letter / -retention / + # -replay (the exact names are defined in govstack-asyncapi-common.yaml; §17.15 + # confirms x-govstack-replay). Value may be a bare string or {status: ...}. + govstack-17.13: + description: "Each operation must declare redelivery/dead-letter/retention/replay as supported/unsupported/notApplicable (guide 17.13, [M+R])." + message: "[17.13][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: s17-opExtensions + functionOptions: + require: + - x-govstack-redelivery + - x-govstack-dead-letter + - x-govstack-retention + - x-govstack-replay + enumEach: [supported, unsupported, notApplicable] + + # 17.15 [M+R] — delivery/ordering/replay declarations MUST be machine-readable + # (x-govstack-delivery, x-govstack-ordering, x-govstack-replay) AND + # human-readable in the operation description. + govstack-17.15: + description: "Each operation must carry x-govstack-delivery/ordering/replay plus a description (guide 17.15, [M+R])." + message: "[17.15][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: s17-opExtensions + functionOptions: + require: + - x-govstack-delivery + - x-govstack-ordering + - x-govstack-replay + requireDescription: true + + # 17.16 [M+R] — PROXY (bucket B), MUST -> warn. Asserts an async rejection/ + # failure message using the §11 error envelope exists and is correlated. Does + # NOT identify which operations are command-like, nor verify the correlation + # actually points at the initiating message. + govstack-17.16: + description: "An async rejection message using the §11 error envelope, correlated, must exist (guide 17.16, [M+R], proxy)." + message: "[17.16][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1716-async-rejection-error-messages + severity: warn + formats: [aas3] + given: $ + then: + function: s17-rejectionMessage + + # 17.17 [M+R] — when an operation declares a reply, it MUST declare the reply + # channel/address AND a correlation mechanism (a message correlationId or a + # reply address location). Fire-and-forget "pretending" is not decidable. + govstack-17.17: + description: "Request-reply operations must declare a reply channel/address and a correlation mechanism (guide 17.17, [M+R])." + message: "[17.17][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1717-declared-request-reply-correlation + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: s17-requestReply + + # 17.19 [M+R] — PROXY (bucket B), MUST -> warn. Where servers declare a + # binding-relevant protocol, each channel (or an operation bound to it) should + # declare a matching protocol binding. Does NOT verify the specific binding + # fields (topic/key, QoS/retained, exchange/routing-key, framing). + govstack-17.19: + description: "Channels/operations should declare protocol bindings matching the server protocol (guide 17.19, [M+R], proxy)." + message: "[17.19][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant + severity: warn + formats: [aas3] + given: $ + then: + function: s17-protocolBindings + + # 17.20 [M+R] — every components.messages entry MUST define at least one + # example. The SHOULD to show headers+payload per message family + # (command/event/error/completion) is not mechanically verified. + govstack-17.20: + description: "Every message must define at least one example (guide 17.20, [M+R])." + message: "[17.20][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message + severity: error + formats: [aas3] + given: $.components.messages[*] + then: + function: schema + functionOptions: + schema: + type: object + required: [examples] + properties: + examples: + type: array + minItems: 1 diff --git a/api-design-guide/linter/rulesets/s18.yaml b/api-design-guide/linter/rulesets/s18.yaml new file mode 100644 index 0000000..1f4a6d6 --- /dev/null +++ b/api-design-guide/linter/rulesets/s18.yaml @@ -0,0 +1,135 @@ +# Rules for §18 compatibility and lifecycle — generated from the guide; see coverage.yaml. +# +# Implements guide rules 18.1, 18.2, 18.5, 18.7. Source text: ../../rules.yaml. +# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# 18.3/18.4 (backward-/breaking-change diffing against a previous version) and +# 18.6 (informative) are out of scope for this fragment. +# +# Custom functions: s18-versionMajorConsistency reads info.version alongside +# paths/channels in one pass (18.2); no shared function combines those two +# reads. s18-deprecatedHeaders (18.5) walks operations directly instead of +# relying on a `given` JSONPath filter chained after a bracketed method list +# ($.paths[*][get,put,...][?(@.deprecated == true)]) — that chained-filter +# form does not select reliably against this repo's installed Spectral/nimma +# version (confirmed by inspecting nimma's compiled matcher; the filter +# silently matches nothing regardless of the predicate). valuePattern / +# extensionShape are shared. +functionsDir: "../functions" +functions: + - valuePattern + - extensionShape + - s18-versionMajorConsistency + - s18-deprecatedHeaders +rules: + # 18.1 [M] — info.version MUST follow SemVer. Surface: Universal, so this + # checks both OpenAPI and AsyncAPI documents. formats widened the same way + # as the 2.5/3.5 info rules so a wrong-spec-version doc still gets told its + # info.version is non-SemVer. + govstack-18.1: + description: "info.version must be a SemVer string (guide 18.1, [M])." + message: "[18.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#181-semver-versioning + severity: error + formats: [oas3, aas2, aas3] + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$' + + # 18.2 [M] OpenAPI half — a path's /v{N}/ prefix (when present) MUST match + # info.version's major segment. Paths lacking a version prefix entirely are + # guide 5.1's concern, not this rule's. + govstack-18.2-openapi: + description: "OpenAPI path version prefixes must match info.version's major segment (guide 18.2, [M])." + message: "[18.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel + severity: error + formats: [oas3] + given: $ + then: + function: s18-versionMajorConsistency + functionOptions: + surface: openapi + + # 18.2 [M] AsyncAPI half — a channel address MUST include the major version, + # consistent with info.version's major segment. Does NOT verify the text's + # alternative "equivalent machine-readable version field documented in + # govstack-asyncapi-common.yaml" (needs the vendored common file, out of + # scope here); only the address-embedded convention is checked. + govstack-18.2-asyncapi: + description: "AsyncAPI channel addresses must include a major version matching info.version's major segment (guide 18.2, [M])." + message: "[18.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel + severity: error + formats: [aas2, aas3] + given: $ + then: + function: s18-versionMajorConsistency + functionOptions: + surface: asyncapi + + # 18.5 [M+R] — deprecated OpenAPI operations MUST return a Deprecation + # header (RFC 9745) and a Sunset header (RFC 8594). Checked on 2xx + # responses (the primary success contract); the RFC 9745/8594 structured- + # field VALUE syntax is not checkable from a static header declaration, only + # the headers' presence is. Given is the whole paths object rather than a + # filtered operation list — see the functions: comment above for why. + govstack-18.5: + description: "deprecated operations must declare Deprecation and Sunset response headers (guide 18.5, [M+R])." + message: "[18.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers + severity: error + formats: [oas3_1] + given: $.paths + then: + function: s18-deprecatedHeaders + + # 18.7 [M+R] — AsyncAPI channels/operations/messages that declare + # x-govstack-deprecated MUST shape it as {since, sunset, replacement, + # reason}. Detecting "is this deprecated" relies on the extension's own + # presence, since AsyncAPI 3.0 has no native `deprecated` flag; an entity + # deprecated only in prose without the extension cannot be detected this + # way. + govstack-18.7: + description: "x-govstack-deprecated must declare since, sunset, replacement, and reason (guide 18.7, [M+R])." + message: "[18.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata + severity: error + formats: [aas3] + given: + - "$.channels[?(@['x-govstack-deprecated'])]" + - "$.operations[?(@['x-govstack-deprecated'])]" + - "$.components.messages[?(@['x-govstack-deprecated'])]" + then: + function: extensionShape + functionOptions: + extension: x-govstack-deprecated + valueType: object + requiredKeys: [since, sunset, replacement, reason] + + # 18.7 [M+R] — the same deprecated channels/operations/messages MUST also + # note the deprecation in their description. Plain-text presence check for + # a "deprecat*" mention; cannot verify the description is substantively + # useful. + govstack-18.7-description: + description: "deprecated channels/operations/messages must mention the deprecation in their description (guide 18.7, [M+R])." + message: "[18.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata + severity: error + formats: [aas3] + given: + - "$.channels[?(@['x-govstack-deprecated'])]" + - "$.operations[?(@['x-govstack-deprecated'])]" + - "$.components.messages[?(@['x-govstack-deprecated'])]" + then: + function: schema + functionOptions: + schema: + type: object + required: [description] + properties: + description: + type: string + pattern: '[Dd]eprecat' diff --git a/api-design-guide/linter/rulesets/s19.yaml b/api-design-guide/linter/rulesets/s19.yaml new file mode 100644 index 0000000..eea7da4 --- /dev/null +++ b/api-design-guide/linter/rulesets/s19.yaml @@ -0,0 +1,50 @@ +# Rules for §19 localisation — generated from the guide; see coverage.yaml. +# +# Implements guide rule 19.4 as a partial-proxy (bucket B). Source text: +# ../../rules.yaml. 19.1-19.3 (honouring request language, not translating +# stable content, default response language) are runtime behaviour, out of +# scope for a static linter. +# +# 19.4's "Responses or messages with localised content MUST include the +# response language header" cannot be checked directly: whether a document +# node carries "localised content" is not decidable from a static spec. The +# proxy inverts the MUST into its checkable half: wherever the localisation +# *input* signal is present (Accept-Language / acceptLanguage), the paired +# *output* header MUST also be present. Severity is notched one step down +# from the rule's MUST per the partial-proxy policy (MUST -> warn). +functionsDir: "../functions" +functions: + - s19-localisationHeaders +rules: + # 19.4 [M+R] OpenAPI half — PROXY. Does NOT verify that a response actually + # carries localised content when Accept-Language is absent; only that, once + # an operation accepts Accept-Language, some response declares + # Content-Language. + govstack-19.4-openapi: + description: "operations accepting Accept-Language must have a response declaring Content-Language (guide 19.4, [M+R], proxy)." + message: "[19.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/19-localisation.md#194-declare-the-response-language + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch,options,head,trace]" + then: + function: s19-localisationHeaders + functionOptions: + surface: openapi + + # 19.4 [M+R] AsyncAPI half — PROXY, coarser: pairing is done at the channel + # level (any message on the channel declaring acceptLanguage requires some + # message on the same channel to declare contentLanguage), because guide + # 17.9 puts these two headers on *different* messages (inbound vs outbound) + # by design, so a per-message check would misfire on compliant specs. + govstack-19.4-asyncapi: + description: "channels with an acceptLanguage message must have a message declaring contentLanguage (guide 19.4, [M+R], proxy)." + message: "[19.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/19-localisation.md#194-declare-the-response-language + severity: warn + formats: [aas3] + given: $.channels[*] + then: + function: s19-localisationHeaders + functionOptions: + surface: asyncapi diff --git a/api-design-guide/linter/rulesets/s20.yaml b/api-design-guide/linter/rulesets/s20.yaml new file mode 100644 index 0000000..6588352 --- /dev/null +++ b/api-design-guide/linter/rulesets/s20.yaml @@ -0,0 +1,56 @@ +# Rules for §20 conformance and validation — generated from the guide; see coverage.yaml. +# +# Implements guide rule 20.3 (the info.x-govstack-api-guide conformance +# declaration). Source text: ../../rules.yaml. 20.1 (run the OpenAPI/AsyncAPI +# schema validator) and 20.2 (meta/self-referential governance of the ruleset +# itself) are driver/CI and human territory respectively, out of scope here. +functionsDir: "../functions" +functions: + - extensionShape +rules: + # 20.3 [M] — info.x-govstack-api-guide MUST be an object with a SemVer + # `version`. Surface: Universal, so this applies to both OpenAPI and + # AsyncAPI canonical files' info block; formats widened like the other + # info-level rules so it still fires on a wrong-spec-version doc. + govstack-20.3: + description: "info.x-govstack-api-guide must declare a SemVer version (guide 20.3, [M])." + message: "[20.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version + severity: error + formats: [oas3, aas2, aas3] + given: $.info + then: + function: extensionShape + functionOptions: + extension: x-govstack-api-guide + valueType: object + requiredKeys: [version] + semverKeys: [version] + + # 20.3 [M] — the optional `exceptions` list, when present, must be an array + # of objects, not bare rule-id strings: the guide text requires each entry + # carry both a rule ID and a reference to its approved exception record, + # which a plain string cannot. `[OPEN-20-A]` leaves the exact per-entry key + # names undefined, so only the "array of objects" shape is enforced here, + # not specific field names. + govstack-20.3-exceptions: + description: "x-govstack-api-guide.exceptions entries must be objects referencing an approved exception record (guide 20.3, [M])." + message: "[20.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version + severity: error + formats: [oas3, aas2, aas3] + given: $.info + then: + function: schema + functionOptions: + schema: + type: object + properties: + x-govstack-api-guide: + type: object + properties: + exceptions: + type: array + items: + type: object + minProperties: 1 diff --git a/api-design-guide/linter/strict.yaml b/api-design-guide/linter/strict.yaml new file mode 100644 index 0000000..ea377c1 --- /dev/null +++ b/api-design-guide/linter/strict.yaml @@ -0,0 +1,22 @@ +# GovStack API Design Guide — Spectral ruleset (STRICT profile) +# ============================================================= +# Everything in the normative ruleset.yaml PLUS opt-in "strict" heuristics that +# are deliberately noisy (plural-resource guesses, abbreviation dictionaries, +# personal-data term scans, …). Run this profile for a thorough review; run +# ruleset.yaml for CI gating. +# +# npx spectral lint -r strict.yaml path/to/openapi.yaml +# +# Composition (verified to load via bundler and CLI): strict.yaml extends the +# main ruleset (an extends-of-extends: strict -> ruleset.yaml -> section +# fragments) plus one strict fragment per section that owns STRICT-only rules. +# Section agents only ever edit their own rulesets/sNN-strict.yaml; they never +# touch this file. The strict fragments are pre-listed so the bundle loads today. +extends: + - ./ruleset.yaml + - ./rulesets/s04-strict.yaml + - ./rulesets/s05-strict.yaml + - ./rulesets/s08-strict.yaml + - ./rulesets/s09-strict.yaml + - ./rulesets/s17-strict.yaml +rules: {} diff --git a/api-design-guide/linter/tests/_lint-helper.mjs b/api-design-guide/linter/tests/_lint-helper.mjs new file mode 100644 index 0000000..c5acc55 --- /dev/null +++ b/api-design-guide/linter/tests/_lint-helper.mjs @@ -0,0 +1,43 @@ +// Shared helpers for the test suite. Not a test file (the leading underscore +// keeps `node --test` from treating it as one). Bundles a YAML ruleset the same +// way the Spectral CLI does — via the ruleset bundler + loader — and lints +// documents with spectral-core, so tests exercise the exact shipping config. + +import pkg from '@stoplight/spectral-core'; +const { Spectral, Document } = pkg; +import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; +import Parsers from '@stoplight/spectral-parsers'; +import { fetch } from '@stoplight/spectral-runtime'; +import * as fs from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +export const LINTER_DIR = resolve(HERE, '..'); +export const STRICT_PATH = resolve(LINTER_DIR, 'strict.yaml'); +export const RULESET_PATH = resolve(LINTER_DIR, 'ruleset.yaml'); + +/** Bundle + load a YAML ruleset file into a spectral-core Ruleset. */ +export async function loadRuleset(rulesetFile) { + return bundleAndLoadRuleset(resolve(rulesetFile), { fs, fetch }); +} + +/** A Spectral instance with the given ruleset (default: the strict bundle). */ +export async function makeSpectral(rulesetFile = STRICT_PATH) { + const ruleset = await loadRuleset(rulesetFile); + const spectral = new Spectral(); + spectral.setRuleset(ruleset); + return spectral; +} + +/** Lint a file on disk. Returns spectral-core findings. */ +export async function lintFile(spectral, filePath) { + const abs = resolve(filePath); + const doc = new Document(fs.readFileSync(abs, 'utf8'), Parsers.Yaml, abs); + return spectral.run(doc); +} + +/** Lint an in-memory YAML string. */ +export async function lintString(spectral, source, uri = 'inline.yaml') { + return spectral.run(new Document(source, Parsers.Yaml, uri)); +} diff --git a/api-design-guide/linter/tests/coverage.test.mjs b/api-design-guide/linter/tests/coverage.test.mjs new file mode 100644 index 0000000..fa8348c --- /dev/null +++ b/api-design-guide/linter/tests/coverage.test.mjs @@ -0,0 +1,90 @@ +// Coverage drift checks. Opt-in: only runs under COVERAGE_ENFORCE=1, otherwise +// every check is skipped (with a message). Reconciles three artefacts: +// ../rules.yaml — the 166-rule catalogue (source of truth) +// ./coverage.yaml — the coverage contract (rule -> status -> spectral rules) +// ruleset.yaml/strict.yaml bundles — the rules actually shipped +// +// It NEVER edits coverage.yaml; it only asserts they agree. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { loadRuleset, LINTER_DIR, RULESET_PATH, STRICT_PATH } from './_lint-helper.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(HERE, 'fixtures'); + +const enforce = process.env.COVERAGE_ENFORCE === '1'; +const opts = enforce ? {} : { skip: 'set COVERAGE_ENFORCE=1 to run coverage drift checks' }; + +const IMPLEMENTED = new Set(['implemented', 'partial-proxy']); + +// ---- load artefacts (guarded so the skipped case never throws at import) ---- +function loadCatalogueIds() { + const doc = YAML.parse(readFileSync(resolve(LINTER_DIR, '..', 'rules.yaml'), 'utf8')); + return (doc.rules || []).map((r) => String(r.id)); +} +function loadCoverage() { + const doc = YAML.parse(readFileSync(join(LINTER_DIR, 'coverage.yaml'), 'utf8')); + return doc.rules || []; +} +function unionSpectralRules(coverage, statusFilter) { + const set = new Set(); + for (const e of coverage) { + if (statusFilter(e.status)) for (const r of e.spectral_rules || []) set.add(r); + } + return set; +} +function diff(a, b) { + return [...a].filter((x) => !b.has(x)); +} +function assertSameSet(actual, expected, label) { + const missing = diff(expected, actual); // in contract, not shipped + const extra = diff(actual, expected); // shipped, not in contract + assert.ok( + missing.length === 0 && extra.length === 0, + `${label}\n in contract but not shipped: ${JSON.stringify(missing)}\n` + + ` shipped but not in contract: ${JSON.stringify(extra)}`, + ); +} + +test('(a) rules.yaml and coverage.yaml list exactly the same rule ids, once each', opts, () => { + const catalogue = loadCatalogueIds(); + const coverage = loadCoverage(); + const covIds = coverage.map((e) => String(e.id)); + + const dupes = covIds.filter((id, i) => covIds.indexOf(id) !== i); + assert.equal(dupes.length, 0, `coverage.yaml has duplicate ids: ${JSON.stringify([...new Set(dupes)])}`); + + assertSameSet(new Set(covIds), new Set(catalogue), 'rule id mismatch between rules.yaml and coverage.yaml:'); +}); + +test('(b) implemented/partial-proxy spectral_rules == rules shipped in ruleset.yaml', opts, async () => { + const coverage = loadCoverage(); + const contract = unionSpectralRules(coverage, (s) => IMPLEMENTED.has(s)); + const bundle = new Set(Object.keys((await loadRuleset(RULESET_PATH)).rules)); + assertSameSet(bundle, contract, 'ruleset.yaml bundle vs coverage implemented/partial-proxy:'); +}); + +test('(c) strict-only spectral_rules == the extra rules strict.yaml adds', opts, async () => { + const coverage = loadCoverage(); + const contract = unionSpectralRules(coverage, (s) => s === 'strict-only'); + const main = new Set(Object.keys((await loadRuleset(RULESET_PATH)).rules)); + const strict = new Set(Object.keys((await loadRuleset(STRICT_PATH)).rules)); + const strictExtra = new Set(diff(strict, main)); + assertSameSet(strictExtra, contract, 'strict.yaml extra rules vs coverage strict-only:'); +}); + +test('(d) every implemented/partial-proxy/strict-only rule has pass+fail fixtures', opts, () => { + const coverage = loadCoverage(); + const names = unionSpectralRules(coverage, (s) => IMPLEMENTED.has(s) || s === 'strict-only'); + const missing = []; + for (const name of names) { + const dir = join(FIXTURES, name); + if (!existsSync(join(dir, 'pass.yaml')) || !existsSync(join(dir, 'fail.yaml'))) missing.push(name); + } + assert.equal(missing.length, 0, `spectral rules without pass+fail fixtures: ${JSON.stringify(missing.sort())}`); +}); diff --git a/api-design-guide/linter/tests/driver-fixtures/mini-ruleset.yaml b/api-design-guide/linter/tests/driver-fixtures/mini-ruleset.yaml new file mode 100644 index 0000000..0507ef2 --- /dev/null +++ b/api-design-guide/linter/tests/driver-fixtures/mini-ruleset.yaml @@ -0,0 +1,22 @@ +# Self-contained ruleset for driver tests ONLY. +# Two inline rules using built-in Spectral functions (no custom function files), so the +# driver tests do not depend on the real ruleset being finished. Rule codes carry the +# `govstack-<id>-<suffix>` shape the driver strips to a guide rule id (e.g. 2.5). +documentationUrl: https://example.org/govstack-guide +rules: + govstack-2.5-contact: + description: "info block must declare a contact" + message: "info.contact is required (§2.5)" + given: "$.info" + severity: error + then: + field: contact + function: truthy + govstack-2.5-description: + description: "info block must declare a description" + message: "info.description is required (§2.5)" + given: "$.info" + severity: warn + then: + field: description + function: truthy diff --git a/api-design-guide/linter/tests/driver.test.mjs b/api-design-guide/linter/tests/driver.test.mjs new file mode 100644 index 0000000..93c4dfa --- /dev/null +++ b/api-design-guide/linter/tests/driver.test.mjs @@ -0,0 +1,320 @@ +// End-to-end tests for the lint driver (cli.mjs). Each test builds a crafted temp repo +// under os.tmpdir() and runs the CLI as a child process. Tests always pass +// --skip-validators (external validators may be absent) except the one that deliberately +// exercises the validator-missing NOTICE path, and use a tiny self-contained mini-ruleset +// so they do not depend on the real ruleset being finished. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(HERE, '..', 'cli.mjs'); +const MINI_RULESET = path.join(HERE, 'driver-fixtures', 'mini-ruleset.yaml'); + +// Build a temp repo from a { relativePath: contents } map. Returns the repo dir. +function makeRepo(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'govstack-driver-')); + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'api'), { recursive: true }); + for (const [rel, contents] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, contents); + } + return dir; +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// Run the CLI. `extraArgs` are appended; `env` overrides process env when provided. +function runCli(args, { env } = {}) { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + env: env ?? process.env, + }); + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +function runCliJson(args, opts) { + const r = runCli(['--format', 'json', ...args], opts); + let json; + try { + json = JSON.parse(r.stdout); + } catch { + json = null; + } + return { ...r, json }; +} + +const CLEAN_OPENAPI = `openapi: 3.1.0 +info: + title: Demo + version: 1.0.0 + description: A demo API + contact: + name: Team +paths: {} +`; + +const OPENAPI_MISSING_CONTACT = (guideVersion, extraGuide = '') => `openapi: 3.1.0 +info: + title: Demo + version: ${guideVersion} + description: A demo API + x-govstack-api-guide: + version: ${guideVersion}${extraGuide} +paths: {} +`; + +test('no spec files at all -> prominent notice, exit 0', () => { + const dir = makeRepo({}); + try { + const r = runCli(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + assert.equal(r.status, 0); + assert.match(r.stdout, /no API spec files found to lint/i); + } finally { + cleanup(dir); + } +}); + +test('non-empty legacy swagger.yaml -> file-canonical-name error, exit 1', () => { + const dir = makeRepo({ 'api/swagger.yaml': 'openapi: 3.1.0\ninfo: {title: X}\n' }); + try { + const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + assert.equal(r.status, 1); + const codes = r.json.files.flatMap((f) => f.findings.map((x) => x.code)); + assert.ok(codes.includes('file-canonical-name')); + const finding = r.json.files + .flatMap((f) => f.findings) + .find((x) => x.code === 'file-canonical-name'); + assert.equal(finding.guideRule, '2.2'); + assert.equal(finding.severity, 'error'); + assert.equal(r.json.failed, true); + } finally { + cleanup(dir); + } +}); + +test('empty legacy placeholder -> notice only, exit 0', () => { + const dir = makeRepo({ 'api/swagger.yaml': ' \n', 'api/swagger.json': '' }); + try { + const r = runCli(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + assert.equal(r.status, 0); + assert.match(r.stdout, /Empty legacy placeholder api\/swagger\.yaml/); + assert.match(r.stdout, /Empty legacy placeholder api\/swagger\.json/); + assert.doesNotMatch(r.stdout, /file-canonical-name/); + } finally { + cleanup(dir); + } +}); + +test('divergent copy detection honours the exclusion list', () => { + const dir = makeRepo({ + 'api/openapi.yaml': CLEAN_OPENAPI, + 'docs/copy.yaml': 'openapi: 3.1.0\ninfo: {title: dup}\n', + 'other.json': 'asyncapi: 3.0.0\ninfo: {title: ev}\n', + 'random.yaml': 'name: ci\njobs: {}\n', + // Excluded directories: must NOT be flagged. + 'spec/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', + 'examples/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', + 'test/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', + 'node_modules/foo/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', + 'api-design-guide/fixtures/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', + }); + try { + const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + // Warn-level findings do not fail the default (fail-on error) run. + assert.equal(r.status, 0); + const divergent = r.json.files + .flatMap((f) => f.findings) + .filter((x) => x.code === 'file-divergent-copies'); + const flaggedPaths = r.json.files + .filter((f) => f.findings.some((x) => x.code === 'file-divergent-copies')) + .map((f) => f.path) + .sort(); + assert.deepEqual(flaggedPaths, ['docs/copy.yaml', 'other.json']); + // The AsyncAPI copy maps to guide rule 3.3, the OpenAPI copy to 2.3. + const byPath = Object.fromEntries( + r.json.files.map((f) => [f.path, f.findings.find((x) => x.code === 'file-divergent-copies')]), + ); + assert.equal(byPath['docs/copy.yaml'].guideRule, '2.3'); + assert.equal(byPath['other.json'].guideRule, '3.3'); + assert.ok(divergent.every((x) => x.severity === 'warn')); + } finally { + cleanup(dir); + } +}); + +test('exceptions suppress a firing rule end-to-end (object form), exit 0', () => { + const dir = makeRepo({ + 'api/openapi.yaml': OPENAPI_MISSING_CONTACT( + '0.2.0', + '\n exceptions:\n - rule: "2.5"\n record: "EXC-2024-001"', + ), + }); + try { + const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + assert.equal(r.status, 0); + const active = r.json.files.flatMap((f) => f.findings); + assert.ok(!active.some((x) => x.code === 'govstack-2.5-contact')); + const suppressed = r.json.files.flatMap((f) => f.suppressed); + const s = suppressed.find((x) => x.code === 'govstack-2.5-contact'); + assert.ok(s, 'contact finding should be suppressed'); + assert.equal(s.guideRule, '2.5'); + assert.equal(s.exceptionRecord, 'EXC-2024-001'); + assert.equal(r.json.summary.suppressed, 1); + } finally { + cleanup(dir); + } +}); + +test('exceptions accept the plain-string form', () => { + const dir = makeRepo({ + 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.0', '\n exceptions:\n - "2.5"'), + }); + try { + const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + assert.equal(r.status, 0); + const suppressed = r.json.files.flatMap((f) => f.suppressed); + const s = suppressed.find((x) => x.code === 'govstack-2.5-contact'); + assert.ok(s, 'contact finding should be suppressed via string exception'); + assert.equal(s.exceptionRecord, null); + } finally { + cleanup(dir); + } +}); + +test('guide version major.minor mismatch emits a notice; matching version does not', () => { + const mismatchDir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.1.0') }); + const matchDir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.5') }); + try { + const mm = runCliJson([ + '--repo-root', mismatchDir, '--skip-validators', '--ruleset', MINI_RULESET, + ]); + assert.ok(mm.json.notices.some((n) => /major\.minor mismatch/.test(n))); + + const ok = runCliJson([ + '--repo-root', matchDir, '--skip-validators', '--ruleset', MINI_RULESET, + ]); + assert.ok(!ok.json.notices.some((n) => /major\.minor mismatch/.test(n))); + } finally { + cleanup(mismatchDir); + cleanup(matchDir); + } +}); + +test('--format json has the documented shape', () => { + const dir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.0') }); + try { + const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + assert.ok(r.json, 'stdout must be valid JSON'); + assert.deepEqual( + Object.keys(r.json).sort(), + ['failOn', 'failed', 'files', 'notices', 'summary'], + ); + assert.ok(Array.isArray(r.json.files)); + const file = r.json.files[0]; + assert.deepEqual(Object.keys(file).sort(), ['findings', 'path', 'suppressed']); + const finding = file.findings[0]; + assert.deepEqual( + Object.keys(finding).sort(), + ['code', 'documentationUrl', 'guideRule', 'message', 'path', 'range', 'severity'], + ); + assert.deepEqual( + Object.keys(r.json.summary).sort(), + ['errors', 'filesLinted', 'info', 'suppressed', 'warnings'], + ); + assert.equal(r.json.failOn, 'error'); + assert.equal(typeof r.json.failed, 'boolean'); + } finally { + cleanup(dir); + } +}); + +test('--fail-on never exits 0 even with error findings', () => { + const dir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.0') }); + try { + const r = runCliJson([ + '--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET, '--fail-on', 'never', + ]); + assert.equal(r.status, 0); + assert.equal(r.json.failed, false); + // The error finding is still reported, just not fatal. + assert.ok(r.json.summary.errors >= 1); + } finally { + cleanup(dir); + } +}); + +test('unparseable spec -> exit 2 naming the file', () => { + const dir = makeRepo({ 'api/openapi.yaml': 'openapi: 3.1.0\ninfo: {title: [oops\n bad: : :\n' }); + try { + const r = runCli(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + assert.equal(r.status, 2); + assert.match(r.stderr, /Cannot parse spec file .*api\/openapi\.yaml/); + } finally { + cleanup(dir); + } +}); + +test('bad flag value -> exit 2', () => { + const dir = makeRepo({}); + try { + const r = runCli([ + '--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET, '--fail-on', 'bogus', + ]); + assert.equal(r.status, 2); + assert.match(r.stderr, /Invalid --fail-on/); + } finally { + cleanup(dir); + } +}); + +test('missing ruleset -> exit 2 when there is a spec to lint', () => { + const dir = makeRepo({ 'api/openapi.yaml': CLEAN_OPENAPI }); + try { + const r = runCli([ + '--repo-root', dir, '--skip-validators', '--ruleset', path.join(dir, 'does-not-exist.yaml'), + ]); + assert.equal(r.status, 2); + assert.match(r.stderr, /Ruleset not found/); + } finally { + cleanup(dir); + } +}); + +test('validator-missing NOTICE path (empty PATH) does not crash, exit 0', () => { + const dir = makeRepo({ + 'api/openapi.yaml': CLEAN_OPENAPI, + 'api/asyncapi.yaml': `asyncapi: 3.0.0 +info: + title: Ev + version: 1.0.0 + description: events + contact: + name: Team +operations: {} +`, + }); + try { + // Empty PATH so spawned validators (openapi-spec-validator, npx/asyncapi) ENOENT. + // node itself is invoked by absolute path, so the CLI still runs. + const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: '' }, + }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /openapi-spec-validator not found/); + assert.match(r.stdout, /AsyncAPI CLI not found/); + assert.match(r.stdout, /pip install openapi-spec-validator/); + assert.match(r.stdout, /npm i -g @asyncapi\/cli/); + } finally { + cleanup(dir); + } +}); diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.1/fail.yaml new file mode 100644 index 0000000..4976c6a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.1/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.1 (opaque resource identifiers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + id: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.1/pass.yaml new file mode 100644 index 0000000..561eed9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.1/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.1 (opaque resource identifiers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + id: + type: string + format: uuid diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.10/fail.yaml new file mode 100644 index 0000000..e1f8f2e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.10/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.10 (ISO 4217 currency codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + currency: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.10/pass.yaml new file mode 100644 index 0000000..9aa1e28 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.10/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.10 (ISO 4217 currency codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + currency: + type: string + pattern: '^[A-Z]{3}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.2/fail.yaml new file mode 100644 index 0000000..656662e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.2/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.2 (RFC 3339 timestamps). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + createdAt: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.2/pass.yaml new file mode 100644 index 0000000..3df8e9a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.2/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.2 (RFC 3339 timestamps). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + createdAt: + type: string + format: date-time diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.3/fail.yaml new file mode 100644 index 0000000..71c53f2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.3/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.3 (RFC 3339 calendar dates). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + birthDate: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.3/pass.yaml new file mode 100644 index 0000000..0d3741e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.3/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.3 (RFC 3339 calendar dates). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + birthDate: + type: string + format: date diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.4/fail.yaml new file mode 100644 index 0000000..caccfca --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.4/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.4 (decimal-string monetary amounts). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + price: + type: number diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.4/pass.yaml new file mode 100644 index 0000000..62daf7a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.4/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.4 (decimal-string monetary amounts). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + price: + type: object + required: [amount, currency] + properties: + amount: + type: string + currency: + type: string + pattern: '^[A-Z]{3}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.5/fail.yaml new file mode 100644 index 0000000..ca3927e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.5/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.5 (E.164 phone numbers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + phoneNumber: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.5/pass.yaml new file mode 100644 index 0000000..372c6a9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.5/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.5 (E.164 phone numbers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + phoneNumber: + type: string + pattern: '^\+[1-9]\d{1,14}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.6/fail.yaml new file mode 100644 index 0000000..ee88717 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.6/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.6 (RFC 5322 email addresses). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + email: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.6/pass.yaml new file mode 100644 index 0000000..4ae6350 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.6/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.6 (RFC 5322 email addresses). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + email: + type: string + format: email diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.7/fail.yaml new file mode 100644 index 0000000..18cad44 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.7/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.7 (base64 payloads). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + signature: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.7/pass.yaml new file mode 100644 index 0000000..7095c93 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.7/pass.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.7 (base64 payloads). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + signature: + type: string + contentEncoding: base64 + maxLength: 4096 diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.8/fail.yaml new file mode 100644 index 0000000..7320e60 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.8/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.8 (ISO 3166-1 country codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + country: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.8/pass.yaml new file mode 100644 index 0000000..2418706 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.8/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.8 (ISO 3166-1 country codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + country: + type: string + pattern: '^[A-Z]{2}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.9/fail.yaml new file mode 100644 index 0000000..4d7a5aa --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.9/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.9 (BCP 47 language codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + language: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.9/pass.yaml new file mode 100644 index 0000000..c45108d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.9/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.9 (BCP 47 language codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + language: + type: string + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.1/fail.yaml new file mode 100644 index 0000000..b30d450 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.1/fail.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.1. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/json: + schema: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.1/pass.yaml new file mode 100644 index 0000000..57b03de --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.1/pass.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.1. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: { type: string } + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml new file mode 100644 index 0000000..4648732 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.2. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [code] + properties: + code: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml new file mode 100644 index 0000000..ef58f4b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.2. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: { type: string } + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.3/fail.yaml new file mode 100644 index 0000000..0388905 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.3/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.3. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml new file mode 100644 index 0000000..81c0de6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.3. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: { type: string } + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml new file mode 100644 index 0000000..d273b20 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.4. + contact: {} +paths: + /v1/foos: + post: + operationId: createFoo + summary: Create a foo + description: Create a foo. + tags: [foos] + requestBody: + content: + application/json: + schema: { type: object } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object } + '400': + description: Bad request + content: + application/problem+json: + schema: + type: object + required: [type, title, status] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml new file mode 100644 index 0000000..931ef27 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.4. + contact: {} +paths: + /v1/foos: + post: + operationId: createFoo + summary: Create a foo + description: Create a foo. + tags: [foos] + requestBody: + content: + application/json: + schema: { type: object } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object } + '400': + description: Bad request + content: + application/problem+json: + schema: + type: object + required: [type, title, status, errors] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + errors: + type: array + items: + type: object + required: [pointer, code, message] + properties: + pointer: { type: string } + code: { type: string } + message: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/fail.yaml new file mode 100644 index 0000000..ae5161e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/fail.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.5-enum. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: + type: string + enum: [FooNotFound] + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml new file mode 100644 index 0000000..453e6d0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.5-enum. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: + type: string + enum: [org.govstack.identity.personNotFound] + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-example/fail.yaml new file mode 100644 index 0000000..0c40eac --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.5-example/fail.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.5-example. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: + type: string + example: FooNotFound + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml new file mode 100644 index 0000000..d60e6b4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.5-example. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: + type: string + example: org.govstack.identity.personNotFound + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.1/fail.yaml new file mode 100644 index 0000000..dd50e0d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.1/fail.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.1. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml new file mode 100644 index 0000000..77e9308 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.1. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor, hasMore] + properties: + nextCursor: { type: string, nullable: true } + hasMore: { type: boolean } + # Guide 12.1 covers endpoints returning collections; the operational liveness + # probe /health is not a collection, so a bare GET must NOT fire this rule. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml new file mode 100644 index 0000000..487813a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.2. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor, hasMore] + properties: + nextCursor: { type: string, nullable: true } + hasMore: { type: boolean } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml new file mode 100644 index 0000000..a9603f9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.2. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor, hasMore] + properties: + nextCursor: { type: string, nullable: true } + hasMore: { type: boolean } + # Guide 12.2 covers collection endpoints; the operational liveness probe + # /health is exempt (s12-collectionPagination), so a bare GET must NOT fire. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.3/fail.yaml new file mode 100644 index 0000000..6800c27 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.3/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.3. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml new file mode 100644 index 0000000..2cd6eb4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.3. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor, hasMore] + properties: + nextCursor: { type: string, nullable: true } + hasMore: { type: boolean } + # Guide 12.3 covers collection endpoints; the operational liveness probe + # /health is exempt (s12-collectionPagination), so a bare GET must NOT fire. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.4/fail.yaml new file mode 100644 index 0000000..8a8b960 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.4/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.4. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.4/pass.yaml new file mode 100644 index 0000000..d9cdd98 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.4/pass.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.4. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } + # Guide 12.4 (pageSize bounds) applies to collection endpoints; the + # operational liveness probe /health is exempt in the given, so a bare GET + # with no pageSize must NOT fire this rule. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.6/fail.yaml new file mode 100644 index 0000000..1b6f103 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.6/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.6. + contact: {} +paths: + /v1/admin/foos: + get: + operationId: listAdminFoos + summary: List foos (admin) + description: List foos for admin tooling, offset-paginated. + tags: [foos] + parameters: + - name: offset + in: query + schema: { type: integer, default: 0 } + - name: limit + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, offset, limit] + properties: + items: + type: array + items: { type: object } + offset: { type: integer } + limit: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.6/pass.yaml new file mode 100644 index 0000000..86010e6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.6/pass.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.6. + contact: {} +paths: + /v1/admin/foos: + get: + operationId: listAdminFoos + summary: List foos (admin) + description: List foos for admin tooling, offset-paginated. + tags: [foos] + parameters: + - name: offset + in: query + schema: { type: integer, default: 0 } + - name: limit + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, offset, limit, total] + properties: + items: + type: array + items: { type: object } + offset: { type: integer } + limit: { type: integer } + total: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/fail.yaml new file mode 100644 index 0000000..e43e831 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-grammar. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: sort + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/pass.yaml new file mode 100644 index 0000000..3a3fbff --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-grammar. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: sort + in: query + schema: { type: string, pattern: '^-?[A-Za-z_][A-Za-z0-9_]*(,-?[A-Za-z_][A-Za-z0-9_]*)*$' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-name/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/fail.yaml new file mode 100644 index 0000000..61c9d68 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-name. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: orderBy + in: query + schema: { type: string, pattern: '^-?[A-Za-z_][A-Za-z0-9_]*(,-?[A-Za-z_][A-Za-z0-9_]*)*$' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-name/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/pass.yaml new file mode 100644 index 0000000..81c8634 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-name. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: sort + in: query + schema: { type: string, pattern: '^-?[A-Za-z_][A-Za-z0-9_]*(,-?[A-Za-z_][A-Za-z0-9_]*)*$' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.8/fail.yaml new file mode 100644 index 0000000..3d87dd6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.8/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.8. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: 'status[gte]' + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.8/pass.yaml new file mode 100644 index 0000000..5cca17a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.8/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.8. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: status + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml new file mode 100644 index 0000000..c48d454 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml @@ -0,0 +1,39 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-body. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor, hasMore] + properties: + nextCursor: { type: string, nullable: true } + hasMore: { type: boolean } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml new file mode 100644 index 0000000..73477a4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-body. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + pageSize: + type: integer + cursor: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor, hasMore] + properties: + nextCursor: { type: string, nullable: true } + hasMore: { type: boolean } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-response/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/fail.yaml new file mode 100644 index 0000000..7291a2d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-response. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + pageSize: + type: integer + cursor: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml new file mode 100644 index 0000000..77f8f87 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-response. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + pageSize: + type: integer + cursor: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor, hasMore] + properties: + nextCursor: { type: string, nullable: true } + hasMore: { type: boolean } diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.1/fail.yaml new file mode 100644 index 0000000..6435b23 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.1/fail.yaml @@ -0,0 +1,20 @@ +# 13.1 fail: a scheme is declared but GET /v1/things is covered by neither a +# root-level nor an operation-level security requirement. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: {} +paths: + /v1/things: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.1/pass.yaml new file mode 100644 index 0000000..fc30050 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.1/pass.yaml @@ -0,0 +1,21 @@ +# 13.1 pass: a root-level security requirement covers every operation. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +security: + - OAuth: [] +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: {} +paths: + /v1/things: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2/fail.yaml new file mode 100644 index 0000000..7b4d9df --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2/fail.yaml @@ -0,0 +1,19 @@ +# 13.2 fail: no openIdConnect or oauth2 scheme is declared (only an apiKey). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2/pass.yaml new file mode 100644 index 0000000..e92e7e9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2/pass.yaml @@ -0,0 +1,18 @@ +# 13.2 pass: an openIdConnect scheme is declared for citizen-facing operations. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OIDC: + type: openIdConnect + openIdConnectUrl: https://example.org/.well-known/openid-configuration +paths: + /v1/things: + get: + security: + - OIDC: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.3/fail.yaml new file mode 100644 index 0000000..efd7ee5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.3/fail.yaml @@ -0,0 +1,26 @@ +# 13.3 fail: no service-to-service scheme. Only OIDC and an oauth2 scheme whose +# single flow is authorizationCode (not clientCredentials). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OIDC: + type: openIdConnect + openIdConnectUrl: https://example.org/.well-known/openid-configuration + OAuth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.org/authorize + tokenUrl: https://example.org/token + scopes: {} +paths: + /v1/things: + get: + security: + - OIDC: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.3/pass.yaml new file mode 100644 index 0000000..2523cc0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.3/pass.yaml @@ -0,0 +1,20 @@ +# 13.3 pass: a mutualTLS scheme is declared for BB-to-BB (service-to-service) calls. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OIDC: + type: openIdConnect + openIdConnectUrl: https://example.org/.well-known/openid-configuration + MutualTLS: + type: mutualTLS +paths: + /v1/things: + get: + security: + - OIDC: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.4/fail.yaml new file mode 100644 index 0000000..19a47ad --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.4/fail.yaml @@ -0,0 +1,22 @@ +# 13.4 fail: an OAuth scope string that follows none of the documented shapes. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + read_things: read things +paths: + /v1/things: + get: + security: + - OAuth: [read_things] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.4/pass.yaml new file mode 100644 index 0000000..1d2e4cc --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.4/pass.yaml @@ -0,0 +1,23 @@ +# 13.4 pass: scope strings follow the default bb:{bb-code}:{resource}:{action} shape. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + bb:registry:person:read: read persons + bb:registry:person:write: write persons +paths: + /v1/things: + get: + security: + - OAuth: [bb:registry:person:read] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.5/fail.yaml new file mode 100644 index 0000000..3df0e64 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.5/fail.yaml @@ -0,0 +1,19 @@ +# 13.5 fail: an apiKey scheme carries the credential in a query parameter. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + ApiKey: + type: apiKey + in: query + name: api_key +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.5/pass.yaml new file mode 100644 index 0000000..cfd7e27 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.5/pass.yaml @@ -0,0 +1,19 @@ +# 13.5 pass: the apiKey scheme is carried in a header, not a query or cookie. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.6/fail.yaml new file mode 100644 index 0000000..cd13e41 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.6/fail.yaml @@ -0,0 +1,20 @@ +# 13.6 fail: an apiKey scheme is applied (via root security) to a data +# operation (GET /v1/users), not just an operational endpoint. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +security: + - ApiKey: [] +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key +paths: + /v1/users: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.6/pass.yaml new file mode 100644 index 0000000..36bb47e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.6/pass.yaml @@ -0,0 +1,32 @@ +# 13.6 pass: the apiKey scheme is applied only to /health; data operations use OAuth. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +security: + - OAuth: [] +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: {} +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok + /v1/users: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-14.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-14.1/fail.yaml new file mode 100644 index 0000000..88829ac --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-14.1/fail.yaml @@ -0,0 +1,15 @@ +# 14.1 fail: a create-POST (declares 201) does not accept an Idempotency-Key header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/things: + post: + responses: + '201': + description: created + headers: + Location: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-14.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-14.1/pass.yaml new file mode 100644 index 0000000..9900858 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-14.1/pass.yaml @@ -0,0 +1,21 @@ +# 14.1 pass: the create-POST accepts an Idempotency-Key request header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/things: + post: + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + responses: + '201': + description: created + headers: + Location: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.1/fail.yaml new file mode 100644 index 0000000..41a05ac --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.1/fail.yaml @@ -0,0 +1,11 @@ +# 15.1 fail: a 202 Accepted response does not declare a Location header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.1/pass.yaml new file mode 100644 index 0000000..a42d148 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.1/pass.yaml @@ -0,0 +1,15 @@ +# 15.1 pass: the 202 Accepted response declares a Location header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted + headers: + Location: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml new file mode 100644 index 0000000..e852ad6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml @@ -0,0 +1,17 @@ +# 15.2 fail: the in-document Operation resource omits required shape properties +# (result, error, createdAt, updatedAt are missing from `required`). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: {} +components: + schemas: + Operation: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml new file mode 100644 index 0000000..45c31ea --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml @@ -0,0 +1,30 @@ +# 15.2 pass: the Operation resource declares the full shared shape +# ({ id, status, result, error, createdAt, updatedAt }; progress optional). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: {} +components: + schemas: + Operation: + type: object + required: [id, status, result, error, createdAt, updatedAt] + properties: + id: + type: string + status: + type: string + enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] + result: + type: object + error: + type: object + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + progress: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml new file mode 100644 index 0000000..a97582c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml @@ -0,0 +1,26 @@ +# 15.3 fail: the Operation status property declares no fixed enum. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: {} +components: + schemas: + Operation: + type: object + required: [id, status, result, error, createdAt, updatedAt] + properties: + id: + type: string + status: + type: string + result: + type: object + error: + type: object + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml new file mode 100644 index 0000000..a29331a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml @@ -0,0 +1,27 @@ +# 15.3 pass: the Operation status property declares the fixed enum. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: {} +components: + schemas: + Operation: + type: object + required: [id, status, result, error, createdAt, updatedAt] + properties: + id: + type: string + status: + type: string + enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] + result: + type: object + error: + type: object + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.4/fail.yaml new file mode 100644 index 0000000..953f9e8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.4/fail.yaml @@ -0,0 +1,20 @@ +# 15.4 fail: the Operations pattern is used (an Operation schema exists and a +# job returns 202) but no canonical GET /v{N}/operations/{operationId} exists. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted + headers: + Location: + schema: + type: string +components: + schemas: + Operation: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.4/pass.yaml new file mode 100644 index 0000000..de51260 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.4/pass.yaml @@ -0,0 +1,30 @@ +# 15.4 pass: the canonical GET /v1/operations/{operationId} poll endpoint exists. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted + headers: + Location: + schema: + type: string + /v1/operations/{operationId}: + get: + parameters: + - name: operationId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok +components: + schemas: + Operation: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.5/fail.yaml new file mode 100644 index 0000000..a04c99d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.5/fail.yaml @@ -0,0 +1,12 @@ +# 15.5 fail: an Operation-cancellation path that is not the canonical shape +# (missing the /v{N} version prefix). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /operations/{operationId}/cancel: + post: + responses: + '202': + description: accepted diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.5/pass.yaml new file mode 100644 index 0000000..90aa71a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.5/pass.yaml @@ -0,0 +1,17 @@ +# 15.5 pass: cancellation is POST /v1/operations/{operationId}/cancel. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/operations/{operationId}/cancel: + post: + parameters: + - name: operationId + in: path + required: true + schema: + type: string + responses: + '202': + description: accepted diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.1/fail.yaml new file mode 100644 index 0000000..9069257 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.1/fail.yaml @@ -0,0 +1,31 @@ +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 + description: HTTP push modelled with operation callbacks (should use webhooks). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/orders: + post: + operationId: createOrder + summary: Create an order + description: Creates an order and pushes status changes to a callback URL. + tags: [orders] + responses: + '201': + description: Created + callbacks: + orderStatus: + '{$request.body#/callbackUrl}': + post: + summary: Order status changed + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.1/pass.yaml new file mode 100644 index 0000000..3fcee58 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.1/pass.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 + description: HTTP push modelled with top-level webhooks (no callbacks). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/orders: + post: + operationId: createOrder + summary: Create an order + description: Creates an order. + tags: [orders] + responses: + '201': + description: Created +webhooks: + orderStatusChanged: + post: + summary: Order status changed + requestBody: + content: + application/cloudevents+json: + schema: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml new file mode 100644 index 0000000..de96b8b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Subscriptions API + version: 1.0.0 + description: Subscription surface is missing a rotate-secret interface. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/subscriptions: + post: + operationId: createSubscription + summary: Create a subscription + description: Registers a webhook subscription. + tags: [subscriptions] + responses: + '201': + description: Created + get: + operationId: listSubscriptions + summary: List subscriptions + description: Lists webhook subscriptions. + tags: [subscriptions] + responses: + '200': + description: OK + /v1/subscriptions/{subscriptionId}: + delete: + operationId: deleteSubscription + summary: Delete a subscription + description: Removes a webhook subscription. + tags: [subscriptions] + parameters: + - name: subscriptionId + in: path + required: true + schema: + type: string + responses: + '204': + description: No Content diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml new file mode 100644 index 0000000..fbe2406 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml @@ -0,0 +1,56 @@ +openapi: 3.1.0 +info: + title: Subscriptions API + version: 1.0.0 + description: Subscription surface exposes create/list/rotate-secret/delete. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/subscriptions: + post: + operationId: createSubscription + summary: Create a subscription + description: Registers a webhook subscription. + tags: [subscriptions] + responses: + '201': + description: Created + get: + operationId: listSubscriptions + summary: List subscriptions + description: Lists webhook subscriptions. + tags: [subscriptions] + responses: + '200': + description: OK + /v1/subscriptions/{subscriptionId}: + delete: + operationId: deleteSubscription + summary: Delete a subscription + description: Removes a webhook subscription. + tags: [subscriptions] + parameters: + - name: subscriptionId + in: path + required: true + schema: + type: string + responses: + '204': + description: No Content + /v1/subscriptions/{subscriptionId}/rotate-secret: + post: + operationId: rotateSubscriptionSecret + summary: Rotate the signing secret + description: Rotates the subscription signing secret. + tags: [subscriptions] + parameters: + - name: subscriptionId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml new file mode 100644 index 0000000..dafe949 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents envelope omits the recommended time/datacontenttype. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "org.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml new file mode 100644 index 0000000..229f791 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents envelope includes the recommended time/datacontenttype. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "org.govstack.payments.payment.completed" + time: + type: string + format: date-time + datacontenttype: + type: string + const: "application/json" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2/fail.yaml new file mode 100644 index 0000000..4bd2051 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2/fail.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook payload is not a valid CloudEvents envelope. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type] + properties: + specversion: + type: string + id: + type: string + source: + type: string + type: + type: string + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml new file mode 100644 index 0000000..3cee2f7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook payload is a valid CloudEvents v1.0 envelope. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "org.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml new file mode 100644 index 0000000..89064ca --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type embeds a version segment (v1), which is forbidden. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "org.govstack.payments.v1.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml new file mode 100644 index 0000000..89c1d87 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type is reverse-DNS with no version segment. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "org.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml new file mode 100644 index 0000000..f88bebb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents source points at a deployment host/env/port. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + examples: + - "https://pay-prod-eu1.internal:8443/events" + type: + type: string + const: "org.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml new file mode 100644 index 0000000..60074f3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents source is a stable BB identifier. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "org.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml new file mode 100644 index 0000000..b7f844a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook delivery does not declare a GovStack-Signature header. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + parameters: + - name: Content-Type + in: header + required: true + schema: + type: string + requestBody: + content: + application/cloudevents+json: + schema: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml new file mode 100644 index 0000000..be3a3fa --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook delivery declares the GovStack-Signature header. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + parameters: + - name: GovStack-Signature + in: header + required: true + description: Detached JWS signature over the canonicalised event. + schema: + type: string + requestBody: + content: + application/cloudevents+json: + schema: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml new file mode 100644 index 0000000..14d6c21 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + # 17.1: "publish" is AsyncAPI 2.x wording; 3.0 requires send/receive. + action: publish + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml new file mode 100644 index 0000000..0994788 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml @@ -0,0 +1,25 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml new file mode 100644 index 0000000..ac307bb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml @@ -0,0 +1,34 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka + # 17.10: no server-level security, and the operation declares none either. +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + securitySchemes: + userToken: + type: http + scheme: bearer + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml new file mode 100644 index 0000000..1de7c3a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml @@ -0,0 +1,35 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/userToken' +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + securitySchemes: + userToken: + type: http + scheme: bearer + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml new file mode 100644 index 0000000..6a9ecd4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + # 17.11: no x-govstack-delivery declared. + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml new file mode 100644 index 0000000..e560ff7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' + x-govstack-delivery: atLeastOnce +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml new file mode 100644 index 0000000..7dcb0b9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + # 17.12: no x-govstack-ordering declared (neither a key/scope nor explicit none). + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml new file mode 100644 index 0000000..ac97c02 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml @@ -0,0 +1,28 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' + x-govstack-ordering: + guarantee: keyed + key: personId +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml new file mode 100644 index 0000000..90e72c3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml @@ -0,0 +1,29 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + # 17.13: does not declare redelivery/dead-letter/retention/replay capabilities. + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' + x-govstack-redelivery: supported + x-govstack-retention: notApplicable + # x-govstack-dead-letter and x-govstack-replay are missing. +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml new file mode 100644 index 0000000..69766a2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml @@ -0,0 +1,29 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' + x-govstack-redelivery: supported + x-govstack-dead-letter: supported + x-govstack-retention: notApplicable + x-govstack-replay: unsupported +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml new file mode 100644 index 0000000..cfba6a7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml @@ -0,0 +1,31 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + summary: Consume person-created events + # 17.15: machine-readable extensions present but no human-readable description. + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' + x-govstack-delivery: atLeastOnce + x-govstack-ordering: + guarantee: none + x-govstack-replay: supported +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml new file mode 100644 index 0000000..17a1792 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml @@ -0,0 +1,33 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + summary: Consume person-created events + description: >- + Consumes person-created events at least once, with no ordering guarantee, + and supports replay from the retained log. + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' + x-govstack-delivery: atLeastOnce + x-govstack-ordering: + guarantee: none + x-govstack-replay: supported +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml new file mode 100644 index 0000000..dab3dc9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml @@ -0,0 +1,39 @@ +asyncapi: 3.0.0 +info: + title: Payments commands + version: 1.0.0 +channels: + submitPayment: + address: org.govstack.pay.v1.payment.submit + messages: + cmd: + $ref: '#/components/messages/SubmitPayment' +operations: + onSubmitPayment: + action: receive + channel: + $ref: '#/channels/submitPayment' + messages: + - $ref: '#/channels/submitPayment/messages/cmd' +components: + messages: + # 17.16: a command message exists but no rejection/failure message using the + # §11 error envelope is defined anywhere. + SubmitPayment: + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + data: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml new file mode 100644 index 0000000..8bd6ce0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml @@ -0,0 +1,59 @@ +asyncapi: 3.0.0 +info: + title: Payments commands + version: 1.0.0 +channels: + submitPayment: + address: org.govstack.pay.v1.payment.submit + messages: + cmd: + $ref: '#/components/messages/SubmitPayment' + rejected: + $ref: '#/components/messages/PaymentRejected' +operations: + onSubmitPayment: + action: receive + channel: + $ref: '#/channels/submitPayment' + messages: + - $ref: '#/channels/submitPayment/messages/cmd' +components: + messages: + SubmitPayment: + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + data: + type: object + examples: + - name: sample + payload: {} + PaymentRejected: + # §11 error envelope, correlated to the original command. + correlationId: + location: '$message.header#/correlationId' + payload: + type: object + required: [type, title, status] + properties: + type: + type: string + title: + type: string + status: + type: integer + examples: + - name: sample + payload: + type: about:blank + title: Payment rejected + status: 422 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml new file mode 100644 index 0000000..756ad39 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml @@ -0,0 +1,36 @@ +asyncapi: 3.0.0 +info: + title: Query service + version: 1.0.0 +channels: + personQuery: + address: org.govstack.reg.v1.person.query + messages: + cmd: + payload: + type: object + examples: + - name: q + payload: {} + personQueryReply: + address: org.govstack.reg.v1.person.query-reply + messages: + res: + # 17.17: no correlationId, and the reply declares no address location. + payload: + type: object + examples: + - name: r + payload: {} +operations: + askPerson: + action: send + channel: + $ref: '#/channels/personQuery' + messages: + - $ref: '#/channels/personQuery/messages/cmd' + reply: + channel: + $ref: '#/channels/personQueryReply' + messages: + - $ref: '#/channels/personQueryReply/messages/res' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml new file mode 100644 index 0000000..6f4af44 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml @@ -0,0 +1,37 @@ +asyncapi: 3.0.0 +info: + title: Query service + version: 1.0.0 +channels: + personQuery: + address: org.govstack.reg.v1.person.query + messages: + cmd: + payload: + type: object + examples: + - name: q + payload: {} + personQueryReply: + address: org.govstack.reg.v1.person.query-reply + messages: + res: + correlationId: + location: '$message.header#/correlationId' + payload: + type: object + examples: + - name: r + payload: {} +operations: + askPerson: + action: send + channel: + $ref: '#/channels/personQuery' + messages: + - $ref: '#/channels/personQuery/messages/cmd' + reply: + channel: + $ref: '#/channels/personQueryReply' + messages: + - $ref: '#/channels/personQueryReply/messages/res' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml new file mode 100644 index 0000000..5c1989a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml @@ -0,0 +1,31 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka +channels: + personCreated: + # 17.19: server speaks kafka, but neither the channel nor its operation + # declares kafka bindings. + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml new file mode 100644 index 0000000..0b3d1ce --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml @@ -0,0 +1,33 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka +channels: + personCreated: + address: org.govstack.reg.v1.person.created + bindings: + kafka: + topic: org.govstack.reg.v1.person.created + bindingVersion: '0.5.0' + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml new file mode 100644 index 0000000..53d50e5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + # 17.2: not reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event}. + address: com.example.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml new file mode 100644 index 0000000..0994788 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml @@ -0,0 +1,25 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml new file mode 100644 index 0000000..cbffe48 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml @@ -0,0 +1,23 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + # 17.20: no examples declared for this message. + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml new file mode 100644 index 0000000..56d009e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml @@ -0,0 +1,27 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + summary: A person-created event + payload: + id: e-1 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml new file mode 100644 index 0000000..1ec68a1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml @@ -0,0 +1,30 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personEmail: + # 17.3: "email" is a directly-identifying attribute in the channel address. + address: org.govstack.reg.v1.person.email + parameters: + # 17.3: personal-data token in a channel parameter name. + phoneNumber: + description: caller phone + messages: + evt: + $ref: '#/components/messages/PersonEvent' +operations: + onPersonEvent: + action: receive + channel: + $ref: '#/channels/personEmail' + messages: + - $ref: '#/channels/personEmail/messages/evt' +components: + messages: + PersonEvent: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml new file mode 100644 index 0000000..122f260 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml @@ -0,0 +1,30 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + parameters: + tenant: + description: tenant routing key + region: + description: deployment region + messages: + evt: + $ref: '#/components/messages/PersonEvent' +operations: + onPersonEvent: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonEvent: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml new file mode 100644 index 0000000..4a4ecca --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml @@ -0,0 +1,29 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.{tenant}.person.created + parameters: + # 17.4: the {tenant} parameter is declared but not documented (no description). + tenant: + enum: [alpha, beta] + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml new file mode 100644 index 0000000..ceda514 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml @@ -0,0 +1,29 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.{tenant}.person.created + parameters: + tenant: + description: Tenant routing key; selects the owning tenant partition. + enum: [alpha, beta] + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml new file mode 100644 index 0000000..3447890 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + # 17.5: environment token "prod" belongs in servers, not the channel address. + address: org.govstack.reg.v1.prod.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml new file mode 100644 index 0000000..0994788 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml @@ -0,0 +1,25 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml new file mode 100644 index 0000000..94bb6bc --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml @@ -0,0 +1,30 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + # 17.6: payload is a bare domain object, not a structured CloudEvent. + payload: + type: object + properties: + personId: + type: string + examples: + - name: sample + payload: + personId: p-1 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml new file mode 100644 index 0000000..2b9dde6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml @@ -0,0 +1,46 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + data: + type: object + properties: + personId: + type: string + examples: + - name: sample + payload: + specversion: "1.0" + id: e-1 + source: org.govstack.reg + type: org.govstack.reg.v1.person.created + data: + personId: p-1 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml new file mode 100644 index 0000000..052db2e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml @@ -0,0 +1,31 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + # 17.8: header names must be camelCase and must not use the X- prefix. + X-Custom-Header: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml new file mode 100644 index 0000000..31682f2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml @@ -0,0 +1,32 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + idempotencyKey: + type: string + acceptLanguage: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml new file mode 100644 index 0000000..19179fb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml @@ -0,0 +1,32 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + # 17.9: localisation header must be exactly acceptLanguage, not the + # HTTP-style hyphenated variant. + Accept-Language: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml new file mode 100644 index 0000000..1ab4e5c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml @@ -0,0 +1,32 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: org.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + acceptLanguage: + type: string + contentLanguage: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.1/fail.yaml new file mode 100644 index 0000000..5595128 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.1/fail.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: Sample API + version: v1.0 + description: Fixture for govstack-18.1 — info.version is not SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.1/pass.yaml new file mode 100644 index 0000000..8c49f3a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.1/pass.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-18.1 — info.version is SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml new file mode 100644 index 0000000..cb56e2e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml @@ -0,0 +1,45 @@ +# Fixture for govstack-18.2-asyncapi — channel address major (v1) does not +# match info.version's major (2). +asyncapi: 3.0.0 +info: + title: Identity Events + version: 2.0.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml new file mode 100644 index 0000000..8860194 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml @@ -0,0 +1,45 @@ +# Fixture for govstack-18.2-asyncapi — channel address major (v2) matches +# info.version's major (2). +asyncapi: 3.0.0 +info: + title: Identity Events + version: 2.0.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v2.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/fail.yaml new file mode 100644 index 0000000..2cee918 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 2.0.0 + description: Fixture for govstack-18.2-openapi — path major (v1) does not match info.version's major (2). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/pass.yaml new file mode 100644 index 0000000..94e67e6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 2.0.0 + description: Fixture for govstack-18.2-openapi — path major (v2) matches info.version's major (2). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v2/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.5/fail.yaml new file mode 100644 index 0000000..2fc3e27 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.5/fail.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-18.5 — deprecated operation missing Deprecation/Sunset headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + deprecated: true + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.5/pass.yaml new file mode 100644 index 0000000..6566ec2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.5/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-18.5 — deprecated operation declares Deprecation/Sunset headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + deprecated: true + responses: + '200': + description: OK + headers: + Deprecation: + description: RFC 9745 deprecation timestamp. + schema: + type: string + Sunset: + description: RFC 8594 planned removal date. + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml new file mode 100644 index 0000000..4889fbe --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml @@ -0,0 +1,51 @@ +# Fixture for govstack-18.7-description — x-govstack-deprecated shape is +# complete but the description never mentions the deprecation. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Emitted when a user completes registration. + x-govstack-deprecated: + since: '2025-01-01' + sunset: '2025-07-01' + replacement: UserRegistered + reason: Superseded by a richer event schema. + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml new file mode 100644 index 0000000..58085d4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml @@ -0,0 +1,50 @@ +# Fixture for govstack-18.7-description — description mentions the deprecation. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Deprecated; superseded by UserRegistered. + x-govstack-deprecated: + since: '2025-01-01' + sunset: '2025-07-01' + replacement: UserRegistered + reason: Superseded by a richer event schema. + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml new file mode 100644 index 0000000..9d7fc17 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml @@ -0,0 +1,49 @@ +# Fixture for govstack-18.7 — x-govstack-deprecated is present but missing +# sunset/replacement/reason. Description already mentions deprecation so this +# fixture isolates the shape violation from govstack-18.7-description. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Deprecated; superseded by UserRegistered. + x-govstack-deprecated: + since: '2025-01-01' + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml new file mode 100644 index 0000000..fd1aafa --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml @@ -0,0 +1,50 @@ +# Fixture for govstack-18.7 — x-govstack-deprecated carries all four keys. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Deprecated; superseded by UserRegistered. + x-govstack-deprecated: + since: '2025-01-01' + sunset: '2025-07-01' + replacement: UserRegistered + reason: Superseded by a richer event schema. + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml new file mode 100644 index 0000000..529262b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml @@ -0,0 +1,40 @@ +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.0.0 + description: Fixture for govstack-19.4-asyncapi — acceptLanguage message present but no contentLanguage message on the channel. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + requestMessage: + name: RequestMessage + headers: + type: object + properties: + acceptLanguage: + type: string + payload: + type: object + properties: + userId: + type: string +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/requestMessage' diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml new file mode 100644 index 0000000..dec4880 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml @@ -0,0 +1,53 @@ +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.0.0 + description: Fixture for govstack-19.4-asyncapi — both acceptLanguage and contentLanguage messages present on the channel. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + requestMessage: + name: RequestMessage + headers: + type: object + properties: + acceptLanguage: + type: string + payload: + type: object + properties: + userId: + type: string + responseMessage: + name: ResponseMessage + headers: + type: object + properties: + contentLanguage: + type: string + payload: + type: object + properties: + userId: + type: string +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/requestMessage' + - $ref: '#/channels/userSignedUp/messages/responseMessage' diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/fail.yaml new file mode 100644 index 0000000..c1f8ec7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-19.4-openapi — Accept-Language accepted but no response declares Content-Language. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/pass.yaml new file mode 100644 index 0000000..96a96b8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-19.4-openapi — Accept-Language accepted and Content-Language declared on the response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + '200': + description: OK + headers: + Content-Language: + description: Language of the localised response content. + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml new file mode 100644 index 0000000..099abe4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: A sample API on the wrong OpenAPI version. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml new file mode 100644 index 0000000..c6ce592 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API on the correct OpenAPI version. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/fail.yaml new file mode 100644 index 0000000..913309b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: v1 + description: Version is not SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/pass.yaml new file mode 100644 index 0000000..bc3c041 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.2.3 + description: Version is SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5/fail.yaml new file mode 100644 index 0000000..1f251bb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5/fail.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5/pass.yaml new file mode 100644 index 0000000..e8fb414 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A complete info block. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.6/fail.yaml new file mode 100644 index 0000000..fe458df --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.6/fail.yaml @@ -0,0 +1,11 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Servers block points at localhost. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: http://localhost:8080/sample/v1 +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml new file mode 100644 index 0000000..f3df1f5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Parameterised, non-local servers block. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + description: Non-production reference deployment. + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.7/fail.yaml new file mode 100644 index 0000000..fbd7852 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.7/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Operation is missing metadata. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/things: + get: + operationId: Get_Things + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.7/pass.yaml new file mode 100644 index 0000000..d259ef5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.7/pass.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Operation carries full metadata. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: + - things + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml new file mode 100644 index 0000000..6f8ec09 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3-exceptions — exceptions entries are bare strings, not objects. + contact: + name: Sample BB Team + url: https://example.org/contact + x-govstack-api-guide: + version: 0.2.0 + exceptions: + - RULE-9.3 + - RULE-11.2 +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml new file mode 100644 index 0000000..d9ae70b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3-exceptions — exceptions entries are objects referencing an approved exception record. + contact: + name: Sample BB Team + url: https://example.org/contact + x-govstack-api-guide: + version: 0.2.0 + exceptions: + - rule: RULE-9.3 + approvedBy: https://example.org/exceptions/RULE-9.3 + - rule: RULE-11.2 + approvedBy: https://example.org/exceptions/RULE-11.2 +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3/fail.yaml new file mode 100644 index 0000000..a29f5fb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3/fail.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3 — info lacks the x-govstack-api-guide conformance declaration. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml new file mode 100644 index 0000000..6206af9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml @@ -0,0 +1,11 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3 — info declares a SemVer x-govstack-api-guide.version. + contact: + name: Sample BB Team + url: https://example.org/contact + x-govstack-api-guide: + version: 0.2.0 +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.1/fail.yaml new file mode 100644 index 0000000..d7d8a2a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.1/fail.yaml @@ -0,0 +1,16 @@ +# 3.1 fail: a legacy AsyncAPI 2.x document (detected as aas2) — must be told to +# move to 3.0.0. asyncapi is not "3.0.0". +asyncapi: 2.6.0 +info: + title: Legacy Events + version: 1.0.0 + description: A legacy 2.x event document that must migrate to AsyncAPI 3.0.0. + contact: + name: GovStack Identity BB + url: https://example.org/contact +channels: + userSignedUp: + subscribe: + message: + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml new file mode 100644 index 0000000..3827bcd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml new file mode 100644 index 0000000..bf5a924 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml @@ -0,0 +1,39 @@ +# 3.5 fail (semver): info.version is present but not a SemVer string. +asyncapi: 3.0.0 +info: + title: Identity Events + version: "1.0" + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml new file mode 100644 index 0000000..3827bcd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml new file mode 100644 index 0000000..b55a716 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml @@ -0,0 +1,36 @@ +# 3.5 fail: info block is missing `contact` (title/version/description present). +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml new file mode 100644 index 0000000..3827bcd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml new file mode 100644 index 0000000..b52d0db --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml @@ -0,0 +1,39 @@ +# 3.6 fail (host): a server points at localhost. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: 'localhost:9092' + protocol: kafka +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml new file mode 100644 index 0000000..3827bcd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml new file mode 100644 index 0000000..e08c251 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml @@ -0,0 +1,30 @@ +# 3.6 fail: `operations` is empty — a document with only schemas and channels is +# not an API contract. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: {} +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml new file mode 100644 index 0000000..3827bcd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml new file mode 100644 index 0000000..6f90b5a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml @@ -0,0 +1,36 @@ +# 3.7 fail: the operation is missing summary, description and tags. Its action, +# channel reference and message reference are otherwise valid and resolvable. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml new file mode 100644 index 0000000..3827bcd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml new file mode 100644 index 0000000..1fa448d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml @@ -0,0 +1,45 @@ +# 3.9 fail: a message payload declares non-camelCase property names (user_id +# snake_case, SignedUpAt PascalCase), violating the §9 JSON conventions. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + user_id: + type: string + description: Identifier of the user. + SignedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml new file mode 100644 index 0000000..3827bcd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: org.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.1/fail.yaml new file mode 100644 index 0000000..e7110b7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.1/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + id: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.1/pass.yaml new file mode 100644 index 0000000..cd9e932 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.1/pass.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + description: A single widget resource. + properties: + id: + type: string + description: The widget's unique identifier. diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.2/fail.yaml new file mode 100644 index 0000000..848e7e3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.2/fail.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + post: + operationId: createWidget + summary: Create widget + description: Creates a widget. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + description: A widget to create. + properties: + status: + type: string + enum: [active, retired] + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + description: The created widget. + properties: + status: + type: string + description: Widget status. + enum: [active, retired] diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.2/pass.yaml new file mode 100644 index 0000000..835fa4b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.2/pass.yaml @@ -0,0 +1,48 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + post: + operationId: createWidget + summary: Create widget + description: Creates a widget. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + description: A widget to create. + properties: + status: + type: string + description: "Widget status: active (in use) or retired (decommissioned)." + enum: [active, retired] + example: + status: active + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + description: The created widget. + properties: + status: + type: string + description: "Widget status: active (in use) or retired (decommissioned)." + enum: [active, retired] + example: + status: active diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.3/fail.yaml new file mode 100644 index 0000000..eefa87e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.3/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: TBD + tags: [widgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.3/pass.yaml new file mode 100644 index 0000000..6f1bc82 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.3/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.4/fail.yaml new file mode 100644 index 0000000..f093166 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.4/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets, sorted by creation date. + tags: [widgets] + responses: + '200': + description: OK + /v1/gadgets: + get: + operationId: listGadgets + summary: List gadgets + description: Returns the collection of widgets, sorted by creation date. + tags: [gadgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.4/pass.yaml new file mode 100644 index 0000000..9657512 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.4/pass.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets, sorted by creation date. + tags: [widgets] + responses: + '200': + description: OK + /v1/gadgets: + get: + operationId: listGadgets + summary: List gadgets + description: Returns the collection of gadgets, sorted by name. + tags: [gadgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.1/fail.yaml new file mode 100644 index 0000000..8efd423 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.1/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with an unversioned path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + responses: + "200": + description: A page of events. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.1/pass.yaml new file mode 100644 index 0000000..0b31e62 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.1/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a versioned path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + responses: + "200": + description: A page of events. + # Guide 5.9 mandates an UNVERSIONED /health endpoint; 5.1 must not fire on it. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [Health] + security: [] + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.2/fail.yaml new file mode 100644 index 0000000..3a97b4c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.2/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a singular resource collection name. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policy: + get: + operationId: listPolicies + summary: List policies + description: Returns a page of policies. + tags: [Policies] + responses: + "200": + description: A page of policies. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.2/pass.yaml new file mode 100644 index 0000000..7011508 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.2/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a plural resource collection name. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policies: + get: + operationId: listPolicies + summary: List policies + description: Returns a page of policies. + tags: [Policies] + responses: + "200": + description: A page of policies. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.3/fail.yaml new file mode 100644 index 0000000..56ef262 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.3/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a camelCase path segment. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/eventSubscriptions: + get: + operationId: listEventSubscriptions + summary: List event subscriptions + description: Returns a page of event subscriptions. + tags: [Events] + responses: + "200": + description: A page of event subscriptions. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.3/pass.yaml new file mode 100644 index 0000000..56204db --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.3/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a kebab-case path segment. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/event-subscriptions: + get: + operationId: listEventSubscriptions + summary: List event subscriptions + description: Returns a page of event subscriptions. + tags: [Events] + responses: + "200": + description: A page of event subscriptions. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.4/fail.yaml new file mode 100644 index 0000000..dd1fc2a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.4/fail.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a deeply nested path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policies/{policyId}/documents/{documentId}/pages: + get: + operationId: listPolicyDocumentPages + summary: List policy document pages + description: Returns the pages of a policy document. + tags: [Policies] + parameters: + - name: policyId + in: path + required: true + schema: { type: string } + - name: documentId + in: path + required: true + schema: { type: string } + responses: + "200": + description: A page listing. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.4/pass.yaml new file mode 100644 index 0000000..d4c9c60 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.4/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a shallow path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policies/{policyId}: + get: + operationId: getPolicy + summary: Get policy + description: Returns a single policy. + tags: [Policies] + parameters: + - name: policyId + in: path + required: true + schema: { type: string } + responses: + "200": + description: A policy. + # Guide 5.8/15.5 mandate this shape: three raw segments after /v1/, but only + # two non-param levels (operations, cancel). `{param}` must not count, so 5.4 + # must NOT fire here. + /v1/operations/{operationId}/cancel: + post: + operationId: cancelOperation + summary: Cancel an operation + description: Requests cancellation of a long-running operation. + tags: [Operations] + parameters: + - name: operationId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The operation state after cancellation. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.5/fail.yaml new file mode 100644 index 0000000..6b8a77e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.5/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API that identifies a resource via a query parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: getEvent + summary: Get event + description: Returns a single event, identified by a query parameter (anti-pattern). + tags: [Events] + parameters: + - name: eventId + in: query + required: true + schema: { type: string } + responses: + "200": + description: The event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.5/pass.yaml new file mode 100644 index 0000000..cc369c1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.5/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API that identifies a resource via a path parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events/{eventId}: + get: + operationId: getEvent + summary: Get event + description: Returns a single event, identified by a path parameter. + tags: [Events] + parameters: + - name: eventId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.6/fail.yaml new file mode 100644 index 0000000..6f11ce9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.6/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a snake_case query parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + parameters: + - name: created_after + in: query + required: false + schema: { type: string, format: date-time } + responses: + "200": + description: A page of events. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.6/pass.yaml new file mode 100644 index 0000000..162c835 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.6/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a camelCase query parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + parameters: + - name: createdAfter + in: query + required: false + schema: { type: string, format: date-time } + responses: + "200": + description: A page of events. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.7/fail.yaml new file mode 100644 index 0000000..d518430 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.7/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a verb in a CRUD path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/event/new: + post: + operationId: createEvent + summary: Create event + description: Creates a new event (anti-pattern path). + tags: [Events] + responses: + "201": + description: The created event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.7/pass.yaml new file mode 100644 index 0000000..eb8efb2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.7/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a plain CRUD path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + post: + operationId: createEvent + summary: Create event + description: Creates a new event. + tags: [Events] + responses: + "201": + description: The created event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.8/fail.yaml new file mode 100644 index 0000000..90d3a36 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.8/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with an action endpoint missing the resource identifier. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events/cancel: + post: + operationId: cancelEvent + summary: Cancel event + description: Cancels an event without identifying which one (anti-pattern path). + tags: [Events] + responses: + "200": + description: The cancelled event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.8/pass.yaml new file mode 100644 index 0000000..6477978 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.8/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with an action expressed as a sub-resource. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events/{eventId}/cancel: + post: + operationId: cancelEvent + summary: Cancel event + description: Cancels a single, identified event. + tags: [Events] + parameters: + - name: eventId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The cancelled event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml new file mode 100644 index 0000000..e7c23bd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API whose /health endpoint uses the wrong media type. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [pass, fail, warn] diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml new file mode 100644 index 0000000..21fc343 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/health+json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [pass, fail, warn] + # Per guide 11.1, the error response is problem+json, NOT health+json. + # 5.9-media-type is scoped to the 200 response, so it must NOT fire here. + "500": + description: Service is unhealthy. + content: + application/problem+json: + schema: + type: object + required: [type, title, status] + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml new file mode 100644 index 0000000..bf559eb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API whose /health endpoint wrongly requires authentication. + contact: + name: Sample BB Team + url: https://example.org/contact +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: + - apiKeyAuth: [] + responses: + "200": + description: Service is healthy. + content: + application/health+json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [pass, fail, warn] diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml new file mode 100644 index 0000000..06d5bd1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/health+json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [pass, fail, warn] diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/fail.yaml new file mode 100644 index 0000000..e38de99 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with no /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns a page of widgets. + tags: [Widgets] + responses: + "200": + description: A page of widgets. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml new file mode 100644 index 0000000..06d5bd1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/health+json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [pass, fail, warn] diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/fail.yaml new file mode 100644 index 0000000..b5c2321 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API whose /health status enum does not match the guide. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/health+json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [up, down] diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/pass.yaml new file mode 100644 index 0000000..a2e85c2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/pass.yaml @@ -0,0 +1,42 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/health+json: + schema: + type: object + required: [status] + properties: + status: + type: string + enum: [pass, fail, warn] + # Per guide 11.1, the error response is problem+json with an integer + # `status`, NOT the health pass|fail|warn enum. 5.9-status-enum is scoped + # to the 200 response, so this problem envelope must NOT fire it. + "500": + description: Service is unhealthy. + content: + application/problem+json: + schema: + type: object + required: [type, title, status] + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.1/fail.yaml new file mode 100644 index 0000000..848ad53 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.1/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.1/pass.yaml new file mode 100644 index 0000000..6f1bc82 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.1/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.4/fail.yaml new file mode 100644 index 0000000..b4ecee6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.4/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + patch: + operationId: updateWidget + summary: Update widget + description: Partially updates a widget. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.4/pass.yaml new file mode 100644 index 0000000..5eec356 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.4/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + patch: + operationId: updateWidget + summary: Update widget + description: Partially updates a widget. + tags: [widgets] + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.5/fail.yaml new file mode 100644 index 0000000..59e4424 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.5/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + delete: + operationId: deleteWidget + summary: Delete widget + description: Deletes a widget. + tags: [widgets] + responses: + '204': + description: Deleted + content: + application/json: + schema: + type: object + '201': + description: Somehow created? diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.5/pass.yaml new file mode 100644 index 0000000..d8e6915 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.5/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + delete: + operationId: deleteWidget + summary: Delete widget + description: Deletes a widget. + tags: [widgets] + responses: + '204': + description: Deleted diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.6/fail.yaml new file mode 100644 index 0000000..ec8552c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.6/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/search: + post: + operationId: searchWidgets + summary: Search widgets + description: Runs a complex widget search. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '201': + description: Search results diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.6/pass.yaml new file mode 100644 index 0000000..fb20923 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.6/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/search: + post: + operationId: searchWidgets + summary: Search widgets + description: Runs a complex widget search. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: Search results diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.7/fail.yaml new file mode 100644 index 0000000..0a9ae12 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.7/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + patch: + operationId: bulkUpdateWidgets + summary: Bulk update widgets + description: Partially updates every widget in the collection. + tags: [widgets] + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.7/pass.yaml new file mode 100644 index 0000000..4b7f8b1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.7/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + patch: + operationId: bulkUpdateWidgets + summary: Bulk update widgets + description: Partially updates every widget matching the given status filter. + tags: [widgets] + parameters: + - name: status + in: query + required: true + schema: + type: string + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.13/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.13/fail.yaml new file mode 100644 index 0000000..65c456d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.13/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.13 — operation missing a 500 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.13/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.13/pass.yaml new file mode 100644 index 0000000..3107770 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.13/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.13 — operation declares a 500 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14/fail.yaml new file mode 100644 index 0000000..27b474e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.14 — operation declares only a 200 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14/pass.yaml new file mode 100644 index 0000000..1f49277 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.14 — operation declares multiple codes including a non-2xx. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.16/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.16/fail.yaml new file mode 100644 index 0000000..d921503 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.16/fail.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.16 — GET 200 response missing ETag and a 304 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.16/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.16/pass.yaml new file mode 100644 index 0000000..254e442 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.16/pass.yaml @@ -0,0 +1,46 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.16 — GET 200 response declares ETag and a 304 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + headers: + ETag: + description: Opaque validator derived from the resource state. + schema: + type: string + '304': + description: Not Modified + '500': + description: Internal Server Error + # Guide 7.16 applies to endpoints that return resources; the operational + # liveness probe /health is exempt, so a bare GET with no ETag/304 must NOT + # fire 7.16. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.17/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.17/fail.yaml new file mode 100644 index 0000000..28b2822 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.17/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.17 — PUT missing If-Match parameter and 412 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + put: + operationId: replaceThing + summary: Replace a thing + description: Replaces a thing in its entirety. + tags: [things] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.17/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.17/pass.yaml new file mode 100644 index 0000000..de87d83 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.17/pass.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.17 — PUT declares an If-Match parameter and a 412 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + put: + operationId: replaceThing + summary: Replace a thing + description: Replaces a thing in its entirety. + tags: [things] + parameters: + - name: If-Match + in: header + required: false + description: Conditional update precondition (RFC 9110 If-Match). + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + '412': + description: Precondition Failed + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.18/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.18/fail.yaml new file mode 100644 index 0000000..874e290 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.18/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.18 — 405 response missing an Allow header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '405': + description: Method Not Allowed + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.18/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.18/pass.yaml new file mode 100644 index 0000000..ee4885b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.18/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.18 — 405 response declares an Allow header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '405': + description: Method Not Allowed + headers: + Allow: + description: Methods supported by this resource. + schema: + type: string + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.19/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.19/fail.yaml new file mode 100644 index 0000000..9f6c08a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.19/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.19 — PATCH operation missing a 415 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + patch: + operationId: updateThing + summary: Partially update a thing + description: Applies a merge patch to a thing. + tags: [things] + requestBody: + required: true + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.19/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.19/pass.yaml new file mode 100644 index 0000000..23593f8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.19/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.19 — PATCH operation declares a 415 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + patch: + operationId: updateThing + summary: Partially update a thing + description: Applies a merge patch to a thing. + tags: [things] + requestBody: + required: true + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK + '415': + description: Unsupported Media Type + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.2/fail.yaml new file mode 100644 index 0000000..be050e4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.2/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.2 — 201 response missing a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + post: + operationId: createThing + summary: Create a thing + description: Creates a new thing. + tags: [things] + responses: + '201': + description: Created + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.2/pass.yaml new file mode 100644 index 0000000..b533634 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.2/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.2 — 201 response declares a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + post: + operationId: createThing + summary: Create a thing + description: Creates a new thing. + tags: [things] + responses: + '201': + description: Created + headers: + Location: + description: URL of the created thing. + schema: + type: string + format: uri + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.20/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.20/fail.yaml new file mode 100644 index 0000000..3bf6795 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.20/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.20 — problem+json response missing Cache-Control. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '404': + description: Not Found + content: + application/problem+json: + schema: + type: object + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.20/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.20/pass.yaml new file mode 100644 index 0000000..1a6313d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.20/pass.yaml @@ -0,0 +1,37 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.20 — problem+json response declares Cache-Control. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '404': + description: Not Found + content: + application/problem+json: + schema: + type: object + headers: + Cache-Control: + description: Prevents caches from replaying a stale error. + schema: + type: string + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.3/fail.yaml new file mode 100644 index 0000000..db5882d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.3/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.3 — 202 response missing a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/exports: + post: + operationId: createExport + summary: Start an export job + description: Starts an asynchronous export job. + tags: [exports] + responses: + '202': + description: Accepted + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.3/pass.yaml new file mode 100644 index 0000000..208ffbb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.3/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.3 — 202 response declares a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/exports: + post: + operationId: createExport + summary: Start an export job + description: Starts an asynchronous export job. + tags: [exports] + responses: + '202': + description: Accepted + headers: + Location: + description: URL of the Operation resource tracking this job (§15). + schema: + type: string + format: uri + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.6/fail.yaml new file mode 100644 index 0000000..ab9370b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.6/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.6 — 401 response missing a WWW-Authenticate header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '401': + description: Unauthorized + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.6/pass.yaml new file mode 100644 index 0000000..1b8a207 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.6/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.6 — 401 response declares a WWW-Authenticate header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '401': + description: Unauthorized + headers: + WWW-Authenticate: + description: Challenge for the required authentication scheme. + schema: + type: string + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.1/fail.yaml new file mode 100644 index 0000000..6d80498 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.1/fail.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Credentials travel in the wrong place. + contact: + name: Sample BB Team + url: https://example.org/contact +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: query + name: api_key +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: access_token + in: query + required: true + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.1/pass.yaml new file mode 100644 index 0000000..a6ad5e0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.1/pass.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Credentials travel via the Authorization header. + contact: + name: Sample BB Team + url: https://example.org/contact +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: Authorization +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: id + in: query + required: false + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.2/fail.yaml new file mode 100644 index 0000000..89af8fb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.2/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Accepts Accept-Language but never echoes Content-Language. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /messages: + get: + operationId: listMessages + summary: List messages + description: List localised messages. + tags: [messages] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.2/pass.yaml new file mode 100644 index 0000000..6da44d8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.2/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Echoes Content-Language when Accept-Language is accepted. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /messages: + get: + operationId: listMessages + summary: List messages + description: List localised messages. + tags: [messages] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + "200": + description: OK + headers: + Content-Language: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.3/fail.yaml new file mode 100644 index 0000000..361c83d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.3/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Create-POST does not accept an Idempotency-Key header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /orders: + post: + operationId: createOrder + summary: Create an order + description: Create an order. + tags: [orders] + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + description: Created diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.3/pass.yaml new file mode 100644 index 0000000..afc6b33 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.3/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Create-POST accepts an Idempotency-Key header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /orders: + post: + operationId: createOrder + summary: Create an order + description: Create an order. + tags: [orders] + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + description: Created diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml new file mode 100644 index 0000000..31ffdd1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: No X-Request-Id correlation at all. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml new file mode 100644 index 0000000..b28331e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Accepts and echoes X-Request-Id. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: X-Request-Id + in: header + required: false + schema: + type: string + responses: + "200": + description: OK + headers: + X-Request-Id: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.5/fail.yaml new file mode 100644 index 0000000..4de5fa3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.5/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Introduces a new X- prefixed header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: X-Custom-Trace + in: header + required: false + schema: + type: string + responses: + "200": + description: OK + headers: + X-Total-Count: + schema: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml new file mode 100644 index 0000000..50fa999 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: No new X- prefixed headers; the legacy X-Request-Id is still allowed. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: X-Request-Id + in: header + required: false + schema: + type: string + - name: Custom-Trace + in: header + required: false + schema: + type: string + responses: + "200": + description: OK + headers: + Total-Count: + schema: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.6/fail.yaml new file mode 100644 index 0000000..71e3b95 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.6/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Personal data addresses a citizen record in the URL. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /citizens/{email}: + get: + operationId: getCitizenByEmail + summary: Get a citizen record + description: Get a citizen record by email. + tags: [citizens] + parameters: + - name: email + in: path + required: true + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.6/pass.yaml new file mode 100644 index 0000000..b04ed81 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.6/pass.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Citizen records are addressed by an opaque server-generated ID. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /citizens/{citizenId}: + get: + operationId: getCitizen + summary: Get a citizen record + description: Get a citizen record by opaque ID. + tags: [citizens] + parameters: + - name: citizenId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7/fail.yaml new file mode 100644 index 0000000..7a4a2bd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Rate-limited endpoint missing rate-limit headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + responses: + "200": + description: OK + "429": + description: Too Many Requests diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml new file mode 100644 index 0000000..c183a5c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Rate-limited endpoint declares rate-limit headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + responses: + "200": + description: OK + headers: + RateLimit-Limit: + schema: + type: integer + RateLimit-Remaining: + schema: + type: integer + RateLimit-Reset: + schema: + type: integer + "429": + description: Too Many Requests + headers: + Retry-After: + schema: + type: integer + RateLimit-Limit: + schema: + type: integer + RateLimit-Remaining: + schema: + type: integer + RateLimit-Reset: + schema: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.1/fail.yaml new file mode 100644 index 0000000..867cb86 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.1/fail.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.1 fail + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + responses: + '200': + description: A list of things, offered only as XML. + content: + application/xml: + schema: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.1/pass.yaml new file mode 100644 index 0000000..f4d223b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.1/pass.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.1 pass + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + responses: + '200': + description: A list of things, offered as JSON. + content: + application/json: + schema: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml new file mode 100644 index 0000000..35be76b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.10 fail + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + x-delivery: atLeastOnce + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml new file mode 100644 index 0000000..5c5a725 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.10 pass + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + x-govstack-delivery: atLeastOnce + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.11/fail.yaml new file mode 100644 index 0000000..6e45a5f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.11/fail.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.11 fail + version: 1.0.0 +paths: {} +components: + securitySchemes: + oauth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + "bb:identity:person:read": Read persons + "bb:payments:invoice:read": Read invoices diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.11/pass.yaml new file mode 100644 index 0000000..9c35ef3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.11/pass.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.11 pass + version: 1.0.0 +paths: {} +components: + securitySchemes: + oauth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + "bb:identity:person:read": Read persons + "bb:identity:invoice:read": Read invoices diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.2/fail.yaml new file mode 100644 index 0000000..c39f244 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.2/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.2 fail + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + first_name: + type: string + Last_Name: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.2/pass.yaml new file mode 100644 index 0000000..db845e0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.2/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.2 pass + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + firstName: + type: string + lastName: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.3/fail.yaml new file mode 100644 index 0000000..02f50b5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.3/fail.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.3 fail + version: 1.0.0 +paths: {} +components: + schemas: + Account: + type: object + properties: + isActive: + type: string + hasConsent: + type: string + enum: ["true", "false"] diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.3/pass.yaml new file mode 100644 index 0000000..e8d7071 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.3/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.3 pass + version: 1.0.0 +paths: {} +components: + schemas: + Account: + type: object + properties: + isActive: + type: boolean + hasConsent: + type: boolean diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.4/fail.yaml new file mode 100644 index 0000000..ff04a3d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.4/fail.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: Section 9.4 fail + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + middleName: + type: string + nullable: true diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.4/pass.yaml new file mode 100644 index 0000000..c2b0d23 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.4/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.4 pass + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + middleName: + type: [string, "null"] diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.5/fail.yaml new file mode 100644 index 0000000..ef1b458 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.5/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.5 fail + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + "full name": + type: string + "prénom": + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.5/pass.yaml new file mode 100644 index 0000000..b55c283 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.5/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.5 pass + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + fullName: + type: string + firstName: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.6/fail.yaml new file mode 100644 index 0000000..4655d19 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.6/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.6 fail + version: 1.0.0 +paths: {} +components: + schemas: + OrderLine: + type: object + properties: + qty: + type: integer + custAddr: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.6/pass.yaml new file mode 100644 index 0000000..1ab1087 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.6/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.6 pass + version: 1.0.0 +paths: {} +components: + schemas: + OrderLine: + type: object + properties: + quantity: + type: integer + customerAddress: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.7/fail.yaml new file mode 100644 index 0000000..7394820 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.7/fail.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.7 fail + version: 1.0.0 +paths: {} +components: + schemas: + Status: + type: string + enum: + - active + - pendingReview diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml new file mode 100644 index 0000000..384c0da --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.7 pass + version: 1.0.0 +paths: {} +components: + schemas: + Status: + type: string + enum: + - ACTIVE + - PENDING_REVIEW diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.8/fail.yaml new file mode 100644 index 0000000..eb30748 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.8/fail.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Section 9.8 fail + version: 1.0.0 +paths: + /things: + get: + operationId: getThing + responses: + '200': + description: A thing whose body schema is closed at the top level. + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + id: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.8/pass.yaml new file mode 100644 index 0000000..7306225 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.8/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Section 9.8 pass + version: 1.0.0 +paths: + /things: + get: + operationId: getThing + responses: + '200': + description: A thing whose body schema stays open for new fields. + content: + application/json: + schema: + type: object + properties: + id: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.9/fail.yaml new file mode 100644 index 0000000..9c01daf --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.9/fail.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: Section 9.9 fail + version: 1.0.0 +paths: {} +components: + schemas: + Color: + type: string + enum: + - RED + - GREEN + - BLUE diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.9/pass.yaml new file mode 100644 index 0000000..853dfdd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.9/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.9 pass + version: 1.0.0 +paths: {} +components: + schemas: + Color: + type: string + enum: + - RED + - GREEN + - BLUE + - UNKNOWN diff --git a/api-design-guide/linter/tests/functions.test.mjs b/api-design-guide/linter/tests/functions.test.mjs new file mode 100644 index 0000000..9171b0c --- /dev/null +++ b/api-design-guide/linter/tests/functions.test.mjs @@ -0,0 +1,230 @@ +// Direct unit tests of the shared custom functions. These import the ESM +// default exports and call them with crafted inputs — no Spectral runtime — so +// they pin each function's contract, cycle-safety and bad-input behaviour. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import valuePattern from '../functions/valuePattern.js'; +import schemaPropertyNames from '../functions/schemaPropertyNames.js'; +import schemaDescriptions from '../functions/schemaDescriptions.js'; +import responseHeaderRequired from '../functions/responseHeaderRequired.js'; +import operationResponses from '../functions/operationResponses.js'; +import pathSegments from '../functions/pathSegments.js'; +import envelopeShape from '../functions/envelopeShape.js'; +import securityCoverage from '../functions/securityCoverage.js'; +import schemaFieldFormat from '../functions/schemaFieldFormat.js'; +import extensionShape from '../functions/extensionShape.js'; +import mediaTypeExpected from '../functions/mediaTypeExpected.js'; +import { walkSchema } from '../functions/lib/schemaWalk.js'; + +const count = (r) => (r === undefined ? 0 : r.length); + +// Every function must tolerate junk input and return undefined, never throw. +const ALL = { + valuePattern, + schemaPropertyNames, + schemaDescriptions, + responseHeaderRequired, + operationResponses, + pathSegments, + envelopeShape, + securityCoverage, + schemaFieldFormat, + extensionShape, + mediaTypeExpected, +}; +test('all functions return undefined on bad input, never throw', () => { + for (const [name, fn] of Object.entries(ALL)) { + for (const junk of [undefined, null, 42, 'str', [], true]) { + assert.equal(fn(junk, {}, { path: [] }), undefined, `${name}(${JSON.stringify(junk)})`); + } + } +}); + +test('valuePattern: match / notMatch / forbidPattern', () => { + assert.equal(count(valuePattern('1.2.3', { match: '^\\d+\\.\\d+\\.\\d+$' })), 0); + assert.equal(count(valuePattern('v1', { match: '^\\d+\\.\\d+\\.\\d+$' })), 1); + assert.equal(count(valuePattern('http://localhost/x', { forbidPattern: 'localhost' })), 1); + assert.equal(count(valuePattern('https://gw/x', { notMatch: 'localhost' })), 0); + assert.equal(valuePattern(123, { match: 'x' }), undefined); +}); + +test('schemaPropertyNames: casing + forbidPattern + cycle safety', () => { + const schema = { + type: 'object', + properties: { + goodName: { type: 'string' }, + Bad_Name: { type: 'string' }, + nested: { type: 'object', properties: { alsoBad: { type: 'string' }, 'has space': {} } }, + }, + }; + const camel = schemaPropertyNames(schema, { casing: 'camel' }, { path: ['x'] }); + // Bad_Name and 'has space' violate camel; nested/alsoBad is fine. + assert.equal(count(camel), 2); + assert.ok(camel[0].path[0] === 'x', 'context.path is prefixed onto finding path'); + + const spaces = schemaPropertyNames(schema, { forbidPattern: '\\s' }); + assert.equal(count(spaces), 1); // only 'has space' + + // circular schema must terminate + const cyc = { type: 'object', properties: { Bad: { type: 'string' } } }; + cyc.properties.self = cyc; + const res = schemaPropertyNames(cyc, { casing: 'camel' }); + assert.equal(count(res), 1); // 'Bad'; 'self' is fine; walk terminates +}); + +test('schemaDescriptions: properties vs all mode', () => { + const schema = { + type: 'object', + description: 'root', + properties: { a: { type: 'string', description: 'A' }, b: { type: 'string' } }, + }; + assert.equal(count(schemaDescriptions(schema, {})), 1); // b lacks description + assert.equal(count(schemaDescriptions(schema, { mode: 'all' })), 1); // b node + const noRoot = { type: 'object', properties: { a: { type: 'string', description: 'A' } } }; + assert.equal(count(schemaDescriptions(noRoot, { includeRoot: true })), 1); // root +}); + +test('responseHeaderRequired: header presence + companion status', () => { + const responses = { + '201': { description: 'created' }, + '200': { description: 'ok', headers: { Location: {} } }, + }; + assert.equal(count(responseHeaderRequired(responses, { status: '201', headers: ['Location'] })), 1); + assert.equal(count(responseHeaderRequired(responses, { status: '200', headers: ['location'] })), 0); // case-insensitive + assert.equal( + count(responseHeaderRequired(responses, { status: '2xx', headers: ['ETag'], alsoRequireStatus: '304' })), + 3, // 201 + 200 lack ETag, and no 304 present + ); + assert.equal(count(responseHeaderRequired({}, { status: '201', headers: ['Location'], requireStatus: true })), 1); +}); + +test('operationResponses: require / forbid / counts', () => { + const op = { responses: { '200': {}, '404': {} } }; + assert.equal(count(operationResponses(op, { require: ['500'] })), 1); + assert.equal(count(operationResponses(op, { require: ['2xx', '4xx'] })), 0); + assert.equal(count(operationResponses({ responses: { '200': {}, '201': {} } }, { forbid: ['201'] })), 1); + assert.equal(count(operationResponses({ responses: { '200': {} } }, { minNonSuccess: 1 })), 1); + assert.equal(count(operationResponses({ responses: { '200': {} } }, { minCount: 2 })), 1); + assert.equal(count(operationResponses({}, { require: ['500'] })), 1); // no responses object +}); + +test('pathSegments: version prefix / casing / depth', () => { + const paths = { + '/v1/account-holders': {}, + '/accounts': {}, + '/v1/Bad_Segment': {}, + // 3 raw segments but only 2 NON-PARAM levels (accounts, transactions): + // `{param}` segments do not count, so this must NOT fire 5.4. + '/v1/accounts/{id}/transactions/{txId}': {}, + // 3 non-param levels (accounts, transactions, lines): must fire 5.4. + '/v1/accounts/{id}/transactions/{txId}/lines': {}, + }; + assert.equal(count(pathSegments(paths, { check: 'versionPrefix' })), 1); // /accounts + assert.equal(count(pathSegments(paths, { check: 'versionPrefix', exemptPaths: ['/accounts'] })), 0); // 5.9 exemption + assert.equal(count(pathSegments(paths, { check: 'segmentCasing', casing: 'kebab' })), 1); // Bad_Segment + assert.equal(count(pathSegments(paths, { check: 'maxDepthAfterVersion', max: 2 })), 1); // only the 3-non-param-level path +}); + +test('envelopeShape: required / nested / const / enum / allOf', () => { + const page = { + type: 'object', + required: ['items', 'pageInfo'], + properties: { + items: { type: 'array' }, + pageInfo: { type: 'object', required: ['nextCursor', 'hasMore'], properties: { nextCursor: {}, hasMore: {} } }, + }, + }; + assert.equal( + count(envelopeShape(page, { requiredProperties: ['items', 'pageInfo'], properties: { pageInfo: { requiredProperties: ['nextCursor', 'hasMore'] } } })), + 0, + ); + const bad = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } }; + assert.ok(count(envelopeShape(bad, { requiredProperties: ['items', 'pageInfo'] })) >= 1); + + const event = { type: 'object', properties: { specversion: { const: '1.0' }, type: { const: 'x' } } }; + assert.equal(count(envelopeShape(event, { properties: { specversion: { const: '1.0' } } })), 0); + assert.equal(count(envelopeShape(event, { properties: { specversion: { const: '2.0' } } })), 1); + + const status = { type: 'string', enum: ['PENDING', 'DONE'] }; + assert.equal(count(envelopeShape(status, { enum: ['PENDING', 'DONE'] })), 0); + assert.equal(count(envelopeShape(status, { enum: ['PENDING', 'DONE', 'FAILED'] })), 1); + + const composed = { allOf: [{ required: ['a'], properties: { a: {} } }, { required: ['b'], properties: { b: {} } }] }; + assert.equal(count(envelopeShape(composed, { requiredProperties: ['a', 'b'] })), 0); +}); + +test('securityCoverage: covered vs none mode', () => { + const doc = { + security: [{ oauth: [] }], + components: { securitySchemes: { oauth: {} } }, + paths: { '/v1/x': { get: {}, post: { security: [] } } }, + }; + assert.equal(count(securityCoverage(doc, {})), 0); // both covered (root + explicit override) + + const uncovered = { paths: { '/v1/x': { get: {} } } }; + assert.equal(count(securityCoverage(uncovered, {})), 1); + assert.equal(count(securityCoverage(uncovered, { requireSchemes: true })), 2); // + missing schemes + + assert.equal(count(securityCoverage({ security: [{ a: [] }] }, { mode: 'none' })), 1); + assert.equal(count(securityCoverage({ security: [] }, { mode: 'none' })), 0); +}); + +test('schemaFieldFormat: format / forbidType / mustDeclare', () => { + const schema = { + type: 'object', + properties: { + createdAt: { type: 'string' }, + updatedAt: { type: 'string', format: 'date-time' }, + amount: { type: 'number' }, + photo: { type: 'string' }, + }, + }; + assert.equal(count(schemaFieldFormat(schema, { namePattern: 'At$', require: { format: 'date-time' } })), 1); // createdAt + assert.equal(count(schemaFieldFormat(schema, { namePattern: '^amount$', require: { forbidType: 'number' } })), 1); + assert.equal( + count(schemaFieldFormat(schema, { namePattern: '^photo$', require: { contentEncoding: 'base64', mustDeclare: ['maxLength'] } })), + 2, + ); +}); + +test('extensionShape: presence / enum / object shape / semver', () => { + assert.equal(count(extensionShape({}, { extension: 'x-govstack-delivery' })), 1); // required, absent + assert.equal(count(extensionShape({ 'x-govstack-delivery': 'atLeastOnce' }, { extension: 'x-govstack-delivery', enum: ['atMostOnce', 'atLeastOnce', 'effectivelyOnce'] })), 0); + assert.equal(count(extensionShape({ 'x-govstack-delivery': 'sometimes' }, { extension: 'x-govstack-delivery', enum: ['atMostOnce', 'atLeastOnce'] })), 1); + + const good = { 'x-govstack-api-guide': { version: '0.2.0' } }; + assert.equal(count(extensionShape(good, { extension: 'x-govstack-api-guide', valueType: 'object', requiredKeys: ['version'], semverKeys: ['version'] })), 0); + const bad = { 'x-govstack-api-guide': { version: 'draft' } }; + assert.equal(count(extensionShape(bad, { extension: 'x-govstack-api-guide', valueType: 'object', requiredKeys: ['version'], semverKeys: ['version'] })), 1); +}); + +test('mediaTypeExpected: require / requireOneOf / forbid', () => { + const content = { 'application/json': {}, 'application/problem+json': {} }; + assert.equal(count(mediaTypeExpected(content, { require: ['application/problem\\+json'] })), 0); + assert.equal(count(mediaTypeExpected(content, { require: ['application/merge-patch\\+json'] })), 1); + assert.equal(count(mediaTypeExpected(content, { requireOneOf: ['application/merge-patch\\+json', 'application/json'] })), 0); + assert.equal(count(mediaTypeExpected({ 'text/plain': {} }, { forbid: ['text/plain'] })), 1); +}); + +test('walkSchema: visits combinators and is cycle-safe', () => { + const seen = []; + const schema = { + type: 'object', + properties: { a: { type: 'string' } }, + allOf: [{ type: 'object', properties: { b: {} } }], + items: { type: 'number' }, + }; + walkSchema(schema, (_node, path) => seen.push(path.join('/'))); + assert.ok(seen.includes('')); // root + assert.ok(seen.includes('properties/a')); + assert.ok(seen.includes('allOf/0')); + assert.ok(seen.includes('items')); + + const cyc = { type: 'object' }; + cyc.items = cyc; + let visits = 0; + walkSchema(cyc, () => (visits += 1)); + assert.equal(visits, 1); // visited once, no infinite loop +}); diff --git a/api-design-guide/linter/tests/golden.test.mjs b/api-design-guide/linter/tests/golden.test.mjs new file mode 100644 index 0000000..311d81d --- /dev/null +++ b/api-design-guide/linter/tests/golden.test.mjs @@ -0,0 +1,58 @@ +// Golden reference specs — conformance test. +// +// Lints the two GOLDEN reference documents (tests/golden/*.yaml) with the DEFAULT +// bundled ruleset (ruleset.yaml, all formats). The goldens are hand-built to +// satisfy every default check simultaneously and double as reference examples for +// BB spec editors. +// +// BOTH goldens MUST lint to ZERO findings of any severity under the default +// ruleset. The ruleset exempts the guide §5.9 operational endpoints (/health, +// /ready) from the resource-oriented rules that do not apply to a liveness probe +// (§5.1 version prefix, §12.x pagination, §7.16 ETag), scopes the §5.9 health+json +// media type / status enum to the SUCCESS response so the §11.1 problem+json error +// response is satisfiable, and counts only non-param path segments toward §5.4 max +// nesting depth so the §15.5/§16.11 mandated action sub-resource paths stay in +// bounds. Any finding is therefore a genuine regression; the test prints the full +// finding list on failure for debugging. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeSpectral, lintFile, RULESET_PATH } from './_lint-helper.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const GOLDEN = join(HERE, 'golden'); +const OPENAPI_GOLDEN = resolve(GOLDEN, 'openapi-golden.yaml'); +const ASYNCAPI_GOLDEN = resolve(GOLDEN, 'asyncapi-golden.yaml'); + +// The DEFAULT ruleset (not the strict profile), matching CI gating. +const spectral = await makeSpectral(RULESET_PATH); + +/** A stable, sorted, human-readable dump of findings for assertion messages. */ +const dump = (findings) => + JSON.stringify( + findings + .map((f) => ({ code: f.code, severity: f.severity, path: f.path.join('/'), message: f.message })) + .sort((a, b) => (a.code + a.path).localeCompare(b.code + b.path)), + null, + 2, + ); + +test('asyncapi-golden.yaml lints clean under the default ruleset (zero findings)', async () => { + const findings = await lintFile(spectral, ASYNCAPI_GOLDEN); + assert.equal( + findings.length, + 0, + `Expected zero findings for the AsyncAPI golden, got ${findings.length}:\n${dump(findings)}`, + ); +}); + +test('openapi-golden.yaml lints clean under the default ruleset (zero findings)', async () => { + const findings = await lintFile(spectral, OPENAPI_GOLDEN); + assert.equal( + findings.length, + 0, + `Expected zero findings for the OpenAPI golden, got ${findings.length}:\n${dump(findings)}`, + ); +}); diff --git a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml new file mode 100644 index 0000000..6ce384c --- /dev/null +++ b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml @@ -0,0 +1,454 @@ +# GovStack API Design Guide — GOLDEN AsyncAPI reference spec +# ========================================================== +# A small, realistic AsyncAPI 3.0.0 spec for the same fictional GovStack Building +# Block (bb-code: "registry"). It is written to satisfy EVERY check in the default +# bundled ruleset simultaneously and to double as a copy-able reference for BB +# spec editors. +# +# It exercises the guide's event-driven shapes: reverse-DNS channel addresses with +# the major version (§17.2) and declared+described parameters (§17.4), operations +# with send/receive perspective and complete metadata (§3.7/§17.1), CloudEvents +# JSON payloads (§17.6) with camelCase properties (§3.9) and camelCase headers +# (§17.8), message examples (§17.20), security covering every operation via the +# server (§17.10), the machine-readable delivery/ordering/replay extensions +# (§17.11/§17.12/§17.13/§17.15), an async rejection message using the §11 error +# envelope with correlation (§17.16), a request-reply pair with a reply channel +# and correlationId (§17.17), protocol bindings matching the server protocol +# (§17.19), and the §20.3 conformance declaration. +# +# Unlike the OpenAPI golden, this file lints to ZERO findings: AsyncAPI has no +# unversioned /health analogue, so none of the rule-scoping contradictions apply. +asyncapi: 3.0.0 +info: + title: GovStack Registry Building Block Events + version: 1.0.0 + description: >- + Event-driven surface of the fictional GovStack Registry Building Block: + publishes registrant lifecycle events and consumes registrant commands over + Kafka. This document is a conformance reference for the GovStack Cross-BB API + Design Guide. + contact: + name: GovStack Registry BB Maintainers + url: https://example.gov/registry/support + email: registry-api@example.gov + license: + name: CC-BY-4.0 + url: https://creativecommons.org/licenses/by/4.0/ + x-govstack-api-guide: + version: 0.2.0 +servers: + production: + host: kafka.example.gov:9092 + protocol: kafka + description: Production Kafka broker for the Registry Building Block. + security: + - $ref: '#/components/securitySchemes/registryOAuth' +defaultContentType: application/json +channels: + registrantRegistered: + address: org.govstack.registry.v1.registrant.registered + description: Registrant lifecycle events published by the Registry BB. + servers: + - $ref: '#/servers/production' + messages: + registered: + $ref: '#/components/messages/registrantRegisteredEvent' + bindings: + kafka: + bindingVersion: '0.5.0' + registrantCommands: + address: org.govstack.registry.v1.registrant.{registrantId}.commands + description: Commands directed at a specific registrant, consumed by the Registry BB. + parameters: + registrantId: + $ref: '#/components/parameters/registrantId' + servers: + - $ref: '#/servers/production' + messages: + deregister: + $ref: '#/components/messages/deregisterRegistrantCommand' + bindings: + kafka: + bindingVersion: '0.5.0' + registrantCommandReplies: + address: org.govstack.registry.v1.registrant.command-replies + description: Replies and rejections for registrant commands. + servers: + - $ref: '#/servers/production' + messages: + result: + $ref: '#/components/messages/deregisterRegistrantResult' + rejected: + $ref: '#/components/messages/registrantDeregistrationRejected' + bindings: + kafka: + bindingVersion: '0.5.0' +operations: + sendRegistrantRegistered: + action: send + summary: Publish a registrant-registered event + description: >- + The Registry BB publishes a CloudEvents envelope whenever a registrant is + registered. Delivery is at-least-once and ordered per registrant; consumers + must de-duplicate on the event id. + tags: + - name: Registrants + channel: + $ref: '#/channels/registrantRegistered' + messages: + - $ref: '#/channels/registrantRegistered/messages/registered' + x-govstack-delivery: atLeastOnce + x-govstack-ordering: + scope: partitionKey + key: registrantId + x-govstack-redelivery: supported + x-govstack-dead-letter: supported + x-govstack-retention: supported + x-govstack-replay: supported + receiveDeregisterCommand: + action: receive + summary: Consume a deregister-registrant command + description: >- + The Registry BB consumes a deregister command for a registrant and replies + on the command-replies channel. Delivery is at-least-once; the command is + idempotent on its idempotencyKey header. This is a request-reply operation + correlated by correlationId. + tags: + - name: Registrants + channel: + $ref: '#/channels/registrantCommands' + messages: + - $ref: '#/channels/registrantCommands/messages/deregister' + reply: + channel: + $ref: '#/channels/registrantCommandReplies' + messages: + - $ref: '#/channels/registrantCommandReplies/messages/result' + x-govstack-delivery: atLeastOnce + x-govstack-ordering: + scope: partitionKey + key: registrantId + x-govstack-redelivery: supported + x-govstack-dead-letter: supported + x-govstack-retention: supported + x-govstack-replay: notApplicable + sendDeregistrationRejected: + action: send + summary: Publish a deregistration-rejected error + description: >- + The Registry BB publishes a rejection using the §11 error envelope when a + deregister command cannot be honoured. Correlated to the originating command + by correlationId. Delivery is at-least-once. + tags: + - name: Registrants + channel: + $ref: '#/channels/registrantCommandReplies' + messages: + - $ref: '#/channels/registrantCommandReplies/messages/rejected' + x-govstack-delivery: atLeastOnce + x-govstack-ordering: none + x-govstack-redelivery: supported + x-govstack-dead-letter: supported + x-govstack-retention: supported + x-govstack-replay: notApplicable +components: + securitySchemes: + registryOAuth: + type: oauth2 + description: OAuth 2.0 client-credentials for service-to-service event access. + flows: + clientCredentials: + tokenUrl: https://id.example.gov/oauth2/token + availableScopes: + bb:registry:events:subscribe: Consume registrant lifecycle events. + bb:registry:events:publish: Publish registrant lifecycle events. + parameters: + registrantId: + description: Opaque, server-generated identifier of the registrant the channel routes on. + messages: + registrantRegisteredEvent: + name: registrantRegisteredEvent + title: Registrant registered + summary: Emitted when a registrant is registered. + contentType: application/json + headers: + type: object + description: Message metadata headers for the registrant-registered event. + properties: + idempotencyKey: + type: string + format: uuid + description: Producer-assigned key so consumers can de-duplicate redeliveries. + payload: + type: object + description: CloudEvents 1.0 envelope wrapping the registrant-registered domain event. + required: [specversion, id, source, type, time, datacontenttype, data] + properties: + specversion: + type: string + description: CloudEvents specification version; always "1.0". + const: '1.0' + id: + type: string + description: Unique identifier of this event occurrence. + source: + type: string + description: Stable logical identifier of the publishing context. + const: /govstack/registry + type: + type: string + description: Reverse-DNS event type carrying no version segment. + const: org.govstack.registry.registrant.registered + time: + type: string + format: date-time + description: RFC 3339 timestamp at which the event occurred. + datacontenttype: + type: string + description: Media type of the data member. + const: application/json + subject: + type: string + description: Identifier of the registrant the event concerns. + data: + type: object + description: GovStack domain payload for the registrant-registered event. + required: [registrantId, registrationStatus, occurredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was registered. + registrationStatus: + type: string + description: Registration status at the time of the event. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + occurredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registration occurred. + examples: + - name: registrantRegistered + summary: A newly registered registrant. + headers: + idempotencyKey: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e + payload: + specversion: '1.0' + id: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e + source: /govstack/registry + type: org.govstack.registry.registrant.registered + time: '2026-07-10T12:34:56Z' + datacontenttype: application/json + subject: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + registrationStatus: ACTIVE + occurredAt: '2026-07-10T12:34:56Z' + deregisterRegistrantCommand: + name: deregisterRegistrantCommand + title: Deregister registrant command + summary: Requests deregistration of a registrant. + contentType: application/json + headers: + type: object + description: Message metadata headers for the deregister command. + properties: + idempotencyKey: + type: string + format: uuid + description: Command idempotency key so redeliveries are processed once. + correlationId: + type: string + format: uuid + description: Correlation identifier echoed on the reply. + correlationId: + location: $message.header#/correlationId + description: Correlates the command with its reply on the command-replies channel. + payload: + type: object + description: CloudEvents 1.0 envelope wrapping the deregister command. + required: [specversion, id, source, type, time, datacontenttype, data] + properties: + specversion: + type: string + description: CloudEvents specification version; always "1.0". + const: '1.0' + id: + type: string + description: Unique identifier of this command occurrence. + source: + type: string + description: Stable logical identifier of the command issuer. + const: /govstack/registry + type: + type: string + description: Reverse-DNS command type carrying no version segment. + const: org.govstack.registry.registrant.deregisterRequested + time: + type: string + format: date-time + description: RFC 3339 timestamp at which the command was issued. + datacontenttype: + type: string + description: Media type of the data member. + const: application/json + data: + type: object + description: GovStack domain payload for the deregister command. + required: [registrantId, reason] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant to deregister. + reason: + type: string + description: Human-readable reason for the deregistration. + examples: + - name: deregisterRegistrant + summary: A deregister command for a registrant. + headers: + idempotencyKey: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f + correlationId: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d + payload: + specversion: '1.0' + id: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f + source: /govstack/registry + type: org.govstack.registry.registrant.deregisterRequested + time: '2026-07-10T12:40:00Z' + datacontenttype: application/json + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + reason: Requested by registrant. + deregisterRegistrantResult: + name: deregisterRegistrantResult + title: Deregister registrant result + summary: Reports the outcome of a deregister command. + contentType: application/json + headers: + type: object + description: Message metadata headers for the deregister result. + properties: + correlationId: + type: string + format: uuid + description: Correlation identifier copied from the originating command. + correlationId: + location: $message.header#/correlationId + description: Correlates the reply back to the originating command. + payload: + type: object + description: CloudEvents 1.0 envelope wrapping the deregister result. + required: [specversion, id, source, type, time, datacontenttype, data] + properties: + specversion: + type: string + description: CloudEvents specification version; always "1.0". + const: '1.0' + id: + type: string + description: Unique identifier of this result occurrence. + source: + type: string + description: Stable logical identifier of the publishing context. + const: /govstack/registry + type: + type: string + description: Reverse-DNS event type carrying no version segment. + const: org.govstack.registry.registrant.deregistered + time: + type: string + format: date-time + description: RFC 3339 timestamp at which the registrant was deregistered. + datacontenttype: + type: string + description: Media type of the data member. + const: application/json + data: + type: object + description: GovStack domain payload for the deregister result. + required: [registrantId, deregisteredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was deregistered. + deregisteredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registrant was deregistered. + examples: + - name: deregisterResult + summary: A successful deregistration result. + headers: + correlationId: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d + payload: + specversion: '1.0' + id: 3d4e5f6a-7b8c-9d0e-1f2a-3b4c5d6e7f80 + source: /govstack/registry + type: org.govstack.registry.registrant.deregistered + time: '2026-07-10T12:41:00Z' + datacontenttype: application/json + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + deregisteredAt: '2026-07-10T12:41:00Z' + registrantDeregistrationRejected: + name: registrantDeregistrationRejected + title: Registrant deregistration rejected + summary: Reports that a deregister command was rejected. + contentType: application/json + headers: + type: object + description: Message metadata headers for the rejection. + properties: + correlationId: + type: string + format: uuid + description: Correlation identifier copied from the originating command. + correlationId: + location: $message.header#/correlationId + description: Correlates the rejection back to the originating command. + payload: + type: object + description: >- + RFC 9457 problem details envelope with GovStack extension fields, used + for asynchronous command rejections (§17.16). + required: [type, title, status, code, traceId, timestamp] + properties: + type: + type: string + format: uri + description: URI reference identifying the problem type. + title: + type: string + description: Short, human-readable summary of the problem type. + status: + type: integer + description: Nominal HTTP-equivalent status for the failure class. + detail: + type: string + description: Human-readable explanation specific to this rejection. + code: + type: string + description: Stable, namespaced GovStack error code. + traceId: + type: string + description: Distributed-trace identifier correlating this error to server logs. + timestamp: + type: string + format: date-time + description: RFC 3339 timestamp at which the rejection was produced. + examples: + - name: deregistrationRejected + summary: A rejected deregistration. + headers: + correlationId: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d + payload: + type: https://docs.example.gov/registry/problems/deregistration-rejected + title: Deregistration rejected + status: 409 + detail: The registrant has an active obligation and cannot be deregistered. + code: org.govstack.registry.deregistrationRejected + traceId: 9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c + timestamp: '2026-07-10T12:41:05Z' diff --git a/api-design-guide/linter/tests/golden/openapi-golden.yaml b/api-design-guide/linter/tests/golden/openapi-golden.yaml new file mode 100644 index 0000000..ceec728 --- /dev/null +++ b/api-design-guide/linter/tests/golden/openapi-golden.yaml @@ -0,0 +1,1411 @@ +# GovStack API Design Guide — GOLDEN OpenAPI reference spec +# ========================================================= +# A small, realistic OpenAPI 3.1.0 spec for a fictional GovStack Building Block +# (bb-code: "registry", a civil-registration registry). It is written to satisfy +# EVERY check in the default bundled ruleset simultaneously and to double as a +# copy-able reference for BB spec editors. +# +# It deliberately exercises the guide's shapes: /v1 kebab-case paths, camelCase +# params/properties, described schemas (§4.1) with examples (§4.2), the §5.9 +# health endpoint family, RFC 9457 problem+json error envelopes with the +# GovStack extension fields and field-level errors[] (§11), cursor pagination +# (§12), a creating POST with Idempotency-Key + Location (§8.3/§14.1), X-Request-Id +# correlation (§8.4), ETag/If-Match optimistic concurrency (§7.16/§7.17), merge- +# patch PATCH (§6.4), async long-running Operations (§15), CloudEvents webhooks + +# subscription control plane (§16), OAuth2/OIDC/mTLS security with namespaced +# scopes (§13), and the §20.3 conformance declaration. +# +# This spec lints to ZERO findings under the default ruleset. The ruleset +# exempts the operational /health (and /ready) endpoint from the resource- +# oriented rules that do not apply to a liveness probe (§5.1 version prefix, +# §12.x pagination, §7.16 ETag), scopes the §5.9 health+json media type and +# status enum to the SUCCESS (200) response so the §11.1 problem+json error +# response is satisfiable, and counts only non-param segments toward §5.4 max +# nesting depth so the §15.5/§16.11 mandated action sub-resource paths +# (/v1/operations/{operationId}/cancel, .../subscriptions/{id}/rotate-secret) +# stay within two levels. +openapi: 3.1.0 +info: + title: GovStack Registry Building Block API + version: 1.0.0 + description: >- + Synchronous REST surface of the fictional GovStack Registry Building Block: + manage registrants, run long-running bulk imports as asynchronous + Operations, and manage webhook subscriptions for registrant lifecycle + events. This document is a conformance reference for the GovStack Cross-BB + API Design Guide. + contact: + name: GovStack Registry BB Maintainers + url: https://example.gov/registry/support + email: registry-api@example.gov + license: + name: CC-BY-4.0 + url: https://creativecommons.org/licenses/by/4.0/ + x-govstack-api-guide: + version: 0.2.0 +servers: + - url: https://api.example.gov/registry + description: Production gateway for the Registry Building Block. + - url: https://sandbox.api.example.gov/registry + description: Sandbox environment for integration testing. +security: + - registryOAuth: + - bb:registry:registrant:read +tags: + - name: Registrants + description: Create, read, update and delete registrant records. + - name: Operations + description: Poll and cancel long-running asynchronous Operations. + - name: Subscriptions + description: Manage webhook subscriptions for registrant lifecycle events. + - name: Health + description: Operational liveness endpoint. +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: >- + Unversioned operational liveness endpoint per guide §5.9. Reports overall + service health using the application/health+json media type. Unauthenticated + and free of citizen data and system-internal detail. + tags: [Health] + security: [] + parameters: + - $ref: '#/components/parameters/XRequestId' + responses: + '200': + description: The service is alive; body reports the aggregate status. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + content: + application/health+json: + schema: + $ref: '#/components/schemas/HealthStatus' + # §7.13 requires a documented 500. Per §11.1 every 4xx/5xx MUST be + # application/problem+json, so the /health error response uses the + # standard problem envelope (Cache-Control: no-store included). The + # §5.9 health+json media type and pass|fail|warn status enum apply to + # the SUCCESS (200) payload above, not to error responses. + '500': + $ref: '#/components/responses/ServerError' + /v1/registrants: + get: + operationId: listRegistrants + summary: List registrants + description: >- + Returns a cursor-paginated page of registrant records, most recently + updated first. Supports simple equality filtering and sorting. + tags: [Registrants] + parameters: + - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Sort' + - $ref: '#/components/parameters/FilterRegistrationStatus' + - $ref: '#/components/parameters/FilterCountryCode' + responses: + '200': + description: A page of registrants. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantPage' + '304': + $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/ServerError' + post: + operationId: createRegistrant + summary: Create a registrant + description: >- + Registers a new registrant and returns the created record. Non-idempotent + create: clients must supply an Idempotency-Key so retries are safe. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: Attributes for the registrant to create. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantCreate' + responses: + '201': + description: The registrant was created. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + Location: + $ref: '#/components/headers/Location' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/ServerError' + /v1/registrants/{registrantId}: + parameters: + - $ref: '#/components/parameters/RegistrantId' + get: + operationId: getRegistrant + summary: Fetch a registrant + description: Returns a single registrant record by its opaque identifier. + tags: [Registrants] + parameters: + - $ref: '#/components/parameters/XRequestId' + responses: + '200': + description: The registrant record. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '304': + $ref: '#/components/responses/NotModified' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + put: + operationId: replaceRegistrant + summary: Replace a registrant + description: >- + Fully replaces a registrant record. Uses optimistic concurrency: the + caller must echo the current ETag via If-Match. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + description: The full replacement representation of the registrant. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantCreate' + responses: + '200': + description: The registrant was replaced. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '412': + $ref: '#/components/responses/PreconditionFailed' + '500': + $ref: '#/components/responses/ServerError' + patch: + operationId: updateRegistrant + summary: Partially update a registrant + description: >- + Applies a JSON Merge Patch (RFC 7396) to a registrant record. Uses + optimistic concurrency via If-Match. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + description: The merge-patch document describing the fields to change. + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/RegistrantPatch' + responses: + '200': + description: The registrant was updated. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '412': + $ref: '#/components/responses/PreconditionFailed' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '500': + $ref: '#/components/responses/ServerError' + delete: + operationId: deleteRegistrant + summary: Delete a registrant + description: Permanently removes a registrant record. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/XRequestId' + responses: + '204': + description: The registrant was deleted; no body is returned. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /v1/registrants/search: + post: + operationId: searchRegistrants + summary: Search registrants + description: >- + Complex, read-only registrant query. Returns 200 (never 201) and uses the + cursor pagination envelope; filter criteria and pagination travel in the + request body per guide §6.6/§12.9. + tags: [Registrants] + parameters: + - $ref: '#/components/parameters/XRequestId' + requestBody: + required: true + description: The search filter plus cursor pagination controls. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantSearchRequest' + responses: + '200': + description: A page of matching registrants. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantPage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/ServerError' + /v1/bulk-imports: + post: + operationId: startBulkImport + summary: Start a bulk import + description: >- + Starts a long-running bulk import of registrants from an external source. + The work cannot complete synchronously, so the endpoint returns 202 with a + Location pointing at the Operation resource to poll. + tags: [Operations] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: Location of the source data to import. + content: + application/json: + schema: + $ref: '#/components/schemas/BulkImportRequest' + responses: + '202': + description: The import was accepted; poll the Operation for progress. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/Operation' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/ServerError' + /v1/operations/{operationId}: + parameters: + - $ref: '#/components/parameters/OperationId' + get: + operationId: getOperation + summary: Poll an Operation + description: >- + Returns the current state of a long-running Operation so a client can poll + for completion, per guide §15.4. + tags: [Operations] + parameters: + - $ref: '#/components/parameters/XRequestId' + responses: + '200': + description: The current Operation state. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Operation' + '304': + $ref: '#/components/responses/NotModified' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /v1/operations/{operationId}/cancel: + parameters: + - $ref: '#/components/parameters/OperationId' + post: + operationId: cancelOperation + summary: Cancel an Operation + description: >- + Requests cancellation of a long-running Operation, per guide §15.5. Returns + the Operation with its updated status. + tags: [Operations] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/XRequestId' + responses: + '200': + description: Cancellation was requested; the Operation state is returned. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/Operation' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/ServerError' + /v1/subscriptions: + get: + operationId: listSubscriptions + summary: List subscriptions + description: Returns a cursor-paginated page of webhook subscriptions. + tags: [Subscriptions] + security: + - registryOAuth: + - bb:registry:subscription:manage + parameters: + - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of subscriptions. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionPage' + '304': + $ref: '#/components/responses/NotModified' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/ServerError' + post: + operationId: createSubscription + summary: Create a subscription + description: >- + Registers a webhook subscription for registrant lifecycle events. Returns + the created subscription including its initial signing secret. Idempotent + via Idempotency-Key. + tags: [Subscriptions] + security: + - registryOAuth: + - bb:registry:subscription:manage + parameters: + - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: The subscription to create. + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionCreate' + responses: + '201': + description: The subscription was created. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/ServerError' + /v1/subscriptions/{subscriptionId}: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + delete: + operationId: deleteSubscription + summary: Delete a subscription + description: Cancels and removes a webhook subscription. + tags: [Subscriptions] + security: + - registryOAuth: + - bb:registry:subscription:manage + parameters: + - $ref: '#/components/parameters/XRequestId' + responses: + '204': + description: The subscription was deleted; no body is returned. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /v1/subscriptions/{subscriptionId}/rotate-secret: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + post: + operationId: rotateSubscriptionSecret + summary: Rotate a subscription signing secret + description: >- + Rotates the HMAC signing secret used to compute the GovStack-Signature on + deliveries for this subscription, per guide §16.11. + tags: [Subscriptions] + security: + - registryOAuth: + - bb:registry:subscription:manage + parameters: + - $ref: '#/components/parameters/XRequestId' + responses: + '200': + description: The secret was rotated; the new secret is returned once. + headers: + X-Request-Id: + $ref: '#/components/headers/XRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionSecret' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' +webhooks: + registrantRegistered: + post: + operationId: onRegistrantRegistered + summary: Registrant registered event + description: >- + Delivered to a subscriber's callback URL when a registrant is registered. + The body is a structured CloudEvents 1.0 envelope; the delivery carries a + GovStack-Signature header the receiver verifies against the subscription + secret. + tags: [Subscriptions] + parameters: + - $ref: '#/components/parameters/GovStackSignature' + requestBody: + required: true + description: A CloudEvents 1.0 envelope wrapping the registrant event. + content: + application/cloudevents+json: + schema: + $ref: '#/components/schemas/RegistrantRegisteredEvent' + responses: + '200': + description: The subscriber accepted the event. + '410': + description: >- + The subscriber endpoint is gone; the publisher may disable the + subscription. +components: + securitySchemes: + registryOAuth: + type: oauth2 + description: >- + OAuth 2.0 for citizen-facing and inter-BB access. The authorizationCode + flow serves user-facing clients; the clientCredentials flow serves + service-to-service (BB-to-BB) calls. + flows: + authorizationCode: + authorizationUrl: https://id.example.gov/oauth2/authorize + tokenUrl: https://id.example.gov/oauth2/token + refreshUrl: https://id.example.gov/oauth2/token + scopes: + bb:registry:registrant:read: Read registrant records. + bb:registry:registrant:write: Create, update and delete registrant records. + bb:registry:subscription:manage: Manage webhook subscriptions. + clientCredentials: + tokenUrl: https://id.example.gov/oauth2/token + scopes: + bb:registry:registrant:read: Read registrant records. + bb:registry:registrant:write: Create, update and delete registrant records. + bb:registry:subscription:manage: Manage webhook subscriptions. + registryOidc: + type: openIdConnect + description: OpenID Connect discovery for citizen authentication. + openIdConnectUrl: https://id.example.gov/.well-known/openid-configuration + registryMtls: + type: mutualTLS + description: Mutual TLS for high-assurance service-to-service calls. + parameters: + XRequestId: + name: X-Request-Id + in: header + required: false + description: >- + Client-supplied correlation identifier (§8.4). Echoed on the response; if + omitted the server generates one. + schema: + type: string + format: uuid + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: >- + Unique key making a non-idempotent create safe to retry (§8.3/§14.1). The + server returns the original result for a repeated key. + schema: + type: string + maxLength: 255 + IfMatch: + name: If-Match + in: header + required: true + description: >- + ETag of the version the caller intends to modify, for optimistic + concurrency (§7.17). A mismatch yields 412. + schema: + type: string + RegistrantId: + name: registrantId + in: path + required: true + description: Opaque, server-generated identifier of a registrant. + schema: + type: string + format: uuid + OperationId: + name: operationId + in: path + required: true + description: Opaque identifier of a long-running Operation. + schema: + type: string + format: uuid + SubscriptionId: + name: subscriptionId + in: path + required: true + description: Opaque identifier of a webhook subscription. + schema: + type: string + format: uuid + GovStackSignature: + name: GovStack-Signature + in: header + required: true + description: >- + Detached HMAC signature of the event body, for the receiver to verify + authenticity against the subscription secret (§16.6). + schema: + type: string + PageSize: + name: pageSize + in: query + required: false + description: Maximum number of items to return in one page. + schema: + type: integer + minimum: 1 + default: 20 + maximum: 100 + Cursor: + name: cursor + in: query + required: false + description: >- + Opaque pagination cursor returned as pageInfo.nextCursor by a previous + call. Omit to fetch the first page. + schema: + type: string + Sort: + name: sort + in: query + required: false + description: >- + Comma-separated sort keys; prefix a key with "-" for descending order + (e.g. "-updatedAt,familyName"). + schema: + type: string + pattern: '^-?[a-zA-Z][a-zA-Z0-9]*(,-?[a-zA-Z][a-zA-Z0-9]*)*$' + FilterRegistrationStatus: + name: registrationStatus + in: query + required: false + description: Equality filter on the registrant's registration status. + schema: + type: string + pattern: '^[A-Z][A-Z0-9_]*$' + FilterCountryCode: + name: countryCode + in: query + required: false + description: Equality filter on the registrant's ISO 3166-1 alpha-2 country code. + schema: + type: string + pattern: '^[A-Z]{2}$' + headers: + XRequestId: + description: Correlation identifier echoed from the request or generated by the server. + schema: + type: string + format: uuid + Location: + description: Absolute URL of the created or referenced resource. + schema: + type: string + format: uri-reference + ETag: + description: Entity tag of the returned representation, for conditional requests. + schema: + type: string + CacheControl: + description: Caching directives; error and Operation-status bodies use no-store. + schema: + type: string + WwwAuthenticate: + description: Authentication challenge describing how to authenticate (RFC 9110). + schema: + type: string + responses: + NotModified: + description: The representation is unchanged from the caller's cached ETag. + headers: + ETag: + $ref: '#/components/headers/ETag' + BadRequest: + description: The request was malformed or failed field-level validation. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ValidationProblem' + Unauthorized: + description: Authentication is required or the supplied credentials are invalid. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WwwAuthenticate' + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + NotFound: + description: The requested resource does not exist. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Conflict: + description: The request conflicts with the current state of the resource. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + PreconditionFailed: + description: The If-Match precondition did not hold; the resource was modified. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + UnsupportedMediaType: + description: The request media type is not supported by this endpoint. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + ServerError: + description: An unexpected error occurred while processing the request. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + schemas: + Problem: + type: object + description: >- + RFC 9457 problem details envelope with GovStack extension fields. Used for + every 4xx and 5xx response. + required: [type, title, status, code, traceId, timestamp] + properties: + type: + type: string + format: uri + description: URI reference identifying the problem type. + title: + type: string + description: Short, human-readable summary of the problem type. + status: + type: integer + description: HTTP status code duplicated in the body for convenience. + detail: + type: string + description: Human-readable explanation specific to this occurrence. + instance: + type: string + format: uri-reference + description: URI reference identifying this specific occurrence. + code: + type: string + description: >- + Stable, namespaced GovStack error code (§11.5), reverse-DNS + org.govstack.{bb-code}.{errorName}. + example: org.govstack.registry.requestFailed + traceId: + type: string + description: Distributed-trace identifier correlating this error to server logs. + timestamp: + type: string + format: date-time + description: RFC 3339 timestamp at which the error was produced. + examples: + - type: https://docs.example.gov/registry/problems/request-failed + title: Request failed + status: 500 + detail: The request could not be processed. + instance: /v1/registrants/6f9619ff-8b86-d011-b42d-00cf4fc964ff + code: org.govstack.registry.requestFailed + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + timestamp: '2026-07-10T12:34:56Z' + ValidationProblem: + description: >- + Problem details for field-level validation failures (§11.4): the standard + problem envelope plus an errors array pinpointing each offending field. + allOf: + - $ref: '#/components/schemas/Problem' + - type: object + description: The field-level errors extension of the problem envelope. + required: [errors] + properties: + errors: + type: array + description: One entry per field that failed validation. + items: + $ref: '#/components/schemas/FieldError' + examples: + - type: https://docs.example.gov/registry/problems/validation + title: Validation failed + status: 400 + detail: One or more fields are invalid. + code: org.govstack.registry.validationFailed + traceId: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d + timestamp: '2026-07-10T12:34:56Z' + errors: + - pointer: /emailAddress + code: org.govstack.registry.invalidEmail + message: emailAddress must be a valid email address. + FieldError: + type: object + description: A single field-level validation error. + required: [pointer, code, message] + properties: + pointer: + type: string + description: JSON Pointer to the offending field in the request body. + code: + type: string + description: Stable, namespaced code identifying the validation failure. + message: + type: string + description: Human-readable description of what is wrong with the field. + HealthStatus: + type: object + description: application/health+json body reporting aggregate service health. + required: [status] + properties: + status: + type: string + description: >- + Aggregate health indicator: "pass" (healthy), "warn" (healthy but with + concerns) or "fail" (unhealthy). + enum: [pass, warn, fail] + x-extensible-enum: true + version: + type: string + description: Version of the service reporting health. + releaseId: + type: string + description: Deployed release identifier of the service. + examples: + - status: pass + version: '1.0.0' + releaseId: '2026.07.10' + Money: + type: object + description: >- + A monetary amount as a decimal string plus an ISO 4217 currency code + (§10.4); never a binary float. + required: [amount, currency] + properties: + amount: + type: string + description: Decimal amount as a string, e.g. "12.50". + pattern: '^-?[0-9]+(\.[0-9]+)?$' + currency: + type: string + description: ISO 4217 alphabetic currency code. + pattern: '^[A-Z]{3}$' + Registrant: + type: object + description: A registrant record held by the Registry Building Block. + required: + - registrantId + - givenName + - familyName + - registrationStatus + - createdAt + - updatedAt + properties: + registrantId: + type: string + format: uuid + description: Opaque, server-generated unique identifier of the registrant. + givenName: + type: string + description: The registrant's given (first) name. + familyName: + type: string + description: The registrant's family (last) name. + emailAddress: + type: string + format: email + description: The registrant's contact email address (RFC 5322). + phoneNumber: + type: string + description: The registrant's phone number in E.164 form. + pattern: '^\+[1-9]\d{1,14}$' + birthDate: + type: string + format: date + description: The registrant's date of birth (RFC 3339 full-date). + countryCode: + type: string + description: ISO 3166-1 alpha-2 country of residence. + pattern: '^[A-Z]{2}$' + preferredLanguage: + type: string + description: Preferred communication language as a BCP 47 tag. + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' + registrationStatus: + type: string + description: >- + Lifecycle status of the registration: ACTIVE, SUSPENDED or + DEREGISTERED. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + registrationFee: + $ref: '#/components/schemas/Money' + signature: + type: string + contentEncoding: base64 + maxLength: 100000 + description: Base64-encoded captured signature image, if provided (§10.7). + createdAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registrant was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registrant was last modified. + examples: + - registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + givenName: Amina + familyName: Diallo + emailAddress: amina.diallo@example.com + phoneNumber: '+221771234567' + birthDate: '1990-04-12' + countryCode: SN + preferredLanguage: fr + registrationStatus: ACTIVE + registrationFee: + amount: '12.50' + currency: XOF + createdAt: '2026-01-15T09:00:00Z' + updatedAt: '2026-07-01T14:30:00Z' + RegistrantCreate: + type: object + description: Writable attributes accepted when creating or replacing a registrant. + required: [givenName, familyName] + properties: + givenName: + type: string + description: The registrant's given (first) name. + familyName: + type: string + description: The registrant's family (last) name. + emailAddress: + type: string + format: email + description: The registrant's contact email address (RFC 5322). + phoneNumber: + type: string + description: The registrant's phone number in E.164 form. + pattern: '^\+[1-9]\d{1,14}$' + birthDate: + type: string + format: date + description: The registrant's date of birth (RFC 3339 full-date). + countryCode: + type: string + description: ISO 3166-1 alpha-2 country of residence. + pattern: '^[A-Z]{2}$' + preferredLanguage: + type: string + description: Preferred communication language as a BCP 47 tag. + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' + registrationFee: + $ref: '#/components/schemas/Money' + examples: + - givenName: Amina + familyName: Diallo + emailAddress: amina.diallo@example.com + phoneNumber: '+221771234567' + birthDate: '1990-04-12' + countryCode: SN + preferredLanguage: fr + RegistrantPatch: + type: object + description: >- + JSON Merge Patch body for a registrant (§6.4). Present fields are replaced; + an explicit null clears a field. + properties: + emailAddress: + type: string + format: email + description: New contact email address. + phoneNumber: + type: string + description: New phone number in E.164 form. + pattern: '^\+[1-9]\d{1,14}$' + preferredLanguage: + type: string + description: New preferred language as a BCP 47 tag. + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' + registrationStatus: + type: string + description: New registration status. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + examples: + - emailAddress: amina.new@example.com + registrationStatus: SUSPENDED + RegistrantSearchRequest: + type: object + description: Complex search filter plus cursor pagination controls (§12.9). + properties: + givenName: + type: string + description: Match registrants whose given name equals this value. + familyName: + type: string + description: Match registrants whose family name equals this value. + countryCode: + type: string + description: Match registrants in this ISO 3166-1 alpha-2 country. + pattern: '^[A-Z]{2}$' + registrationStatus: + type: string + description: Match registrants with this registration status. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + pageSize: + type: integer + description: Maximum number of items to return in one page. + minimum: 1 + default: 20 + maximum: 100 + cursor: + type: string + description: Opaque pagination cursor from a previous page. + examples: + - countryCode: SN + registrationStatus: ACTIVE + pageSize: 20 + PageInfo: + type: object + description: Cursor pagination metadata (§12.3). + required: [nextCursor, hasMore] + properties: + nextCursor: + type: [string, 'null'] + description: >- + Opaque cursor to pass as the cursor parameter to fetch the next page; + null on the last page. + hasMore: + type: boolean + description: Whether more items exist beyond this page. + examples: + - nextCursor: b3BhcXVlLWN1cnNvci0y + hasMore: true + RegistrantPage: + type: object + description: A cursor-paginated page of registrant records. + required: [items, pageInfo] + properties: + items: + type: array + description: The registrant records in this page. + items: + $ref: '#/components/schemas/Registrant' + pageInfo: + $ref: '#/components/schemas/PageInfo' + examples: + - items: + - registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + givenName: Amina + familyName: Diallo + registrationStatus: ACTIVE + createdAt: '2026-01-15T09:00:00Z' + updatedAt: '2026-07-01T14:30:00Z' + pageInfo: + nextCursor: b3BhcXVlLWN1cnNvci0y + hasMore: true + SubscriptionPage: + type: object + description: A cursor-paginated page of webhook subscriptions. + required: [items, pageInfo] + properties: + items: + type: array + description: The subscriptions in this page. + items: + $ref: '#/components/schemas/Subscription' + pageInfo: + $ref: '#/components/schemas/PageInfo' + examples: + - items: + - subscriptionId: 9d1f3b2a-6c4e-4a1b-8f0d-2e5c7a9b1d33 + callbackUrl: https://partner.example.org/hooks/registry + eventTypes: + - org.govstack.registry.registrant.registered + status: ACTIVE + createdAt: '2026-06-01T08:00:00Z' + updatedAt: '2026-06-01T08:00:00Z' + pageInfo: + nextCursor: null + hasMore: false + BulkImportRequest: + type: object + description: Instructions for a long-running bulk import. + required: [sourceUrl] + properties: + sourceUrl: + type: string + format: uri + description: URL of the source dataset to import. + dryRun: + type: boolean + description: When true, validate the source without persisting records. + examples: + - sourceUrl: https://data.example.gov/registry/import-2026-07.csv + dryRun: false + Operation: + type: object + description: >- + A long-running asynchronous Operation resource (§15.2). Clients poll it + until status is terminal. + required: [id, status, result, error, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: Opaque identifier of the Operation. + status: + type: string + description: >- + Lifecycle status of the Operation: PENDING, RUNNING, SUCCEEDED, FAILED + or CANCELLED. + enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] + x-extensible-enum: true + progress: + type: integer + description: Best-effort completion percentage from 0 to 100. + minimum: 0 + maximum: 100 + result: + type: [object, 'null'] + description: >- + Operation-specific result payload once status is SUCCEEDED; null + otherwise. + error: + type: [object, 'null'] + description: >- + Problem details once status is FAILED; null otherwise. + createdAt: + type: string + format: date-time + description: RFC 3339 timestamp when the Operation was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 timestamp when the Operation last changed state. + examples: + - id: 3a7c1e94-2b6d-4f0a-9c1e-8d5f6a7b0c11 + status: RUNNING + progress: 42 + result: null + error: null + createdAt: '2026-07-10T12:00:00Z' + updatedAt: '2026-07-10T12:03:00Z' + Subscription: + type: object + description: A webhook subscription for registrant lifecycle events. + required: [subscriptionId, callbackUrl, eventTypes, status, createdAt, updatedAt] + properties: + subscriptionId: + type: string + format: uuid + description: Opaque identifier of the subscription. + callbackUrl: + type: string + format: uri + description: HTTPS URL the events are delivered to. + eventTypes: + type: array + description: Reverse-DNS event types this subscription receives. + items: + type: string + description: A single reverse-DNS event type. + status: + type: string + description: Whether the subscription is ACTIVE or PAUSED. + enum: [ACTIVE, PAUSED] + x-extensible-enum: true + secret: + type: string + description: >- + Current signing secret; returned only at creation and rotation, then + masked. + createdAt: + type: string + format: date-time + description: RFC 3339 timestamp when the subscription was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 timestamp when the subscription was last modified. + examples: + - subscriptionId: 9d1f3b2a-6c4e-4a1b-8f0d-2e5c7a9b1d33 + callbackUrl: https://partner.example.org/hooks/registry + eventTypes: + - org.govstack.registry.registrant.registered + status: ACTIVE + createdAt: '2026-06-01T08:00:00Z' + updatedAt: '2026-06-01T08:00:00Z' + SubscriptionCreate: + type: object + description: Attributes accepted when creating a subscription. + required: [callbackUrl, eventTypes] + properties: + callbackUrl: + type: string + format: uri + description: HTTPS URL the events will be delivered to. + eventTypes: + type: array + description: Reverse-DNS event types to subscribe to. + items: + type: string + description: A single reverse-DNS event type. + examples: + - callbackUrl: https://partner.example.org/hooks/registry + eventTypes: + - org.govstack.registry.registrant.registered + SubscriptionSecret: + type: object + description: A freshly rotated subscription signing secret. + required: [secret, rotatedAt] + properties: + secret: + type: string + description: The new signing secret; shown once and not retrievable later. + rotatedAt: + type: string + format: date-time + description: RFC 3339 timestamp when the secret was rotated. + examples: + - secret: whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c + rotatedAt: '2026-07-10T12:34:56Z' + RegistrantRegisteredEvent: + type: object + description: >- + CloudEvents 1.0 envelope delivered when a registrant is registered (§16.2). + GovStack domain data lives under data. + required: [specversion, id, source, type, time, datacontenttype, data] + properties: + specversion: + type: string + description: CloudEvents specification version; always "1.0". + const: '1.0' + id: + type: string + description: Unique identifier of this event occurrence. + source: + type: string + description: >- + Stable logical identifier of the publishing context; never a + deployment host, pod, broker or environment (§16.4). + const: /govstack/registry + type: + type: string + description: >- + Reverse-DNS event type, org.govstack.{bb-code}.{resource}.{action}, + carrying no version segment (§16.3). + const: org.govstack.registry.registrant.registered + time: + type: string + format: date-time + description: RFC 3339 timestamp at which the event occurred. + datacontenttype: + type: string + description: Media type of the data member. + const: application/json + subject: + type: string + description: Identifier of the registrant the event concerns. + data: + $ref: '#/components/schemas/RegistrantEventData' + examples: + - specversion: '1.0' + id: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e + source: /govstack/registry + type: org.govstack.registry.registrant.registered + time: '2026-07-10T12:34:56Z' + datacontenttype: application/json + subject: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + registrationStatus: ACTIVE + occurredAt: '2026-07-10T12:34:56Z' + RegistrantEventData: + type: object + description: GovStack domain payload for a registrant-registered event. + required: [registrantId, registrationStatus, occurredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was registered. + registrationStatus: + type: string + description: Registration status at the time of the event. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + occurredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registration occurred. diff --git a/api-design-guide/linter/tests/harness.test.mjs b/api-design-guide/linter/tests/harness.test.mjs new file mode 100644 index 0000000..b325698 --- /dev/null +++ b/api-design-guide/linter/tests/harness.test.mjs @@ -0,0 +1,70 @@ +// Sanity checks for the plumbing: the shipped bundle loads, and Spectral's +// format detection classifies minimal AsyncAPI 3.0.0 / OpenAPI 3.1.0 documents +// as we rely on in the fragments. The format probe rules below are built inline +// for the test and are NOT part of the shipped ruleset. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import corePkg from '@stoplight/spectral-core'; +const { Spectral, Document } = corePkg; +import SpectralFunctions from '@stoplight/spectral-functions'; +import SpectralFormats from '@stoplight/spectral-formats'; +import Parsers from '@stoplight/spectral-parsers'; +import { loadRuleset, STRICT_PATH } from './_lint-helper.mjs'; + +const { truthy } = SpectralFunctions; +const { aas3, oas3_1 } = SpectralFormats; + +test('shipped strict bundle loads and includes the §2 proof-of-concept rules', async () => { + const ruleset = await loadRuleset(STRICT_PATH); + const names = Object.keys(ruleset.rules); + assert.ok(names.length > 0, 'bundle has no rules'); + for (const expected of ['govstack-2.1', 'govstack-2.5', 'govstack-2.5-semver', 'govstack-2.6', 'govstack-2.7']) { + assert.ok(names.includes(expected), `bundle missing ${expected}`); + } +}); + +// A probe rule that fires whenever its `formats` gate lets it run. +function probeSpectral(formatFn) { + const s = new Spectral(); + s.setRuleset({ + rules: { + probe: { + given: '$', + severity: 'error', + formats: [formatFn], + then: { field: '__format_probe_absent__', function: truthy }, + }, + }, + }); + return s; +} +const run = (spectral, obj) => spectral.run(new Document(JSON.stringify(obj), Parsers.Json, 'probe.json')); + +const ASYNC_DOC = { + asyncapi: '3.0.0', + info: { title: 'x', version: '1.0.0' }, + channels: {}, + operations: {}, +}; +const OPENAPI_DOC = { + openapi: '3.1.0', + info: { title: 'x', version: '1.0.0' }, + paths: {}, +}; + +test('minimal AsyncAPI 3.0.0 document is detected as aas3', async () => { + const spectral = probeSpectral(aas3); + const onAsync = (await run(spectral, ASYNC_DOC)).filter((r) => r.code === 'probe'); + const onOpenapi = (await run(spectral, OPENAPI_DOC)).filter((r) => r.code === 'probe'); + assert.equal(onAsync.length, 1, 'aas3 probe should fire on the AsyncAPI document'); + assert.equal(onOpenapi.length, 0, 'aas3 probe must not fire on the OpenAPI document'); +}); + +test('minimal OpenAPI 3.1.0 document is detected as oas3_1', async () => { + const spectral = probeSpectral(oas3_1); + const onOpenapi = (await run(spectral, OPENAPI_DOC)).filter((r) => r.code === 'probe'); + const onAsync = (await run(spectral, ASYNC_DOC)).filter((r) => r.code === 'probe'); + assert.equal(onOpenapi.length, 1, 'oas3_1 probe should fire on the OpenAPI document'); + assert.equal(onAsync.length, 0, 'oas3_1 probe must not fire on the AsyncAPI document'); +}); diff --git a/api-design-guide/linter/tests/run-fixtures.test.mjs b/api-design-guide/linter/tests/run-fixtures.test.mjs new file mode 100644 index 0000000..8382f7e --- /dev/null +++ b/api-design-guide/linter/tests/run-fixtures.test.mjs @@ -0,0 +1,77 @@ +// Data-driven fixture runner. +// +// Every directory under tests/fixtures/ is named after a Spectral rule and +// contains fail.yaml (a minimal doc where that rule MUST fire) and pass.yaml +// (the corrected doc where that rule MUST be silent). Optional meta.yaml with +// `expect_fail_count: N` pins the number of findings in fail.yaml. +// +// The full strict bundle (ruleset.yaml + strict.yaml) is loaded once; findings +// are filtered by `code === <dir name>` so unrelated rules firing on a fixture +// are ignored (expected and fine). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { makeSpectral, lintFile } from './_lint-helper.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(HERE, 'fixtures'); + +const spectral = await makeSpectral(); // strict bundle, once + +const dirs = existsSync(FIXTURES) + ? readdirSync(FIXTURES, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort() + : []; + +const fmt = (findings) => + JSON.stringify(findings.map((f) => ({ message: f.message, path: f.path.join('/') })), null, 2); + +for (const name of dirs) { + test(`fixture ${name}`, async () => { + const dir = join(FIXTURES, name); + const failPath = join(dir, 'fail.yaml'); + const passPath = join(dir, 'pass.yaml'); + assert.ok(existsSync(failPath), `${name}: missing fail.yaml`); + assert.ok(existsSync(passPath), `${name}: missing pass.yaml`); + + let expectExact = null; + const metaPath = join(dir, 'meta.yaml'); + if (existsSync(metaPath)) { + const meta = YAML.parse(readFileSync(metaPath, 'utf8')) || {}; + if (Number.isInteger(meta.expect_fail_count)) expectExact = meta.expect_fail_count; + } + + const fail = (await lintFile(spectral, failPath)).filter((f) => f.code === name); + const pass = (await lintFile(spectral, passPath)).filter((f) => f.code === name); + + if (expectExact === null) { + assert.ok( + fail.length >= 1, + `${name}/fail.yaml: expected >=1 finding of "${name}", got ${fail.length}.`, + ); + } else { + assert.equal( + fail.length, + expectExact, + `${name}/fail.yaml: expected exactly ${expectExact} findings of "${name}" ` + + `(meta.yaml), got ${fail.length}:\n${fmt(fail)}`, + ); + } + + assert.equal( + pass.length, + 0, + `${name}/pass.yaml: expected 0 findings of "${name}", got ${pass.length}:\n${fmt(pass)}`, + ); + }); +} + +test('fixtures directory is non-empty', () => { + assert.ok(dirs.length > 0, 'no fixture directories found under tests/fixtures/'); +}); diff --git a/api-design-guide/tools/check_links.py b/api-design-guide/tools/check_links.py index ddeac8f..748b283 100644 --- a/api-design-guide/tools/check_links.py +++ b/api-design-guide/tools/check_links.py @@ -3,7 +3,8 @@ This script is part of the guide's machine layer. It reads every `.md` file under the book root (including `guides/`, `appendix/`, `SUMMARY.md` and -`README.md`) and enforces the book's internal-consistency contract: +`README.md`, but excluding `linter/`, which is tooling rather than book +content) and enforces the book's internal-consistency contract: 1. Every relative markdown link resolves to a file that exists. 2. Every link fragment `#x` matches an explicit anchor id in the target file. @@ -104,7 +105,14 @@ class Checker: def __init__(self, book_root): self.book_root = book_root self.failures = [] - self.md_files = sorted(book_root.rglob("*.md")) + # linter/ is lint tooling, not book content: its markdown (own READMEs, + # node_modules) is not pages. Links from pages into linter/ still get + # their existence checked like any other target. + self.md_files = sorted( + p + for p in book_root.rglob("*.md") + if p.relative_to(book_root).parts[0] != "linter" + ) # Per-file data keyed by absolute Path. self.anchors = {} # path -> set of anchor ids self.links = [] # (path, lineno, target) diff --git a/api-design-guide/version-history.md b/api-design-guide/version-history.md index 045e09e..bfa6704 100644 --- a/api-design-guide/version-history.md +++ b/api-design-guide/version-history.md @@ -32,6 +32,7 @@ This edition supersedes the circulated v0.1 document. It is the same rulebook, r - [Appendix B](appendix/b-open-questions.md) gained a **Blocks v1.0?** column marking the decisions that must precede ratification. - A non-normative [Guides](guides/README.md) group: spec editor checklist, validation commands, AI-agent instructions, and maintenance notes. - A machine layer: [Rules at a glance](all-rules.md) and `rules.yaml` (both generated from the pages by `tools/build_rules_index.py`), plus `tools/check_links.py` as a consistency guard. +- A draft of the GovStack Spectral ruleset with lint tooling ([`linter/`](linter/README.md)): 125 Spectral rules across both surfaces plus 8 opt-in strict heuristics, a driver that adds the [§20.1](part-e/20-conformance-and-validation.md#201-every-file-passes-validation) base validators, file-layout checks, and [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling, per-rule coverage recorded in `linter/coverage.yaml`, and a composite GitHub Action with a template workflow. The formal v1.0 companion publication remains pending ([Appendix A](appendix/a-companion-documents.md)). ## v0.1 (DRAFT, 2026-05-31) From c1c9544c9ea86f9d8c3b09777dd3a0cde4f0b1f1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:15:28 +0700 Subject: [PATCH 03/19] docs: extend API design guide to 171 rules v0.2 is no longer a pure restructuring of v0.1: this adds new normative rules and strengthens existing ones, so version-history now says so plainly rather than claiming presentation-only changes. Substantive additions: api/index.yaml surface inventory and coverage dispositions (4.5, 4.6); 201-vs-202 creation semantics and mandatory response schemas (7.21); W3C Trace Context in place of X-Request-Id (8.4); the revision-11 Structured Field RateLimit contract (8.7); transport-neutral GovStackAsyncError (11.8); the RFC 9700 OAuth baseline with the password grant forbidden outright (13); a required Idempotency-Key on non-idempotent POSTs (14.1); and detached JWS ES256 with RFC 8785 canonicalization, dropping the v0.1 HMAC fallback (16.8). Resolves OPEN-7-A, OPEN-7-B, OPEN-15-C, OPEN-15-D and OPEN-20-A. OPEN-15-A is resolved in-draft but is marked v1.0-blocking and still needs committee ratification. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/1-introduction.md | 13 +- api-design-guide/all-rules.md | 39 +-- .../appendix/a-companion-documents.md | 10 +- api-design-guide/appendix/b-open-questions.md | 19 +- .../appendix/c-normative-references.md | 17 +- api-design-guide/guides/README.md | 4 +- .../guides/maintaining-this-guide.md | 2 +- .../guides/spec-editor-checklist.md | 16 +- .../guides/using-with-ai-agents.md | 2 + .../guides/validating-your-spec.md | 10 +- .../part-a/2-openapi-document-standards.md | 14 +- .../part-a/3-asyncapi-document-standards.md | 6 +- .../part-a/4-documentation-requirements.md | 37 +++ api-design-guide/part-b/6-http-methods.md | 2 +- .../part-b/7-http-status-codes.md | 36 ++- api-design-guide/part-b/8-headers.md | 10 +- api-design-guide/part-c/11-errors.md | 14 +- .../part-c/12-pagination-filtering-sorting.md | 10 +- .../part-c/9-json-conventions-and-naming.md | 4 +- .../13-authentication-and-authorisation.md | 8 +- api-design-guide/part-d/14-idempotency.md | 10 +- .../part-d/15-asynchronous-operations.md | 6 +- .../part-d/16-cloudevents-and-webhooks.md | 18 +- .../part-d/17-asyncapi-channel-rules.md | 10 +- .../part-d/18-compatibility-and-lifecycle.md | 10 +- .../part-e/20-conformance-and-validation.md | 23 +- api-design-guide/rules.yaml | 289 ++++++++++-------- api-design-guide/version-history.md | 20 +- 28 files changed, 411 insertions(+), 248 deletions(-) diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md index 6077ee1..375791d 100644 --- a/api-design-guide/1-introduction.md +++ b/api-design-guide/1-introduction.md @@ -21,6 +21,7 @@ The test for inclusion: *would two BB editors writing two different specs need t - Event-driven APIs over brokered transports and event streams, documented in AsyncAPI 3.0 (MQTT, AMQP, Kafka, WebSockets, SSE). - Webhook subscriptions, documented via OpenAPI 3.1 `webhooks` for HTTP push. - The companion files `govstack-openapi-common.yaml` (REST security scheme, error schema, pagination components, common headers, Operation resource) and `govstack-asyncapi-common.yaml` (event envelope, message headers, security schemes, signing metadata, delivery declarations, common error messages) that BBs reference. +- The repository-level API inventory (`api/index.yaml`, when the default canonical paths are insufficient) and functional-requirement traceability file (`api/coverage.yaml`). CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally lighter in v0.1: BBs must declare the relevant bindings where they affect the contract, while detailed broker-operation guidance belongs in the Security & Operations companion or a later protocol profile. The 2026 audit is OpenAPI-centric only because the current BB set is predominantly REST, not because the ecosystem should remain so. @@ -28,9 +29,9 @@ The decision tree below summarises which artifact documents which kind of surfac ```mermaid flowchart TD - Q{"What kind of API surface?"} -->|"Synchronous HTTP request-response"| R["REST: OpenAPI 3.1<br/>canonical file at api/openapi.yaml"] + Q{"What kind of API surface?"} -->|"Synchronous HTTP request-response"| R["REST: OpenAPI 3.1<br/>default api/openapi.yaml or api/index.yaml entry"] Q -->|"HTTP push to subscriber URLs"| W["Webhooks: OpenAPI 3.1 webhooks section"] - Q -->|"Brokered channels or event streams<br/>(MQTT, AMQP, Kafka, WebSockets, SSE)"| A["AsyncAPI 3.0<br/>canonical file at api/asyncapi.yaml"] + Q -->|"Brokered channels or event streams<br/>(MQTT, AMQP, Kafka, WebSockets, SSE)"| A["AsyncAPI 3.0<br/>default api/asyncapi.yaml or api/index.yaml entry"] W --> CE["Domain events use the CloudEvents v1.0.2 envelope"] A --> CE ``` @@ -64,7 +65,7 @@ The guide uses RFC 2119 language: **MUST**, **MUST NOT**, **SHOULD**, **SHOULD N ## 1.6 Exception process <a href="#16-exception-process" id="16-exception-process"></a> -A BB editor MAY propose deviating from a MUST rule via the exception process to be defined in the proposed **GovStack API Lifecycle & Governance** companion document. The lifecycle (submission, review, expiry, public log) is governance, not design, and belongs there once ratified. +A BB editor **MAY** propose deviating from a **MUST** rule through the exception process to be defined in the proposed **GovStack API Lifecycle & Governance** companion document. An exception is effective only after approval and only for its recorded scope and lifetime. The canonical specification **MUST** declare each approved exception using the exact fields in [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); an expired entry or one with a missing or syntactically invalid exception record URI does not suppress a rule. The submission, review, renewal, public-log, and revocation workflow remains governance. ## 1.7 Precedence of external standards <a href="#17-precedence-of-external-standards" id="17-precedence-of-external-standards"></a> @@ -116,4 +117,8 @@ The tag is guidance for the ruleset author and the conformance process, not part ## 1.10 Applicability and transition <a href="#110-applicability-and-transition" id="110-applicability-and-transition"></a> -This guide applies in full to new API surfaces and to new major versions of existing surfaces. An already-published BB specification is not retroactively non-conformant: it is expected to reach conformance at its next major version, and bringing a wire contract into conformance is itself a breaking change ([§18](part-d/18-compatibility-and-lifecycle.md), [note on retrofitting](part-d/18-compatibility-and-lifecycle.md#note-on-retrofitting)). The transition schedule, conformance levels, and enforcement for existing BBs are governance questions for the GovStack API Lifecycle & Governance companion ([Appendix A](appendix/a-companion-documents.md)). The guide itself is versioned with SemVer: a guide minor release only adds rules or relaxes existing ones; removals or strengthened requirements arrive only in a guide major release. Each BB spec declares the guide version it conforms to ([§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)). +This guide applies in full to new API surfaces and to new major versions of existing surfaces. An already-published BB specification is not retroactively wire-incompatible merely because a newer guide exists. Existing surfaces **MUST** adopt requirements that do not change their public wire contract as soon as practical: canonical-file designation, `api/index.yaml` where needed, `api/coverage.yaml`, validation, complete metadata and descriptions, accurate examples, and security declarations that describe the behaviour already deployed. A change to paths, field names, representations, identifiers, status semantics, security behaviour, event addresses, or another consumer-visible contract **MUST NOT** be made in place solely to satisfy this guide; it **MUST** be released in the next major API version under [§18.4](part-d/18-compatibility-and-lifecycle.md#184-breaking-changes-bump-major-version). Until that major version, the existing surface documents the gap and follows the approved exception process rather than silently changing the wire contract. + +The transition schedule, conformance levels, and enforcement dates for existing BBs are governance questions for the GovStack API Lifecycle & Governance companion ([Appendix A](appendix/a-companion-documents.md)). Every canonical specification pins the exact guide and ruleset versions it uses under [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); validation tooling **MUST NOT** silently substitute a newer compatible-looking version. + +The guide itself follows SemVer, including the current exact prerelease identifier `0.2.0-draft`. A guide patch release **MUST NOT** change which specifications conform; it may only correct prose or tooling defects without changing normative meaning. A guide minor release **MAY** add optional guidance, deprecate a rule, or relax a requirement, but **MUST NOT** add or strengthen a mandatory requirement. Adding or strengthening a **MUST** or **SHOULD**, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming **MUST** increment the guide major version. These rules apply even during the `0.x` drafting series so that exact conformance declarations remain meaningful. diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md index 17d60c2..b20716c 100644 --- a/api-design-guide/all-rules.md +++ b/api-design-guide/all-rules.md @@ -10,7 +10,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | -| [2.1](part-a/2-openapi-document-standards.md#21-openapi-310-required) | M | MUST | OpenAPI | OpenAPI 3.1.0 required | +| [2.1](part-a/2-openapi-document-standards.md#21-openapi-31-required) | M | MUST | OpenAPI | OpenAPI 3.1 required | | [2.2](part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint) | M+R | MUST | OpenAPI | One canonical OpenAPI entrypoint | | [2.3](part-a/2-openapi-document-standards.md#23-no-divergent-openapi-copies) | R | MUST | OpenAPI | No divergent OpenAPI copies | | [2.4](part-a/2-openapi-document-standards.md#24-passes-openapi-spec-validator) | M | MUST | OpenAPI | Passes openapi-spec-validator | @@ -41,6 +41,8 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [4.2](part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums) | M+R | MUST | Universal | Examples for bodies and enums | | [4.3](part-a/4-documentation-requirements.md#43-no-placeholder-text) | M+R | MUST | Universal | No placeholder text | | [4.4](part-a/4-documentation-requirements.md#44-accurate-operation-descriptions) | R | MUST | Universal | Accurate operation descriptions | +| [4.5](part-a/4-documentation-requirements.md#45-api-surface-inventory) | M+R | MUST | Universal | API surface inventory | +| [4.6](part-a/4-documentation-requirements.md#46-functional-requirement-traceability) | M+R | MUST | Universal | Functional-requirement traceability | ## 5. URL structure and versioning @@ -61,7 +63,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | | [6.1](part-b/6-http-methods.md#61-get-is-safe-and-idempotent) | M+R | MUST | OpenAPI | GET is safe and idempotent | -| [6.2](part-b/6-http-methods.md#62-post-creates-or-performs-actions) | R | — | OpenAPI | POST creates or performs actions | +| [6.2](part-b/6-http-methods.md#62-post-creates-or-performs-actions) | R | MUST | OpenAPI | POST creates or performs actions | | [6.3](part-b/6-http-methods.md#63-put-replaces-the-entire-resource) | R | MUST | OpenAPI | PUT replaces the entire resource | | [6.4](part-b/6-http-methods.md#64-patch-uses-json-merge-patch) | M+R | MUST | OpenAPI | PATCH uses JSON Merge Patch | | [6.5](part-b/6-http-methods.md#65-delete-response-semantics) | M+R | MUST | OpenAPI | DELETE response semantics | @@ -72,26 +74,27 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | -| [7.1](part-b/7-http-status-codes.md#71-200-for-successful-reads) | R | — | OpenAPI | 200 for successful reads | +| [7.1](part-b/7-http-status-codes.md#71-200-for-successful-reads) | R | MUST | OpenAPI | 200 for successful reads | | [7.2](part-b/7-http-status-codes.md#72-201-created-with-location) | M | MUST | OpenAPI | 201 Created with Location | | [7.3](part-b/7-http-status-codes.md#73-202-accepted-for-async-operations) | M+R | MUST | OpenAPI | 202 Accepted for async operations | -| [7.4](part-b/7-http-status-codes.md#74-204-for-void-responses) | R | — | OpenAPI | 204 for void responses | -| [7.5](part-b/7-http-status-codes.md#75-400-for-malformed-requests) | R | — | OpenAPI | 400 for malformed requests | +| [7.4](part-b/7-http-status-codes.md#74-204-for-void-responses) | R | MUST | OpenAPI | 204 for void responses | +| [7.5](part-b/7-http-status-codes.md#75-400-for-malformed-requests) | R | MUST | OpenAPI | 400 for malformed requests | | [7.6](part-b/7-http-status-codes.md#76-401-with-www-authenticate) | M+R | MUST | OpenAPI | 401 with WWW-Authenticate | -| [7.7](part-b/7-http-status-codes.md#77-403-when-not-authorised) | R | — | OpenAPI | 403 when not authorised | -| [7.8](part-b/7-http-status-codes.md#78-404-for-missing-resources) | R | — | OpenAPI | 404 for missing resources | -| [7.9](part-b/7-http-status-codes.md#79-409-for-state-conflicts) | R | — | OpenAPI | 409 for state conflicts | -| [7.10](part-b/7-http-status-codes.md#710-410-for-permanent-removal) | R | — | OpenAPI | 410 for permanent removal | -| [7.11](part-b/7-http-status-codes.md#711-422-for-semantic-errors) | R | — | OpenAPI | 422 for semantic errors | -| [7.12](part-b/7-http-status-codes.md#712-429-for-rate-limits) | R | — | OpenAPI | 429 for rate limits | +| [7.7](part-b/7-http-status-codes.md#77-403-when-not-authorised) | R | MUST | OpenAPI | 403 when not authorised | +| [7.8](part-b/7-http-status-codes.md#78-404-for-missing-resources) | R | MUST | OpenAPI | 404 for missing resources | +| [7.9](part-b/7-http-status-codes.md#79-409-for-state-conflicts) | R | MUST | OpenAPI | 409 for state conflicts | +| [7.10](part-b/7-http-status-codes.md#710-410-for-permanent-removal) | R | MUST | OpenAPI | 410 for permanent removal | +| [7.11](part-b/7-http-status-codes.md#711-422-for-semantic-errors) | R | MUST | OpenAPI | 422 for semantic errors | +| [7.12](part-b/7-http-status-codes.md#712-429-for-rate-limits) | R | MUST | OpenAPI | 429 for rate limits | | [7.13](part-b/7-http-status-codes.md#713-server-errors-documented) | M | MUST | OpenAPI | Server errors documented | | [7.14](part-b/7-http-status-codes.md#714-all-status-codes-declared) | M | MUST | OpenAPI | All status codes declared | -| [7.15](part-b/7-http-status-codes.md#715-412-for-failed-preconditions) | R | — | OpenAPI | 412 for failed preconditions | -| [7.16](part-b/7-http-status-codes.md#716-etag-and-if-none-match) | M+R | SHOULD | OpenAPI | ETag and If-None-Match | -| [7.17](part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match) | M+R | SHOULD | OpenAPI | Optimistic concurrency with If-Match | +| [7.15](part-b/7-http-status-codes.md#715-412-for-failed-preconditions) | R | MUST | OpenAPI | 412 for failed preconditions | +| [7.16](part-b/7-http-status-codes.md#716-etag-and-if-none-match) | M+R | MUST | OpenAPI | ETag and If-None-Match | +| [7.17](part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match) | M+R | MUST | OpenAPI | Optimistic concurrency with If-Match | | [7.18](part-b/7-http-status-codes.md#718-405-with-allow-header) | M | MUST | OpenAPI | 405 with Allow header | | [7.19](part-b/7-http-status-codes.md#719-415-for-unsupported-media-types) | M+R | MUST | OpenAPI | 415 for unsupported media types | | [7.20](part-b/7-http-status-codes.md#720-no-store-on-error-responses) | M | SHOULD | OpenAPI | No-store on error responses | +| [7.21](part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies) | M | MUST | OpenAPI | Schemas for successful response bodies | ## 8. Headers @@ -100,7 +103,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [8.1](part-b/8-headers.md#81-credentials-in-authorization-header) | M+R | MUST | OpenAPI | Credentials in Authorization header | | [8.2](part-b/8-headers.md#82-accept-language-and-content-language) | M+R | MUST | OpenAPI | Accept-Language and Content-Language | | [8.3](part-b/8-headers.md#83-idempotency-key-header-accepted) | M+R | MUST | OpenAPI | Idempotency-Key header accepted | -| [8.4](part-b/8-headers.md#84-x-request-id-correlation) | M+R | MUST | OpenAPI | X-Request-Id correlation | +| [8.4](part-b/8-headers.md#84-w3c-trace-context-correlation) | M+R | MUST | OpenAPI | W3C Trace Context correlation | | [8.5](part-b/8-headers.md#85-no-new-x--prefixed-headers) | M | MUST | OpenAPI | No new X- prefixed headers | | [8.6](part-b/8-headers.md#86-no-personal-data-in-addressable-locations) | R | MUST | OpenAPI | No personal data in addressable locations | | [8.7](part-b/8-headers.md#87-rate-limit-headers-declared) | M+R | MUST | OpenAPI | Rate-limit headers declared | @@ -147,6 +150,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [11.5](part-c/11-errors.md#115-namespaced-stable-error-codes) | M+R | MUST | Universal | Namespaced stable error codes | | [11.6](part-c/11-errors.md#116-stable-codes-across-languages) | R | MUST | Universal | Stable codes across languages | | [11.7](part-c/11-errors.md#117-common-error-catalogue) | M+R | MUST | Universal | Common error catalogue | +| [11.8](part-c/11-errors.md#118-transport-neutral-asynchronous-errors) | M+R | MUST | Universal | Transport-neutral asynchronous errors | ## 12. Pagination, filtering, sorting @@ -173,6 +177,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [13.4](part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | M | MUST | Universal | Namespaced OAuth scopes | | [13.5](part-d/13-authentication-and-authorisation.md#135-authorization-is-the-credential-channel) | M+R | MUST | Universal | Authorization is the credential channel | | [13.6](part-d/13-authentication-and-authorisation.md#136-api-keys-only-for-operational-endpoints) | R | MUST | Universal | API keys only for operational endpoints | +| [13.7](part-d/13-authentication-and-authorisation.md#137-protected-transport) | M+R | MUST | Universal | Protected transport | ## 14. Idempotency @@ -192,7 +197,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [15.1](part-d/15-asynchronous-operations.md#151-202-with-operation-location) | M+R | MUST | OpenAPI | 202 with Operation Location | | [15.2](part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape) | M | MUST | OpenAPI | Shared Operation resource shape | | [15.3](part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | M | MUST | OpenAPI | Fixed Operation status enum | -| [15.4](part-d/15-asynchronous-operations.md#154-polling-the-operation-resource) | M+R | — | OpenAPI | Polling the Operation resource | +| [15.4](part-d/15-asynchronous-operations.md#154-polling-the-operation-resource) | M+R | MUST | OpenAPI | Polling the Operation resource | | [15.5](part-d/15-asynchronous-operations.md#155-cancellation-via-cancel-sub-resource) | M+R | MUST | OpenAPI | Cancellation via cancel sub-resource | | [15.6](part-d/15-asynchronous-operations.md#156-webhook-completion-notification) | R | SHOULD | OpenAPI | Webhook completion notification | | [15.7](part-d/15-asynchronous-operations.md#157-documented-result-retention) | R | MUST | OpenAPI | Documented result retention | @@ -218,7 +223,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | | [17.1](part-d/17-asyncapi-channel-rules.md#171-send-and-receive-perspective) | M+R | MUST | AsyncAPI | Send and receive perspective | -| [17.2](part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses) | M | MUST | AsyncAPI | Reverse-DNS channel addresses | +| [17.2](part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) | M+R | MUST | AsyncAPI | Stable logical channel IDs and native addresses | | [17.3](part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels) | R | MUST | AsyncAPI | No personal data in channels | | [17.4](part-d/17-asyncapi-channel-rules.md#174-declared-channel-parameters) | M+R | MUST | AsyncAPI | Declared channel parameters | | [17.5](part-d/17-asyncapi-channel-rules.md#175-no-environment-names-in-addresses) | M+R | SHOULD | AsyncAPI | No environment names in addresses | diff --git a/api-design-guide/appendix/a-companion-documents.md b/api-design-guide/appendix/a-companion-documents.md index f1af040..dcf568e 100644 --- a/api-design-guide/appendix/a-companion-documents.md +++ b/api-design-guide/appendix/a-companion-documents.md @@ -9,9 +9,9 @@ description: "Companion documents and artifacts that pick up the topics this gui | Companion | Status | What it covers | |---|---|---| | **GovStack API Lifecycle & Governance** | Proposed local v0.1 outline, not a ratified GovStack artifact | Ratification, enforcement, exception lifecycle, transition timelines, conformance levels, companion-artifact ownership, BB editor support, self-amendment of this guide. Reuses the existing GovStack Specification Framework where applicable and defines only the missing API-specific lifecycle, exception, publication, and conformance processes. | -| **GovStack API Security & Operations** | Not yet drafted | Operational behaviour of a deployed BB: token validation, certificate trust, key rotation, replay enforcement, audit logging, log hygiene, alg allowlists, FAPI conformance. | -| `govstack-openapi-common.yaml` | To be authored alongside v1.0 | Shared security scheme, RFC 9457 error schema, pagination envelope, common headers, Operation resource, common error catalogue ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)). | -| `govstack-asyncapi-common.yaml` | To be authored alongside v1.0 | Shared CloudEvents envelope ([§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)), common message headers ([§17](../part-d/17-asyncapi-channel-rules.md)), common security schemes (OAuth 2.0, OpenID Connect, X.509/mTLS), signing metadata, delivery-semantics extensions, and common error messages referencing the [§11](../part-c/11-errors.md) error envelope. | -| **Spectral ruleset** | Draft ships in-repo at [`linter/`](../linter/README.md); formal v1.0 companion publication pending | Machine-enforceable subset of the guide's rules ([§20](../part-e/20-conformance-and-validation.md)). The draft covers OpenAPI, CloudEvents, and AsyncAPI documentation rules ([`linter/coverage.yaml`](../linter/coverage.yaml) records per-rule coverage); protocol-profile rules may be added later. | +| **GovStack API Security & Operations** | Not yet drafted | Deployment details below the interface baseline in [§13](../part-d/13-authentication-and-authorisation.md): token and claim validation, certificate trust, TLS configuration, key rotation, replay enforcement, audit logging, log hygiene, algorithm allowlists, and FAPI conformance. It must not weaken the RFC 9700 and protected-transport requirements in this guide. | +| [`api/common/govstack-openapi-common.yaml`](../../api/common/govstack-openapi-common.yaml) | Draft artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Shared security schemes, RFC 9457 error schema, pagination envelope, W3C Trace Context headers, Operation resource, common error catalogue ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)), and the event-signature profile. | +| [`api/common/govstack-asyncapi-common.yaml`](../../api/common/govstack-asyncapi-common.yaml) | Draft artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Shared CloudEvents envelope ([§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)), `GovStackAsyncError` ([§11.8](../part-c/11-errors.md#118-transport-neutral-asynchronous-errors)), common message headers ([§17](../part-d/17-asyncapi-channel-rules.md)), security schemes, the event-signature profile, and delivery-semantics extensions. | +| **Spectral ruleset** | Exact draft `0.2.0-draft` ships in-repo at [`linter/`](../linter/README.md); formal ratification pending | Machine-enforceable subset of the exact guide version declared under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version), including canonical discovery, `api/index.yaml`, and `api/coverage.yaml`. [`linter/coverage.yaml`](../linter/coverage.yaml) records per-rule coverage. | | **Conformance test pack** | Future companion artifact | Governance-defined contract tests beyond schema and Spectral validation. | -| **Reference BB implementation** | Future companion artifact | Worked example applying the guide end-to-end to one BB. | +| **Reference BB implementation** | Draft example ships in this template; formal ratification pending | Worked example applying the guide end-to-end through [`api/openapi.yaml`](../../api/openapi.yaml), [`api/index.yaml`](../../api/index.yaml), [`api/coverage.yaml`](../../api/coverage.yaml), and the [generic BB specification](../../spec/README.md). | diff --git a/api-design-guide/appendix/b-open-questions.md b/api-design-guide/appendix/b-open-questions.md index 83edebf..0cf3a1d 100644 --- a/api-design-guide/appendix/b-open-questions.md +++ b/api-design-guide/appendix/b-open-questions.md @@ -14,23 +14,30 @@ The **Blocks v1.0?** column marks the questions whose answers shape the shared ` | OPEN-4-B | Health endpoint shape: align with `draft-inadarei-api-health-check`, or use a simpler local shape | Align with the draft | [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | No | | OPEN-4-C | Path nesting depth: soft cap of two levels under `/v{N}/` | Keep as SHOULD with the soft cap | [§5.4](../part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | No | | OPEN-6-A | 400 vs 422 boundary | Keep both (400 unparseable, 422 semantic) | [§7](../part-b/7-http-status-codes.md) | No | -| OPEN-7-A | RFC 6648 migration of `X-Request-Id` | Retain `X-Request-Id`; strict 6648 rename is the alternative | [§8.4](../part-b/8-headers.md#84-x-request-id-correlation)–[8.5](../part-b/8-headers.md#85-no-new-x--prefixed-headers) | No | -| OPEN-7-B | RateLimit header form: three-header variant vs structured-field form from current IETF draft | Three-header variant in v0.1; re-pick in v1.0 once the draft stabilises | [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared) | No | | OPEN-10-A | Error code shape: reverse-DNS named code vs reverse-DNS numeric code vs shorter BB-prefixed code | Reverse-DNS named code: `org.govstack.{bb-code}.{error-name}` | [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes) | Yes | | OPEN-10-B | Common error catalogue: which canonical errors to include | The `google.rpc.Code` set mapped to reverse-DNS GovStack codes | [§11.7](../part-c/11-errors.md#117-common-error-catalogue) | Yes | | OPEN-12-A | OAuth scope syntax: `bb:{bb-code}:{resource}:{action}` vs reverse-DNS vs `resource.action` | `bb:` prefix for namespacing; reverse-DNS is the alternative | [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | Yes | | OPEN-14-A | Operation resource: GovStack-local shape vs strict Google AIP-151 mirror | AIP-151-aligned hybrid; strict AIP-151 is the alternative | [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | Yes | -| OPEN-15-A | Event signature scheme: detached JWS over canonicalised structured CloudEvents JSON vs HMAC-SHA256 | Detached JWS, with HMAC allowed only as a governed deployment fallback | [§16.5](../part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery), [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile) | Yes | | OPEN-15-B | Event `type` naming convention | `org.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | -| OPEN-15-C | Event signature metadata name: `GovStack-Signature` vs other | `GovStack-Signature` (RFC 6648 compliant, namespaced) | [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header) | No | -| OPEN-15-D | AsyncAPI channel naming | `org.govstack.{bb-code}.v{major}.{resource}.{event}` | [§17.2](../part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses) | No | | OPEN-15-E | CloudEvents binding style for AsyncAPI | Structured CloudEvents JSON payload | [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | No | | OPEN-15-F | AsyncAPI protocol-binding depth | Require bindings where they affect interoperability; future profiles may add deeper broker-specific rules | [§17.19](../part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | No | | OPEN-15-G | GovStack AsyncAPI extension names and schemas | Define in `govstack-asyncapi-common.yaml` | [§17.15](../part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions) | No | | OPEN-16-A | AsyncAPI deprecation metadata | `x-govstack-deprecated` with `since`, `sunset`, `replacement`, `reason` | [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata) | No | | OPEN-17-A | Mandated language coverage | Per BB | [§19](../part-e/19-localisation.md) | No | | OPEN-9-A | BB-code register: where the canonical register of BB codes lives and who assigns them | Propose in the Lifecycle & Governance companion; until then, agree codes through the API Working Group | [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code) | No | -| OPEN-20-A | Guide-version declaration: shape of the `x-govstack-api-guide` extension and its exception-record references | As drafted in §20.3 | [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) | No | + +## Resolved in `0.2.0-draft` + +The identifiers below remain frozen for discussion-history links, but they are no longer open design choices. + +| ID | Resolution | Section | +|---|---|---| +| OPEN-7-A | Use W3C `traceparent` / `tracestate`; do not introduce `X-Request-Id` as the cross-BB standard. | [§8.4–8.5](../part-b/8-headers.md#84-w3c-trace-context-correlation) | +| OPEN-7-B | Pin the Structured Field `RateLimit` / `RateLimit-Policy` form from draft revision 11; the legacy three-field form is not draft-conformant. | [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared) | +| OPEN-15-C | Use HTTP `GovStack-Signature` and camelCase AsyncAPI metadata `govstackSignature`, unless a protocol binding supplies a standard field. | [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header) | +| OPEN-15-D | Put the reverse-DNS GovStack name and major version in the logical AsyncAPI channel ID; keep `address` protocol-native. | [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) | +| OPEN-15-A | Use detached JWS with `ES256` over the RFC 8785-canonicalized structured CloudEvent, using RFC 7515 detached-content semantics rather than RFC 7797 unencoded payloads. This drops the v0.1 option of an HMAC fallback for constrained deployments. This item was marked **Blocks v1.0? Yes**, so this resolution in particular needs explicit committee ratification. | [§16.5](../part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery), [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile) | +| OPEN-20-A | Pin exact `version` and `rulesetVersion`; exceptions carry scoped rationale, HTTPS evidence, approving authority, review date, and expiry using the exact fields in §20.3. | [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) | **A note on identifiers.** The `OPEN-N-X` identifiers are frozen from the circulated v0.1 draft and predate the v0.2 section renumbering: the `N` in an identifier refers to the v0.1 section number and is treated as an opaque label, so existing feedback threads stay valid. The Section column shows current section numbers. Questions added in v0.2 or later use current numbering (`OPEN-9-A`, `OPEN-20-A`). The old-to-new section mapping is on [How to use this guide](../how-to-use-this-guide.md). diff --git a/api-design-guide/appendix/c-normative-references.md b/api-design-guide/appendix/c-normative-references.md index 13c5eee..a6d3fb4 100644 --- a/api-design-guide/appendix/c-normative-references.md +++ b/api-design-guide/appendix/c-normative-references.md @@ -5,27 +5,37 @@ description: "Normative references cited throughout the guide." # Appendix C. Normative references - IETF RFC 2119, *Key words for use in RFCs to Indicate Requirement Levels* +- IETF RFC 8174, *Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words* - IETF RFC 3339, *Date and Time on the Internet: Timestamps* - IETF RFC 5322, *Internet Message Format* - IETF RFC 6648, *Deprecating the "X-" Prefix in Application Protocols* - IETF RFC 6749, *The OAuth 2.0 Authorization Framework* - IETF RFC 6750, *The OAuth 2.0 Authorization Framework: Bearer Token Usage* (cited by [§7.6](../part-b/7-http-status-codes.md#76-401-with-www-authenticate)) +- IETF RFC 6585, *Additional HTTP Status Codes* (`428 Precondition Required`) - IETF RFC 6901, *JavaScript Object Notation (JSON) Pointer* - IETF RFC 6902, *JavaScript Object Notation (JSON) Patch* - IETF RFC 7396, *JSON Merge Patch* - IETF RFC 7515, *JSON Web Signature (JWS)* +- IETF RFC 7797, *JSON Web Signature (JWS) Unencoded Payload Option* (explicitly excluded by the `0.2.0-draft` event-signature profile; cited by [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) +- IETF RFC 8785, *JSON Canonicalization Scheme (JCS)* (cited by [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) - IETF RFC 8594, *The Sunset HTTP Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) +- IETF RFC 8705, *OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens* - IETF RFC 9110, *HTTP Semantics* (obsoletes RFC 7231) - IETF RFC 9396, *OAuth 2.0 Rich Authorization Requests* +- IETF RFC 9325 / BCP 195, *Recommendations for Secure Use of TLS and DTLS* +- IETF RFC 9449, *OAuth 2.0 Demonstrating Proof of Possession* - IETF RFC 9457, *Problem Details for HTTP APIs* (obsoletes RFC 7807) +- IETF RFC 9700 / BCP 240, *Best Current Practice for OAuth 2.0 Security* - IETF RFC 9745, *The Deprecation HTTP Response Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) -- IETF draft `draft-ietf-httpapi-ratelimit-headers`, *RateLimit Header Fields for HTTP* (active Internet-Draft, not yet an RFC; cited by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared)) -- IETF draft `draft-ietf-httpapi-idempotency-key-header`, *The Idempotency-Key HTTP Header Field* (Internet-Draft, not yet an RFC; cited by [§14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) +- IETF draft `draft-ietf-httpapi-ratelimit-headers-11`, *RateLimit Header Fields for HTTP* (pinned work-in-progress revision; cited by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared)) +- IETF draft `draft-ietf-httpapi-idempotency-key-header-07`, *The Idempotency-Key HTTP Header Field* (pinned expired Internet-Draft revision used as a GovStack convention; cited by [§14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) - IETF draft `draft-inadarei-api-health-check`, *Health Check Response Format for HTTP APIs* (expired Internet-Draft, never an RFC; cited by [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) -- OpenAPI Specification 3.1.0 +- OpenAPI Specification 3.1 patch series; guide/ruleset `0.2.0-draft` qualify 3.1.0, 3.1.1, and 3.1.2 - AsyncAPI Specification 3.0 (cited by [§1.2](../1-introduction.md#12-scope), [§3](../part-a/3-asyncapi-document-standards.md), [§16.1](../part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented), [§17](../part-d/17-asyncapi-channel-rules.md), [§20](../part-e/20-conformance-and-validation.md)) - OpenID Connect Core 1.0 - CloudEvents v1.0.2 (CNCF), *CloudEvents Specification* and JSON Format (cited by [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)) +- CloudEvents, *Distributed Tracing Extension* (`traceparent`, `tracestate`) +- W3C Recommendation, *Trace Context* - Google AIP-151, *Long-running operations* (cited by [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum)) - Google AIP-158, *Pagination* (cited by [§12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) - GraphQL Cursor Connections Specification (cited by [§12.3](../part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope)) @@ -34,3 +44,4 @@ description: "Normative references cited throughout the guide." - ISO 4217 (currency codes) - BCP 47 (language tags) - E.164 (international phone number format) +- Semantic Versioning 2.0.0 diff --git a/api-design-guide/guides/README.md b/api-design-guide/guides/README.md index e8d64ad..5a56387 100644 --- a/api-design-guide/guides/README.md +++ b/api-design-guide/guides/README.md @@ -14,7 +14,7 @@ Four guides live here today: - [Maintaining this guide](../guides/maintaining-this-guide.md): where the canonical copy lives, how to edit a rule without breaking its anchor, and how to regenerate the machine-readable index. {% hint style="info" %} -This book is DRAFT v0.2 and not yet ratified. The guides above describe the tooling and workflow as they exist today; some referenced artifacts, most notably the GovStack Spectral ruleset, are v1.0 companions that do not exist yet (see [Appendix A](../appendix/a-companion-documents.md)). +This book is exact draft version `0.2.0-draft` and is not yet ratified. The matching draft Spectral ruleset ships in this repository; the common OpenAPI/AsyncAPI component files and conformance test pack remain publication prerequisites (see [Appendix A](../appendix/a-companion-documents.md)). {% endhint %} -At v1.0, this section is expected to accrete three things: a worked positive and negative example for every numbered rule, a cross-reference from each `[M]`/`[M+R]` rule to its GovStack Spectral rule ID once that ruleset is authored, and a conformance walkthrough that takes one reference BB specification from a blank file to a passing conformance run. +Before ratification, this section is expected to gain worked positive and negative examples for every numbered rule and a conformance walkthrough that takes one reference BB specification from a blank file to a passing run. diff --git a/api-design-guide/guides/maintaining-this-guide.md b/api-design-guide/guides/maintaining-this-guide.md index 613c547..33e5502 100644 --- a/api-design-guide/guides/maintaining-this-guide.md +++ b/api-design-guide/guides/maintaining-this-guide.md @@ -51,4 +51,4 @@ Append a row to [Appendix B](../appendix/b-open-questions.md) using an ID of the ## Versioning the guide itself -This guide is versioned with SemVer, per [§1.10](../1-introduction.md#110-applicability-and-transition). A minor release may only add or relax a rule; removing a rule or strengthening an existing requirement needs a major release. Record every substantive change, in either case, in the [version history](../version-history.md). +This guide is versioned with SemVer, per [§1.10](../1-introduction.md#110-applicability-and-transition), and its current exact identifier is `0.2.0-draft`. A patch release may correct prose or tooling without changing conformance. A minor release may add optional guidance, deprecate a rule, or relax a requirement. Adding or strengthening a mandatory rule, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming requires a major release. A ruleset release is separately versioned and every canonical spec pins both exact versions under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version). Record every substantive change in the [version history](../version-history.md). diff --git a/api-design-guide/guides/spec-editor-checklist.md b/api-design-guide/guides/spec-editor-checklist.md index aad28e5..63dbe09 100644 --- a/api-design-guide/guides/spec-editor-checklist.md +++ b/api-design-guide/guides/spec-editor-checklist.md @@ -14,7 +14,9 @@ Run this before submitting a BB specification for review. Each item links to the - [ ] Every schema has a `description`. ([4.1](../part-a/4-documentation-requirements.md#41-every-schema-described)) - [ ] Every request and response body has at least one `example`; every `enum` documents what its values mean. ([4.2](../part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums)) - [ ] No placeholder text anywhere: no `TBD`, no `Lorem ipsum`, no `a, b, c`, no leftover content copied from another BB. ([4.3](../part-a/4-documentation-requirements.md#43-no-placeholder-text)) +- [ ] Canonical surfaces are discoverable at the default paths or through a valid `api/index.yaml`; `api/coverage.yaml` exactly traces every marked functional requirement to an operation, message, external contract, rationale, or tracked plan. ([4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory), [4.6](../part-a/4-documentation-requirements.md#46-functional-requirement-traceability)) - [ ] A security scheme is declared and applied to every operation by default, with per-operation overrides explicit. ([13.1](../part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation)) +- [ ] `info.x-govstack-api-guide` pins exact guide and ruleset versions (`0.2.0-draft`) and every exception has all seven §20.3 fields: `rule`, `scope`, `rationale`, `record`, `reviewedBy`, `reviewedAt`, and `expiresAt`. ([20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)) - [ ] The file passes its validator (see [Validating your spec](../guides/validating-your-spec.md)). ([20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation)) - [ ] `info.version` follows SemVer. ([18.1](../part-d/18-compatibility-and-lifecycle.md#181-semver-versioning)) - [ ] JSON field names are `camelCase`, applied consistently; check the [carve-outs](../part-c/9-json-conventions-and-naming.md#carve-out-from-92) before flagging fields imported from an external standard (RFC 9457, CloudEvents) as violations. ([9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names)) @@ -22,12 +24,16 @@ Run this before submitting a BB specification for review. Each item links to the ## REST surfaces (OpenAPI 3.1) -- [ ] The file declares `openapi: 3.1.0`. ([2.1](../part-a/2-openapi-document-standards.md#21-openapi-310-required)) -- [ ] The canonical entrypoint is at `api/openapi.yaml`, not `api/swagger.yaml` or a JSON copy. ([2.2](../part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint)) +- [ ] The file declares a qualified OpenAPI 3.1 patch (`3.1.0`, `3.1.1`, or `3.1.2` for guide/ruleset `0.2.0-draft`). ([2.1](../part-a/2-openapi-document-standards.md#21-openapi-31-required)) +- [ ] The canonical entrypoint is at the default `api/openapi.yaml` path or is listed in `api/index.yaml`; no legacy `api/swagger.yaml` or JSON copy is treated as canonical. ([2.2](../part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint)) +- [ ] Every non-local server and webhook URL uses HTTPS, and OAuth declarations follow the RFC 9700 baseline: authorization code plus PKCE, no password or implicit grant, access tokens rather than ID Tokens. ([13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations), [13.7](../part-d/13-authentication-and-authorisation.md#137-protected-transport)) - [ ] The major version appears in the URL path (`/v{N}/...`). ([5.1](../part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path)) - [ ] `/health` is unversioned, uses `application/health+json`, and carries no citizen authentication. ([5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) - [ ] No bulk-mutating or bulk-deleting operation can run against an entire collection with no selection parameter. ([6.7](../part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection)) - [ ] Every operation declares every status code it can actually return; no operation declares only `200`. ([7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) +- [ ] Input operations declare `400`; secured operations declare applicable `401`/`403`; resource operations declare `404`; conflict-capable operations declare `409`; every operation declares `500`. Creation completed now is `201`, while accepted work is `202` plus an Operation. ([7.2](../part-b/7-http-status-codes.md#72-201-created-with-location)–[7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) +- [ ] Every successful response body declares a concrete media type and schema; `204` declares no content. ([7.21](../part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies)) +- [ ] Cross-service operations declare W3C `traceparent` and optional `tracestate`; error `traceId` maps to the W3C trace-id. ([8.4](../part-b/8-headers.md#84-w3c-trace-context-correlation)) - [ ] Error responses use `application/problem+json` (RFC 9457) with `code`, `traceId`, and `timestamp` present alongside the standard fields. ([11.1](../part-c/11-errors.md#111-rfc-9457-problem-details), [11.3](../part-c/11-errors.md#113-govstack-error-extension-fields)) - [ ] Collection endpoints paginate, and cursor pagination (`pageSize`, opaque `cursor`) is the default. ([12.1](../part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate), [12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) - [ ] Non-idempotent POST endpoints (creation, payment, submission, subscription, job start) accept an `Idempotency-Key` header. ([14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) @@ -35,11 +41,11 @@ Run this before submitting a BB specification for review. Each item links to the ## Event-driven surfaces (CloudEvents / AsyncAPI 3.0) -- [ ] The file declares `asyncapi: 3.0.0` and lives at `api/asyncapi.yaml`. ([3.1](../part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required), [3.2](../part-a/3-asyncapi-document-standards.md#32-one-canonical-asyncapi-entrypoint)) +- [ ] The file declares `asyncapi: 3.0.0` and lives at the default `api/asyncapi.yaml` path or is listed in `api/index.yaml`. ([3.1](../part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required), [3.2](../part-a/3-asyncapi-document-standards.md#32-one-canonical-asyncapi-entrypoint)) - [ ] Every domain event conforms to the CloudEvents envelope (`specversion`, `id`, `source`, `type`, domain payload under `data`). ([16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)) - [ ] Event `type` values follow the reverse-DNS convention. ([16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types)) -- [ ] Channel addresses follow the reverse-DNS convention. ([17.2](../part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses)) -- [ ] No channel address, topic, queue name, routing key, or channel parameter carries personal data. ([17.3](../part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels)) +- [ ] Logical channel IDs follow the versioned reverse-DNS convention, while Channel Object addresses use protocol-native syntax and bindings document the mapping. ([17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses)) +- [ ] No logical channel ID, native address, topic, queue name, routing key, or channel parameter carries personal data. ([17.3](../part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels)) - [ ] Every operation documents its delivery guarantee, ordering guarantee, and supported delivery-management capabilities. ([17.11](../part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees)–[17.13](../part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities)) - [ ] Every message has an example. ([17.20](../part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message)) diff --git a/api-design-guide/guides/using-with-ai-agents.md b/api-design-guide/guides/using-with-ai-agents.md index 29b7e17..e41a948 100644 --- a/api-design-guide/guides/using-with-ai-agents.md +++ b/api-design-guide/guides/using-with-ai-agents.md @@ -23,6 +23,8 @@ This repository's API specifications must conform to the GovStack Cross-BB API D Before writing or reviewing OpenAPI/AsyncAPI content: - Read api-design-guide/rules.yaml and treat every MUST rule as blocking. +- Require each canonical spec to pin guide and ruleset version `0.2.0-draft`; do not substitute a newer version. +- Validate api/index.yaml discovery and api/coverage.yaml requirement traceability before editing an API surface. - Cite rule IDs (for example 9.2, 11.1) when flagging or fixing violations. - Lint the spec: `cd api-design-guide/linter && npm ci && node cli.mjs --repo-root ../..` Every finding is prefixed with the guide rule id it enforces. Fix findings in the diff --git a/api-design-guide/guides/validating-your-spec.md b/api-design-guide/guides/validating-your-spec.md index 07d75d5..16a2146 100644 --- a/api-design-guide/guides/validating-your-spec.md +++ b/api-design-guide/guides/validating-your-spec.md @@ -9,23 +9,23 @@ description: "The commands to run against a BB's OpenAPI or AsyncAPI file, and a Install the validator and run it against the canonical entrypoint: ```bash -pip install openapi-spec-validator +pip install openapi-spec-validator==0.9.0 openapi-spec-validator api/openapi.yaml ``` -This checks that the file is a structurally valid OpenAPI 3.1.0 document. It is the mechanical check behind [2.4](../part-a/2-openapi-document-standards.md#24-passes-openapi-spec-validator) and [20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation). +This checks that the entrypoint is structurally valid for its declared qualified OpenAPI 3.1 patch and that its references resolve. It is the mechanical check behind [2.4](../part-a/2-openapi-document-standards.md#24-passes-openapi-spec-validator) and [20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation). ## AsyncAPI ```bash -npx @asyncapi/cli validate api/asyncapi.yaml +npx @asyncapi/cli@6.0.2 validate api/asyncapi.yaml ``` This checks that the file is a structurally valid AsyncAPI 3.0 document. It is the mechanical check behind [3.4](../part-a/3-asyncapi-document-standards.md#34-passes-an-asyncapi-validator). ## The GovStack Spectral ruleset -The ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) ships in this repository at [`linter/`](../linter/README.md), as a draft of the v1.0 companion artifact tracked in [Appendix A](../appendix/a-companion-documents.md). The recommended entrypoint is the driver, which also runs the base validators above, the file-layout checks, and the [20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling: +The exact `0.2.0-draft` ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) ships at [`linter/`](../linter/README.md). The recommended entrypoint is the driver, which discovers default canonical files or consumes `api/index.yaml`, validates `api/coverage.yaml`, runs the base validators and file-layout checks, and applies [20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling: ```bash cd api-design-guide/linter && npm ci @@ -38,7 +38,7 @@ Or run Spectral directly against the ruleset: npx @stoplight/spectral-cli lint -r api-design-guide/linter/ruleset.yaml api/openapi.yaml ``` -Every finding is prefixed with the guide rule id it enforces (for example `[7.13][M]`) and links to the rule's section. Findings for rule ids declared in `info.x-govstack-api-guide.exceptions` are reported as suppressed rather than counted. An opt-in `strict.yaml` adds noisier heuristics; [`linter/coverage.yaml`](../linter/coverage.yaml) records, for every rule in this guide, whether and how the linter covers it. In CI, the same checks run via the composite GitHub Action in `linter/action.yml`. +Every finding is prefixed with the guide rule ID it enforces (for example `[7.13][M]`) and links to the rule. Each canonical file must declare `info.x-govstack-api-guide.version: 0.2.0-draft` and `rulesetVersion: 0.2.0-draft`; a missing, unavailable, or different exact version is an error, not a request to use the latest rules. An exception suppresses only the declared `rule` at or below its JSON Pointer `scope`, and only when all governance fields required by [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) are valid and unexpired. Offline validation checks the HTTPS record URI syntax but does not dereference it. An opt-in `strict.yaml` adds noisier heuristics; [`linter/coverage.yaml`](../linter/coverage.yaml) records how each guide rule is covered. In CI, the same checks run through `linter/action.yml`. Running `npx @stoplight/spectral-cli lint api/openapi.yaml` *without* `-r` applies only Spectral's generic built-in rules: useful as a quick structural check, but it knows nothing about this guide. diff --git a/api-design-guide/part-a/2-openapi-document-standards.md b/api-design-guide/part-a/2-openapi-document-standards.md index 1e1f1c9..2a260bd 100644 --- a/api-design-guide/part-a/2-openapi-document-standards.md +++ b/api-design-guide/part-a/2-openapi-document-standards.md @@ -10,21 +10,23 @@ description: "Rules governing the canonical OpenAPI document: version, location, **Applies to:** OpenAPI surface. AsyncAPI document-level rules are in [§3](../part-a/3-asyncapi-document-standards.md). {% endhint %} -## 2.1 OpenAPI 3.1.0 required <a href="#21-openapi-310-required" id="21-openapi-310-required"></a> +## 2.1 OpenAPI 3.1 required <a href="#21-openapi-31-required" id="21-openapi-31-required"></a> -**[M]** The spec **MUST** declare `openapi: 3.1.0`. Earlier versions **MUST NOT** be used. +**[M]** The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. ## 2.2 One canonical OpenAPI entrypoint <a href="#22-one-canonical-openapi-entrypoint" id="22-one-canonical-openapi-entrypoint"></a> -**[M+R]** The canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. (The audit found these files predominantly at `api/swagger.yaml`/`api/swagger.json`; renaming to `api/openapi.yaml` is part of conformance.) It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent API surfaces that version on independent cadences **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml` (which then serves as the single registry of canonical files). Either way there **MUST** be exactly one canonical artifact per surface and no divergent copies. +**[M+R]** In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using [§4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory). Either discovery form **MUST** identify exactly one canonical artifact per surface. The audit's legacy `api/swagger.yaml` and `api/swagger.json` names are not canonical under this guide. ## 2.3 No divergent OpenAPI copies <a href="#23-no-divergent-openapi-copies" id="23-no-divergent-openapi-copies"></a> **[R]** Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it. +An operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy. + ## 2.4 Passes openapi-spec-validator <a href="#24-passes-openapi-spec-validator" id="24-passes-openapi-spec-validator"></a> -**[M]** The file **MUST** pass `openapi-spec-validator` against the 3.1.0 schema. +**[M]** The canonical entrypoint **MUST** pass `openapi-spec-validator` for its declared OpenAPI 3.1 patch version, with every local reference resolved. Referenced JSON Schema fragments **MUST** validate against their declared dialect but are not required to be standalone OpenAPI documents. ## 2.5 Complete info block <a href="#25-complete-info-block" id="25-complete-info-block"></a> @@ -32,7 +34,7 @@ description: "Rules governing the canonical OpenAPI document: version, location, ## 2.6 Meaningful servers block <a href="#26-meaningful-servers-block" id="26-meaningful-servers-block"></a> -**[M+R]** The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}/v1`). Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production. +**[M+R]** The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Every non-local server URL **MUST** use `https`. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}`). Because [§5.1](../part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path) places `/v{N}` in each OpenAPI path key, a server URL **MUST NOT** repeat that version segment. Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples and **MUST** be labelled as non-production. ## 2.7 Complete operation metadata <a href="#27-complete-operation-metadata" id="27-complete-operation-metadata"></a> @@ -40,6 +42,6 @@ description: "Rules governing the canonical OpenAPI document: version, location, ## 2.8 Pinned vendored common components <a href="#28-pinned-vendored-common-components" id="28-pinned-vendored-common-components"></a> -**[M]** Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit. +**[M]** Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit. Vendoring is required because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable. diff --git a/api-design-guide/part-a/3-asyncapi-document-standards.md b/api-design-guide/part-a/3-asyncapi-document-standards.md index 64d46c6..85e3a8e 100644 --- a/api-design-guide/part-a/3-asyncapi-document-standards.md +++ b/api-design-guide/part-a/3-asyncapi-document-standards.md @@ -16,12 +16,14 @@ description: "Rules governing the canonical AsyncAPI document: version, location ## 3.2 One canonical AsyncAPI entrypoint <a href="#32-one-canonical-asyncapi-entrypoint" id="32-one-canonical-asyncapi-entrypoint"></a> -**[M+R]** The canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent event-driven surfaces **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml`. +**[M+R]** In the absence of `api/index.yaml`, the canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned event-driven surfaces **MUST** enumerate every surface in `api/index.yaml` using [§4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory). Either discovery form **MUST** identify exactly one canonical artifact per surface. ## 3.3 No divergent AsyncAPI copies <a href="#33-no-divergent-asyncapi-copies" id="33-no-divergent-asyncapi-copies"></a> **[R]** Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it. +An operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy. + ## 3.4 Passes an AsyncAPI validator <a href="#34-passes-an-asyncapi-validator" id="34-passes-an-asyncapi-validator"></a> **[M]** The file **MUST** pass an AsyncAPI 3.0 parser/validator (for example, `@asyncapi/parser` or the AsyncAPI CLI). @@ -40,7 +42,7 @@ description: "Rules governing the canonical AsyncAPI document: version, location ## 3.8 Pinned vendored AsyncAPI components <a href="#38-pinned-vendored-asyncapi-components" id="38-pinned-vendored-asyncapi-components"></a> -**[M]** Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit. +**[M]** Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit. ## 3.9 JSON Schema payload conventions <a href="#39-json-schema-payload-conventions" id="39-json-schema-payload-conventions"></a> diff --git a/api-design-guide/part-a/4-documentation-requirements.md b/api-design-guide/part-a/4-documentation-requirements.md index fb2b651..5f4601c 100644 --- a/api-design-guide/part-a/4-documentation-requirements.md +++ b/api-design-guide/part-a/4-documentation-requirements.md @@ -25,3 +25,40 @@ description: "Documentation requirements for schemas, examples, and operation de ## 4.4 Accurate operation descriptions <a href="#44-accurate-operation-descriptions" id="44-accurate-operation-descriptions"></a> **[R]** Operation `description` **MUST** describe what the operation actually does. (The audit found at least 9 BBs with cross-endpoint description mismatches from copy-paste.) + +## 4.5 API surface inventory <a href="#45-api-surface-inventory" id="45-api-surface-inventory"></a> + +**[M+R]** A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`. Each `apis` entry **MUST** contain only the discovery fields needed here: `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`). Every listed path **MUST** resolve to a canonical specification of the declared type. `apis` and `noApi` **MUST NOT** coexist. A repository with neither a discoverable canonical specification nor an explicit `noApi` declaration is non-conformant. + +**Example (informative).** A BB with two independently versioned surfaces: + +```yaml +version: 1 +apis: + - type: openapi + path: api/citizen/openapi.yaml + - type: asyncapi + path: api/events/asyncapi.yaml +``` + +## 4.6 Functional-requirement traceability <a href="#46-functional-requirement-traceability" id="46-functional-requirement-traceability"></a> + +**[M+R]** Every normative functional requirement in `spec/**/*.md` **MUST** use the exact list-item marker `- **<stable-ID>** **REQUIRED|RECOMMENDED|OPTIONAL**: <text>`, with a stable ID unique across the BB. Every BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`, and its requirement entries **MUST** match that marker-derived ID set exactly: no missing or extra IDs. A repository that uses `noApi: true` under [§4.5](#45-api-surface-inventory) **MUST NOT** contain `api/coverage.yaml`. + +Each coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `not-applicable` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated. + +A requirement marked **REQUIRED** **MUST NOT** use the `not-applicable` disposition. If it does not belong in the BB contract, the specification **MUST** change its requirement strength or scope through the normal specification review process instead of bypassing it in the coverage file. + +**Example (informative).** + +```yaml +version: 1 +requirements: + - id: REG-FR-001 + disposition: operation + operations: + - createApplication + - id: REG-FR-002 + disposition: planned + issue: https://github.com/GovStackWorkingGroup/example/issues/42 +``` diff --git a/api-design-guide/part-b/6-http-methods.md b/api-design-guide/part-b/6-http-methods.md index 88bf539..42dd51f 100644 --- a/api-design-guide/part-b/6-http-methods.md +++ b/api-design-guide/part-b/6-http-methods.md @@ -16,7 +16,7 @@ description: "Rules defining the meaning, safety, and idempotency guarantees of ## 6.2 POST creates or performs actions <a href="#62-post-creates-or-performs-actions" id="62-post-creates-or-performs-actions"></a> -**[R]** `POST` creates a resource or performs a non-idempotent action. +**[R]** `POST` **MUST** be used to create a server-assigned resource or to perform an action that is not expressed by another HTTP method. A creation completed during the request **MUST** return `201 Created`; work accepted but not completed **MUST** return `202 Accepted` with an Operation resource; a completed non-creation action **MUST** return `200 OK` with a result or `204 No Content` without one. A POST action **MAY** be naturally idempotent or made retry-safe under [§14](../part-d/14-idempotency.md). ## 6.3 PUT replaces the entire resource <a href="#63-put-replaces-the-entire-resource" id="63-put-replaces-the-entire-resource"></a> diff --git a/api-design-guide/part-b/7-http-status-codes.md b/api-design-guide/part-b/7-http-status-codes.md index e029435..8d5318c 100644 --- a/api-design-guide/part-b/7-http-status-codes.md +++ b/api-design-guide/part-b/7-http-status-codes.md @@ -12,55 +12,55 @@ description: "Rules mapping API outcomes to standard HTTP status codes, caching, ## 7.1 200 for successful reads <a href="#71-200-for-successful-reads" id="71-200-for-successful-reads"></a> -**[R]** `200 OK`: successful read or non-creation action. +**[R]** A successful read or completed non-creation action that returns a representation **MUST** use `200 OK`. ## 7.2 201 Created with Location <a href="#72-201-created-with-location" id="72-201-created-with-location"></a> -**[M]** `201 Created`: resource creation. Response **MUST** include a `Location` header pointing to the created resource. +**[M]** A resource creation that completes during the request **MUST** use `201 Created`. The response **MUST** include a `Location` header pointing to the created resource. An operation that has only been accepted for later processing **MUST NOT** return `201`; it **MUST** use `202` under [§7.3](#73-202-accepted-for-async-operations). ## 7.3 202 Accepted for async operations <a href="#73-202-accepted-for-async-operations" id="73-202-accepted-for-async-operations"></a> -**[M+R]** `202 Accepted`: async operation. Response **MUST** include a `Location` header pointing to an Operation resource (see [§15](../part-d/15-asynchronous-operations.md)). +**[M+R]** Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the shared schema in [§15](../part-d/15-asynchronous-operations.md). `202` **MUST NOT** claim that the requested work succeeded. ## 7.4 204 for void responses <a href="#74-204-for-void-responses" id="74-204-for-void-responses"></a> -**[R]** `204 No Content`: successful DELETE or void response. +**[R]** A successful synchronous DELETE or other successful operation with no response representation **MUST** use `204 No Content` and **MUST NOT** include a response body. ## 7.5 400 for malformed requests <a href="#75-400-for-malformed-requests" id="75-400-for-malformed-requests"></a> -**[R]** `400 Bad Request`: request is malformed or unparseable. +**[R]** An operation that accepts path, query, header, or body input **MUST** declare `400 Bad Request` for malformed, unparseable, or structurally invalid input. Well-formed input that violates domain semantics **MUST** use `422` under [§7.11](#711-422-for-semantic-errors). ## 7.6 401 with WWW-Authenticate <a href="#76-401-with-www-authenticate" id="76-401-with-www-authenticate"></a> -**[M+R]** `401 Unauthorized`: missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For OAuth 2.0 bearer schemes ([§13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations)) it **SHOULD** carry the RFC 6750 challenge with an `error` value such as `invalid_token`. +**[M+R]** Every operation requiring authentication **MUST** declare `401 Unauthorized` for missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For an OAuth 2.0 bearer scheme ([§13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations)), a challenge for an invalid token **SHOULD** carry the RFC 6750 `invalid_token` error; a challenge for a request containing no authentication credentials **SHOULD NOT** include an OAuth error code. ## 7.7 403 when not authorised <a href="#77-403-when-not-authorised" id="77-403-when-not-authorised"></a> -**[R]** `403 Forbidden`: authenticated but not authorised. +**[R]** Every secured operation that can reject an authenticated caller for insufficient permission **MUST** declare `403 Forbidden`. A BB **MAY** return `404` instead when concealing the existence of a forbidden resource is part of its documented security contract, as allowed by RFC 9110. ## 7.8 404 for missing resources <a href="#78-404-for-missing-resources" id="78-404-for-missing-resources"></a> -**[R]** `404 Not Found`: resource does not exist. +**[R]** Every operation that addresses a specific resource **MUST** declare `404 Not Found` for an absent resource or for a resource whose existence is intentionally concealed under [§7.7](#77-403-when-not-authorised). An empty collection **MUST** return `200` with an empty `items` array, not `404`. ## 7.9 409 for state conflicts <a href="#79-409-for-state-conflicts" id="79-409-for-state-conflicts"></a> -**[R]** `409 Conflict`: a conflict with the current state of the resource that is not expressed as a failed precondition (e.g., creating a duplicate of a uniquely-keyed resource, an illegal state transition, or a concurrent in-flight idempotency retry per [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch)). A failed conditional precondition is `412` ([7.15](#715-412-for-failed-preconditions)), not `409`. +**[R]** An operation that creates a uniquely keyed resource, performs a state transition, or supports idempotent retry **MUST** declare `409 Conflict` when it can conflict with current resource or processing state, including an illegal transition or concurrent in-flight retry under [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). A failed conditional request precondition **MUST** use `412` ([§7.15](#715-412-for-failed-preconditions)), not `409`. ## 7.10 410 for permanent removal <a href="#710-410-for-permanent-removal" id="710-410-for-permanent-removal"></a> -**[R]** `410 Gone`: resource permanently removed; deprecated endpoint past sunset. +**[R]** A server **MUST** use `410 Gone` only when it knows that a resource or endpoint has been permanently removed; otherwise it **MUST** use `404`. ## 7.11 422 for semantic errors <a href="#711-422-for-semantic-errors" id="711-422-for-semantic-errors"></a> -**[R]** `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"): request is well-formed but semantically invalid. The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). [`[OPEN-6-A]`](../appendix/b-open-questions.md) +**[R]** A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"). The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). [`[OPEN-6-A]`](../appendix/b-open-questions.md) ## 7.12 429 for rate limits <a href="#712-429-for-rate-limits" id="712-429-for-rate-limits"></a> -**[R]** `429 Too Many Requests`: client exceeded rate limit. +**[R]** An operation that enforces a caller-visible rate limit **MUST** declare `429 Too Many Requests` and the headers required by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared). ## 7.13 Server errors documented <a href="#713-server-errors-documented" id="713-server-errors-documented"></a> -**[M]** `500`, `502`, `503`, `504`: server errors. Specs **MUST** document `500` at minimum. +**[M]** Every operation **MUST** declare `500 Internal Server Error`. Operations exposed through a gateway or dependent service **SHOULD** additionally declare the applicable `502`, `503`, and `504` responses. ## 7.14 All status codes declared <a href="#714-all-status-codes-declared" id="714-all-status-codes-declared"></a> @@ -68,15 +68,15 @@ description: "Rules mapping API outcomes to standard HTTP status codes, caching, ## 7.15 412 for failed preconditions <a href="#715-412-for-failed-preconditions" id="715-412-for-failed-preconditions"></a> -**[R]** `412 Precondition Failed`: conditional request precondition (e.g., `If-Match`) was not satisfied. +**[R]** A failed conditional request precondition such as `If-Match` **MUST** use `412 Precondition Failed`. ## 7.16 ETag and If-None-Match <a href="#716-etag-and-if-none-match" id="716-etag-and-if-none-match"></a> -**[M+R]** Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the resource state. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change. +**[M+R]** Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the selected representation. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change. An endpoint using ETags for write concurrency **MUST** provide a strong validator suitable for the strong comparison required by `If-Match`. ## 7.17 Optimistic concurrency with If-Match <a href="#717-optimistic-concurrency-with-if-match" id="717-optimistic-concurrency-with-if-match"></a> -**[M+R]** `PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: <etag>` and the server returns `412 Precondition Failed` ([7.15](#715-412-for-failed-preconditions)) if the resource has changed since that ETag. A failed `If-Match` precondition is `412`, not `409`; `409` ([7.9](#79-409-for-state-conflicts)) is reserved for state or uniqueness conflicts that are not expressed as a conditional precondition. +**[M+R]** `PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: <strong-etag>` and the server returns `412 Precondition Failed` ([§7.15](#715-412-for-failed-preconditions)) if the resource has changed. An endpoint that requires a conditional write **SHOULD** return `428 Precondition Required` when `If-Match` is absent. A failed `If-Match` precondition **MUST** use `412`, not `409`; `409` ([§7.9](#79-409-for-state-conflicts)) is reserved for conflicts not expressed by a conditional precondition. ## 7.18 405 with Allow header <a href="#718-405-with-allow-header" id="718-405-with-allow-header"></a> @@ -89,3 +89,7 @@ description: "Rules mapping API outcomes to standard HTTP status codes, caching, ## 7.20 No-store on error responses <a href="#720-no-store-on-error-responses" id="720-no-store-on-error-responses"></a> **[M]** Error responses (`application/problem+json`) and Operation-status responses ([§15](../part-d/15-asynchronous-operations.md)) **SHOULD** declare `Cache-Control: no-store`, so a shared cache cannot replay a transient failure or stale operation state. Broader caching behaviour is operational and out of scope ([§1.2](../1-introduction.md#12-scope)). + +## 7.21 Schemas for successful response bodies <a href="#721-schemas-for-successful-response-bodies" id="721-schemas-for-successful-response-bodies"></a> + +**[M]** Every declared `2xx` response that carries a body **MUST** declare at least one concrete media type and a response schema for each declared media type. An empty schema or description without a schema **MUST NOT** stand in for a response contract. Responses whose HTTP semantics prohibit a body, including `204`, **MUST NOT** declare response content. diff --git a/api-design-guide/part-b/8-headers.md b/api-design-guide/part-b/8-headers.md index 2b81bbd..a9e7371 100644 --- a/api-design-guide/part-b/8-headers.md +++ b/api-design-guide/part-b/8-headers.md @@ -16,19 +16,19 @@ description: "Rules governing standard, custom, and rate-limit HTTP headers used ## 8.2 Accept-Language and Content-Language <a href="#82-accept-language-and-content-language" id="82-accept-language-and-content-language"></a> -**[M+R]** Localisation requests **MUST** use `Accept-Language`; responses **MUST** echo via `Content-Language`. +**[M+R]** Localisation requests **MUST** use `Accept-Language`; a localised response **MUST** identify the language actually selected using `Content-Language`, which is not necessarily the request's first preference. A cacheable response selected using `Accept-Language` **MUST** declare `Vary: Accept-Language`. ## 8.3 Idempotency-Key header accepted <a href="#83-idempotency-key-header-accepted" id="83-idempotency-key-header-accepted"></a> **[M+R]** POST endpoints that require idempotency under [§14](../part-d/14-idempotency.md) **MUST** accept an `Idempotency-Key` header, unless [§14.6](../part-d/14-idempotency.md#146-naturally-idempotent-designs) applies. -## 8.4 X-Request-Id correlation <a href="#84-x-request-id-correlation" id="84-x-request-id-correlation"></a> +## 8.4 W3C Trace Context correlation <a href="#84-w3c-trace-context-correlation" id="84-w3c-trace-context-correlation"></a> -**[M+R]** Every request **SHOULD** carry an `X-Request-Id` header for correlation. The server **MUST** echo this header in the response (or generate one if absent). This correlation identifier is distinct from the error-envelope `traceId` ([§11.3](../part-c/11-errors.md#113-govstack-error-extension-fields)); a BB **MAY** reuse the same value but is not required to, and any propagation between them is operational and out of scope ([§1.2](../1-introduction.md#12-scope)). +**[M+R]** Every cross-service HTTP operation **MUST** declare the W3C Trace Context `traceparent` request header and **MAY** declare `tracestate`. A conforming implementation **MUST** propagate a valid received trace context on downstream calls and **MUST** create a valid new context when none is present or the received value is invalid. `tracestate` **MUST NOT** contain personal data. The RFC 9457 `traceId` extension in [§11.3](../part-c/11-errors.md#113-govstack-error-extension-fields) **MUST** equal the 32-hex-digit trace-id component of the request's effective `traceparent`. A separate business or support correlation identifier **MAY** be defined, but **MUST NOT** replace Trace Context. ## 8.5 No new X- prefixed headers <a href="#85-no-new-x--prefixed-headers" id="85-no-new-x--prefixed-headers"></a> -**[M]** New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix (per RFC 6648), except for the legacy correlation header explicitly allowed in [§8.4](#84-x-request-id-correlation) pending [`[OPEN-7-A]`](../appendix/b-open-questions.md). +**[M]** New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix, per RFC 6648. Existing private `X-` headers **MAY** remain only on an unchanged legacy major version and **MUST NOT** be introduced on a new surface or new major version. ## 8.6 No personal data in addressable locations <a href="#86-no-personal-data-in-addressable-locations" id="86-no-personal-data-in-addressable-locations"></a> @@ -36,4 +36,4 @@ description: "Rules governing standard, custom, and rate-limit HTTP headers used ## 8.7 Rate-limit headers declared <a href="#87-rate-limit-headers-declared" id="87-rate-limit-headers-declared"></a> -**[M+R]** Endpoints rate-limited by the BB itself **MUST** declare rate-limit response headers per `draft-ietf-httpapi-ratelimit-headers` (an active, still-evolving Internet-Draft, not yet an RFC); where rate limiting is delegated to an API gateway or interoperability mediator, the spec **MUST** state that, rather than declaring headers the BB does not emit. The default v0.1 form is the three-header variant: `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`, chosen deliberately for its current deployment ubiquity over the draft's newer structured-field form. `429` responses **MUST** additionally declare `Retry-After`. [`[OPEN-7-B]`](../appendix/b-open-questions.md) +**[M+R]** An endpoint rate-limited by the BB itself **MUST** document the quota scope and **MUST** declare the `RateLimit` response header using the Structured Field syntax pinned from `draft-ietf-httpapi-ratelimit-headers-11`. A BB that advertises quota-policy details **MUST** use `RateLimit-Policy`. The server **MAY** omit these advisory headers on individual responses as allowed by the draft, but a `429` response **MUST** declare `Retry-After`; when `Retry-After` and `RateLimit` are both present, clients **MUST** treat `Retry-After` as authoritative. Where an API gateway or interoperability mediator owns rate limiting, the BB specification **MUST** state that fact instead of claiming to emit headers it does not control. The legacy `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` fields **MUST NOT** be described as conforming to the pinned draft. diff --git a/api-design-guide/part-c/11-errors.md b/api-design-guide/part-c/11-errors.md index 052dc05..c5488e0 100644 --- a/api-design-guide/part-c/11-errors.md +++ b/api-design-guide/part-c/11-errors.md @@ -7,20 +7,20 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi {% hint style="info" %} **Intent.** One error format ecosystem-wide. A shared error schema lets integrators handle failures uniformly across BBs. -**Applies to:** Universal at the envelope and code-catalogue level. HTTP status mapping ([§7](../part-b/7-http-status-codes.md)) is OpenAPI-specific; the AsyncAPI surface signals errors via transport-appropriate mechanisms using the same envelope. +**Applies to:** Universal at the stable type, code, trace, and field-error level. RFC 9457 and its `status` member apply only to HTTP responses. AsyncAPI rejection and failure messages use the transport-neutral shape in [§11.8](#118-transport-neutral-asynchronous-errors). {% endhint %} ## 11.1 RFC 9457 problem details <a href="#111-rfc-9457-problem-details" id="111-rfc-9457-problem-details"></a> -**[M]** Error responses **MUST** use media type `application/problem+json` per RFC 9457 (which obsoletes RFC 7807 and retains the `application/problem+json` media type). The standard provides broad client and tooling support and removes the burden of maintaining a custom envelope. +**[M]** HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; [§11.8](#118-transport-neutral-asynchronous-errors) defines that mapping. ## 11.2 Standard problem fields present <a href="#112-standard-problem-fields-present" id="112-standard-problem-fields-present"></a> -**[M+R]** Standard RFC 9457 fields `type`, `title`, `status` **MUST** be present. `type` **SHOULD** be a stable URI for the problem type and **MAY** be a dereferenceable documentation URL, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details (stack traces, hostnames, query fragments). +**[M+R]** Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. ## 11.3 GovStack error extension fields <a href="#113-govstack-error-extension-fields" id="113-govstack-error-extension-fields"></a> -**[M]** GovStack extensions **MUST** include `code` (machine-stable error code), `traceId` (correlation), and `timestamp`. +**[M]** GovStack HTTP problems and asynchronous errors **MUST** include `code` (machine-stable error code), `traceId` (the W3C trace-id defined by [§8.4](../part-b/8-headers.md#84-w3c-trace-context-correlation)), and `timestamp` (an RFC 3339 `date-time`). ## 11.4 Field-level errors array <a href="#114-field-level-errors-array" id="114-field-level-errors-array"></a> @@ -36,7 +36,7 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi "detail": "Two request fields failed validation.", "instance": "/v1/applications/3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", "code": "org.govstack.registration.validationFailed", - "traceId": "6f1c3f0e2a9b4c8d", + "traceId": "6f1c3f0e2a9b4c8d7e6f5a4b3c2d1e0f", "timestamp": "2026-07-10T08:30:00Z", "errors": [ { @@ -64,3 +64,7 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi ## 11.7 Common error catalogue <a href="#117-common-error-catalogue" id="117-common-error-catalogue"></a> **[M+R]** A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `org.govstack.common.unauthenticated`, `org.govstack.common.permissionDenied`, `org.govstack.common.notFound`, `org.govstack.common.invalidArgument`, `org.govstack.common.alreadyExists`, `org.govstack.common.aborted`, `org.govstack.common.resourceExhausted`, `org.govstack.common.internal`, `org.govstack.common.unimplemented`. The final list is [`[OPEN-10-B]`](../appendix/b-open-questions.md). + +## 11.8 Transport-neutral asynchronous errors <a href="#118-transport-neutral-asynchronous-errors" id="118-transport-neutral-asynchronous-errors"></a> + +**[M+R]** An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. The schema **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`, and **MAY** contain `detail` and `errors` with the semantics in [§11.4](#114-field-level-errors-array). When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines. diff --git a/api-design-guide/part-c/12-pagination-filtering-sorting.md b/api-design-guide/part-c/12-pagination-filtering-sorting.md index d675407..5e3e2d4 100644 --- a/api-design-guide/part-c/12-pagination-filtering-sorting.md +++ b/api-design-guide/part-c/12-pagination-filtering-sorting.md @@ -16,11 +16,11 @@ description: "Mandatory pagination for collections, cursor and offset envelopes, ## 12.2 Cursor pagination by default <a href="#122-cursor-pagination-by-default" id="122-cursor-pagination-by-default"></a> -**[M+R]** Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken` to align with the wider non-Google ecosystem (GraphQL Relay Connections, GitHub, Twitter). The cursor **MUST** be opaque to clients (server-encoded, typically base64 of an internal representation); clients **MUST NOT** parse or construct cursor values. +**[M+R]** Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable problem code. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries. ## 12.3 Cursor pagination envelope <a href="#123-cursor-pagination-envelope" id="123-cursor-pagination-envelope"></a> -**[M]** Pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. The `pageInfo` wrapper is inspired by the GraphQL Relay Connections specification but deliberately simplified: it uses a flat `items` array rather than Relay's `edges`/`node`, and `nextCursor`/`hasMore` rather than Relay's `endCursor`/`hasNextPage`. +**[M]** The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. `nextCursor` **MUST** be a non-empty string when `hasMore` is `true` and **MUST** be `null` when `hasMore` is `false`; its schema therefore **MUST** declare explicit nullability. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and simplified continuation fields. **Example (informative).** A cursor-paginated collection response (`total` omitted per [§12.5](#125-optional-total-count)): @@ -31,7 +31,7 @@ description: "Mandatory pagination for collections, cursor and offset envelopes, { "id": "8a1f9c2b-7e64-4f0d-8a3b-2c5d9e0f1b47", "status": "PENDING_REVIEW" } ], "pageInfo": { - "nextCursor": "eyJvZmZzZXQiOjQyfQ", + "nextCursor": "pgn_7JpQ9m2W4xK8fR3cT6vN1", "hasMore": true } } @@ -68,11 +68,11 @@ description: "Mandatory pagination for collections, cursor and offset envelopes, ## 12.8 Simple equality filtering <a href="#128-simple-equality-filtering" id="128-simple-equality-filtering"></a> -**[M+R]** Simple filtering **MUST** use one query parameter per field, equality only. +**[M+R]** Simple equality filtering on non-personal, non-secret fields **MUST** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by [§8.6](../part-b/8-headers.md#86-no-personal-data-in-addressable-locations) **MUST NOT** use a query parameter and **MUST** use the body-based search pattern in [§12.9](#129-complex-filtering-via-search). ## 12.9 Complex filtering via search <a href="#129-complex-filtering-via-search" id="129-complex-filtering-via-search"></a> -**[M+R]** Complex filtering **MUST** use `POST /v1/{collection}/search` per [§6.6](../part-b/6-http-methods.md#66-post-search-for-complex-queries). For this endpoint, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the [§12.3](#123-cursor-pagination-envelope) envelope. +**[M+R]** Complex filtering and any filtering that contains personal data **MUST** use `POST /v1/{collection}/search` per [§6.6](../part-b/6-http-methods.md#66-post-search-for-complex-queries). Pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the [§12.3](#123-cursor-pagination-envelope) envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor. ## 12.10 Sparse fieldsets out of scope <a href="#1210-sparse-fieldsets-out-of-scope" id="1210-sparse-fieldsets-out-of-scope"></a> diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md index 8c2d8d6..618c6cf 100644 --- a/api-design-guide/part-c/9-json-conventions-and-naming.md +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -40,7 +40,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.8 Forward-compatible schemas <a href="#98-forward-compatible-schemas" id="98-forward-compatible-schemas"></a> -**[M]** Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values must be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in [§18.6](../part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields) as a non-normative reader expectation. +**[M]** Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values **MUST** be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in [§18.6](../part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields) as a non-normative reader expectation. ## 9.9 No closed enums for growing sets <a href="#99-no-closed-enums-for-growing-sets" id="99-no-closed-enums-for-growing-sets"></a> @@ -52,7 +52,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.11 Single registered BB code <a href="#911-single-registered-bb-code" id="911-single-registered-bb-code"></a> -**[M+R]** Every namespace that embeds a BB code (error codes [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), problem-type URLs [§11.2](../part-c/11-errors.md#112-standard-problem-fields-present), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), channel addresses [§17.2](../part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)). The BB-code register is proposed for the Lifecycle & Governance companion ([Appendix A](../appendix/a-companion-documents.md)); until it exists, codes **SHOULD** be agreed through the API Working Group. [`[OPEN-9-A]`](../appendix/b-open-questions.md) +**[M+R]** Every namespace that embeds a BB code (error codes [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), problem-type URLs [§11.2](../part-c/11-errors.md#112-standard-problem-fields-present), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), logical channel IDs [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)). The BB-code register is proposed for the Lifecycle & Governance companion ([Appendix A](../appendix/a-companion-documents.md)); until it exists, codes **SHOULD** be agreed through the API Working Group. [`[OPEN-9-A]`](../appendix/b-open-questions.md) ## Note on 9.2 <a href="#note-on-92" id="note-on-92"></a> diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md index 136c05b..27b1690 100644 --- a/api-design-guide/part-d/13-authentication-and-authorisation.md +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -16,11 +16,11 @@ description: "Rules for how BB API specs declare security schemes, OAuth scopes, ## 13.2 OAuth and OIDC for citizen operations <a href="#132-oauth-and-oidc-for-citizen-operations" id="132-oauth-and-oidc-for-citizen-operations"></a> -**[M+R]** Citizen-facing operations **MUST** declare an OAuth 2.0 + OIDC security requirement. On the OpenAPI surface this is either a `type: openIdConnect` scheme carrying `openIdConnectUrl` (the OIDC discovery document) or a `type: oauth2` scheme declaring the relevant `flows`. (A `type: oauth2` scheme does not carry a discovery URL; the discovery URL belongs to the `openIdConnect` scheme type.) Reference specifications that are not tied to a live identity provider **MAY** use documented deployment variables or reserved documentation domains for discovery, authorisation, token, and JWKS URLs. Adopter-specific identity-provider endpoints belong in implementation profiles. +**[M+R]** Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles. ## 13.3 Distinct scheme for BB-to-BB calls <a href="#133-distinct-scheme-for-bb-to-bb-calls" id="133-distinct-scheme-for-bb-to-bb-calls"></a> -**[M+R]** BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service auth. On OpenAPI this is typically `type: mutualTLS` or OAuth client credentials; on AsyncAPI this is typically `type: X509`, OAuth client credentials, or a protocol-specific scheme such as SASL where the broker requires it. The spec **MUST** make clear which operations are citizen-facing vs inter-BB. +**[M+R]** BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service authentication. On OpenAPI this **MUST** be `type: mutualTLS` or an OAuth client-credentials scheme using confidential-client authentication; on AsyncAPI it **MUST** be `type: X509`, OAuth client credentials, or a documented protocol-specific scheme such as SASL. OAuth access tokens **MUST** be audience-restricted to the intended BB and scope-restricted to the operation. Sender-constrained access tokens under RFC 8705 or RFC 9449 **SHOULD** be used across an inter-BB trust boundary. The spec **MUST** distinguish citizen-facing from inter-BB operations. ## 13.4 Namespaced OAuth scopes <a href="#134-namespaced-oauth-scopes" id="134-namespaced-oauth-scopes"></a> @@ -37,3 +37,7 @@ description: "Rules for how BB API specs declare security schemes, OAuth scopes, ## Note on consent propagation <a href="#note-on-consent-propagation" id="note-on-consent-propagation"></a> Cross-service propagation of end-user consent or authorisation context (for example, when one BB calls another on behalf of a data subject) is not specified by this guide. The expected mechanism is OAuth-native (scopes, claims, or RFC 9396 Rich Authorization Requests inside the access token), and the concrete shape belongs in the relevant consent, authorisation, and inter-BB trust specifications, not here. + +## 13.7 Protected transport <a href="#137-protected-transport" id="137-protected-transport"></a> + +**[M+R]** Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion. diff --git a/api-design-guide/part-d/14-idempotency.md b/api-design-guide/part-d/14-idempotency.md index 379b476..58f2a37 100644 --- a/api-design-guide/part-d/14-idempotency.md +++ b/api-design-guide/part-d/14-idempotency.md @@ -14,23 +14,23 @@ description: "Rules for the Idempotency-Key header contract that lets clients sa ## 14.1 Idempotency-Key on non-idempotent POSTs <a href="#141-idempotency-key-on-non-idempotent-posts" id="141-idempotency-key-on-non-idempotent-posts"></a> -**[M+R]** Except where [§14.6](#146-naturally-idempotent-designs) applies (a naturally idempotent design), POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support idempotency unless the operation is naturally idempotent by contract and documents its duplicate-handling semantics. Read-like POSTs, such as complex search endpoints, **MAY** support idempotency but are not required to. The header follows the convention established by Stripe and is being standardized in the IETF httpapi working group as `draft-ietf-httpapi-idempotency-key-header` (an Internet-Draft, not yet an RFC). +**[M+R]** Except where [§14.6](#146-naturally-idempotent-designs) applies, POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** require and accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support it unless their naturally idempotent contract documents duplicate handling. Read-like POSTs such as search **MAY** support it. GovStack pins the header syntax and error semantics from `draft-ietf-httpapi-idempotency-key-header-07`; this guide is the stable GovStack profile if that work-in-progress draft changes or expires. ## 14.2 Opaque client-generated keys <a href="#142-opaque-client-generated-keys" id="142-opaque-client-generated-keys"></a> -**[R]** The key **MUST** be an opaque, client-generated string and **SHOULD** be a UUID. (The cited draft RECOMMENDS a UUID rather than requiring one; per [§1.7](../1-introduction.md#17-precedence-of-external-standards) the guide does not specify past the adopted standard by mandating a particular UUID version.) +**[R]** The key **MUST** be an opaque, client-generated, high-entropy value and **SHOULD** be a UUID. On the wire it **MUST** use the Structured Field String syntax pinned from draft revision 07, including the required quotation marks. The specification **MUST** document the accepted syntax and maximum length, and the server **MUST** reject a malformed, missing-required, or oversized key with `400 Bad Request` before processing the operation. ## 14.3 Documented replay window <a href="#143-documented-replay-window" id="143-documented-replay-window"></a> -**[R]** The spec **MUST** document the idempotency replay-window contract. A reference specification **SHOULD** state the required minimum replay window or the configuration parameter that controls it. Concrete replay-window values belong in implementation profiles. +**[R]** The spec **MUST** document the idempotency replay-window contract, the required minimum replay window or controlling configuration parameter, and what happens after expiry. Within the documented window the key **MUST** retain the semantics in [§14.4](#144-replay-returns-original-response) and [§14.5](#145-key-reuse-and-fingerprint-mismatch); after expiry the server **MAY** process the same key as a new request only if that behaviour is stated explicitly. Concrete retention values belong in implementation profiles. ## 14.4 Replay returns original response <a href="#144-replay-returns-original-response" id="144-replay-returns-original-response"></a> -**[R]** A repeated request with the same key within the documented window **MUST** return the original response (status, body, headers). +**[R]** A completed repeated request with the same lookup scope, key, and fingerprint within the documented window **MUST** return the original operation result: the same status, body, and result-defining representation headers such as `Content-Type` and `Location`. Per-attempt, temporal, security, tracing, rate-limit, retry, and hop-by-hop headers **MUST** be regenerated or omitted rather than replayed; this includes `Date`, `traceparent`, `tracestate`, `RateLimit`, `Retry-After`, and `Set-Cookie`. ## 14.5 Key reuse and fingerprint mismatch <a href="#145-key-reuse-and-fingerprint-mismatch" id="145-key-reuse-and-fingerprint-mismatch"></a> -**[R]** A repeated request reusing the same key with a *different* request body **MUST** return `422 Unprocessable Content` (the request fingerprint does not match the original), per the cited draft. A repeated request that arrives while the original is still being processed (a concurrent in-flight retry) **MUST** return `409 Conflict`. The request fingerprint **MUST** be computed over at least the canonicalised request body; a BB **MAY** additionally include the method and target. The spec **MUST** document a maximum accepted key length so oversized keys are rejected deterministically. +**[R]** The server's idempotency lookup scope **MUST** include the effective HTTP method, canonical target URI, key, and, for authenticated operations, the authenticated client and tenant or equivalent authorization partition. The request fingerprint **MUST** include the method, canonical target URI, request content type, canonicalised body, and every documented header that changes operation semantics. Reusing a key in the same lookup scope with a different fingerprint **MUST** return `422 Unprocessable Content`; a matching retry received while the original remains in flight **MUST** return `409 Conflict`. These are GovStack **MUST** requirements even though draft revision 07 expresses the status-code choices as **SHOULD**. ## 14.6 Naturally idempotent designs <a href="#146-naturally-idempotent-designs" id="146-naturally-idempotent-designs"></a> diff --git a/api-design-guide/part-d/15-asynchronous-operations.md b/api-design-guide/part-d/15-asynchronous-operations.md index 8f4fcc9..70db156 100644 --- a/api-design-guide/part-d/15-asynchronous-operations.md +++ b/api-design-guide/part-d/15-asynchronous-operations.md @@ -12,7 +12,7 @@ description: "The shared Operation resource shape and polling pattern BBs use fo ## 15.1 202 with Operation Location <a href="#151-202-with-operation-location" id="151-202-with-operation-location"></a> -**[M+R]** Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource. +**[M+R]** Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource and **MUST** return the current Operation representation in the response body. ## 15.2 Shared Operation resource shape <a href="#152-shared-operation-resource-shape" id="152-shared-operation-resource-shape"></a> @@ -36,11 +36,11 @@ description: "The shared Operation resource shape and polling pattern BBs use fo ## 15.4 Polling the Operation resource <a href="#154-polling-the-operation-resource" id="154-polling-the-operation-resource"></a> -**[M+R]** Clients poll via `GET /v1/operations/{operationId}`. +**[M+R]** A BB exposing an Operation resource **MUST** expose polling via `GET /v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval. ## 15.5 Cancellation via cancel sub-resource <a href="#155-cancellation-via-cancel-sub-resource" id="155-cancellation-via-cancel-sub-resource"></a> -**[M+R]** Cancellation, when supported, **MUST** be `POST /v1/operations/{operationId}/cancel`. +**[M+R]** Cancellation, when supported, **MUST** be `POST /v{major}/operations/{operationId}/cancel`. ## 15.6 Webhook completion notification <a href="#156-webhook-completion-notification" id="156-webhook-completion-notification"></a> diff --git a/api-design-guide/part-d/16-cloudevents-and-webhooks.md b/api-design-guide/part-d/16-cloudevents-and-webhooks.md index 6ef680d..f2e6aa3 100644 --- a/api-design-guide/part-d/16-cloudevents-and-webhooks.md +++ b/api-design-guide/part-d/16-cloudevents-and-webhooks.md @@ -9,7 +9,7 @@ description: "Rules governing the CloudEvents envelope, event-type and source na **Applies to:** Event-driven. CloudEvents rules apply to HTTP webhooks, brokered event channels, and event streams. OpenAPI and AsyncAPI are documentation formats for those surfaces, not alternative event-envelope standards. -**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§16.1](#161-event-surfaces-documented), [§16.3](#163-reverse-dns-event-types), [§16.4](#164-stable-cloudevents-source), [§16.6](#166-govstack-signature-header), [§16.10](#1610-documented-delivery-failure-contract), and [§16.11](#1611-subscription-management-interfaces) constrain the specification (documentation and declaration). [§16.5](#165-signed-event-delivery) and [§16.7](#167-replay-detectable-signed-material) are behavioural-contract rules, verified by the conformance test pack once the event-signature profile ([§16.8](#168-pinned-signature-profile), [`[OPEN-15-A]`](../appendix/b-open-questions.md)) exists; that profile is a v1.0 prerequisite. [§16.9](#169-operational-signing-concerns-out-of-scope) keeps replay enforcement and signing-key rotation in the Security & Operations companion. +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§16.1](#161-event-surfaces-documented), [§16.3](#163-reverse-dns-event-types), [§16.4](#164-stable-cloudevents-source), [§16.6](#166-govstack-signature-header), [§16.10](#1610-documented-delivery-failure-contract), and [§16.11](#1611-subscription-management-interfaces) constrain the specification (documentation and declaration). [§16.5](#165-signed-event-delivery) and [§16.7](#167-replay-detectable-signed-material) are behavioural-contract rules verified by the conformance test pack against the event-signature profile in [§16.8](#168-pinned-signature-profile). [§16.9](#169-operational-signing-concerns-out-of-scope) keeps replay enforcement and signing-key rotation in the Security & Operations companion. {% endhint %} ## 16.1 Event surfaces documented <a href="#161-event-surfaces-documented" id="161-event-surfaces-documented"></a> @@ -22,11 +22,11 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.3 Reverse-DNS event types <a href="#163-reverse-dns-event-types" id="163-reverse-dns-event-types"></a> -**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and does not include the major version; the versioned transport contract is carried in the channel address or equivalent AsyncAPI version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-15-B]`](../appendix/b-open-questions.md) +**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-15-B]`](../appendix/b-open-questions.md) ## 16.4 Stable CloudEvents source <a href="#164-stable-cloudevents-source" id="164-stable-cloudevents-source"></a> -**[M+R]** The CloudEvents `source` field **MUST** identify the publishing BB or BB surface in a stable way. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment. +**[M+R]** The CloudEvents `source` field **MUST** be a stable, non-empty URI-reference identifying the publishing BB or BB surface; an absolute URI or URN **SHOULD** be used. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment. **Example (informative).** A structured CloudEvents JSON event with a GovStack trace extension attribute: @@ -34,11 +34,11 @@ description: "Rules governing the CloudEvents envelope, event-type and source na { "specversion": "1.0", "id": "5e0c63c2-2b8a-4d3f-9a51-7c6b0d9e8f21", - "source": "org.govstack.registration", + "source": "urn:govstack:bb:registration", "type": "org.govstack.registration.application.approved", "time": "2026-07-10T08:30:00Z", "datacontenttype": "application/json", - "traceid": "6f1c3f0e2a9b4c8d", + "traceparent": "00-6f1c3f0e2a9b4c8d7e6f5a4b3c2d1e0f-5b1e4d7ca8f01e2d-01", "data": { "applicationId": "3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", "approvedAt": "2026-07-10T08:29:58Z" @@ -48,11 +48,11 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.5 Signed event delivery <a href="#165-signed-event-delivery" id="165-signed-event-delivery"></a> -**[R]** Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`. [`[OPEN-15-A]`](../appendix/b-open-questions.md) +**[R]** Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`. ## 16.6 GovStack-Signature header <a href="#166-govstack-signature-header" id="166-govstack-signature-header"></a> -**[M+R]** On the OpenAPI/webhooks surface the signature **MUST** travel in a single ecosystem-wide HTTP header named `GovStack-Signature` (modelled on Stripe's `Stripe-Signature` and GitHub's `X-Hub-Signature-256`). On the AsyncAPI surface the signature **MUST** travel in the transport's message-metadata channel under the same field name unless the chosen protocol binding defines a more precise field. [`[OPEN-15-C]`](../appendix/b-open-questions.md) +**[M+R]** On the OpenAPI/webhooks surface the signature **MUST** travel in the ecosystem-wide HTTP header `GovStack-Signature`. On the AsyncAPI surface, a GovStack-owned transport/application metadata field **MUST** be named `govstackSignature` so it satisfies [§17.8](../part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata); when a protocol binding defines a standard signature field, that field **SHOULD** be used and the mapping **MUST** be documented. ## 16.7 Replay-detectable signed material <a href="#167-replay-detectable-signed-material" id="167-replay-detectable-signed-material"></a> @@ -60,7 +60,7 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.8 Pinned signature profile <a href="#168-pinned-signature-profile" id="168-pinned-signature-profile"></a> -**[R]** `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** pin the exact bytes that are signed (the canonicalisation: which fields, in which order, with which serialisation), the signing algorithm and its identifier, and the signature verification inputs, so that two independently-built BBs can verify each other's signatures. The default signature scheme is detached JWS over a canonicalised structured CloudEvents JSON payload. HMAC-SHA256 **MAY** be used only where shared-key distribution is explicitly governed. The event-signature profile is required for v1.0 publication because [§16.5](#165-signed-event-delivery) is not mechanically enforceable without it. [`[OPEN-15-A]`](../appendix/b-open-questions.md) +**[R]** `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it. ## 16.9 Operational signing concerns out of scope <a href="#169-operational-signing-concerns-out-of-scope" id="169-operational-signing-concerns-out-of-scope"></a> @@ -72,4 +72,4 @@ Operational concerns such as replay-window enforcement and signing-key rotation ## 16.11 Subscription management interfaces <a href="#1611-subscription-management-interfaces" id="1611-subscription-management-interfaces"></a> -**[M+R]** Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane. +**[M+R]** Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret or verification key material used by the selected profile, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane. diff --git a/api-design-guide/part-d/17-asyncapi-channel-rules.md b/api-design-guide/part-d/17-asyncapi-channel-rules.md index b44a7b2..ac2db57 100644 --- a/api-design-guide/part-d/17-asyncapi-channel-rules.md +++ b/api-design-guide/part-d/17-asyncapi-channel-rules.md @@ -14,13 +14,13 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me **[M+R]** Each AsyncAPI document **MUST** define the BB's perspective. An operation with `action: send` means the BB publishes that message to the channel. An operation with `action: receive` means the BB consumes that message from the channel. -## 17.2 Reverse-DNS channel addresses <a href="#172-reverse-dns-channel-addresses" id="172-reverse-dns-channel-addresses"></a> +## 17.2 Stable logical channel IDs and native addresses <a href="#172-stable-logical-channel-ids-and-native-addresses" id="172-stable-logical-channel-ids-and-native-addresses"></a> -**[M]** Channel addresses **MUST** follow one ecosystem-wide naming convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). [`[OPEN-15-D]`](../appendix/b-open-questions.md) +**[M+R]** Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address. ## 17.3 No personal data in channels <a href="#173-no-personal-data-in-channels" id="173-no-personal-data-in-channels"></a> -**[R]** Channel addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead. +**[R]** Logical channel IDs, native addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead. ## 17.4 Declared channel parameters <a href="#174-declared-channel-parameters" id="174-declared-channel-parameters"></a> @@ -40,7 +40,7 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.8 Message headers and idempotency metadata <a href="#178-message-headers-and-idempotency-metadata" id="178-message-headers-and-idempotency-metadata"></a> -**[M+R]** GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. For structured CloudEvents messages, trace and workflow metadata **SHOULD** be carried as CloudEvents extension attributes: `traceid`, `correlationid`, and `causationid`. These names are lowercase because CloudEvents requires lowercase extension-attribute names; the same concepts use camelCase in GovStack-owned JSON bodies and transport/application headers. Transport/application headers **MAY** mirror these values where broker tooling requires header-level metadata, but the CloudEvent remains the normative event envelope. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key. For structured CloudEvents command messages, the key **MUST** be the CloudEvents extension attribute `idempotencykey`; for non-CloudEvents command messages, it **MUST** be the message header `idempotencyKey`. +**[M+R]** GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. The event-signature metadata name **MUST** follow [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header). Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`. ## 17.9 Message localisation headers <a href="#179-message-localisation-headers" id="179-message-localisation-headers"></a> @@ -72,7 +72,7 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.16 Async rejection error messages <a href="#1716-async-rejection-error-messages" id="1716-async-rejection-error-messages"></a> -**[M+R]** Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using the common GovStack error envelope from [§11](../part-c/11-errors.md). The error message **MUST** be correlated to the original message using the correlation metadata rules in [§17.8](#178-message-headers-and-idempotency-metadata) or an equivalent protocol binding. +**[M+R]** Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from [§11.8](../part-c/11-errors.md#118-transport-neutral-asynchronous-errors), not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using [§17.8](#178-message-headers-and-idempotency-metadata) or an equivalent protocol binding. ## 17.17 Declared request-reply correlation <a href="#1717-declared-request-reply-correlation" id="1717-declared-request-reply-correlation"></a> diff --git a/api-design-guide/part-d/18-compatibility-and-lifecycle.md b/api-design-guide/part-d/18-compatibility-and-lifecycle.md index 758b107..8043901 100644 --- a/api-design-guide/part-d/18-compatibility-and-lifecycle.md +++ b/api-design-guide/part-d/18-compatibility-and-lifecycle.md @@ -12,23 +12,23 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.1 SemVer versioning <a href="#181-semver-versioning" id="181-semver-versioning"></a> -**[M]** `info.version` **MUST** follow SemVer. +**[M]** `info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under [§18.2](#182-major-version-in-path-or-channel). ## 18.2 Major version in path or channel <a href="#182-major-version-in-path-or-channel" id="182-major-version-in-path-or-channel"></a> -**[M]** Major version increments **MUST** be reflected in the OpenAPI URL path (`/v2/`). AsyncAPI channels **MUST** include the major version in the channel address or an equivalent machine-readable version field documented in `govstack-asyncapi-common.yaml`. +**[M]** A major version increment **MUST** be reflected in every OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it. ## 18.3 Backward-compatible minor changes <a href="#183-backward-compatible-minor-changes" id="183-backward-compatible-minor-changes"></a> -**[M+R]** Minor and patch increments **MUST** be backward-compatible. Adding optional fields, adding endpoints, adding enum values (for fields declared extensibly per [§9.9](../part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets)), and relaxing constraints are non-breaking. +**[M+R]** Compatibility **MUST** be evaluated as an existing client communicating with a newer server, separately for input and output. A patch increment **MUST** be limited to a backward-compatible correction that does not add public functionality. A minor increment **MAY** add an endpoint; add an optional request field whose absence preserves the old behaviour; broaden values accepted in a request; or add a response field that conforming clients ignore under [§9.8](../part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas). A response enum value **MAY** be added only when the field was declared extensibly under [§9.9](../part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets). For messaging, the same test **MUST** be applied from publisher to existing consumer for sent messages and from existing publisher to consumer for received messages. Additive syntax **MUST NOT** be called compatible when it changes defaults, pagination boundaries, ordering, authorization, delivery guarantees, or other observable semantics. ## 18.4 Breaking changes bump major version <a href="#184-breaking-changes-bump-major-version" id="184-breaking-changes-bump-major-version"></a> -**[M+R]** Breaking changes (removing endpoints, removing fields, narrowing types, narrowing enums, tightening required, changing semantic meaning) **MUST** be released as a new major version. +**[M+R]** A breaking change **MUST** be released as a new major version. Breaking changes include removing or renaming an endpoint, operation, message, field, or enum value; adding a required request field; rejecting a previously accepted request; widening the type, length, format, or closed-enum values a server may emit beyond the old response schema; ceasing to emit a required response field; changing a success status, media type, default, identifier construction, field presence, pagination or sort behaviour, error-code meaning, idempotency behaviour, security requirement or scope, event meaning, delivery guarantee, or ordering guarantee. Moving a contract component in a way that breaks generated-client references **MUST** also be treated as breaking even when the serialized wire shape is unchanged. ## 18.5 Deprecation and Sunset headers <a href="#185-deprecation-and-sunset-headers" id="185-deprecation-and-sunset-headers"></a> -**[M+R]** Deprecated endpoints **MUST** return a `Deprecation` header per RFC 9745 (a structured-field date carrying the deprecation timestamp, e.g. `Deprecation: @1735689600`) and a `Sunset` header per RFC 8594 indicating planned removal. The minimum deprecation window between announcement and sunset, and the maximum number of concurrent major versions a BB can keep in production, are operational policy and are proposed for the Lifecycle & Governance companion, not here. +**[M+R]** Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The minimum deprecation window and maximum concurrent major versions remain policy for the Lifecycle & Governance companion. ## 18.6 Clients ignore unknown fields <a href="#186-clients-ignore-unknown-fields" id="186-clients-ignore-unknown-fields"></a> diff --git a/api-design-guide/part-e/20-conformance-and-validation.md b/api-design-guide/part-e/20-conformance-and-validation.md index 10180ce..bb9298f 100644 --- a/api-design-guide/part-e/20-conformance-and-validation.md +++ b/api-design-guide/part-e/20-conformance-and-validation.md @@ -12,15 +12,32 @@ description: "Rules governing mechanical conformance verification of BB API spec ## 20.1 Every file passes validation <a href="#201-every-file-passes-validation" id="201-every-file-passes-validation"></a> -**[M]** Every BB OpenAPI file **MUST** pass `openapi-spec-validator`. Every BB AsyncAPI file **MUST** pass an equivalent AsyncAPI parser/validator (e.g., `asyncapi/parser`). +**[M]** Every canonical OpenAPI entrypoint **MUST** pass `openapi-spec-validator` for its declared qualified 3.1 patch, with local references resolved. Every canonical AsyncAPI entrypoint **MUST** pass an AsyncAPI 3.0 parser/validator such as `@asyncapi/parser`. A referenced schema fragment **MUST** validate against its own declared schema dialect and **MUST NOT** be rejected merely because it is not a standalone OpenAPI or AsyncAPI document. ## 20.2 Passes the GovStack Spectral ruleset <a href="#202-passes-the-govstack-spectral-ruleset" id="202-passes-the-govstack-spectral-ruleset"></a> -**[M]** Every BB API spec **MUST** pass the GovStack Spectral ruleset for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of rules tagged `[M+R]` ([§1.9](../1-introduction.md#19-rule-enforcement-classes)). The v0.1 ruleset **MUST** include the OpenAPI rules, CloudEvents event rules, and AsyncAPI documentation rules from [§3](../part-a/3-asyncapi-document-standards.md), [§16](../part-d/16-cloudevents-and-webhooks.md), and [§17](../part-d/17-asyncapi-channel-rules.md). Future protocol profiles may add deeper Kafka, MQTT, AMQP, WebSocket, or SSE rules. +**[M]** Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under [§20.3](#203-declared-guide-conformance-version) for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules ([§1.9](../1-introduction.md#19-rule-enforcement-classes)). Ruleset `0.2.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility. ## 20.3 Declared guide conformance version <a href="#203-declared-guide-conformance-version" id="203-declared-guide-conformance-version"></a> -**[M]** Each canonical specification file **MUST** declare the guide version it conforms to via the `info`-level extension `x-govstack-api-guide`: an object with `version` (the guide version targeted, SemVer) and optional `exceptions` (a list of rule IDs, each with a reference to its approved exception record per [§1.6](../1-introduction.md#16-exception-process)). Validation tooling ([§20.2](#202-passes-the-govstack-spectral-ruleset)) selects the matching ruleset version from this declaration. [`[OPEN-20-A]`](../appendix/b-open-questions.md) +**[M]** Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.2.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under [§1.6](../1-introduction.md#16-exception-process) **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI. + +**Example (informative).** + +```yaml +info: + x-govstack-api-guide: + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft + exceptions: + - rule: "5.2" + scope: /paths/~1v1~1status/get + rationale: Legacy statutory endpoint name cannot change before v2. + record: https://docs.govstack.org/api-exceptions/registration-2026-004 + reviewedBy: GovStack API Working Group + reviewedAt: "2026-07-10" + expiresAt: "2027-01-31" +``` ## Note on governance <a href="#note-on-governance" id="note-on-governance"></a> diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index a6819c7..9632cef 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -5,16 +5,16 @@ # The same `#anchor` fragment resolves on both GitHub and GitBook. guide: GovStack Cross-BB API Design Guide version: 0.2.0-draft -rule_count: 166 +rule_count: 171 rules: - id: "2.1" - title: "OpenAPI 3.1.0 required" + title: "OpenAPI 3.1 required" class: M strengths: ["MUST NOT", "MUST"] surface: OpenAPI page: part-a/2-openapi-document-standards.md - anchor: 21-openapi-310-required - text: "The spec **MUST** declare `openapi: 3.1.0`. Earlier versions **MUST NOT** be used." + anchor: 21-openapi-31-required + text: "The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." open_questions: [] - id: "2.2" title: "One canonical OpenAPI entrypoint" @@ -23,7 +23,7 @@ rules: surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 22-one-canonical-openapi-entrypoint - text: "The canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. (The audit found these files predominantly at `api/swagger.yaml`/`api/swagger.json`; renaming to `api/openapi.yaml` is part of conformance.) It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent API surfaces that version on independent cadences **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml` (which then serves as the single registry of canonical files). Either way there **MUST** be exactly one canonical artifact per surface and no divergent copies." + text: "In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface. The audit's legacy `api/swagger.yaml` and `api/swagger.json` names are not canonical under this guide." open_questions: [] - id: "2.3" title: "No divergent OpenAPI copies" @@ -32,7 +32,7 @@ rules: surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 23-no-divergent-openapi-copies - text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it." + text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it.\nAn operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy." open_questions: [] - id: "2.4" title: "Passes openapi-spec-validator" @@ -41,7 +41,7 @@ rules: surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 24-passes-openapi-spec-validator - text: "The file **MUST** pass `openapi-spec-validator` against the 3.1.0 schema." + text: "The canonical entrypoint **MUST** pass `openapi-spec-validator` for its declared OpenAPI 3.1 patch version, with every local reference resolved. Referenced JSON Schema fragments **MUST** validate against their declared dialect but are not required to be standalone OpenAPI documents." open_questions: [] - id: "2.5" title: "Complete info block" @@ -59,7 +59,7 @@ rules: surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 26-meaningful-servers-block - text: "The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}/v1`). Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production." + text: "The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Every non-local server URL **MUST** use `https`. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}`). Because §5.1 places `/v{N}` in each OpenAPI path key, a server URL **MUST NOT** repeat that version segment. Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples and **MUST** be labelled as non-production." open_questions: [] - id: "2.7" title: "Complete operation metadata" @@ -77,7 +77,7 @@ rules: surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 28-pinned-vendored-common-components - text: "Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit.\nVendoring is required because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable." + text: "Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit.\nVendoring is required because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable." open_questions: [] - id: "3.1" title: "AsyncAPI 3.0.0 required" @@ -95,7 +95,7 @@ rules: surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 32-one-canonical-asyncapi-entrypoint - text: "The canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other files in the repository, provided every reference resolves and there is exactly one entrypoint. A BB with genuinely independent event-driven surfaces **MAY** instead ship one canonical file per surface, each at a documented path and all enumerated in `api/index.yaml`." + text: "In the absence of `api/index.yaml`, the canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned event-driven surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface." open_questions: [] - id: "3.3" title: "No divergent AsyncAPI copies" @@ -104,7 +104,7 @@ rules: surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 33-no-divergent-asyncapi-copies - text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it." + text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it.\nAn operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy." open_questions: [] - id: "3.4" title: "Passes an AsyncAPI validator" @@ -149,7 +149,7 @@ rules: surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 38-pinned-vendored-asyncapi-components - text: "Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally in each BB repository at the pinned version, and the pinned version **MUST** be explicit." + text: "Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit." open_questions: [] - id: "3.9" title: "JSON Schema payload conventions" @@ -196,6 +196,24 @@ rules: anchor: 44-accurate-operation-descriptions text: "Operation `description` **MUST** describe what the operation actually does. (The audit found at least 9 BBs with cross-endpoint description mismatches from copy-paste.)" open_questions: [] +- id: "4.5" + title: "API surface inventory" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 45-api-surface-inventory + text: "A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`. Each `apis` entry **MUST** contain only the discovery fields needed here: `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`). Every listed path **MUST** resolve to a canonical specification of the declared type. `apis` and `noApi` **MUST NOT** coexist. A repository with neither a discoverable canonical specification nor an explicit `noApi` declaration is non-conformant." + open_questions: [] +- id: "4.6" + title: "Functional-requirement traceability" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 46-functional-requirement-traceability + text: "Every normative functional requirement in `spec/**/*.md` **MUST** use the exact list-item marker `- **<stable-ID>** **REQUIRED|RECOMMENDED|OPTIONAL**: <text>`, with a stable ID unique across the BB. Every BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`, and its requirement entries **MUST** match that marker-derived ID set exactly: no missing or extra IDs. A repository that uses `noApi: true` under §4.5 **MUST NOT** contain `api/coverage.yaml`.\nEach coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `not-applicable` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated.\nA requirement marked **REQUIRED** **MUST NOT** use the `not-applicable` disposition. If it does not belong in the BB contract, the specification **MUST** change its requirement strength or scope through the normal specification review process instead of bypassing it in the coverage file." + open_questions: [] - id: "5.1" title: "Major version in the path" class: M @@ -289,11 +307,11 @@ rules: - id: "6.2" title: "POST creates or performs actions" class: R - strengths: [] + strengths: ["MUST", "MAY"] surface: OpenAPI page: part-b/6-http-methods.md anchor: 62-post-creates-or-performs-actions - text: "`POST` creates a resource or performs a non-idempotent action." + text: "`POST` **MUST** be used to create a server-assigned resource or to perform an action that is not expressed by another HTTP method. A creation completed during the request **MUST** return `201 Created`; work accepted but not completed **MUST** return `202 Accepted` with an Operation resource; a completed non-creation action **MUST** return `200 OK` with a result or `204 No Content` without one. A POST action **MAY** be naturally idempotent or made retry-safe under §14." open_questions: [] - id: "6.3" title: "PUT replaces the entire resource" @@ -343,119 +361,119 @@ rules: - id: "7.1" title: "200 for successful reads" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 71-200-for-successful-reads - text: "`200 OK`: successful read or non-creation action." + text: "A successful read or completed non-creation action that returns a representation **MUST** use `200 OK`." open_questions: [] - id: "7.2" title: "201 Created with Location" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 72-201-created-with-location - text: "`201 Created`: resource creation. Response **MUST** include a `Location` header pointing to the created resource." + text: "A resource creation that completes during the request **MUST** use `201 Created`. The response **MUST** include a `Location` header pointing to the created resource. An operation that has only been accepted for later processing **MUST NOT** return `201`; it **MUST** use `202` under §7.3." open_questions: [] - id: "7.3" title: "202 Accepted for async operations" class: M+R - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 73-202-accepted-for-async-operations - text: "`202 Accepted`: async operation. Response **MUST** include a `Location` header pointing to an Operation resource (see §15)." + text: "Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the shared schema in §15. `202` **MUST NOT** claim that the requested work succeeded." open_questions: [] - id: "7.4" title: "204 for void responses" class: R - strengths: [] + strengths: ["MUST NOT", "MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 74-204-for-void-responses - text: "`204 No Content`: successful DELETE or void response." + text: "A successful synchronous DELETE or other successful operation with no response representation **MUST** use `204 No Content` and **MUST NOT** include a response body." open_questions: [] - id: "7.5" title: "400 for malformed requests" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 75-400-for-malformed-requests - text: "`400 Bad Request`: request is malformed or unparseable." + text: "An operation that accepts path, query, header, or body input **MUST** declare `400 Bad Request` for malformed, unparseable, or structurally invalid input. Well-formed input that violates domain semantics **MUST** use `422` under §7.11." open_questions: [] - id: "7.6" title: "401 with WWW-Authenticate" class: M+R - strengths: ["MUST", "SHOULD"] + strengths: ["MUST", "SHOULD NOT", "SHOULD"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 76-401-with-www-authenticate - text: "`401 Unauthorized`: missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For OAuth 2.0 bearer schemes (§13.2) it **SHOULD** carry the RFC 6750 challenge with an `error` value such as `invalid_token`." + text: "Every operation requiring authentication **MUST** declare `401 Unauthorized` for missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For an OAuth 2.0 bearer scheme (§13.2), a challenge for an invalid token **SHOULD** carry the RFC 6750 `invalid_token` error; a challenge for a request containing no authentication credentials **SHOULD NOT** include an OAuth error code." open_questions: [] - id: "7.7" title: "403 when not authorised" class: R - strengths: [] + strengths: ["MUST", "MAY"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 77-403-when-not-authorised - text: "`403 Forbidden`: authenticated but not authorised." + text: "Every secured operation that can reject an authenticated caller for insufficient permission **MUST** declare `403 Forbidden`. A BB **MAY** return `404` instead when concealing the existence of a forbidden resource is part of its documented security contract, as allowed by RFC 9110." open_questions: [] - id: "7.8" title: "404 for missing resources" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 78-404-for-missing-resources - text: "`404 Not Found`: resource does not exist." + text: "Every operation that addresses a specific resource **MUST** declare `404 Not Found` for an absent resource or for a resource whose existence is intentionally concealed under §7.7. An empty collection **MUST** return `200` with an empty `items` array, not `404`." open_questions: [] - id: "7.9" title: "409 for state conflicts" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 79-409-for-state-conflicts - text: "`409 Conflict`: a conflict with the current state of the resource that is not expressed as a failed precondition (e.g., creating a duplicate of a uniquely-keyed resource, an illegal state transition, or a concurrent in-flight idempotency retry per §14.5). A failed conditional precondition is `412` (7.15), not `409`." + text: "An operation that creates a uniquely keyed resource, performs a state transition, or supports idempotent retry **MUST** declare `409 Conflict` when it can conflict with current resource or processing state, including an illegal transition or concurrent in-flight retry under §14.5. A failed conditional request precondition **MUST** use `412` (§7.15), not `409`." open_questions: [] - id: "7.10" title: "410 for permanent removal" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 710-410-for-permanent-removal - text: "`410 Gone`: resource permanently removed; deprecated endpoint past sunset." + text: "A server **MUST** use `410 Gone` only when it knows that a resource or endpoint has been permanently removed; otherwise it **MUST** use `404`." open_questions: [] - id: "7.11" title: "422 for semantic errors" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 711-422-for-semantic-errors - text: "`422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"): request is well-formed but semantically invalid. The idempotency-fingerprint use of `422` is in §14.5. `[OPEN-6-A]`" + text: "A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"). The idempotency-fingerprint use of `422` is in §14.5. `[OPEN-6-A]`" open_questions: ["OPEN-6-A"] - id: "7.12" title: "429 for rate limits" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 712-429-for-rate-limits - text: "`429 Too Many Requests`: client exceeded rate limit." + text: "An operation that enforces a caller-visible rate limit **MUST** declare `429 Too Many Requests` and the headers required by §8.7." open_questions: [] - id: "7.13" title: "Server errors documented" class: M - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 713-server-errors-documented - text: "`500`, `502`, `503`, `504`: server errors. Specs **MUST** document `500` at minimum." + text: "Every operation **MUST** declare `500 Internal Server Error`. Operations exposed through a gateway or dependent service **SHOULD** additionally declare the applicable `502`, `503`, and `504` responses." open_questions: [] - id: "7.14" title: "All status codes declared" @@ -469,29 +487,29 @@ rules: - id: "7.15" title: "412 for failed preconditions" class: R - strengths: [] + strengths: ["MUST"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 715-412-for-failed-preconditions - text: "`412 Precondition Failed`: conditional request precondition (e.g., `If-Match`) was not satisfied." + text: "A failed conditional request precondition such as `If-Match` **MUST** use `412 Precondition Failed`." open_questions: [] - id: "7.16" title: "ETag and If-None-Match" class: M+R - strengths: ["SHOULD", "MAY"] + strengths: ["MUST", "SHOULD", "MAY"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 716-etag-and-if-none-match - text: "Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the resource state. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change." + text: "Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the selected representation. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change. An endpoint using ETags for write concurrency **MUST** provide a strong validator suitable for the strong comparison required by `If-Match`." open_questions: [] - id: "7.17" title: "Optimistic concurrency with If-Match" class: M+R - strengths: ["SHOULD"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 717-optimistic-concurrency-with-if-match - text: "`PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: <etag>` and the server returns `412 Precondition Failed` (7.15) if the resource has changed since that ETag. A failed `If-Match` precondition is `412`, not `409`; `409` (7.9) is reserved for state or uniqueness conflicts that are not expressed as a conditional precondition." + text: "`PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: <strong-etag>` and the server returns `412 Precondition Failed` (§7.15) if the resource has changed. An endpoint that requires a conditional write **SHOULD** return `428 Precondition Required` when `If-Match` is absent. A failed `If-Match` precondition **MUST** use `412`, not `409`; `409` (§7.9) is reserved for conflicts not expressed by a conditional precondition." open_questions: [] - id: "7.18" title: "405 with Allow header" @@ -520,6 +538,15 @@ rules: anchor: 720-no-store-on-error-responses text: "Error responses (`application/problem+json`) and Operation-status responses (§15) **SHOULD** declare `Cache-Control: no-store`, so a shared cache cannot replay a transient failure or stale operation state. Broader caching behaviour is operational and out of scope (§1.2)." open_questions: [] +- id: "7.21" + title: "Schemas for successful response bodies" + class: M + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 721-schemas-for-successful-response-bodies + text: "Every declared `2xx` response that carries a body **MUST** declare at least one concrete media type and a response schema for each declared media type. An empty schema or description without a schema **MUST NOT** stand in for a response contract. Responses whose HTTP semantics prohibit a body, including `204`, **MUST NOT** declare response content." + open_questions: [] - id: "8.1" title: "Credentials in Authorization header" class: M+R @@ -536,7 +563,7 @@ rules: surface: OpenAPI page: part-b/8-headers.md anchor: 82-accept-language-and-content-language - text: "Localisation requests **MUST** use `Accept-Language`; responses **MUST** echo via `Content-Language`." + text: "Localisation requests **MUST** use `Accept-Language`; a localised response **MUST** identify the language actually selected using `Content-Language`, which is not necessarily the request's first preference. A cacheable response selected using `Accept-Language` **MUST** declare `Vary: Accept-Language`." open_questions: [] - id: "8.3" title: "Idempotency-Key header accepted" @@ -548,23 +575,23 @@ rules: text: "POST endpoints that require idempotency under §14 **MUST** accept an `Idempotency-Key` header, unless §14.6 applies." open_questions: [] - id: "8.4" - title: "X-Request-Id correlation" + title: "W3C Trace Context correlation" class: M+R - strengths: ["MUST", "SHOULD", "MAY"] + strengths: ["MUST NOT", "MUST", "MAY"] surface: OpenAPI page: part-b/8-headers.md - anchor: 84-x-request-id-correlation - text: "Every request **SHOULD** carry an `X-Request-Id` header for correlation. The server **MUST** echo this header in the response (or generate one if absent). This correlation identifier is distinct from the error-envelope `traceId` (§11.3); a BB **MAY** reuse the same value but is not required to, and any propagation between them is operational and out of scope (§1.2)." + anchor: 84-w3c-trace-context-correlation + text: "Every cross-service HTTP operation **MUST** declare the W3C Trace Context `traceparent` request header and **MAY** declare `tracestate`. A conforming implementation **MUST** propagate a valid received trace context on downstream calls and **MUST** create a valid new context when none is present or the received value is invalid. `tracestate` **MUST NOT** contain personal data. The RFC 9457 `traceId` extension in §11.3 **MUST** equal the 32-hex-digit trace-id component of the request's effective `traceparent`. A separate business or support correlation identifier **MAY** be defined, but **MUST NOT** replace Trace Context." open_questions: [] - id: "8.5" title: "No new X- prefixed headers" class: M - strengths: ["MUST NOT"] + strengths: ["MUST NOT", "MAY"] surface: OpenAPI page: part-b/8-headers.md anchor: 85-no-new-x--prefixed-headers - text: "New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix (per RFC 6648), except for the legacy correlation header explicitly allowed in §8.4 pending `[OPEN-7-A]`." - open_questions: ["OPEN-7-A"] + text: "New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix, per RFC 6648. Existing private `X-` headers **MAY** remain only on an unchanged legacy major version and **MUST NOT** be introduced on a new surface or new major version." + open_questions: [] - id: "8.6" title: "No personal data in addressable locations" class: R @@ -577,12 +604,12 @@ rules: - id: "8.7" title: "Rate-limit headers declared" class: M+R - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST", "MAY"] surface: OpenAPI page: part-b/8-headers.md anchor: 87-rate-limit-headers-declared - text: "Endpoints rate-limited by the BB itself **MUST** declare rate-limit response headers per `draft-ietf-httpapi-ratelimit-headers` (an active, still-evolving Internet-Draft, not yet an RFC); where rate limiting is delegated to an API gateway or interoperability mediator, the spec **MUST** state that, rather than declaring headers the BB does not emit. The default v0.1 form is the three-header variant: `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset`, chosen deliberately for its current deployment ubiquity over the draft's newer structured-field form. `429` responses **MUST** additionally declare `Retry-After`. `[OPEN-7-B]`" - open_questions: ["OPEN-7-B"] + text: "An endpoint rate-limited by the BB itself **MUST** document the quota scope and **MUST** declare the `RateLimit` response header using the Structured Field syntax pinned from `draft-ietf-httpapi-ratelimit-headers-11`. A BB that advertises quota-policy details **MUST** use `RateLimit-Policy`. The server **MAY** omit these advisory headers on individual responses as allowed by the draft, but a `429` response **MUST** declare `Retry-After`; when `Retry-After` and `RateLimit` are both present, clients **MUST** treat `Retry-After` as authoritative. Where an API gateway or interoperability mediator owns rate limiting, the BB specification **MUST** state that fact instead of claiming to emit headers it does not control. The legacy `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` fields **MUST NOT** be described as conforming to the pinned draft." + open_questions: [] - id: "9.1" title: "JSON as default media type" class: M+R @@ -653,7 +680,7 @@ rules: surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 98-forward-compatible-schemas - text: "Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values must be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in §18.6 as a non-normative reader expectation." + text: "Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values **MUST** be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in §18.6 as a non-normative reader expectation." open_questions: [] - id: "9.9" title: "No closed enums for growing sets" @@ -680,7 +707,7 @@ rules: surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 911-single-registered-bb-code - text: "Every namespace that embeds a BB code (error codes §11.5, problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, channel addresses §17.2) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts (§11.7). The BB-code register is proposed for the Lifecycle & Governance companion (Appendix A); until it exists, codes **SHOULD** be agreed through the API Working Group. `[OPEN-9-A]`" + text: "Every namespace that embeds a BB code (error codes §11.5, problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, logical channel IDs §17.2) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts (§11.7). The BB-code register is proposed for the Lifecycle & Governance companion (Appendix A); until it exists, codes **SHOULD** be agreed through the API Working Group. `[OPEN-9-A]`" open_questions: ["OPEN-9-A"] - id: "10.1" title: "Opaque server-generated identifiers" @@ -775,20 +802,20 @@ rules: - id: "11.1" title: "RFC 9457 problem details" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-c/11-errors.md anchor: 111-rfc-9457-problem-details - text: "Error responses **MUST** use media type `application/problem+json` per RFC 9457 (which obsoletes RFC 7807 and retains the `application/problem+json` media type). The standard provides broad client and tooling support and removes the burden of maintaining a custom envelope." + text: "HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; §11.8 defines that mapping." open_questions: [] - id: "11.2" title: "Standard problem fields present" class: M+R - strengths: ["MUST", "SHOULD", "MAY"] + strengths: ["MUST", "SHOULD"] surface: Universal page: part-c/11-errors.md anchor: 112-standard-problem-fields-present - text: "Standard RFC 9457 fields `type`, `title`, `status` **MUST** be present. `type` **SHOULD** be a stable URI for the problem type and **MAY** be a dereferenceable documentation URL, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details (stack traces, hostnames, query fragments)." + text: "Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments." open_questions: [] - id: "11.3" title: "GovStack error extension fields" @@ -797,7 +824,7 @@ rules: surface: Universal page: part-c/11-errors.md anchor: 113-govstack-error-extension-fields - text: "GovStack extensions **MUST** include `code` (machine-stable error code), `traceId` (correlation), and `timestamp`." + text: "GovStack HTTP problems and asynchronous errors **MUST** include `code` (machine-stable error code), `traceId` (the W3C trace-id defined by §8.4), and `timestamp` (an RFC 3339 `date-time`)." open_questions: [] - id: "11.4" title: "Field-level errors array" @@ -835,6 +862,15 @@ rules: anchor: 117-common-error-catalogue text: "A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `org.govstack.common.unauthenticated`, `org.govstack.common.permissionDenied`, `org.govstack.common.notFound`, `org.govstack.common.invalidArgument`, `org.govstack.common.alreadyExists`, `org.govstack.common.aborted`, `org.govstack.common.resourceExhausted`, `org.govstack.common.internal`, `org.govstack.common.unimplemented`. The final list is `[OPEN-10-B]`." open_questions: ["OPEN-10-B"] +- id: "11.8" + title: "Transport-neutral asynchronous errors" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-c/11-errors.md + anchor: 118-transport-neutral-asynchronous-errors + text: "An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. The schema **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`, and **MAY** contain `detail` and `errors` with the semantics in §11.4. When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines." + open_questions: [] - id: "12.1" title: "Collections must paginate" class: M+R @@ -851,7 +887,7 @@ rules: surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 122-cursor-pagination-by-default - text: "Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken` to align with the wider non-Google ecosystem (GraphQL Relay Connections, GitHub, Twitter). The cursor **MUST** be opaque to clients (server-encoded, typically base64 of an internal representation); clients **MUST NOT** parse or construct cursor values." + text: "Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable problem code. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries." open_questions: [] - id: "12.3" title: "Cursor pagination envelope" @@ -860,7 +896,7 @@ rules: surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 123-cursor-pagination-envelope - text: "Pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. The `pageInfo` wrapper is inspired by the GraphQL Relay Connections specification but deliberately simplified: it uses a flat `items` array rather than Relay's `edges`/`node`, and `nextCursor`/`hasMore` rather than Relay's `endCursor`/`hasNextPage`." + text: "The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. `nextCursor` **MUST** be a non-empty string when `hasMore` is `true` and **MUST** be `null` when `hasMore` is `false`; its schema therefore **MUST** declare explicit nullability. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and simplified continuation fields." open_questions: [] - id: "12.4" title: "Documented pageSize bounds" @@ -901,11 +937,11 @@ rules: - id: "12.8" title: "Simple equality filtering" class: M+R - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 128-simple-equality-filtering - text: "Simple filtering **MUST** use one query parameter per field, equality only." + text: "Simple equality filtering on non-personal, non-secret fields **MUST** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by §8.6 **MUST NOT** use a query parameter and **MUST** use the body-based search pattern in §12.9." open_questions: [] - id: "12.9" title: "Complex filtering via search" @@ -914,7 +950,7 @@ rules: surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 129-complex-filtering-via-search - text: "Complex filtering **MUST** use `POST /v1/{collection}/search` per §6.6. For this endpoint, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the §12.3 envelope." + text: "Complex filtering and any filtering that contains personal data **MUST** use `POST /v1/{collection}/search` per §6.6. Pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the §12.3 envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor." open_questions: [] - id: "12.10" title: "Sparse fieldsets out of scope" @@ -937,20 +973,20 @@ rules: - id: "13.2" title: "OAuth and OIDC for citizen operations" class: M+R - strengths: ["MUST", "MAY"] + strengths: ["MUST NOT", "MUST", "MAY"] surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 132-oauth-and-oidc-for-citizen-operations - text: "Citizen-facing operations **MUST** declare an OAuth 2.0 + OIDC security requirement. On the OpenAPI surface this is either a `type: openIdConnect` scheme carrying `openIdConnectUrl` (the OIDC discovery document) or a `type: oauth2` scheme declaring the relevant `flows`. (A `type: oauth2` scheme does not carry a discovery URL; the discovery URL belongs to the `openIdConnect` scheme type.) Reference specifications that are not tied to a live identity provider **MAY** use documented deployment variables or reserved documentation domains for discovery, authorisation, token, and JWKS URLs. Adopter-specific identity-provider endpoints belong in implementation profiles." + text: "Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles." open_questions: [] - id: "13.3" title: "Distinct scheme for BB-to-BB calls" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 133-distinct-scheme-for-bb-to-bb-calls - text: "BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service auth. On OpenAPI this is typically `type: mutualTLS` or OAuth client credentials; on AsyncAPI this is typically `type: X509`, OAuth client credentials, or a protocol-specific scheme such as SASL where the broker requires it. The spec **MUST** make clear which operations are citizen-facing vs inter-BB." + text: "BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service authentication. On OpenAPI this **MUST** be `type: mutualTLS` or an OAuth client-credentials scheme using confidential-client authentication; on AsyncAPI it **MUST** be `type: X509`, OAuth client credentials, or a documented protocol-specific scheme such as SASL. OAuth access tokens **MUST** be audience-restricted to the intended BB and scope-restricted to the operation. Sender-constrained access tokens under RFC 8705 or RFC 9449 **SHOULD** be used across an inter-BB trust boundary. The spec **MUST** distinguish citizen-facing from inter-BB operations." open_questions: [] - id: "13.4" title: "Namespaced OAuth scopes" @@ -979,6 +1015,15 @@ rules: anchor: 136-api-keys-only-for-operational-endpoints text: "API keys **MAY** be declared on operational endpoints (`/health` and similar per §5.9). They **MUST NOT** be declared on operations that read or write personal data." open_questions: [] +- id: "13.7" + title: "Protected transport" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 137-protected-transport + text: "Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion." + open_questions: [] - id: "14.1" title: "Idempotency-Key on non-idempotent POSTs" class: M+R @@ -986,7 +1031,7 @@ rules: surface: Universal page: part-d/14-idempotency.md anchor: 141-idempotency-key-on-non-idempotent-posts - text: "Except where §14.6 applies (a naturally idempotent design), POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support idempotency unless the operation is naturally idempotent by contract and documents its duplicate-handling semantics. Read-like POSTs, such as complex search endpoints, **MAY** support idempotency but are not required to. The header follows the convention established by Stripe and is being standardized in the IETF httpapi working group as `draft-ietf-httpapi-idempotency-key-header` (an Internet-Draft, not yet an RFC)." + text: "Except where §14.6 applies, POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** require and accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support it unless their naturally idempotent contract documents duplicate handling. Read-like POSTs such as search **MAY** support it. GovStack pins the header syntax and error semantics from `draft-ietf-httpapi-idempotency-key-header-07`; this guide is the stable GovStack profile if that work-in-progress draft changes or expires." open_questions: [] - id: "14.2" title: "Opaque client-generated keys" @@ -995,16 +1040,16 @@ rules: surface: Universal page: part-d/14-idempotency.md anchor: 142-opaque-client-generated-keys - text: "The key **MUST** be an opaque, client-generated string and **SHOULD** be a UUID. (The cited draft RECOMMENDS a UUID rather than requiring one; per §1.7 the guide does not specify past the adopted standard by mandating a particular UUID version.)" + text: "The key **MUST** be an opaque, client-generated, high-entropy value and **SHOULD** be a UUID. On the wire it **MUST** use the Structured Field String syntax pinned from draft revision 07, including the required quotation marks. The specification **MUST** document the accepted syntax and maximum length, and the server **MUST** reject a malformed, missing-required, or oversized key with `400 Bad Request` before processing the operation." open_questions: [] - id: "14.3" title: "Documented replay window" class: R - strengths: ["MUST", "SHOULD"] + strengths: ["MUST", "MAY"] surface: Universal page: part-d/14-idempotency.md anchor: 143-documented-replay-window - text: "The spec **MUST** document the idempotency replay-window contract. A reference specification **SHOULD** state the required minimum replay window or the configuration parameter that controls it. Concrete replay-window values belong in implementation profiles." + text: "The spec **MUST** document the idempotency replay-window contract, the required minimum replay window or controlling configuration parameter, and what happens after expiry. Within the documented window the key **MUST** retain the semantics in §14.4 and §14.5; after expiry the server **MAY** process the same key as a new request only if that behaviour is stated explicitly. Concrete retention values belong in implementation profiles." open_questions: [] - id: "14.4" title: "Replay returns original response" @@ -1013,16 +1058,16 @@ rules: surface: Universal page: part-d/14-idempotency.md anchor: 144-replay-returns-original-response - text: "A repeated request with the same key within the documented window **MUST** return the original response (status, body, headers)." + text: "A completed repeated request with the same lookup scope, key, and fingerprint within the documented window **MUST** return the original operation result: the same status, body, and result-defining representation headers such as `Content-Type` and `Location`. Per-attempt, temporal, security, tracing, rate-limit, retry, and hop-by-hop headers **MUST** be regenerated or omitted rather than replayed; this includes `Date`, `traceparent`, `tracestate`, `RateLimit`, `Retry-After`, and `Set-Cookie`." open_questions: [] - id: "14.5" title: "Key reuse and fingerprint mismatch" class: R - strengths: ["MUST", "MAY"] + strengths: ["MUST", "SHOULD"] surface: Universal page: part-d/14-idempotency.md anchor: 145-key-reuse-and-fingerprint-mismatch - text: "A repeated request reusing the same key with a *different* request body **MUST** return `422 Unprocessable Content` (the request fingerprint does not match the original), per the cited draft. A repeated request that arrives while the original is still being processed (a concurrent in-flight retry) **MUST** return `409 Conflict`. The request fingerprint **MUST** be computed over at least the canonicalised request body; a BB **MAY** additionally include the method and target. The spec **MUST** document a maximum accepted key length so oversized keys are rejected deterministically." + text: "The server's idempotency lookup scope **MUST** include the effective HTTP method, canonical target URI, key, and, for authenticated operations, the authenticated client and tenant or equivalent authorization partition. The request fingerprint **MUST** include the method, canonical target URI, request content type, canonicalised body, and every documented header that changes operation semantics. Reusing a key in the same lookup scope with a different fingerprint **MUST** return `422 Unprocessable Content`; a matching retry received while the original remains in flight **MUST** return `409 Conflict`. These are GovStack **MUST** requirements even though draft revision 07 expresses the status-code choices as **SHOULD**." open_questions: [] - id: "14.6" title: "Naturally idempotent designs" @@ -1040,7 +1085,7 @@ rules: surface: OpenAPI page: part-d/15-asynchronous-operations.md anchor: 151-202-with-operation-location - text: "Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource." + text: "Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource and **MUST** return the current Operation representation in the response body." open_questions: [] - id: "15.2" title: "Shared Operation resource shape" @@ -1063,11 +1108,11 @@ rules: - id: "15.4" title: "Polling the Operation resource" class: M+R - strengths: [] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-d/15-asynchronous-operations.md anchor: 154-polling-the-operation-resource - text: "Clients poll via `GET /v1/operations/{operationId}`." + text: "A BB exposing an Operation resource **MUST** expose polling via `GET /v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval." open_questions: [] - id: "15.5" title: "Cancellation via cancel sub-resource" @@ -1076,7 +1121,7 @@ rules: surface: OpenAPI page: part-d/15-asynchronous-operations.md anchor: 155-cancellation-via-cancel-sub-resource - text: "Cancellation, when supported, **MUST** be `POST /v1/operations/{operationId}/cancel`." + text: "Cancellation, when supported, **MUST** be `POST /v{major}/operations/{operationId}/cancel`." open_questions: [] - id: "15.6" title: "Webhook completion notification" @@ -1117,20 +1162,20 @@ rules: - id: "16.3" title: "Reverse-DNS event types" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 163-reverse-dns-event-types - text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and does not include the major version; the versioned transport contract is carried in the channel address or equivalent AsyncAPI version metadata (§18.2). `[OPEN-15-B]`" + text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata (§18.2). `[OPEN-15-B]`" open_questions: ["OPEN-15-B"] - id: "16.4" title: "Stable CloudEvents source" class: M+R - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 164-stable-cloudevents-source - text: "The CloudEvents `source` field **MUST** identify the publishing BB or BB surface in a stable way. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment." + text: "The CloudEvents `source` field **MUST** be a stable, non-empty URI-reference identifying the publishing BB or BB surface; an absolute URI or URN **SHOULD** be used. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment." open_questions: [] - id: "16.5" title: "Signed event delivery" @@ -1139,17 +1184,17 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 165-signed-event-delivery - text: "Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`. `[OPEN-15-A]`" - open_questions: ["OPEN-15-A"] + text: "Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`." + open_questions: [] - id: "16.6" title: "GovStack-Signature header" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 166-govstack-signature-header - text: "On the OpenAPI/webhooks surface the signature **MUST** travel in a single ecosystem-wide HTTP header named `GovStack-Signature` (modelled on Stripe's `Stripe-Signature` and GitHub's `X-Hub-Signature-256`). On the AsyncAPI surface the signature **MUST** travel in the transport's message-metadata channel under the same field name unless the chosen protocol binding defines a more precise field. `[OPEN-15-C]`" - open_questions: ["OPEN-15-C"] + text: "On the OpenAPI/webhooks surface the signature **MUST** travel in the ecosystem-wide HTTP header `GovStack-Signature`. On the AsyncAPI surface, a GovStack-owned transport/application metadata field **MUST** be named `govstackSignature` so it satisfies §17.8; when a protocol binding defines a standard signature field, that field **SHOULD** be used and the mapping **MUST** be documented." + open_questions: [] - id: "16.7" title: "Replay-detectable signed material" class: R @@ -1162,12 +1207,12 @@ rules: - id: "16.8" title: "Pinned signature profile" class: R - strengths: ["MUST", "MAY"] + strengths: ["MUST NOT", "MUST"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 168-pinned-signature-profile - text: "`govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** pin the exact bytes that are signed (the canonicalisation: which fields, in which order, with which serialisation), the signing algorithm and its identifier, and the signature verification inputs, so that two independently-built BBs can verify each other's signatures. The default signature scheme is detached JWS over a canonicalised structured CloudEvents JSON payload. HMAC-SHA256 **MAY** be used only where shared-key distribution is explicitly governed. The event-signature profile is required for v1.0 publication because §16.5 is not mechanically enforceable without it. `[OPEN-15-A]`" - open_questions: ["OPEN-15-A"] + text: "`govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it." + open_questions: [] - id: "16.9" title: "Operational signing concerns out of scope" class: informative @@ -1193,7 +1238,7 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 1611-subscription-management-interfaces - text: "Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane." + text: "Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret or verification key material used by the selected profile, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane." open_questions: [] - id: "17.1" title: "Send and receive perspective" @@ -1205,14 +1250,14 @@ rules: text: "Each AsyncAPI document **MUST** define the BB's perspective. An operation with `action: send` means the BB publishes that message to the channel. An operation with `action: receive` means the BB consumes that message from the channel." open_questions: [] - id: "17.2" - title: "Reverse-DNS channel addresses" - class: M - strengths: ["MUST"] + title: "Stable logical channel IDs and native addresses" + class: M+R + strengths: ["MUST NOT", "MUST"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 172-reverse-dns-channel-addresses - text: "Channel addresses **MUST** follow one ecosystem-wide naming convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment is the BB's single registered code per §9.11. `[OPEN-15-D]`" - open_questions: ["OPEN-15-D"] + anchor: 172-stable-logical-channel-ids-and-native-addresses + text: "Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from §9.11. The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address." + open_questions: [] - id: "17.3" title: "No personal data in channels" class: R @@ -1220,7 +1265,7 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 173-no-personal-data-in-channels - text: "Channel addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead." + text: "Logical channel IDs, native addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead." open_questions: [] - id: "17.4" title: "Declared channel parameters" @@ -1261,11 +1306,11 @@ rules: - id: "17.8" title: "Message headers and idempotency metadata" class: M+R - strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + strengths: ["MUST NOT", "MUST", "MAY"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 178-message-headers-and-idempotency-metadata - text: "GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. For structured CloudEvents messages, trace and workflow metadata **SHOULD** be carried as CloudEvents extension attributes: `traceid`, `correlationid`, and `causationid`. These names are lowercase because CloudEvents requires lowercase extension-attribute names; the same concepts use camelCase in GovStack-owned JSON bodies and transport/application headers. Transport/application headers **MAY** mirror these values where broker tooling requires header-level metadata, but the CloudEvent remains the normative event envelope. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key. For structured CloudEvents command messages, the key **MUST** be the CloudEvents extension attribute `idempotencykey`; for non-CloudEvents command messages, it **MUST** be the message header `idempotencyKey`." + text: "GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. The event-signature metadata name **MUST** follow §16.6. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`." open_questions: [] - id: "17.9" title: "Message localisation headers" @@ -1337,7 +1382,7 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 1716-async-rejection-error-messages - text: "Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using the common GovStack error envelope from §11. The error message **MUST** be correlated to the original message using the correlation metadata rules in §17.8 or an equivalent protocol binding." + text: "Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from §11.8, not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using §17.8 or an equivalent protocol binding." open_questions: [] - id: "17.17" title: "Declared request-reply correlation" @@ -1382,25 +1427,25 @@ rules: surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 181-semver-versioning - text: "`info.version` **MUST** follow SemVer." + text: "`info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under §18.2." open_questions: [] - id: "18.2" title: "Major version in path or channel" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 182-major-version-in-path-or-channel - text: "Major version increments **MUST** be reflected in the OpenAPI URL path (`/v2/`). AsyncAPI channels **MUST** include the major version in the channel address or an equivalent machine-readable version field documented in `govstack-asyncapi-common.yaml`." + text: "A major version increment **MUST** be reflected in every OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by §17.2 or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it." open_questions: [] - id: "18.3" title: "Backward-compatible minor changes" class: M+R - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST", "MAY"] surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 183-backward-compatible-minor-changes - text: "Minor and patch increments **MUST** be backward-compatible. Adding optional fields, adding endpoints, adding enum values (for fields declared extensibly per §9.9), and relaxing constraints are non-breaking." + text: "Compatibility **MUST** be evaluated as an existing client communicating with a newer server, separately for input and output. A patch increment **MUST** be limited to a backward-compatible correction that does not add public functionality. A minor increment **MAY** add an endpoint; add an optional request field whose absence preserves the old behaviour; broaden values accepted in a request; or add a response field that conforming clients ignore under §9.8. A response enum value **MAY** be added only when the field was declared extensibly under §9.9. For messaging, the same test **MUST** be applied from publisher to existing consumer for sent messages and from existing publisher to consumer for received messages. Additive syntax **MUST NOT** be called compatible when it changes defaults, pagination boundaries, ordering, authorization, delivery guarantees, or other observable semantics." open_questions: [] - id: "18.4" title: "Breaking changes bump major version" @@ -1409,16 +1454,16 @@ rules: surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 184-breaking-changes-bump-major-version - text: "Breaking changes (removing endpoints, removing fields, narrowing types, narrowing enums, tightening required, changing semantic meaning) **MUST** be released as a new major version." + text: "A breaking change **MUST** be released as a new major version. Breaking changes include removing or renaming an endpoint, operation, message, field, or enum value; adding a required request field; rejecting a previously accepted request; widening the type, length, format, or closed-enum values a server may emit beyond the old response schema; ceasing to emit a required response field; changing a success status, media type, default, identifier construction, field presence, pagination or sort behaviour, error-code meaning, idempotency behaviour, security requirement or scope, event meaning, delivery guarantee, or ordering guarantee. Moving a contract component in a way that breaks generated-client references **MUST** also be treated as breaking even when the serialized wire shape is unchanged." open_questions: [] - id: "18.5" title: "Deprecation and Sunset headers" class: M+R - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 185-deprecation-and-sunset-headers - text: "Deprecated endpoints **MUST** return a `Deprecation` header per RFC 9745 (a structured-field date carrying the deprecation timestamp, e.g. `Deprecation: @1735689600`) and a `Sunset` header per RFC 8594 indicating planned removal. The minimum deprecation window between announcement and sunset, and the maximum number of concurrent major versions a BB can keep in production, are operational policy and are proposed for the Lifecycle & Governance companion, not here." + text: "Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The minimum deprecation window and maximum concurrent major versions remain policy for the Lifecycle & Governance companion." open_questions: [] - id: "18.6" title: "Clients ignore unknown fields" @@ -1477,27 +1522,27 @@ rules: - id: "20.1" title: "Every file passes validation" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-e/20-conformance-and-validation.md anchor: 201-every-file-passes-validation - text: "Every BB OpenAPI file **MUST** pass `openapi-spec-validator`. Every BB AsyncAPI file **MUST** pass an equivalent AsyncAPI parser/validator (e.g., `asyncapi/parser`)." + text: "Every canonical OpenAPI entrypoint **MUST** pass `openapi-spec-validator` for its declared qualified 3.1 patch, with local references resolved. Every canonical AsyncAPI entrypoint **MUST** pass an AsyncAPI 3.0 parser/validator such as `@asyncapi/parser`. A referenced schema fragment **MUST** validate against its own declared schema dialect and **MUST NOT** be rejected merely because it is not a standalone OpenAPI or AsyncAPI document." open_questions: [] - id: "20.2" title: "Passes the GovStack Spectral ruleset" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-e/20-conformance-and-validation.md anchor: 202-passes-the-govstack-spectral-ruleset - text: "Every BB API spec **MUST** pass the GovStack Spectral ruleset for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of rules tagged `[M+R]` (§1.9). The v0.1 ruleset **MUST** include the OpenAPI rules, CloudEvents event rules, and AsyncAPI documentation rules from §3, §16, and §17. Future protocol profiles may add deeper Kafka, MQTT, AMQP, WebSocket, or SSE rules." + text: "Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under §20.3 for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules (§1.9). Ruleset `0.2.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility." open_questions: [] - id: "20.3" title: "Declared guide conformance version" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-e/20-conformance-and-validation.md anchor: 203-declared-guide-conformance-version - text: "Each canonical specification file **MUST** declare the guide version it conforms to via the `info`-level extension `x-govstack-api-guide`: an object with `version` (the guide version targeted, SemVer) and optional `exceptions` (a list of rule IDs, each with a reference to its approved exception record per §1.6). Validation tooling (§20.2) selects the matching ruleset version from this declaration. `[OPEN-20-A]`" - open_questions: ["OPEN-20-A"] + text: "Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.2.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under §1.6 **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI." + open_questions: [] diff --git a/api-design-guide/version-history.md b/api-design-guide/version-history.md index bfa6704..b253566 100644 --- a/api-design-guide/version-history.md +++ b/api-design-guide/version-history.md @@ -6,7 +6,7 @@ description: "What changed in each version of the GovStack Cross-BB API Design G ## v0.2 (DRAFT, 2026-07-10) -This edition supersedes the circulated v0.1 document. It is the same rulebook, restructured for publication as a GitBook and extended with the changes below. No normative wording changed other than what is listed here; a mechanical fidelity check of the v0.2 pages against the v0.1 text backs this list. +This edition supersedes the circulated v0.1 document. It restructures the rulebook for publication as a GitBook and extends it with the changes below — new rules and substantive strengthenings, not only presentation. No normative wording changed other than what is listed here; a mechanical fidelity check of the restructured pages against the v0.1 text backed the restructuring itself. **Structure and presentation** @@ -22,8 +22,20 @@ This edition supersedes the circulated v0.1 document. It is the same rulebook, r **New content (normative)** - New [§1.10 Applicability and transition](1-introduction.md#110-applicability-and-transition): the guide binds new surfaces and new major versions, existing specs are not retroactively non-conformant, and the guide itself is versioned with SemVer. -- New rule [9.11](part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code): every namespace that embeds a `{bb-code}` uses the BB's single registered code, with a required syntax; new open question OPEN-9-A. One-sentence pointers to 9.11 were added to rules [11.5](part-c/11-errors.md#115-namespaced-stable-error-codes), [13.4](part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), [16.3](part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), and [17.2](part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses). -- New rule [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version): each canonical spec declares the guide version it targets via the `x-govstack-api-guide` extension; new open question OPEN-20-A. The extension was added to rule [9.10](part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix)'s example list. +- New rule [9.11](part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code): every namespace that embeds a `{bb-code}` uses the BB's single registered code, with a required syntax; new open question OPEN-9-A. One-sentence pointers to 9.11 were added to rules [11.5](part-c/11-errors.md#115-namespaced-stable-error-codes), [13.4](part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), [16.3](part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), and [17.2](part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses). +- New rule [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version): each canonical spec declares the exact guide and ruleset versions it targets via `x-govstack-api-guide`. The extension was added to rule [9.10](part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix)'s example list. +- OpenAPI [§2.1](part-a/2-openapi-document-standards.md#21-openapi-31-required) now qualifies the published 3.1 patch series instead of freezing `3.1.0`; [§2.6](part-a/2-openapi-document-standards.md#26-meaningful-servers-block) removes the duplicated `/v1` server example and requires HTTPS; [§2.8](part-a/2-openapi-document-standards.md#28-pinned-vendored-common-components) and [§3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components) pin the vendored common-component files to `api/common/`. +- New rules [4.5](part-a/4-documentation-requirements.md#45-api-surface-inventory) and [4.6](part-a/4-documentation-requirements.md#46-functional-requirement-traceability) define `api/index.yaml`, the explicit `noApi` declaration, normative requirement markers, and exact `api/coverage.yaml` dispositions. +- [§6–§7](part-b/6-http-methods.md) now distinguish completed creation (`201`) from accepted work (`202` plus Operation), define applicability for common `4xx`/`500` responses (every operation now declares `500`), and require schemas for every successful response body in new [§7.21](part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies). [§7.16](part-b/7-http-status-codes.md#716-etag-and-if-none-match) now requires a strong validator, and new [§7.17](part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match) guidance adds `428 Precondition Required`. +- [§8.2](part-b/8-headers.md#82-accept-language-and-content-language) now requires `Content-Language` on localised responses and `Vary: Accept-Language` on cacheable ones; [§8.4](part-b/8-headers.md#84-w3c-trace-context-correlation) adopts W3C Trace Context instead of `X-Request-Id`; [§8.7](part-b/8-headers.md#87-rate-limit-headers-declared) pins the revision-11 Structured Field RateLimit contract. OPEN-7-A and OPEN-7-B are resolved. +- [§11](part-c/11-errors.md) limits RFC 9457 `status` semantics to HTTP and adds transport-neutral `GovStackAsyncError`; [§11.2](part-c/11-errors.md#112-standard-problem-fields-present) strengthens `type` to a mandatory stable absolute URI and requires `status` to equal the HTTP status code; [§12](part-c/12-pagination-filtering-sorting.md) requires integrity-protected cursors, stable page arguments and order, and body-based searches for personal criteria. +- [§13](part-d/13-authentication-and-authorisation.md) adds the RFC 9700 OAuth baseline, forbids the password grant outright and the implicit grant on new surfaces, distinguishes access from ID Tokens, and requires protected transport. +- [§14](part-d/14-idempotency.md) pins draft revision 07, defines Structured Field syntax, lookup scope and fingerprints, and prevents replay of per-attempt response headers. [§14.1](part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts) now *requires* (not merely accepts) the `Idempotency-Key` on the listed non-idempotent POSTs, backed by [§14.2](part-d/14-idempotency.md#142-opaque-client-generated-keys)'s `400` on a missing required key. +- [§15](part-d/15-asynchronous-operations.md): [15.1](part-d/15-asynchronous-operations.md#151-202-with-operation-location) now requires the `202` body to carry the current Operation representation, [15.4](part-d/15-asynchronous-operations.md#154-polling-the-operation-resource) adds `Retry-After` advice on non-terminal polls, and the Operation paths are spelled `/v{major}/…`. +- [§16–§17](part-d/16-cloudevents-and-webhooks.md) adopt standard tracing attributes, pin detached JWS `ES256` with RFC 8785 canonicalization (dropping the v0.1 option of an HMAC fallback), separate reverse-DNS logical channel IDs from protocol-native addresses, and resolve the HTTP/AsyncAPI signature-metadata casing conflict. OPEN-15-A (a v1.0-blocking decision), OPEN-15-C, and OPEN-15-D are resolved. +- [§18](part-d/18-compatibility-and-lifecycle.md) now evaluates compatibility separately for inputs, outputs, publishers, and consumers; [§1.10](1-introduction.md#110-applicability-and-transition) separates immediate non-wire adoption from next-major wire changes and defines compatible guide-version evolution. +- [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) now pins exact `version` and `rulesetVersion` values and defines scoped, expiring exception fields, resolving OPEN-20-A. +- Previously descriptive rule text now uses explicit RFC 2119 normative keywords (the §6 method and §7 status-code rules, and [§9.8](part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas)); untagged notes and examples remain informative. **New content (informative)** @@ -32,7 +44,7 @@ This edition supersedes the circulated v0.1 document. It is the same rulebook, r - [Appendix B](appendix/b-open-questions.md) gained a **Blocks v1.0?** column marking the decisions that must precede ratification. - A non-normative [Guides](guides/README.md) group: spec editor checklist, validation commands, AI-agent instructions, and maintenance notes. - A machine layer: [Rules at a glance](all-rules.md) and `rules.yaml` (both generated from the pages by `tools/build_rules_index.py`), plus `tools/check_links.py` as a consistency guard. -- A draft of the GovStack Spectral ruleset with lint tooling ([`linter/`](linter/README.md)): 125 Spectral rules across both surfaces plus 8 opt-in strict heuristics, a driver that adds the [§20.1](part-e/20-conformance-and-validation.md#201-every-file-passes-validation) base validators, file-layout checks, and [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling, per-rule coverage recorded in `linter/coverage.yaml`, and a composite GitHub Action with a template workflow. The formal v1.0 companion publication remains pending ([Appendix A](appendix/a-companion-documents.md)). +- A draft of the GovStack Spectral ruleset with lint tooling ([`linter/`](linter/README.md)): 130 Spectral rules across both surfaces plus 8 opt-in strict heuristics, a driver that adds the [§20.1](part-e/20-conformance-and-validation.md#201-every-file-passes-validation) base validators, file-layout checks, and [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling, per-rule coverage recorded in `linter/coverage.yaml`, and a composite GitHub Action with a template workflow. The formal v1.0 companion publication remains pending ([Appendix A](appendix/a-companion-documents.md)). ## v0.1 (DRAFT, 2026-05-31) From fbf09afacdbbb4c63a9223b69e5d6fd83f32f98c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:15:28 +0700 Subject: [PATCH 04/19] feat: add reference API example and vendored common components Gives the template a worked contract instead of the stale swagger.json and swagger.yaml stubs, which are removed. api/index.yaml is the canonical API registry, api/coverage.yaml maps normative interface requirements, and api/common/ carries the shared OpenAPI and AsyncAPI component files that rules 2.8 and 3.8 pin. The common files are drafts vendored from an incubation repository and are not ratified GovStack artifacts yet. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api/common/README.md | 16 + api/common/govstack-asyncapi-common.yaml | 242 ++++++++++++ api/common/govstack-openapi-common.yaml | 437 ++++++++++++++++++++ api/coverage.yaml | 40 ++ api/index.yaml | 4 + api/openapi.yaml | 483 +++++++++++++++++++++++ api/swagger.json | 0 api/swagger.yaml | 0 8 files changed, 1222 insertions(+) create mode 100644 api/common/README.md create mode 100644 api/common/govstack-asyncapi-common.yaml create mode 100644 api/common/govstack-openapi-common.yaml create mode 100644 api/coverage.yaml create mode 100644 api/index.yaml create mode 100644 api/openapi.yaml delete mode 100644 api/swagger.json delete mode 100644 api/swagger.yaml diff --git a/api/common/README.md b/api/common/README.md new file mode 100644 index 0000000..8035d45 --- /dev/null +++ b/api/common/README.md @@ -0,0 +1,16 @@ +# Vendored GovStack API common components + +These files are vendored from the draft incubation repository: + +- Source: <https://github.com/jeremi/govstack-api-common> +- Component version: `0.1.0-draft` +- Source revision: `0ca50895c3934e30096989c85e249e6431f2a21e` + +| Local file | Upstream file | +|---|---| +| `govstack-openapi-common.yaml` | `openapi/govstack-openapi-common.yaml` | +| `govstack-asyncapi-common.yaml` | `asyncapi/govstack-asyncapi-common.yaml` | + +The source revision is pinned because no ratified release exists yet. Update the +two files and this provenance record together. Do not make component-source +changes only in this vendored directory. diff --git a/api/common/govstack-asyncapi-common.yaml b/api/common/govstack-asyncapi-common.yaml new file mode 100644 index 0000000..a3a1b29 --- /dev/null +++ b/api/common/govstack-asyncapi-common.yaml @@ -0,0 +1,242 @@ +asyncapi: 3.0.0 +info: + title: GovStack AsyncAPI Common Components + version: 0.1.0-draft + description: >- + Minimal reusable CloudEvents, asynchronous error, trace, signature, and + service-authentication components for GovStack AsyncAPI specifications. + contact: + name: GovStack API Working Group + url: https://www.govstack.global/ +servers: {} +channels: {} +operations: {} +x-govstack-components-version: 0.1.0-draft +x-govstack-delivery-extensions: + version: 0.1.0-draft + description: >- + Declares the machine-readable delivery-semantics specification extensions + (guide §17.11–§17.15) that every GovStack AsyncAPI operation carries + alongside a human-readable description. Capability values are a bare + string or an object whose `status` field carries the value. Concrete + retry counts, backoff intervals, retention periods, replay windows, and + dead-letter store settings belong in implementation profiles (§17.14). + extensions: + x-govstack-delivery: + appliesTo: operation + values: [atMostOnce, atLeastOnce, effectivelyOnce] + semantics: >- + Delivery guarantee of the transport contract. effectivelyOnce must be + backed by an idempotency contract, duplicate detection, or a + documented resource-state invariant; it never implies the transport + literally delivers exactly once. + x-govstack-ordering: + appliesTo: operation + values: >- + The declared partition key or scope when ordering is partitioned, + keyed, or scoped; the explicit string "none" when no ordering is + guaranteed. + semantics: Ordering guarantee of the operation, stated explicitly either way. + x-govstack-redelivery: + appliesTo: operation + values: [supported, unsupported, notApplicable] + semantics: Whether the chosen transport contract exposes automatic redelivery. + x-govstack-dead-letter: + appliesTo: operation + values: [supported, unsupported, notApplicable] + semantics: Whether undeliverable or failed messages are routed to a dead-letter store. + x-govstack-retention: + appliesTo: operation + values: [supported, unsupported, notApplicable] + semantics: Whether delivered messages remain retained for later consumption. + x-govstack-replay: + appliesTo: operation + values: [supported, unsupported, notApplicable] + semantics: Whether consumers can replay previously delivered messages. +x-govstack-event-signature-profile: + version: 0.1.0-draft + serialization: JWS Compact Serialization with a detached payload + payloadEncoding: + b64: true + behavior: Default JWS payload encoding; RFC 7797 unencoded payload mode is not used. + protectedHeader: + alg: ES256 + kid: REQUIRED + payloadBytes: UTF-8 bytes of the RFC 8785 canonical JSON structured CloudEvent payload. + signingInput: "BASE64URL(protected) + '.' + BASE64URL(payload)" + wireValue: protected..signature + verificationInputs: The protected kid selects the publisher public key; the full event body is canonicalized and verified. +components: + securitySchemes: + OAuthClientCredentials: + type: oauth2 + description: OAuth 2.0 client-credentials template for authenticated broker access. + flows: + clientCredentials: + tokenUrl: https://identity.example.org/oauth2/token + availableScopes: {} + messages: + CloudEvent: + name: cloudEvent + title: Structured GovStack CloudEvent + summary: Reusable structured CloudEvents JSON envelope for a BB domain event. + contentType: application/json + headers: + $ref: '#/components/schemas/EventHeaders' + payload: + $ref: '#/components/schemas/CloudEventEnvelope' + examples: + - name: referenceEvent + summary: Generic event showing trace and signature metadata. + headers: + govstackSignature: eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIn0..MEUCIQDexample + payload: + specversion: '1.0' + id: 5e0c63c2-2b8a-4d3f-9a51-7c6b0d9e8f21 + source: urn:govstack:bb:template + type: org.govstack.template.record.created + time: '2026-07-10T12:00:00Z' + datacontenttype: application/json + traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 + data: + resourceId: 7d9ad9df-bfc3-451a-94e0-24afae30750f + AsyncError: + name: govStackAsyncError + title: GovStack asynchronous error + summary: Transport-neutral rejection or processing failure for an asynchronous command. + contentType: application/json + payload: + $ref: '#/components/schemas/GovStackAsyncError' + examples: + - name: rejectedCommand + summary: A command rejected because one field is invalid. + payload: + type: https://docs.govstack.org/errors/common/invalidArgument + title: Command validation failed + code: org.govstack.common.invalidArgument + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + errors: + - pointer: /data/name + code: org.govstack.common.invalidArgument + message: Name must not be blank. + schemas: + EventHeaders: + type: object + description: GovStack-owned transport metadata accompanying a structured CloudEvent. + required: + - govstackSignature + properties: + govstackSignature: + type: string + description: Detached JWS over the canonicalized structured CloudEvent payload. + idempotencyKey: + type: string + description: Opaque de-duplication key when the message triggers non-idempotent processing. + maxLength: 255 + CloudEventEnvelope: + type: object + description: CloudEvents 1.0 structured JSON envelope with GovStack trace extensions. + required: + - specversion + - id + - source + - type + - data + properties: + specversion: + type: string + const: '1.0' + description: CloudEvents wire version for the adopted 1.0.x specification. + id: + type: string + description: Unique identifier of this event occurrence. + source: + type: string + format: uri-reference + description: Stable logical URI reference for the publishing BB surface. + type: + type: string + pattern: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$' + description: Reverse-DNS semantic event type without an API version segment. + time: + type: string + format: date-time + description: RFC 3339 time at which the event occurred. + datacontenttype: + type: string + const: application/json + description: Media type of the structured event data. + subject: + type: string + description: Optional opaque identifier of the resource concerned by the event. + traceparent: + type: string + pattern: '^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$' + description: W3C Trace Context extension attribute for the distributed trace. + tracestate: + type: string + maxLength: 512 + description: Optional W3C Trace Context vendor state containing no personal data. + correlationid: + type: string + description: Optional workflow correlation identifier. + causationid: + type: string + description: Optional identifier of the message that caused this event. + data: + type: object + description: BB-specific domain payload specialised by the referencing message. + GovStackAsyncError: + type: object + description: Transport-neutral asynchronous command rejection or processing failure. + required: + - type + - title + - code + - traceId + - timestamp + properties: + type: + type: string + format: uri + description: Stable absolute problem-type URI. + title: + type: string + description: Short human-readable summary of the error type. + detail: + type: string + description: Human-readable explanation specific to this failure. + code: + type: string + description: Stable namespaced machine-readable error identifier. + traceId: + type: string + pattern: '^[\da-f]{32}$' + description: W3C trace-id correlating the failure with the originating work. + timestamp: + type: string + format: date-time + description: RFC 3339 time at which the failure occurred. + errors: + type: array + description: Optional field-level errors for invalid command data. + items: + $ref: '#/components/schemas/AsyncFieldError' + AsyncFieldError: + type: object + description: Field-level validation failure in asynchronous message data. + required: + - pointer + - code + - message + properties: + pointer: + type: string + description: JSON Pointer identifying the invalid message field. + code: + type: string + description: Stable namespaced code for the field failure. + message: + type: string + description: Human-readable explanation of the field failure. diff --git a/api/common/govstack-openapi-common.yaml b/api/common/govstack-openapi-common.yaml new file mode 100644 index 0000000..e6335a2 --- /dev/null +++ b/api/common/govstack-openapi-common.yaml @@ -0,0 +1,437 @@ +openapi: 3.1.0 +info: + title: GovStack OpenAPI Common Components + version: 0.1.0-draft + description: >- + Minimal reusable components for GovStack REST API specifications. Domain + resources and BB-specific OAuth scopes remain in each BB specification. + contact: + name: GovStack API Working Group + url: https://www.govstack.global/ +paths: {} +x-govstack-components-version: 0.1.0-draft +x-govstack-event-signature-profile: + version: 0.1.0-draft + serialization: JWS Compact Serialization with a detached payload + payloadEncoding: + b64: true + behavior: Default JWS payload encoding; RFC 7797 unencoded payload mode is not used. + protectedHeader: + alg: ES256 + kid: REQUIRED + payloadBytes: UTF-8 bytes of the RFC 8785 canonical JSON structured CloudEvent request body. + signingInput: "BASE64URL(protected) + '.' + BASE64URL(payload)" + wireValue: protected..signature + verificationInputs: The protected kid selects the publisher public key; the full event body is canonicalized and verified. +components: + securitySchemes: + OAuthAuthorizationCode: + type: oauth2 + description: >- + OAuth 2.0 authorization-code flow template for an authenticated + end-user. Clients use PKCE with S256, and APIs accept access tokens, + never OpenID Connect ID tokens. A BB declares its own resource scopes. + flows: + authorizationCode: + authorizationUrl: https://identity.example.org/oauth2/authorize + tokenUrl: https://identity.example.org/oauth2/token + scopes: {} + OAuthClientCredentials: + type: oauth2 + description: >- + OAuth 2.0 client-credentials flow template for service-to-service + access. A BB declares its own resource scopes in its canonical API. + flows: + clientCredentials: + tokenUrl: https://identity.example.org/oauth2/token + scopes: {} + MutualTLS: + type: mutualTLS + description: Mutual TLS for service-to-service deployments requiring certificate authentication. + parameters: + Traceparent: + name: traceparent + in: header + required: false + description: W3C Trace Context parent identifier propagated across service boundaries. + schema: + type: string + description: W3C Trace Context traceparent value. + pattern: '^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$' + Tracestate: + name: tracestate + in: header + required: false + description: Optional W3C Trace Context vendor state containing no personal data. + schema: + type: string + description: Ordered W3C Trace Context list-member values. + maxLength: 512 + GovStackSignature: + name: GovStack-Signature + in: header + required: true + description: Detached JWS over the RFC 8785 canonicalized structured CloudEvent body. + schema: + type: string + description: JWS Compact Serialization value in protected..signature wire form. + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: Client-generated opaque key that makes a non-idempotent request safe to retry. + schema: + type: string + description: Opaque idempotency key retained for the BB's documented replay window. + minLength: 1 + maxLength: 255 + PageSize: + name: pageSize + in: query + required: false + description: Maximum number of resources returned in one page. + schema: + type: integer + description: Requested page size within the documented bounds. + minimum: 1 + default: 20 + maximum: 100 + Cursor: + name: cursor + in: query + required: false + description: Opaque cursor returned as pageInfo.nextCursor by the previous page. + schema: + type: string + description: Opaque server-generated continuation cursor. + OperationId: + name: operationId + in: path + required: true + description: Opaque identifier of a long-running Operation resource. + schema: + type: string + format: uuid + description: UUID assigned to the Operation by the server. + headers: + Location: + description: URI reference of the created resource or accepted Operation. + schema: + type: string + format: uri-reference + description: URI reference that the caller can subsequently retrieve. + ETag: + description: Entity tag representing the version of the returned resource. + schema: + type: string + description: Opaque entity tag suitable for a conditional request. + CacheControl: + description: Cache directive; error and Operation status responses use no-store. + schema: + type: string + description: HTTP Cache-Control field value. + example: no-store + WwwAuthenticate: + description: OAuth 2.0 Bearer authentication challenge. + schema: + type: string + description: RFC 6750 Bearer challenge. + example: 'Bearer realm="govstack", error="invalid_token"' + RetryAfter: + description: Number of seconds the caller waits before retrying. + schema: + type: integer + description: Non-negative retry delay in seconds. + minimum: 0 + responses: + NotModified: + description: The resource has not changed since the supplied entity tag. + headers: + ETag: + $ref: '#/components/headers/ETag' + BadRequest: + description: The request is malformed or contains invalid fields. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ValidationProblem' + example: + type: https://docs.govstack.org/errors/common/invalidArgument + title: Request validation failed + status: 400 + detail: One request field is invalid. + instance: /v1/records + code: org.govstack.common.invalidArgument + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + errors: + - pointer: /name + code: org.govstack.common.invalidArgument + message: Name must not be blank. + Unauthorized: + description: Authentication is missing or invalid. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WwwAuthenticate' + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://docs.govstack.org/errors/common/unauthenticated + title: Authentication required + status: 401 + code: org.govstack.common.unauthenticated + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + Forbidden: + description: The authenticated caller is not authorised for the operation. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://docs.govstack.org/errors/common/permissionDenied + title: Permission denied + status: 403 + code: org.govstack.common.permissionDenied + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + NotFound: + description: The addressed resource does not exist. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://docs.govstack.org/errors/common/notFound + title: Resource not found + status: 404 + code: org.govstack.common.notFound + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + Conflict: + description: The request conflicts with the current resource state. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://docs.govstack.org/errors/common/aborted + title: State conflict + status: 409 + code: org.govstack.common.aborted + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + UnprocessableContent: + description: The request is well formed but cannot be processed semantically. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://docs.govstack.org/errors/common/invalidArgument + title: Request cannot be processed + status: 422 + code: org.govstack.common.invalidArgument + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + TooManyRequests: + description: The caller has exceeded an applicable request limit. + headers: + Retry-After: + $ref: '#/components/headers/RetryAfter' + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://docs.govstack.org/errors/common/resourceExhausted + title: Request limit exceeded + status: 429 + code: org.govstack.common.resourceExhausted + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + InternalError: + description: An unexpected server error occurred. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://docs.govstack.org/errors/common/internal + title: Internal error + status: 500 + code: org.govstack.common.internal + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + timestamp: '2026-07-10T12:00:00Z' + schemas: + Problem: + type: object + description: >- + RFC 9457 problem details with stable GovStack error code and correlation + fields. Error text contains no personal data or system-internal detail. + required: + - type + - title + - status + - code + - traceId + - timestamp + properties: + type: + type: string + format: uri + description: Stable absolute URI identifying the problem type. + title: + type: string + description: Short human-readable summary of the problem type. + status: + type: integer + minimum: 400 + maximum: 599 + description: HTTP status code for this occurrence. + detail: + type: string + description: Human-readable explanation specific to this occurrence. + instance: + type: string + format: uri-reference + description: URI reference identifying this problem occurrence. + code: + type: string + pattern: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*$' + description: Stable namespaced machine-readable error identifier. + example: org.govstack.common.internal + traceId: + type: string + pattern: '^[\da-f]{32}$' + description: The 32-hex-digit trace-id component of the effective request traceparent. + timestamp: + type: string + format: date-time + description: RFC 3339 time at which the error occurred. + errors: + type: array + description: Field-level validation failures, omitted for non-field errors. + items: + $ref: '#/components/schemas/FieldError' + ValidationProblem: + description: Problem details for a request containing one or more invalid fields. + allOf: + - $ref: '#/components/schemas/Problem' + - type: object + description: Extension requiring field-level error details. + required: + - errors + properties: + errors: + type: array + minItems: 1 + description: One entry for each request field that failed validation. + items: + $ref: '#/components/schemas/FieldError' + FieldError: + type: object + description: Machine-readable failure associated with one request field. + required: + - pointer + - code + - message + properties: + pointer: + type: string + description: JSON Pointer identifying the invalid request field. + code: + type: string + description: Stable namespaced code for this field failure. + message: + type: string + description: Human-readable explanation of the field failure. + PageInfo: + type: object + description: Cursor metadata for a bounded collection response. + required: + - nextCursor + - hasMore + properties: + nextCursor: + type: + - string + - 'null' + description: Opaque cursor for the next page, or null on the final page. + hasMore: + type: boolean + description: Whether another page is available. + total: + type: integer + minimum: 0 + description: Optional total number of matching resources when inexpensive to compute. + Operation: + type: object + description: Pollable representation of long-running work. + required: + - id + - status + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + description: Opaque server-generated identifier of the Operation. + status: + type: string + description: 'Current lifecycle state: PENDING, RUNNING, SUCCEEDED, FAILED, or CANCELLED.' + enum: + - PENDING + - RUNNING + - SUCCEEDED + - FAILED + - CANCELLED + result: + type: + - object + - 'null' + description: Result metadata when the Operation succeeds. + error: + description: Problem details when the Operation fails. + oneOf: + - $ref: '#/components/schemas/Problem' + - type: 'null' + description: No error has occurred. + createdAt: + type: string + format: date-time + description: RFC 3339 time at which the Operation was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 time at which the Operation last changed. + progress: + type: integer + minimum: 0 + maximum: 100 + description: Optional completion percentage from zero through one hundred. diff --git a/api/coverage.yaml b/api/coverage.yaml new file mode 100644 index 0000000..7b87ba3 --- /dev/null +++ b/api/coverage.yaml @@ -0,0 +1,40 @@ +version: 1 +requirements: + - id: BB-TPL-FR-001 + disposition: operation + operations: + - listRecords + - id: BB-TPL-FR-002 + disposition: operation + operations: + - createRecord + - getRecord + - id: BB-TPL-FR-003 + disposition: operation + operations: + - requestRecordExport + - getOperation + - cancelOperation + - id: BB-TPL-XR-001 + disposition: operation + operations: + - getHealth + - id: BB-TPL-XR-002 + disposition: operation + operations: + - listRecords + - createRecord + - getRecord + - requestRecordExport + - getOperation + - cancelOperation + - getHealth + - id: BB-TPL-XR-003 + disposition: operation + operations: + - listRecords + - createRecord + - getRecord + - requestRecordExport + - getOperation + - cancelOperation diff --git a/api/index.yaml b/api/index.yaml new file mode 100644 index 0000000..152ec4d --- /dev/null +++ b/api/index.yaml @@ -0,0 +1,4 @@ +version: 1 +apis: + - type: openapi + path: api/openapi.yaml diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..f151088 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,483 @@ +openapi: 3.1.0 +info: + title: GovStack Building Block Template Reference API + version: 1.0.0 + description: >- + Small reference contract demonstrating the cross-BB API conventions. A real + Building Block replaces the Record domain while preserving conformance and + requirement traceability. + contact: + name: GovStack API Working Group + url: https://www.govstack.global/ + x-govstack-api-guide: + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft + x-govstack-common-components: + openapi: 0.1.0 + x-govstack-bb-code: template +servers: + - url: https://{gatewayHost}/{bbCode} + description: Non-production parameterised gateway pattern for the reference API. + variables: + gatewayHost: + default: api.example.org + description: Deployment gateway host; example.org is a reserved non-production default. + bbCode: + default: template + description: Registered code of the deployed Building Block. +security: + - BuildingBlockOAuth: + - bb:template:records:read +tags: + - name: Records + description: Reference resource operations used to demonstrate synchronous API conventions. + - name: Exports + description: Reference long-running export request. + - name: Operations + description: Polling and cancellation of long-running work. + - name: Health + description: Operational liveness contract. +paths: + /v1/records: + get: + operationId: listRecords + summary: List reference records + description: Returns a bounded cursor-paginated collection of reference records. + tags: + - Records + security: + - CitizenOAuth: + - bb:template:records:read + - BuildingBlockOAuth: + - bb:template:records:read + parameters: + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/PageSize' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Cursor' + responses: + '200': + description: A page of reference records. + headers: + ETag: + $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/RecordCollection' + example: + items: + - id: 7d9ad9df-bfc3-451a-94e0-24afae30750f + name: Reference record + status: ACTIVE + createdAt: '2026-07-10T10:00:00Z' + updatedAt: '2026-07-10T10:00:00Z' + pageInfo: + nextCursor: pgn_7JpQ9m2W4xK8fR3cT6vN1 + hasMore: true + '304': + $ref: './common/govstack-openapi-common.yaml#/components/responses/NotModified' + '400': + $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + '401': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + '403': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + '500': + $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + post: + operationId: createRecord + summary: Create a reference record + description: Creates a reference record synchronously and returns its representation. + tags: + - Records + security: + - CitizenOAuth: + - bb:template:records:write + - BuildingBlockOAuth: + - bb:template:records:write + parameters: + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: Values supplied by the caller for the new reference record. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRecordRequest' + example: + name: Reference record + status: ACTIVE + responses: + '201': + description: Reference record created. + headers: + Location: + $ref: './common/govstack-openapi-common.yaml#/components/headers/Location' + ETag: + $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Record' + example: + id: 7d9ad9df-bfc3-451a-94e0-24afae30750f + name: Reference record + status: ACTIVE + createdAt: '2026-07-10T10:00:00Z' + updatedAt: '2026-07-10T10:00:00Z' + '400': + $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + '401': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + '403': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + '409': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Conflict' + '422': + $ref: './common/govstack-openapi-common.yaml#/components/responses/UnprocessableContent' + '500': + $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + /v1/records/{recordId}: + get: + operationId: getRecord + summary: Get a reference record + description: Retrieves one reference record by its opaque server-generated identifier. + tags: + - Records + security: + - CitizenOAuth: + - bb:template:records:read + - BuildingBlockOAuth: + - bb:template:records:read + parameters: + - $ref: '#/components/parameters/RecordId' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + responses: + '200': + description: The requested reference record. + headers: + ETag: + $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Record' + example: + id: 7d9ad9df-bfc3-451a-94e0-24afae30750f + name: Reference record + status: ACTIVE + createdAt: '2026-07-10T10:00:00Z' + updatedAt: '2026-07-10T10:00:00Z' + '304': + $ref: './common/govstack-openapi-common.yaml#/components/responses/NotModified' + '400': + $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + '401': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + '403': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + '404': + $ref: './common/govstack-openapi-common.yaml#/components/responses/NotFound' + '500': + $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + /v1/exports: + post: + operationId: requestRecordExport + summary: Request a record export + description: Accepts a long-running record export and returns a pollable Operation. + tags: + - Exports + security: + - BuildingBlockOAuth: + - bb:template:exports:write + parameters: + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: Criteria and output format for the record export. + content: + application/json: + schema: + $ref: '#/components/schemas/ExportRequest' + example: + format: JSON_LINES + status: ACTIVE + responses: + '202': + description: Export accepted for asynchronous processing. + headers: + Location: + $ref: './common/govstack-openapi-common.yaml#/components/headers/Location' + Cache-Control: + $ref: './common/govstack-openapi-common.yaml#/components/headers/CacheControl' + content: + application/json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Operation' + example: + id: b41558c8-c248-4c39-88d5-fbc9a32326e4 + status: PENDING + createdAt: '2026-07-10T10:05:00Z' + updatedAt: '2026-07-10T10:05:00Z' + progress: 0 + '400': + $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + '401': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + '403': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + '409': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Conflict' + '422': + $ref: './common/govstack-openapi-common.yaml#/components/responses/UnprocessableContent' + '500': + $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + /v1/operations/{operationId}: + get: + operationId: getOperation + summary: Get operation status + description: Retrieves current state, result metadata, or error for long-running work. + tags: + - Operations + security: + - BuildingBlockOAuth: + - bb:template:operations:read + parameters: + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/OperationId' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + responses: + '200': + description: Current Operation state. + headers: + ETag: + $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + Cache-Control: + $ref: './common/govstack-openapi-common.yaml#/components/headers/CacheControl' + content: + application/json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Operation' + example: + id: b41558c8-c248-4c39-88d5-fbc9a32326e4 + status: RUNNING + createdAt: '2026-07-10T10:05:00Z' + updatedAt: '2026-07-10T10:05:10Z' + progress: 40 + '304': + $ref: './common/govstack-openapi-common.yaml#/components/responses/NotModified' + '400': + $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + '401': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + '403': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + '404': + $ref: './common/govstack-openapi-common.yaml#/components/responses/NotFound' + '500': + $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + /v1/operations/{operationId}/cancel: + post: + operationId: cancelOperation + summary: Cancel an operation + description: Requests cancellation of long-running work that has not reached a terminal state. + tags: + - Operations + security: + - BuildingBlockOAuth: + - bb:template:operations:write + parameters: + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/OperationId' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/IdempotencyKey' + responses: + '400': + $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + '200': + description: Operation after the cancellation request was applied. + headers: + Cache-Control: + $ref: './common/govstack-openapi-common.yaml#/components/headers/CacheControl' + content: + application/json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Operation' + example: + id: b41558c8-c248-4c39-88d5-fbc9a32326e4 + status: CANCELLED + createdAt: '2026-07-10T10:05:00Z' + updatedAt: '2026-07-10T10:06:00Z' + progress: 40 + '401': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + '403': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + '404': + $ref: './common/govstack-openapi-common.yaml#/components/responses/NotFound' + '409': + $ref: './common/govstack-openapi-common.yaml#/components/responses/Conflict' + '500': + $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + /health: + get: + operationId: getHealth + summary: Get liveness status + description: Reports operational liveness without authentication or internal system detail. + tags: + - Health + security: [] + parameters: + - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + responses: + '200': + description: The Building Block is live. + content: + application/health+json: + schema: + $ref: '#/components/schemas/Health' + example: + status: pass + description: Reference API is live. + '500': + $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' +components: + securitySchemes: + CitizenOAuth: + type: oauth2 + description: >- + OAuth 2.0 authorization-code access for authenticated end-user clients. + Clients use PKCE with S256. The API accepts access tokens and never + treats an OpenID Connect ID token as an API access token. + flows: + authorizationCode: + authorizationUrl: https://identity.example.org/oauth2/authorize + tokenUrl: https://identity.example.org/oauth2/token + scopes: + bb:template:records:read: Read reference records. + bb:template:records:write: Create reference records. + BuildingBlockOAuth: + type: oauth2 + description: OAuth 2.0 client-credentials access for service-to-service calls. + flows: + clientCredentials: + tokenUrl: https://identity.example.org/oauth2/token + scopes: + bb:template:records:read: Read reference records. + bb:template:records:write: Create reference records. + bb:template:exports:write: Request reference record exports. + bb:template:operations:read: Read long-running Operation state. + bb:template:operations:write: Request cancellation of long-running Operations. + parameters: + RecordId: + name: recordId + in: path + required: true + description: Opaque server-generated identifier of a reference record. + schema: + type: string + format: uuid + description: UUID assigned to a reference record by the server. + schemas: + CreateRecordRequest: + type: object + description: Caller-controlled values used to create a reference record. + required: + - name + - status + properties: + name: + type: string + minLength: 1 + maxLength: 200 + description: Human-readable label that contains no personal data. + status: + type: string + description: 'Requested state: ACTIVE for current records or ARCHIVED for retained records.' + enum: + - ACTIVE + - ARCHIVED + x-extensible-enum: true + Record: + type: object + description: Generic resource used to demonstrate the template API conventions. + required: + - id + - name + - status + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + description: Opaque server-generated record identifier. + name: + type: string + description: Human-readable record label without personal data. + status: + type: string + description: 'Current state: ACTIVE for current records or ARCHIVED for retained records.' + enum: + - ACTIVE + - ARCHIVED + x-extensible-enum: true + createdAt: + type: string + format: date-time + description: RFC 3339 time at which the record was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 time at which the record last changed. + RecordCollection: + type: object + description: Cursor-paginated collection of reference records. + required: + - items + - pageInfo + properties: + items: + type: array + description: Reference records in the current page. + items: + $ref: '#/components/schemas/Record' + pageInfo: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/PageInfo' + ExportRequest: + type: object + description: Criteria and representation requested for a long-running record export. + required: + - format + properties: + format: + type: string + description: 'Export representation: JSON_LINES for newline-delimited JSON or CSV for comma-separated values.' + enum: + - JSON_LINES + - CSV + x-extensible-enum: true + status: + type: string + description: 'Optional equality filter: ACTIVE for current records or ARCHIVED for retained records.' + enum: + - ACTIVE + - ARCHIVED + x-extensible-enum: true + Health: + type: object + description: Minimal operational liveness response without internal details. + required: + - status + properties: + status: + type: string + description: Liveness state defined by the adopted health-check convention. + enum: + - pass + - fail + - warn + x-extensible-enum: false + description: + type: string + description: Public, non-sensitive summary of the liveness state. diff --git a/api/swagger.json b/api/swagger.json deleted file mode 100644 index e69de29..0000000 diff --git a/api/swagger.yaml b/api/swagger.yaml deleted file mode 100644 index e69de29..0000000 From 35101e49fbd31455dc657755fe9afc640ea8415e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:15:28 +0700 Subject: [PATCH 05/19] feat: extend Spectral ruleset to 130 rules and add surface discovery Adds --mode and api/index.yaml discovery to the driver so a run covers every declared surface rather than a single file, and extends coverage to the rules added in this cycle. New functions cover baseline and creation responses (7.14), successful response schemas (7.21), W3C Trace Context (8.4) and logical channel IDs (17.2). s08-requestIdCorrelation.js is removed with the X-Request-Id rule it enforced. Every rule keeps a fail/pass fixture pair and a coverage.yaml entry; the golden reference specs still lint clean. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- .github/workflows/api-spec-lint.yml | 58 +- api-design-guide/linter/README.md | 105 +- api-design-guide/linter/action.yml | 32 +- api-design-guide/linter/cli.mjs | 922 +++++++++++++++--- api-design-guide/linter/coverage.yaml | 65 +- .../linter/functions/s07-baselineResponses.js | 50 + .../linter/functions/s07-creationResponses.js | 25 + .../functions/s07-successResponseSchema.js | 49 + .../linter/functions/s08-noXHeaders.js | 6 +- .../linter/functions/s08-rateLimitHeaders.js | 36 +- .../functions/s08-requestIdCorrelation.js | 58 -- .../linter/functions/s08-traceContext.js | 40 + .../linter/functions/s09-bbCode.js | 8 +- .../linter/functions/s17-channelIds.js | 34 + .../functions/s18-versionMajorConsistency.js | 14 +- api-design-guide/linter/package-lock.json | 4 +- api-design-guide/linter/package.json | 4 +- api-design-guide/linter/rulesets/s02.yaml | 26 +- api-design-guide/linter/rulesets/s07.yaml | 41 + api-design-guide/linter/rulesets/s08.yaml | 47 +- api-design-guide/linter/rulesets/s09.yaml | 34 +- api-design-guide/linter/rulesets/s13.yaml | 19 + api-design-guide/linter/rulesets/s17.yaml | 20 +- api-design-guide/linter/rulesets/s18.yaml | 6 +- api-design-guide/linter/rulesets/s20.yaml | 45 +- api-design-guide/linter/tests/driver.test.mjs | 706 ++++++++++---- .../govstack-13.2-forbidden-flows/fail.yaml | 14 + .../govstack-13.2-forbidden-flows/pass.yaml | 12 + .../tests/fixtures/govstack-17.2/fail.yaml | 4 +- .../tests/fixtures/govstack-17.2/pass.yaml | 8 +- .../fixtures/govstack-18.2-asyncapi/fail.yaml | 10 +- .../fixtures/govstack-18.2-asyncapi/pass.yaml | 10 +- .../tests/fixtures/govstack-2.1/fail.yaml | 4 +- .../tests/fixtures/govstack-2.1/pass.yaml | 4 +- .../tests/fixtures/govstack-2.6/pass.yaml | 2 +- .../govstack-20.3-exceptions/fail.yaml | 3 +- .../govstack-20.3-exceptions/pass.yaml | 21 +- .../tests/fixtures/govstack-20.3/pass.yaml | 3 +- .../govstack-7.14-baseline-errors/fail.yaml | 14 + .../govstack-7.14-baseline-errors/pass.yaml | 17 + .../govstack-7.14-creation-status/fail.yaml | 9 + .../govstack-7.14-creation-status/pass.yaml | 9 + .../tests/fixtures/govstack-7.21/fail.yaml | 10 + .../tests/fixtures/govstack-7.21/pass.yaml | 13 + .../tests/fixtures/govstack-8.4/fail.yaml | 4 +- .../tests/fixtures/govstack-8.4/pass.yaml | 10 +- .../tests/fixtures/govstack-8.5/pass.yaml | 4 +- .../fixtures/govstack-8.7-no-legacy/fail.yaml | 12 + .../fixtures/govstack-8.7-no-legacy/pass.yaml | 10 + .../tests/fixtures/govstack-8.7/pass.yaml | 20 +- .../linter/tests/functions.test.mjs | 39 + .../linter/tests/golden/asyncapi-golden.yaml | 36 +- .../linter/tests/golden/openapi-golden.yaml | 164 ++-- 53 files changed, 2232 insertions(+), 688 deletions(-) create mode 100644 api-design-guide/linter/functions/s07-baselineResponses.js create mode 100644 api-design-guide/linter/functions/s07-creationResponses.js create mode 100644 api-design-guide/linter/functions/s07-successResponseSchema.js delete mode 100644 api-design-guide/linter/functions/s08-requestIdCorrelation.js create mode 100644 api-design-guide/linter/functions/s08-traceContext.js create mode 100644 api-design-guide/linter/functions/s17-channelIds.js create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.21/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-7.21/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/pass.yaml diff --git a/.github/workflows/api-spec-lint.yml b/.github/workflows/api-spec-lint.yml index b465e08..26b4fd2 100644 --- a/.github/workflows/api-spec-lint.yml +++ b/.github/workflows/api-spec-lint.yml @@ -3,15 +3,11 @@ # Lints this repo's OpenAPI/AsyncAPI spec against the GovStack Cross-BB API # Design Guide, using the composite action in api-design-guide/linter/. This # workflow is inherited as-is by Building Block (BB) repos instantiated from -# bb-template, so it must work with either the template's empty placeholder -# specs or a BB's real spec. +# bb-template. Repositories without an API declare that explicitly in +# api/index.yaml; empty legacy swagger placeholders are not conformant. # -# Once a BB adds a real spec under api/, lint failures at or above the -# fail-on threshold (default: error) will fail this check. Whether/when to -# tighten that threshold, grant exceptions, or otherwise govern enforcement -# is left to each BB per the guide's own governance note (see -# api-design-guide/part-e/20-conformance-and-validation.md, "Note on -# governance") — this workflow just wires up the default. +# The linter always runs in conformance mode. Deterministic contract failures +# block, while heuristic findings remain advisory warnings by default. # # A BB repo may instead delete the inherited api-design-guide/linter/ folder # and pin the action from this template's repo directly, e.g.: @@ -22,42 +18,42 @@ on: pull_request: paths: - "api/**" - - "api-design-guide/linter/**" + - "spec/**/*.md" + - "api-design-guide/**" - ".github/workflows/api-spec-lint.yml" push: branches: - main paths: - "api/**" - - "api-design-guide/linter/**" + - "spec/**/*.md" + - "api-design-guide/**" - ".github/workflows/api-spec-lint.yml" jobs: - lint: + linter-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - # bb-template ships empty placeholder specs, so a fresh template clone - # (or a BB that hasn't added its spec yet) has nothing to lint. Skip - # the lint step in that case rather than failing the check. - - name: Check for API spec - id: guard - shell: bash + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Test bundled API linter and coverage contract + working-directory: api-design-guide/linter + env: + COVERAGE_ENFORCE: "1" run: | - has_spec=false - for f in api/openapi.yaml api/asyncapi.yaml; do - if [[ -f "$f" ]] && [[ -n "$(tr -d '[:space:]' < "$f")" ]]; then - has_spec=true - fi - done - if [[ "$has_spec" == "true" ]]; then - echo "spec-present=true" >> "$GITHUB_OUTPUT" - else - echo "spec-present=false" >> "$GITHUB_OUTPUT" - echo "no API spec present; skipping lint (the bb-template ships empty placeholders)" - fi + npm ci + npm test + - name: Check generated rule index and guide links + run: | + python3 api-design-guide/tools/build_rules_index.py --check + python3 api-design-guide/tools/check_links.py + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - name: Lint API spec - if: steps.guard.outputs.spec-present == 'true' uses: ./api-design-guide/linter diff --git a/api-design-guide/linter/README.md b/api-design-guide/linter/README.md index a60c6d3..6db674c 100644 --- a/api-design-guide/linter/README.md +++ b/api-design-guide/linter/README.md @@ -5,7 +5,7 @@ The GovStack Spectral ruleset and lint tooling for the behind rule [20.2](../part-e/20-conformance-and-validation.md) (draft; the formal companion publication is tracked in [Appendix A](../appendix/a-companion-documents.md)). It implements guide -version **0.2.0** (`guide_version` in [coverage.yaml](coverage.yaml)). +version **0.2.0-draft** (`guide_version` in [coverage.yaml](coverage.yaml)). ## Quick start @@ -15,23 +15,68 @@ npm ci node cli.mjs --repo-root ../.. # lints api/openapi.yaml + api/asyncapi.yaml ``` +For multiple API surfaces, declare every document explicitly in +`api/index.yaml`: + +```yaml +version: 1 +apis: + - type: openapi + path: api/public-openapi.yaml + - type: asyncapi + path: api/events-asyncapi.yaml +``` + +A BB with no API surface must say so explicitly instead of keeping empty spec +placeholders: + +```yaml +version: 1 +noApi: true +reason: This BB publishes reusable components only. +``` + The driver runs everything rule 20 asks for: -1. **File-tree checks** — canonical entrypoints `api/openapi.yaml` / - `api/asyncapi.yaml`, legacy `swagger.*` names, divergent spec copies - (guide 2.2/2.3/3.2/3.3). -2. **Base validators** (20.1) — `openapi-spec-validator` and - `@asyncapi/cli validate`, when installed (`--skip-validators` to skip). -3. **The Spectral ruleset** — 125 rules across both surfaces (OpenAPI 3.1, +1. **File-tree checks** — `api/index.yaml` or canonical entrypoints, + declaration/type consistency, legacy `swagger.*` names, and undeclared or + divergent spec copies (guide 2.2/2.3/3.2/3.3). +2. **Requirement coverage** — every keyed requirement marker under + `spec/**/*.md` must have one disposition in `api/coverage.yaml`, and mapped + operation/message identifiers must exist and be unambiguous. +3. **Base validators** (20.1) — `openapi-spec-validator` and + `@asyncapi/cli validate` are mandatory in conformance mode. +4. **The Spectral ruleset** — rules across both surfaces (OpenAPI 3.1, AsyncAPI 3.0). Spectral auto-detects the document type. -4. **Declared exceptions** (20.3) — findings for rule ids listed in - `info.x-govstack-api-guide.exceptions` are reported as suppressed, not - dropped. File-tree findings cannot be suppressed. +5. **Declared exceptions** (20.3) — approved, unexpired exceptions suppress a + matching rule only at or below their RFC 6901 JSON Pointer scope. Driver + findings cannot be suppressed. Flags: `--openapi <path>`, `--asyncapi <path>`, `--ruleset <file>`, `--strict`, -`--fail-on error|warn|info|never` (default `error`), `--format text|json`, -`--skip-validators`. Exit codes: `0` clean or below threshold, `1` findings at -or above `--fail-on`, `2` operational error. +`--mode conformance|advisory`, `--fail-on error|warn|info|never` (default +`error`), `--format text|json`, and `--skip-validators`. Conformance mode is +the default and rejects missing or skipped base validators. Advisory mode can +use `--skip-validators` for local ruleset review. Exit codes: `0` clean or +below threshold, `1` findings at or above `--fail-on`, `2` operational error. + +Every spec must declare the exact guide and ruleset version. Exceptions use +the following shape. `record` must be HTTPS, dates use `YYYY-MM-DD`, and +`scope` must be an RFC 6901 JSON Pointer (the empty string means the root). + +```yaml +info: + x-govstack-api-guide: + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft + exceptions: + - rule: "9.5" + scope: /components/schemas/LegacyRecord + rationale: Existing clients depend on this wire name. + record: https://example.gov/decisions/API-42 + reviewedBy: GovStack API review group + reviewedAt: "2026-07-01" + expiresAt: "2026-10-01" +``` You can also run Spectral directly: @@ -64,20 +109,20 @@ per reference path. Fixing the schema clears all copies. ## Coverage -[coverage.yaml](coverage.yaml) maps **all 166 guide rules** to their +[coverage.yaml](coverage.yaml) maps every guide rule to its enforcement status — it is the scope contract, machine-checked by -`tests/coverage.test.mjs` in both directions (set `COVERAGE_ENFORCE=1`): - -| status | count | meaning | -| --- | --- | --- | -| `implemented` | 58 | fully checked by the listed Spectral rules | -| `partial-proxy` | 53 | an automated proxy is checked; the note says what is not | -| `driver` | 7 | checked by `cli.mjs` (file tree, base validators), not Spectral | -| `strict-only` | 8 | noisy heuristic, ships only in `strict.yaml` | -| `needs-context` | 7 | needs input that does not exist yet (common components YAML, BB-code registry) | -| `runtime` | 10 | constrains wire behaviour; test-harness territory | -| `human` | 20 | review/governance judgment | -| `informative` | 3 | non-normative guide entries | +`tests/coverage.test.mjs` in both directions: + +| status | meaning | +| --- | --- | +| `implemented` | fully checked by the listed Spectral rules | +| `partial-proxy` | an automated proxy is checked; the note says what is not | +| `driver` | checked by `cli.mjs`, not Spectral | +| `strict-only` | noisy heuristic, ships only in `strict.yaml` | +| `needs-context` | needs an external registry, common artifact, or comparison input | +| `runtime` | constrains wire behaviour; test-harness territory | +| `human` | review or governance judgment | +| `informative` | non-normative guide entry | Cross-version breaking-change rules (18.3/18.4) are diff territory, not lint territory: run [oasdiff](https://github.com/oasdiff/oasdiff) against the @@ -101,14 +146,14 @@ in a BB repo: fail-on: error ``` -The template's own workflow (`.github/workflows/api-spec-lint.yml`) skips -cleanly while `api/` contains only empty placeholders. +The template's own workflow (`.github/workflows/api-spec-lint.yml`) runs both +the linter's test suite and the repository conformance check. Empty legacy +placeholders are not conformant. ## Development ```bash -npm test # fixtures, functions, driver, golden, harness -COVERAGE_ENFORCE=1 node --test tests/coverage.test.mjs # coverage drift checks +npm test # fixtures, functions, driver, coverage, golden, and harness ``` Every Spectral rule has `tests/fixtures/<rule-name>/{fail,pass}.yaml`: the diff --git a/api-design-guide/linter/action.yml b/api-design-guide/linter/action.yml index 7c7ef4a..30eba61 100644 --- a/api-design-guide/linter/action.yml +++ b/api-design-guide/linter/action.yml @@ -12,13 +12,13 @@ description: "Lints a BB's OpenAPI/AsyncAPI spec against the GovStack Cross-BB A inputs: openapi-path: - description: "Path (relative to the repo root) to the OpenAPI spec to lint." + description: "Optional explicit OpenAPI path. Leave empty to use api/index.yaml or canonical discovery." required: false - default: "api/openapi.yaml" + default: "" asyncapi-path: - description: "Path (relative to the repo root) to the AsyncAPI spec to lint." + description: "Optional explicit AsyncAPI path. Leave empty to use api/index.yaml or canonical discovery." required: false - default: "api/asyncapi.yaml" + default: "" fail-on: description: "Minimum severity that fails the check: error, warn, info, or never." required: false @@ -27,6 +27,10 @@ inputs: description: "Set to 'true' to also lint against the guide's strict ruleset." required: false default: "false" + mode: + description: "conformance blocks missing validators and contract gaps; advisory permits validator-free local review." + required: false + default: "conformance" node-version: description: "Node.js version to set up for running the linter." required: false @@ -35,8 +39,8 @@ inputs: description: >- Set to 'true' to install openapi-spec-validator (Python) and @asyncapi/cli (npm) so the driver can additionally run schema - validation. Set to 'false' to skip installing them; the driver is then - called with --skip-validators. + validation. Set to 'false' only with mode=advisory; conformance rejects + skipped or unavailable validators. required: false default: "true" @@ -48,8 +52,7 @@ runs: with: node-version: ${{ inputs.node-version }} - # openapi-spec-validator is a Python tool the driver shells out to when - # present; it's optional, so only install it when asked. + # openapi-spec-validator is a Python tool the driver shells out to. - name: Set up Python (for openapi-spec-validator) if: inputs.install-validators == 'true' uses: actions/setup-python@v5 @@ -59,12 +62,12 @@ runs: - name: Install openapi-spec-validator if: inputs.install-validators == 'true' shell: bash - run: pip install openapi-spec-validator + run: pip install openapi-spec-validator==0.9.0 - name: Install @asyncapi/cli if: inputs.install-validators == 'true' shell: bash - run: npm i -g @asyncapi/cli + run: npm i -g @asyncapi/cli@6.0.2 - name: Install linter dependencies shell: bash @@ -76,10 +79,15 @@ runs: run: | args=( --repo-root "$GITHUB_WORKSPACE" - --openapi "${{ inputs.openapi-path }}" - --asyncapi "${{ inputs.asyncapi-path }}" + --mode "${{ inputs.mode }}" --fail-on "${{ inputs.fail-on }}" ) + if [[ -n "${{ inputs.openapi-path }}" ]]; then + args+=(--openapi "${{ inputs.openapi-path }}") + fi + if [[ -n "${{ inputs.asyncapi-path }}" ]]; then + args+=(--asyncapi "${{ inputs.asyncapi-path }}") + fi if [[ "${{ inputs.strict }}" == "true" ]]; then args+=(--strict) fi diff --git a/api-design-guide/linter/cli.mjs b/api-design-guide/linter/cli.mjs index ef6193c..728471b 100755 --- a/api-design-guide/linter/cli.mjs +++ b/api-design-guide/linter/cli.mjs @@ -28,12 +28,15 @@ const { Spectral, Document } = spectralCore; const { bundleAndLoadRuleset } = bundler; const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SUPPORTED_GUIDE_VERSION = '0.2.0-draft'; +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; // Spectral severity numbers -> our names. 0=error 1=warn 2=info 3=hint. const SEVERITY_NAME = ['error', 'warn', 'info', 'hint']; const SEVERITY_NUM = { error: 0, warn: 1, info: 2, hint: 3 }; // --fail-on threshold: a finding fails the run when its severity number <= threshold. const FAIL_ON_THRESHOLD = { error: 0, warn: 1, info: 2, never: -1 }; +const MODES = new Set(['conformance', 'advisory']); // Directories never scanned for divergent spec copies (§2.3/§3.3). const DIVERGENT_EXCLUDE_DIRS = new Set([ @@ -42,7 +45,6 @@ const DIVERGENT_EXCLUDE_DIRS = new Set([ 'api-design-guide', 'test', 'examples', - 'spec', ]); // Operational failures (bad flags, unreadable/unparseable spec, ruleset load failure) -> exit 2. @@ -65,6 +67,7 @@ function parseCliArgs(argv) { strict: { type: 'boolean', default: false }, 'fail-on': { type: 'string', default: 'error' }, format: { type: 'string', default: 'text' }, + mode: { type: 'string', default: 'conformance' }, 'skip-validators': { type: 'boolean', default: false }, }, allowPositionals: false, @@ -84,6 +87,16 @@ function parseCliArgs(argv) { if (format !== 'text' && format !== 'json') { throw new OperationalError(`Invalid --format value "${format}" (expected text|json).`); } + if (!MODES.has(values.mode)) { + throw new OperationalError( + `Invalid --mode value "${values.mode}" (expected conformance|advisory).`, + ); + } + if (values.mode === 'conformance' && values['skip-validators']) { + throw new OperationalError( + '--skip-validators is only available with --mode advisory; conformance requires base validators.', + ); + } return values; } @@ -107,14 +120,8 @@ function resolveConfig(values) { ? path.resolve(values['repo-root']) : findRepoRoot(process.cwd()); - const openapiPath = path.resolve( - repoRoot, - values.openapi ?? path.join('api', 'openapi.yaml'), - ); - const asyncapiPath = path.resolve( - repoRoot, - values.asyncapi ?? path.join('api', 'asyncapi.yaml'), - ); + const openapiPath = values.openapi ? path.resolve(repoRoot, values.openapi) : null; + const asyncapiPath = values.asyncapi ? path.resolve(repoRoot, values.asyncapi) : null; let rulesetPath; if (values.ruleset) { @@ -130,6 +137,7 @@ function resolveConfig(values) { rulesetPath, failOn: values['fail-on'], format: values.format, + mode: values.mode, skipValidators: values['skip-validators'], }; } @@ -159,13 +167,205 @@ async function loadSpec(absPath, relDisplay) { return { present: true, empty: false, content, data }; } +function driverFinding(file, code, message, { guideRule = null, severity = 'error' } = {}) { + return { + file, + code, + guideRule, + severity, + message, + jsonPath: [], + range: null, + documentationUrl: null, + }; +} + +async function readOptionalText(absPath) { + try { + return { exists: true, content: await fsp.readFile(absPath, 'utf8') }; + } catch (err) { + if (err.code === 'ENOENT') return { exists: false, content: '' }; + throw new OperationalError(`Cannot read ${absPath}: ${err.message}`); + } +} + +function isInside(parent, candidate) { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +function parseYamlObject(content, displayPath) { + try { + const value = YAML.parse(content); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('document root must be an object'); + } + return value; + } catch (err) { + throw new OperationalError(`Cannot parse ${displayPath}: ${err.message}`); + } +} + +function validateIndexDocument(index, indexRel, repoRoot, findings) { + const declarations = []; + let noApi = false; + + if (index.version !== 1) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'api/index.yaml must declare version: 1.')); + } + + const hasApis = Object.prototype.hasOwnProperty.call(index, 'apis'); + const hasNoApi = index.noApi === true; + if (hasApis === hasNoApi) { + findings.push( + driverFinding( + indexRel, + 'api-index-invalid', + 'api/index.yaml must declare exactly one of a non-empty apis list or noApi: true.', + ), + ); + return { declarations, noApi }; + } + + if (hasNoApi) { + noApi = true; + if (typeof index.reason !== 'string' || index.reason.trim().length < 3) { + findings.push( + driverFinding(indexRel, 'api-index-invalid', 'noApi: true requires a non-empty reason.'), + ); + } + return { declarations, noApi }; + } + + if (!Array.isArray(index.apis) || index.apis.length === 0) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'apis must be a non-empty array.')); + return { declarations, noApi }; + } + + const seen = new Set(); + for (const [i, entry] of index.apis.entries()) { + const itemPath = `${indexRel}#apis[${i}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath} must be an object.`)); + continue; + } + if (entry.type !== 'openapi' && entry.type !== 'asyncapi') { + findings.push( + driverFinding(indexRel, 'api-index-invalid', `${itemPath}.type must be openapi or asyncapi.`), + ); + continue; + } + if (typeof entry.path !== 'string' || !entry.path.trim()) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath}.path is required.`)); + continue; + } + const normalized = entry.path.replaceAll('\\', '/').replace(/^\.\//, ''); + const abs = path.resolve(repoRoot, normalized); + if (!isInside(path.join(repoRoot, 'api'), abs) || !/\.ya?ml$/i.test(normalized)) { + findings.push( + driverFinding( + indexRel, + 'api-index-invalid', + `${itemPath}.path must be a repo-relative YAML path inside api/.`, + ), + ); + continue; + } + if (seen.has(abs)) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath}.path is duplicated.`)); + continue; + } + seen.add(abs); + declarations.push({ kind: entry.type, abs, declaredBy: indexRel }); + } + return { declarations, noApi }; +} + +async function discoverApiDeclarations(cfg, rel, findings, notices) { + const explicit = []; + if (cfg.openapiPath) explicit.push({ kind: 'openapi', abs: cfg.openapiPath, declaredBy: 'CLI' }); + if (cfg.asyncapiPath) explicit.push({ kind: 'asyncapi', abs: cfg.asyncapiPath, declaredBy: 'CLI' }); + if (explicit.length) return { declarations: explicit, noApi: false, indexPresent: false }; + + const indexAbs = path.join(cfg.repoRoot, 'api', 'index.yaml'); + const indexRel = rel(indexAbs); + const indexFile = await readOptionalText(indexAbs); + if (indexFile.exists) { + if (!indexFile.content.trim()) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'api/index.yaml must not be empty.')); + return { declarations: [], noApi: false, indexPresent: true }; + } + const index = parseYamlObject(indexFile.content, indexRel); + return { ...validateIndexDocument(index, indexRel, cfg.repoRoot, findings), indexPresent: true }; + } + + const declarations = []; + for (const [kind, name] of [ + ['openapi', 'openapi.yaml'], + ['asyncapi', 'asyncapi.yaml'], + ]) { + const abs = path.join(cfg.repoRoot, 'api', name); + const candidate = await readOptionalText(abs); + if (candidate.exists) declarations.push({ kind, abs, declaredBy: 'canonical-path' }); + } + + if (declarations.length === 0) { + const message = + 'No API declaration found. Add api/openapi.yaml or api/asyncapi.yaml, or declare noApi: true with a reason in api/index.yaml.'; + if (cfg.mode === 'conformance') { + findings.push(driverFinding('api/index.yaml', 'api-declaration-required', message)); + } else { + notices.push(message); + } + } + return { declarations, noApi: false, indexPresent: false }; +} + +async function loadDeclaredSpecs(declarations, rel, findings) { + const specs = []; + for (const declaration of declarations) { + const relSpec = rel(declaration.abs); + const loaded = await loadSpec(declaration.abs, relSpec); + if (!loaded.present) { + findings.push( + driverFinding( + relSpec, + loaded.empty ? 'declared-spec-empty' : 'declared-spec-missing', + `Declared ${declaration.kind} specification ${relSpec} ${loaded.empty ? 'is empty' : 'does not exist'}.`, + { guideRule: declaration.kind === 'openapi' ? '2.2' : '3.2' }, + ), + ); + continue; + } + const actualKind = + typeof loaded.data?.openapi === 'string' + ? 'openapi' + : typeof loaded.data?.asyncapi === 'string' + ? 'asyncapi' + : null; + if (actualKind !== declaration.kind) { + findings.push( + driverFinding( + relSpec, + 'declared-spec-type', + `Declared ${declaration.kind} specification ${relSpec} does not contain a matching root version field.`, + { guideRule: declaration.kind === 'openapi' ? '2.1' : '3.1' }, + ), + ); + continue; + } + specs.push({ kind: declaration.kind, abs: declaration.abs, ...loaded }); + } + return specs; +} + // -------------------------------------------------------------------------------------- // File-tree checks (§2.2 / §2.3 / §3.2 / §3.3) // -------------------------------------------------------------------------------------- -// Legacy api/swagger.{yaml,json}. Non-empty -> file-canonical-name finding (§2.2, error). -// Empty placeholder -> notice only (the bb-template ships empty placeholders). -async function checkLegacySwagger(repoRoot, rel, findings, notices) { +// Legacy api/swagger.{yaml,json} artifacts are always non-conformant, including +// empty placeholders. An explicit no-API declaration lives in api/index.yaml. +async function checkLegacySwagger(repoRoot, rel, findings) { let anyPresent = false; for (const name of ['swagger.yaml', 'swagger.json']) { const abs = path.join(repoRoot, 'api', name); @@ -177,25 +377,15 @@ async function checkLegacySwagger(repoRoot, rel, findings, notices) { throw new OperationalError(`Cannot read ${rel(abs)}: ${err.message}`); } anyPresent = true; - if (content.trim() === '') { - notices.push( - `Empty legacy placeholder ${rel(abs)}; no OpenAPI surface to lint. ` + - `Rename to api/openapi.yaml when you add one (guide §2.2).`, - ); - } else { - findings.push({ - file: rel(abs), - code: 'file-canonical-name', - guideRule: '2.2', - severity: 'error', - message: - `Legacy ${rel(abs)} must be renamed/converted to api/openapi.yaml; ` + - `the canonical OpenAPI entrypoint is api/openapi.yaml (guide §2.2).`, - jsonPath: [], - range: null, - documentationUrl: null, - }); - } + const qualifier = content.trim() === '' ? 'Empty legacy placeholder' : 'Legacy API specification'; + findings.push( + driverFinding( + rel(abs), + 'file-canonical-name', + `${qualifier} ${rel(abs)} must be removed or migrated to a declared YAML entrypoint under api/ (guide §2.2).`, + { guideRule: '2.2' }, + ), + ); } return anyPresent; } @@ -234,7 +424,7 @@ async function sniffSpec(absPath) { } if (size > FULL_PARSE_MAX_BYTES) { // Too large to parse cheaply; trust the sniff (heuristic). - return { kind: 'API' }; + return { kind: 'API', heuristic: true }; } let data; try { @@ -243,16 +433,31 @@ async function sniffSpec(absPath) { return null; } if (data && typeof data === 'object' && !Array.isArray(data)) { - if (typeof data.openapi === 'string') return { kind: 'OpenAPI' }; - if (typeof data.asyncapi === 'string') return { kind: 'AsyncAPI' }; + const hasEntries = (value) => + value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0; + const hasComponents = hasEntries(data.components); + if (typeof data.openapi === 'string') { + return { + kind: 'OpenAPI', + heuristic: false, + supportOnly: hasComponents && !hasEntries(data.paths) && !hasEntries(data.webhooks), + }; + } + if (typeof data.asyncapi === 'string') { + return { + kind: 'AsyncAPI', + heuristic: false, + supportOnly: hasComponents && !hasEntries(data.channels) && !hasEntries(data.operations), + }; + } } return null; } -// Walk the repo tree for spec documents outside api/, excluding the dirs the guide's own -// fixtures and vendor trees live in. Heuristic; findings say so (§2.3/§3.3, warn). +// Walk the repo for undeclared top-level specs, including api/ and spec/ assets. +// Parsed documents are deterministic errors; only oversized sniff-only hits stay advisory. async function scanDivergentCopies(repoRoot, skipAbs, rel, findings) { - const apiDir = path.resolve(repoRoot, 'api'); + const commonRoot = path.resolve(repoRoot, 'api', 'common'); async function walk(dir) { let entries; @@ -266,27 +471,24 @@ async function scanDivergentCopies(repoRoot, skipAbs, rel, findings) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (DIVERGENT_EXCLUDE_DIRS.has(entry.name)) continue; - if (path.resolve(full) === apiDir) continue; await walk(full); } else if (entry.isFile()) { if (!/\.(ya?ml|json)$/i.test(entry.name)) continue; if (skipAbs.has(path.resolve(full))) continue; const hit = await sniffSpec(full); if (hit) { + const insideCommonRoot = path.resolve(full).startsWith(`${commonRoot}${path.sep}`); + if (insideCommonRoot && hit.supportOnly) continue; const guideRule = hit.kind === 'AsyncAPI' ? '3.3' : '2.3'; - findings.push({ - file: rel(full), - code: 'file-divergent-copies', - guideRule, - severity: 'warn', - message: - `Heuristic: ${rel(full)} looks like an ${hit.kind} document outside api/. ` + - `Canonical specs must live under api/ with no divergent copies (guide §${guideRule}); ` + - `markdown snippets must $ref the canonical file. Verify this is not a stray copy.`, - jsonPath: [], - range: null, - documentationUrl: null, - }); + findings.push( + driverFinding( + rel(full), + 'file-undeclared-spec', + `${hit.heuristic ? 'Heuristic: ' : ''}${rel(full)} is an undeclared ${hit.kind} document. ` + + `Every top-level API specification must be canonical or listed in api/index.yaml (guide §${guideRule}).`, + { guideRule, severity: hit.heuristic ? 'warn' : 'error' }, + ), + ); } } } @@ -295,6 +497,354 @@ async function scanDivergentCopies(repoRoot, skipAbs, rel, findings) { await walk(repoRoot); } +// -------------------------------------------------------------------------------------- +// Requirement-to-contract coverage (api/coverage.yaml) +// -------------------------------------------------------------------------------------- + +const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/; +const DISPOSITIONS = new Set(['operation', 'message', 'external', 'not-applicable', 'planned']); +const REQUIREMENT_MARKER_RE = + /^\s*-\s+\*\*([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)\*\*\s+\*\*(REQUIRED|RECOMMENDED|OPTIONAL)\*\*:\s+(.\S|\S.*)$/; +const LEGACY_REQUIREMENT_RE = /\((REQUIRED|RECOMMENDED|OPTIONAL)\)/; + +function nonEmptyStrings(value) { + return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item.trim()); +} + +function isHttpUrl(value) { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function isHttpsUrl(value) { + if (typeof value !== 'string') return false; + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +async function scanRequirementMarkers(repoRoot, rel, findings) { + const specDir = path.join(repoRoot, 'spec'); + const markers = new Map(); + + async function walk(dir) { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch (err) { + if (err.code === 'ENOENT') return; + throw new OperationalError(`Cannot scan requirement sources under ${rel(dir)}: ${err.message}`); + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full); + continue; + } + if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue; + const lines = (await fsp.readFile(full, 'utf8')).split(/\r?\n/); + let fenced = false; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + fenced = !fenced; + continue; + } + if (fenced) continue; + const match = line.match(REQUIREMENT_MARKER_RE); + if (match) { + const [, id, strength, text] = match; + const location = `${rel(full)}:${i + 1}`; + if (markers.has(id)) { + findings.push( + driverFinding( + rel(full), + 'requirements-duplicate-id', + `Requirement id "${id}" is duplicated at ${markers.get(id).location} and ${location}.`, + ), + ); + } else { + markers.set(id, { id, strength, text: text.trim(), location }); + } + continue; + } + if (LEGACY_REQUIREMENT_RE.test(line)) { + findings.push( + driverFinding( + rel(full), + 'requirements-unkeyed', + `Legacy unkeyed requirement at ${rel(full)}:${i + 1}; use "- **REQ-ID** **REQUIRED|RECOMMENDED|OPTIONAL**: text".`, + ), + ); + } else if (/\*\*(REQUIRED|RECOMMENDED|OPTIONAL)\*\*/.test(line)) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Invalid requirement marker at ${rel(full)}:${i + 1}.`, + ), + ); + } + } + } + } + + await walk(specDir); + return markers; +} + +function collectReferenceInventory(specs, rel, findings, coverageRel) { + const operationOwners = new Map(); + const messageOwners = new Map(); + + const addOwner = (map, id, owner) => { + if (typeof id !== 'string' || !id.trim()) return; + const owners = map.get(id) ?? new Set(); + owners.add(owner); + map.set(id, owners); + }; + + for (const spec of specs) { + const owner = rel(spec.abs); + if (spec.kind === 'openapi') { + const paths = spec.data?.paths; + if (paths && typeof paths === 'object') { + for (const item of Object.values(paths)) { + if (!item || typeof item !== 'object') continue; + for (const method of HTTP_METHODS) addOwner(operationOwners, item[method]?.operationId, owner); + } + } + const webhooks = spec.data?.webhooks; + if (webhooks && typeof webhooks === 'object') { + for (const item of Object.values(webhooks)) { + if (!item || typeof item !== 'object') continue; + for (const method of HTTP_METHODS) addOwner(operationOwners, item[method]?.operationId, owner); + } + } + } else { + for (const id of Object.keys(spec.data?.operations ?? {})) addOwner(operationOwners, id, owner); + const ownMessages = new Set(Object.keys(spec.data?.components?.messages ?? {})); + for (const channel of Object.values(spec.data?.channels ?? {})) { + for (const id of Object.keys(channel?.messages ?? {})) ownMessages.add(id); + } + for (const id of ownMessages) addOwner(messageOwners, id, owner); + } + } + + for (const [id, owners] of operationOwners) { + if (owners.size > 1) { + findings.push( + driverFinding( + coverageRel, + 'coverage-ambiguous-reference', + `Operation identifier "${id}" is declared by multiple surfaces: ${[...owners].join(', ')}.`, + ), + ); + } + } + for (const [id, owners] of messageOwners) { + if (owners.size > 1) { + findings.push( + driverFinding( + coverageRel, + 'coverage-ambiguous-reference', + `Message key "${id}" is declared by multiple surfaces: ${[...owners].join(', ')}.`, + ), + ); + } + } + + return { operationOwners, messageOwners }; +} + +async function validateRequirementCoverage(cfg, specs, noApi, rel, findings) { + const coverageAbs = path.join(cfg.repoRoot, 'api', 'coverage.yaml'); + const coverageRel = rel(coverageAbs); + const file = await readOptionalText(coverageAbs); + + if (noApi) { + if (file.exists) { + findings.push( + driverFinding( + coverageRel, + 'coverage-without-api', + 'api/coverage.yaml must be removed when api/index.yaml declares noApi: true.', + ), + ); + } + return; + } + if (specs.length === 0) return; + const markers = await scanRequirementMarkers(cfg.repoRoot, rel, findings); + if (markers.size === 0) { + findings.push( + driverFinding( + 'spec/', + 'requirements-missing', + 'Declared API surfaces require at least one keyed requirement marker under spec/**/*.md.', + ), + ); + } + if (!file.exists || !file.content.trim()) { + findings.push( + driverFinding( + coverageRel, + file.exists ? 'coverage-empty' : 'coverage-missing', + 'Declared API surfaces require a non-empty api/coverage.yaml requirement mapping.', + ), + ); + return; + } + + const doc = parseYamlObject(file.content, coverageRel); + const { operationOwners, messageOwners } = collectReferenceInventory(specs, rel, findings, coverageRel); + if (doc.version !== 1) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', 'api/coverage.yaml must declare version: 1.')); + } + if (!Array.isArray(doc.requirements) || doc.requirements.length === 0) { + findings.push( + driverFinding(coverageRel, 'coverage-invalid', 'requirements must be a non-empty array.'), + ); + return; + } + + const seenIds = new Set(); + for (const [i, entry] of doc.requirements.entries()) { + const label = `requirements[${i}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label} must be an object.`)); + continue; + } + if (typeof entry.id !== 'string' || !REQUIREMENT_ID_RE.test(entry.id)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-invalid', + `${label}.id must match ${REQUIREMENT_ID_RE}.`, + ), + ); + } else if (seenIds.has(entry.id)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `Requirement id "${entry.id}" is duplicated.`)); + } else { + seenIds.add(entry.id); + if (!markers.has(entry.id)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-unknown-requirement', + `Coverage id "${entry.id}" has no matching marker under spec/**/*.md.`, + ), + ); + } + } + + if (!DISPOSITIONS.has(entry.disposition)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-invalid', + `${label}.disposition must be one of ${[...DISPOSITIONS].join(', ')}.`, + ), + ); + continue; + } + + const allowedKeys = { + operation: new Set(['id', 'disposition', 'operations']), + message: new Set(['id', 'disposition', 'messages']), + external: new Set(['id', 'disposition', 'reference']), + 'not-applicable': new Set(['id', 'disposition', 'rationale']), + planned: new Set(['id', 'disposition', 'issue']), + }[entry.disposition]; + const extras = Object.keys(entry).filter((key) => !allowedKeys.has(key)); + if (extras.length) { + findings.push( + driverFinding( + coverageRel, + 'coverage-invalid', + `${label} has fields incompatible with ${entry.disposition}: ${extras.join(', ')}.`, + ), + ); + } + + if (entry.disposition === 'operation') { + if (!nonEmptyStrings(entry.operations)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.operations must be a non-empty string array.`)); + } else { + for (const id of new Set(entry.operations)) { + if (!operationOwners.has(id)) { + findings.push( + driverFinding(coverageRel, 'coverage-missing-reference', `${label} references unknown operation "${id}".`), + ); + } + } + } + } else if (entry.disposition === 'message') { + if (!nonEmptyStrings(entry.messages)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.messages must be a non-empty string array.`)); + } else { + for (const id of new Set(entry.messages)) { + if (!messageOwners.has(id)) { + findings.push( + driverFinding(coverageRel, 'coverage-missing-reference', `${label} references unknown message "${id}".`), + ); + } + } + } + } else if (entry.disposition === 'external') { + if (!isHttpUrl(entry.reference)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.reference must be an http(s) URL.`)); + } + } else if (entry.disposition === 'not-applicable') { + if (typeof entry.rationale !== 'string' || entry.rationale.trim().length < 3) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.rationale is required.`)); + } + if (markers.get(entry.id)?.strength === 'REQUIRED') { + findings.push( + driverFinding( + coverageRel, + 'coverage-required-not-applicable', + `${label} cannot mark REQUIRED requirement "${entry.id}" as not-applicable.`, + ), + ); + } + } else { + if (!isHttpUrl(entry.issue)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.issue must be an http(s) URL.`)); + } else { + findings.push( + driverFinding( + coverageRel, + 'coverage-planned', + `${label} remains planned and is not implemented: ${entry.issue}.`, + { severity: cfg.mode === 'conformance' ? 'error' : 'warn' }, + ), + ); + } + } + } + + for (const [id, marker] of markers) { + if (!seenIds.has(id)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-missing-requirement', + `Requirement "${id}" at ${marker.location} is not represented in api/coverage.yaml.`, + ), + ); + } + } +} + // -------------------------------------------------------------------------------------- // Base validators (§20.1) // -------------------------------------------------------------------------------------- @@ -328,9 +878,9 @@ async function validateOpenapi(absPath) { const r = await runCommand('openapi-spec-validator', [absPath]); if (r.spawnError) { return { - notice: + unavailable: `openapi-spec-validator not found; skipping OpenAPI base validation (§20.1). ` + - `Install with: pip install openapi-spec-validator`, + `Install with: pip install openapi-spec-validator`, }; } if (r.code === 0) return { ok: true }; @@ -356,31 +906,32 @@ async function validateAsyncapi(absPath) { const global = await runCommand('asyncapi', ['validate', absPath]); if (global.spawnError) { return { - notice: + unavailable: `AsyncAPI CLI not found; skipping AsyncAPI base validation (§20.1). ` + - `Install with: npm i -g @asyncapi/cli`, + `Install with: npm i -g @asyncapi/cli`, }; } if (global.code === 0) return { ok: true }; return { finding: `asyncapi validate: ${trimOutput(`${global.stdout}\n${global.stderr}`)}` }; } -async function runBaseValidator(kind, absPath, rel, findings, notices) { +async function runBaseValidator(kind, absPath, rel, findings, notices, mode) { const result = kind === 'openapi' ? await validateOpenapi(absPath) : await validateAsyncapi(absPath); - if (result.notice) { - notices.push(result.notice); + if (result.unavailable) { + if (mode === 'conformance') { + findings.push( + driverFinding(rel(absPath), 'base-validator-unavailable', result.unavailable, { + guideRule: '20.1', + }), + ); + } else { + notices.push(result.unavailable); + } } else if (result.finding) { - findings.push({ - file: rel(absPath), - code: 'base-validator', - guideRule: '20.1', - severity: 'error', - message: result.finding, - jsonPath: [], - range: null, - documentationUrl: null, - }); + findings.push( + driverFinding(rel(absPath), 'base-validator', result.finding, { guideRule: '20.1' }), + ); } } @@ -427,64 +978,155 @@ function mapSpectralResult(r, relPath) { // Guide-version declaration & exceptions (§20.3) // -------------------------------------------------------------------------------------- -// exceptions entries may be strings ("9.2") or objects ({rule, record}). Normalize to a -// Map of guideRuleId -> record (string|null). -function normalizeExceptions(raw) { - const map = new Map(); - if (!Array.isArray(raw)) return map; - for (const item of raw) { - if (typeof item === 'string') { - map.set(item.trim(), null); - } else if (item && typeof item === 'object') { - const rule = item.rule ?? item.id ?? item.ruleId; - if (typeof rule === 'string') { - map.set(rule.trim(), item.record ?? item.reference ?? item.ref ?? null); - } +let cachedGuideCatalogue; +function getGuideCatalogue() { + if (cachedGuideCatalogue) return cachedGuideCatalogue; + try { + const coverage = YAML.parse(fs.readFileSync(path.join(HERE, 'coverage.yaml'), 'utf8')); + const catalogue = YAML.parse(fs.readFileSync(path.join(HERE, '..', 'rules.yaml'), 'utf8')); + if (coverage?.guide_version !== SUPPORTED_GUIDE_VERSION) { + throw new Error( + `coverage.yaml guide_version is ${coverage?.guide_version ?? '(missing)'}, expected ${SUPPORTED_GUIDE_VERSION}`, + ); + } + if (catalogue?.version !== SUPPORTED_GUIDE_VERSION) { + throw new Error( + `rules.yaml version is ${catalogue?.version ?? '(missing)'}, expected ${SUPPORTED_GUIDE_VERSION}`, + ); } + cachedGuideCatalogue = { + version: SUPPORTED_GUIDE_VERSION, + ids: new Set((catalogue.rules ?? []).map((rule) => String(rule.id))), + }; + return cachedGuideCatalogue; + } catch (err) { + throw new OperationalError(`Cannot load the supported guide catalogue: ${err.message}`); } - return map; } -function majorMinor(version) { - const m = String(version).match(/^(\d+)\.(\d+)/); - return m ? `${m[1]}.${m[2]}` : null; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const EXCEPTION_KEYS = new Set([ + 'rule', + 'scope', + 'rationale', + 'record', + 'reviewedBy', + 'reviewedAt', + 'expiresAt', +]); +const JSON_POINTER_RE = /^(?:\/(?:[^~]|~[01])*)*$/; + +function decodeJsonPointer(pointer) { + if (pointer === '') return []; + if (typeof pointer !== 'string' || !JSON_POINTER_RE.test(pointer)) return null; + return pointer + .slice(1) + .split('/') + .map((token) => token.replaceAll('~1', '/').replaceAll('~0', '~')); } -let cachedGuideVersion; -function getGuideVersion() { - if (cachedGuideVersion !== undefined) return cachedGuideVersion; - cachedGuideVersion = null; - try { - const raw = fs.readFileSync(path.join(HERE, 'coverage.yaml'), 'utf8'); - const parsed = YAML.parse(raw); - if (parsed && typeof parsed.guide_version === 'string') { - cachedGuideVersion = parsed.guide_version; - } - } catch { - // coverage.yaml missing/unparseable: skip version comparison silently (it ships beside - // this CLI; its absence is not a spec error). - } - return cachedGuideVersion; +function applicableException(exceptions, finding) { + const candidates = exceptions.get(finding.guideRule) ?? []; + const findingPath = Array.isArray(finding.jsonPath) ? finding.jsonPath.map(String) : []; + return candidates.find( + (candidate) => + candidate.decodedScope.length <= findingPath.length && + candidate.decodedScope.every((token, i) => token === findingPath[i]), + ); } -// Reads info.x-govstack-api-guide. Returns { exceptions: Map }. Emits a version-mismatch -// notice when the declared major.minor differs from the linter's target guide version. -function consumeGuideDeclaration(specData, relPath, notices) { +function consumeGuideDeclaration(specData, relPath, findings, mode) { + const { version, ids } = getGuideCatalogue(); const decl = specData?.info?.['x-govstack-api-guide']; - if (!decl || typeof decl !== 'object') return { exceptions: new Map() }; - - const guideVersion = getGuideVersion(); - if (typeof decl.version === 'string' && guideVersion) { - const declMM = majorMinor(decl.version); - const targetMM = majorMinor(guideVersion); - if (declMM && targetMM && declMM !== targetMM) { - notices.push( - `${relPath} declares guide version ${decl.version}, but this linter targets ` + - `${guideVersion} (major.minor mismatch); applied rules may differ (guide §20.3).`, + const severity = mode === 'conformance' ? 'error' : 'warn'; + if (!decl || typeof decl !== 'object' || Array.isArray(decl)) { + findings.push( + driverFinding( + relPath, + 'guide-version', + `Specification must declare info.x-govstack-api-guide.version and rulesetVersion as ${version}.`, + { guideRule: '20.3', severity }, + ), + ); + return { exceptions: new Map() }; + } + if (decl.version !== version) { + findings.push( + driverFinding( + relPath, + 'guide-version', + `Specification declares guide version ${String(decl.version)}, but this linter supports exactly ${version}.`, + { guideRule: '20.3', severity }, + ), + ); + } + if (decl.rulesetVersion !== version) { + findings.push( + driverFinding( + relPath, + 'guide-version', + `Specification declares ruleset version ${String(decl.rulesetVersion)}, but this linter supports exactly ${version}.`, + { guideRule: '20.3', severity }, + ), + ); + } + + const exceptions = new Map(); + if (decl.exceptions === undefined) return { exceptions }; + if (!Array.isArray(decl.exceptions)) { + findings.push( + driverFinding(relPath, 'guide-exception', 'x-govstack-api-guide.exceptions must be an array.', { + guideRule: '20.3', + }), + ); + return { exceptions }; + } + + const today = new Date(); + for (const [i, item] of decl.exceptions.entries()) { + const label = `x-govstack-api-guide.exceptions[${i}]`; + const problems = []; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + problems.push('must be an object'); + } else { + const extra = Object.keys(item).filter((key) => !EXCEPTION_KEYS.has(key)); + if (extra.length) problems.push(`has unsupported fields: ${extra.join(', ')}`); + if (typeof item.rule !== 'string' || !ids.has(item.rule)) problems.push('must name a known guide rule'); + const decodedScope = decodeJsonPointer(item.scope); + if (decodedScope === null) problems.push('scope must be a valid RFC 6901 JSON Pointer'); + if (typeof item.rationale !== 'string' || item.rationale.trim().length < 10) { + problems.push('requires a substantive rationale'); + } + if (!isHttpsUrl(item.record)) problems.push('record must be an approved HTTPS link'); + if (typeof item.reviewedBy !== 'string' || item.reviewedBy.trim().length < 2) { + problems.push('reviewedBy is required'); + } + if (!DATE_RE.test(item.reviewedAt ?? '')) problems.push('reviewedAt must be YYYY-MM-DD'); + if (!DATE_RE.test(item.expiresAt ?? '')) problems.push('expiresAt must be YYYY-MM-DD'); + if (DATE_RE.test(item.reviewedAt ?? '') && DATE_RE.test(item.expiresAt ?? '')) { + const reviewed = new Date(`${item.reviewedAt}T00:00:00Z`); + const expires = new Date(`${item.expiresAt}T23:59:59Z`); + if (reviewed > expires) problems.push('expiresAt must be after reviewedAt'); + if (expires < today) problems.push('exception has expired'); + } + const duplicate = (exceptions.get(item.rule) ?? []).some( + (candidate) => candidate.scope === item.scope, + ); + if (duplicate) problems.push(`duplicates exception for rule ${item.rule} at scope ${item.scope}`); + } + if (problems.length) { + findings.push( + driverFinding(relPath, 'guide-exception', `${label} ${problems.join('; ')}.`, { + guideRule: '20.3', + }), ); + continue; } + const candidates = exceptions.get(item.rule) ?? []; + candidates.push({ ...item, decodedScope: decodeJsonPointer(item.scope) }); + exceptions.set(item.rule, candidates); } - return { exceptions: normalizeExceptions(decl.exceptions) }; + return { exceptions }; } // -------------------------------------------------------------------------------------- @@ -525,7 +1167,7 @@ function groupFindings(findings) { } function renderText(report) { - const { files, notices, suppressed, summary, failOn, failed, noSpecBanner } = report; + const { files, notices, suppressed, summary, failOn, mode, failed, noSpecBanner } = report; const lines = []; if (noSpecBanner) { @@ -572,12 +1214,12 @@ function renderText(report) { `Summary: ${summary.filesLinted} file(s) linted; ${summary.errors} error(s), ` + `${summary.warnings} warning(s), ${summary.info} info; ${summary.suppressed} suppressed.`, ); - lines.push(`Result: ${failed ? 'FAIL' : 'PASS'} (fail-on=${failOn}).`); + lines.push(`Result: ${failed ? 'FAIL' : 'PASS'} (mode=${mode}, fail-on=${failOn}).`); return lines.join('\n'); } function renderJson(report) { - const { files, notices, summary, failOn, failed } = report; + const { files, notices, summary, failOn, mode, failed } = report; return JSON.stringify( { files: files.map((f) => ({ @@ -605,6 +1247,7 @@ function renderJson(report) { notices, summary, failOn, + mode, failed, }, null, @@ -627,27 +1270,22 @@ async function main(argv) { const findings = []; const suppressed = []; const notices = []; + getGuideCatalogue(); - // --- Load candidate spec files ------------------------------------------------------- - const openapi = await loadSpec(cfg.openapiPath, rel(cfg.openapiPath)); - const asyncapi = await loadSpec(cfg.asyncapiPath, rel(cfg.asyncapiPath)); - - const specs = []; - if (openapi.present) specs.push({ kind: 'openapi', abs: cfg.openapiPath, ...openapi }); - if (asyncapi.present) specs.push({ kind: 'asyncapi', abs: cfg.asyncapiPath, ...asyncapi }); + // --- Discover and load declared spec files ------------------------------------------- + const discovery = await discoverApiDeclarations(cfg, rel, findings, notices); + const specs = await loadDeclaredSpecs(discovery.declarations, rel, findings); // --- File-tree checks (§2.2/§2.3/§3.2/§3.3) ----------------------------------------- - const legacyPresent = await checkLegacySwagger(cfg.repoRoot, rel, findings, notices); - const skipAbs = new Set([cfg.openapiPath, cfg.asyncapiPath].map((p) => path.resolve(p))); + await checkLegacySwagger(cfg.repoRoot, rel, findings); + const skipAbs = new Set(discovery.declarations.map((entry) => path.resolve(entry.abs))); + skipAbs.add(path.join(cfg.repoRoot, 'api', 'swagger.yaml')); + skipAbs.add(path.join(cfg.repoRoot, 'api', 'swagger.json')); await scanDivergentCopies(cfg.repoRoot, skipAbs, rel, findings); + await validateRequirementCoverage(cfg, specs, discovery.noApi, rel, findings); + if (discovery.noApi) notices.push('api/index.yaml explicitly declares that this BB exposes no API surface.'); const noSpec = specs.length === 0; - if (noSpec && !legacyPresent) { - notices.push( - `No API spec files found (looked for ${rel(cfg.openapiPath)} and ${rel(cfg.asyncapiPath)}). ` + - `An API surface is optional; nothing to lint.`, - ); - } // --- Spectral (§20.2) + base validators (§20.1) + guide declaration (§20.3) ---------- let spectral; @@ -655,11 +1293,11 @@ async function main(argv) { const relSpec = rel(spec.abs); // §20.3: version comparison + exception set for this spec. - const { exceptions } = consumeGuideDeclaration(spec.data, relSpec, notices); + const { exceptions } = consumeGuideDeclaration(spec.data, relSpec, findings, cfg.mode); // §20.1 base validator. if (!cfg.skipValidators) { - await runBaseValidator(spec.kind, spec.abs, rel, findings, notices); + await runBaseValidator(spec.kind, spec.abs, rel, findings, notices, cfg.mode); } // §20.2 Spectral. @@ -678,21 +1316,14 @@ async function main(argv) { for (const r of results) { const finding = mapSpectralResult(r, relSpec); - if (finding.guideRule && exceptions.has(finding.guideRule)) { - suppressed.push({ ...finding, exceptionRecord: exceptions.get(finding.guideRule) }); + const exception = finding.guideRule ? applicableException(exceptions, finding) : null; + if (exception) { + suppressed.push({ ...finding, exceptionRecord: exception.record }); } else { findings.push(finding); } } - // §20.1/§20.3: base-validator findings for this spec are also subject to its exceptions. - for (let i = findings.length - 1; i >= 0; i -= 1) { - const f = findings[i]; - if (f.code === 'base-validator' && f.file === relSpec && exceptions.has(f.guideRule)) { - findings.splice(i, 1); - suppressed.push({ ...f, exceptionRecord: exceptions.get(f.guideRule) }); - } - } } // --- Assemble per-file report -------------------------------------------------------- @@ -737,8 +1368,9 @@ async function main(argv) { suppressed: suppressed.length, }, failOn: cfg.failOn, + mode: cfg.mode, failed, - noSpecBanner: noSpec && !legacyPresent, + noSpecBanner: noSpec && !discovery.noApi, }; const output = cfg.format === 'json' ? renderJson(report) : renderText(report); diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 9e5b3f9..965853d 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -1,6 +1,6 @@ # GovStack API lint coverage manifest: every guide rule and how (or why not) the linter covers it. # Hand-maintained alongside the rulesets; machine-checked by tests/coverage.test.mjs against -# ../rules.yaml (all 166 ids present exactly once) and against the rule names actually defined +# ../rules.yaml (all 171 ids present exactly once) and against the rule names actually defined # in ruleset.yaml / strict.yaml (spectral_rules lists must match, both directions). # # status values: @@ -12,13 +12,13 @@ # runtime - constrains wire behaviour; conformance test-harness territory, not a linter's # human - review/governance judgment; no useful automated check # informative - non-normative guide entry; nothing to enforce -guide_version: "0.2.0" +guide_version: "0.2.0-draft" rules: - id: "2.1" class: "M" status: implemented spectral_rules: [govstack-2.1] - note: "assert openapi == \"3.1.0\"" + note: "assert openapi is one of the ruleset-qualified patches 3.1.0, 3.1.1, 3.1.2" - id: "2.2" class: "M+R" status: driver @@ -43,7 +43,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-2.6] - note: "proxy: servers non-empty and no localhost/127.0.0.1 URLs; \"meaningful\"/\"not fake\" unverifiable" + note: "deterministic: non-empty HTTPS servers, no loopback, no duplicated /vN; meaningful/fake-domain judgment remains review" - id: "2.7" class: "M+R" status: implemented @@ -119,6 +119,16 @@ rules: status: strict-only spectral_rules: [govstack-4.4] note: "proxy: flag identical descriptions reused across operations" + - id: "4.5" + class: "M+R" + status: driver + spectral_rules: [] + note: "driver validates canonical discovery, api/index.yaml shape and paths, declared document types, explicit noApi reasons, and undeclared spec copies" + - id: "4.6" + class: "M+R" + status: driver + spectral_rules: [] + note: "driver validates exact Markdown marker and coverage ID sets, dispositions and compatible fields, REQUIRED/not-applicable conflicts, operation/message references, uniqueness across surfaces, and planned gaps" - id: "5.1" class: "M" status: implemented @@ -267,8 +277,8 @@ rules: - id: "7.14" class: "M" status: partial-proxy - spectral_rules: [govstack-7.14] - note: "proxy: each operation declares >1 code / >=1 non-2xx" + spectral_rules: [govstack-7.14, govstack-7.14-creation-status, govstack-7.14-baseline-errors] + note: "proxy: >1 code and >=1 non-2xx; creation-like POSTs expose 201/202; visible security/scope/body/parameter/path shape deterministically requires baseline 401/403/400/404" - id: "7.15" class: "R" status: human @@ -299,6 +309,11 @@ rules: status: implemented spectral_rules: [govstack-7.20] note: "problem+json / operation-status responses declare Cache-Control (no-store)" + - id: "7.21" + class: "M" + status: implemented + spectral_rules: [govstack-7.21] + note: "declared 2xx response media types carry non-empty schemas; 204 has no content" - id: "8.1" class: "M+R" status: partial-proxy @@ -318,12 +333,12 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-8.4] - note: "proxy: request declares X-Request-Id param + response header" + note: "proxy: operations with effective nonempty security declare W3C traceparent request header; runtime propagation remains review/test" - id: "8.5" class: "M" status: implemented spectral_rules: [govstack-8.5] - note: "no header param/response header uses X- prefix except X-Request-Id" + note: "no header parameter or response header uses the X- prefix" - id: "8.6" class: "R" status: strict-only @@ -332,8 +347,8 @@ rules: - id: "8.7" class: "M+R" status: partial-proxy - spectral_rules: [govstack-8.7] - note: "proxy: 429 responses declare Retry-After; rate-limited ops declare RateLimit-* headers" + spectral_rules: [govstack-8.7, govstack-8.7-no-legacy] + note: "proxy: 429 responses declare Retry-After and structured RateLimit; deterministic error forbids legacy RateLimit-Limit/Remaining/Reset" - id: "9.1" class: "M+R" status: partial-proxy @@ -343,7 +358,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-9.2] - note: "recursive: all schema property names camelCase. Walks components.schemas only; inline body schemas not walked." + note: "recursive: all reusable and inline OpenAPI body fields plus AsyncAPI payload fields use camelCase" - id: "9.3" class: "M" status: partial-proxy @@ -358,7 +373,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-9.5] - note: "recursive: property names no spaces/non-ASCII. Walks components.schemas only; inline body schemas not walked." + note: "recursive: reusable and inline OpenAPI body fields plus AsyncAPI payload fields contain no spaces/non-ASCII" - id: "9.6" class: "R" status: strict-only @@ -388,7 +403,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-9.11] - note: "in-doc: extract bb-code from error codes/scopes/event types/channels, verify identical + regex ^[a-z][a-z0-9-]{1,30}$; ecosystem-uniqueness needs registry" + note: "in-doc: extract bb-code from error codes/scopes/event types/logical channel IDs, verify identical + regex ^[a-z][a-z0-9-]{1,30}$; protocol-native address values are excluded; ecosystem-uniqueness needs registry" - id: "10.1" class: "M+R" status: partial-proxy @@ -474,6 +489,11 @@ rules: status: needs-context spectral_rules: [] note: "common errors $ref'd from govstack-openapi-common.yaml - needs common file" + - id: "11.8" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "shared GovStackAsyncError reference and transport-specific rejection binding require common-artifact resolution; §17.16 has a weaker in-document proxy" - id: "12.1" class: "M+R" status: partial-proxy @@ -532,8 +552,8 @@ rules: - id: "13.2" class: "M+R" status: partial-proxy - spectral_rules: [govstack-13.2] - note: "proxy: an openIdConnect/oauth2 scheme exists" + spectral_rules: [govstack-13.2, govstack-13.2-forbidden-flows] + note: "proxy: an openIdConnect/oauth2 scheme exists; deterministic error forbids password and implicit OAuth2 flows" - id: "13.3" class: "M+R" status: partial-proxy @@ -554,6 +574,11 @@ rules: status: partial-proxy spectral_rules: [govstack-13.6] note: "proxy: apiKey applied only to operational (/health-like) ops" + - id: "13.7" + class: "M+R" + status: human + spectral_rules: [] + note: "externally reachable surface classification, equivalent AsyncAPI transport protection, and deployment TLS configuration require protocol and runtime review; §2.6 separately checks declared OpenAPI server URLs" - id: "14.1" class: "M+R" status: partial-proxy @@ -680,10 +705,10 @@ rules: spectral_rules: [govstack-17.1] note: "every operation has action in {send,receive}" - id: "17.2" - class: "M" - status: implemented + class: "M+R" + status: partial-proxy spectral_rules: [govstack-17.2] - note: "channel address keys match org.govstack.{bb-code}.v{major}.{resource}.{event}" + note: "logical channel keys match org.govstack.{bb-code}.v{major}.{resource}.{event}; native address syntax and binding mapping require protocol-aware review" - id: "17.3" class: "R" status: strict-only @@ -783,7 +808,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-18.2-openapi, govstack-18.2-asyncapi] - note: "OpenAPI paths carry /v{N}; AsyncAPI channels carry major version; consistency with info.version major" + note: "OpenAPI paths and AsyncAPI logical channel IDs carry / match info.version major; the alternative common-schema version field is not checked" - id: "18.3" class: "M+R" status: needs-context @@ -843,4 +868,4 @@ rules: class: "M" status: implemented spectral_rules: [govstack-20.3, govstack-20.3-exceptions] - note: "info.x-govstack-api-guide has version(SemVer) + optional exceptions[]" + note: "exact guide/ruleset versions; scoped exceptions require known rule, rationale, HTTPS record, reviewer, review date, and unexpired expiry" diff --git a/api-design-guide/linter/functions/s07-baselineResponses.js b/api-design-guide/linter/functions/s07-baselineResponses.js new file mode 100644 index 0000000..2dc2b93 --- /dev/null +++ b/api-design-guide/linter/functions/s07-baselineResponses.js @@ -0,0 +1,50 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** Deterministic baseline errors whose applicability is visible in OAS. */ +export default function baselineResponses(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const rootSecurity = Array.isArray(targetVal.security) ? targetVal.security : null; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const results = []; + + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation)) continue; + const responses = isObject(operation.responses) ? operation.responses : {}; + const required = new Set(); + const effectiveSecurity = Array.isArray(operation.security) ? operation.security : rootSecurity; + if (Array.isArray(effectiveSecurity) && effectiveSecurity.length > 0) { + required.add('401'); + const hasScopes = effectiveSecurity.some( + (requirement) => + isObject(requirement) && + Object.values(requirement).some((scopes) => Array.isArray(scopes) && scopes.length > 0), + ); + if (hasScopes) required.add('403'); + } + if (isObject(operation.requestBody)) required.add('400'); + const parameters = [ + ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []), + ...(Array.isArray(operation.parameters) ? operation.parameters : []), + ]; + if (parameters.some((parameter) => isObject(parameter) && ['path', 'query'].includes(parameter.in))) { + required.add('400'); + } + if (/\{[^}]+\}/.test(pathKey)) required.add('404'); + for (const status of required) { + if (!Object.prototype.hasOwnProperty.call(responses, status)) { + results.push({ + message: `${method.toUpperCase()} ${pathKey} should declare applicable baseline response ${status}`, + path: [...base, 'paths', pathKey, method, 'responses'], + }); + } + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s07-creationResponses.js b/api-design-guide/linter/functions/s07-creationResponses.js new file mode 100644 index 0000000..7f2d87d --- /dev/null +++ b/api-design-guide/linter/functions/s07-creationResponses.js @@ -0,0 +1,25 @@ +import { isObject } from './lib/util.js'; + +const CREATE_OPERATION_RE = /^(create|register|add|submit|start|initiate)[A-Z0-9_]/; + +/** Advisory proxy: operationIds that clearly describe creation should expose + * either synchronous 201 or asynchronous 202 semantics. */ +export default function creationResponses(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const results = []; + for (const [pathKey, pathItem] of Object.entries(paths)) { + const operation = isObject(pathItem) ? pathItem.post : null; + if (!isObject(operation) || typeof operation.operationId !== 'string') continue; + if (!CREATE_OPERATION_RE.test(operation.operationId)) continue; + const responses = isObject(operation.responses) ? operation.responses : {}; + if (!Object.prototype.hasOwnProperty.call(responses, '201') && !Object.prototype.hasOwnProperty.call(responses, '202')) { + results.push({ + message: `creation-like POST ${pathKey} (${operation.operationId}) should declare a 201 or 202 response`, + path: [...base, 'paths', pathKey, 'post', 'responses'], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s07-successResponseSchema.js b/api-design-guide/linter/functions/s07-successResponseSchema.js new file mode 100644 index 0000000..4f2b529 --- /dev/null +++ b/api-design-guide/linter/functions/s07-successResponseSchema.js @@ -0,0 +1,49 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** Enforce the deterministic successful-response schema clauses of guide 7.21. */ +export default function successResponseSchema(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const branch of ['paths', 'webhooks']) { + const pathItems = targetVal[branch]; + if (!isObject(pathItems)) continue; + for (const [pathKey, pathItem] of Object.entries(pathItems)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation) || !isObject(operation.responses)) continue; + for (const [status, response] of Object.entries(operation.responses)) { + if (!/^2\d\d$/.test(status) || !isObject(response) || response.content === undefined) continue; + const responsePath = [...base, branch, pathKey, method, 'responses', status, 'content']; + if (status === '204') { + results.push({ + message: `${method.toUpperCase()} ${pathKey} response 204 must not declare content`, + path: responsePath, + }); + continue; + } + if (!isObject(response.content) || Object.keys(response.content).length === 0) { + results.push({ + message: `${method.toUpperCase()} ${pathKey} response ${status} content must declare at least one media type schema`, + path: responsePath, + }); + continue; + } + for (const [mediaType, media] of Object.entries(response.content)) { + if (!isObject(media) || !isObject(media.schema) || Object.keys(media.schema).length === 0) { + results.push({ + message: `${method.toUpperCase()} ${pathKey} response ${status} media type ${mediaType} must declare a non-empty schema`, + path: [...responsePath, mediaType], + }); + } + } + } + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-noXHeaders.js b/api-design-guide/linter/functions/s08-noXHeaders.js index e952839..420140f 100644 --- a/api-design-guide/linter/functions/s08-noXHeaders.js +++ b/api-design-guide/linter/functions/s08-noXHeaders.js @@ -1,12 +1,10 @@ import { isObject } from './lib/util.js'; const OPS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; -const ALLOWED_X_HEADER = 'x-request-id'; /** * s08 noXHeaders — guide 8.5: new custom headers MUST NOT use the `X-` - * prefix (RFC 6648), except the legacy X-Request-Id correlation header - * (§8.4, pending [OPEN-7-A]). + * prefix (RFC 6648). There is no exception on conforming new surfaces. * * Scans every header-carrying location in the document: operation and * path-item parameters with `in: header`, response `headers` maps (inline @@ -27,7 +25,7 @@ export default function noXHeaders(targetVal, options, context) { const flagName = (name, path) => { if (typeof name !== 'string') return; - if (/^x-/i.test(name) && name.toLowerCase() !== ALLOWED_X_HEADER) { + if (/^x-/i.test(name)) { results.push({ message: `header "${name}" uses the reserved "X-" prefix (RFC 6648); rename without the X- prefix`, path, diff --git a/api-design-guide/linter/functions/s08-rateLimitHeaders.js b/api-design-guide/linter/functions/s08-rateLimitHeaders.js index 6ec96f6..ee4ddc3 100644 --- a/api-design-guide/linter/functions/s08-rateLimitHeaders.js +++ b/api-design-guide/linter/functions/s08-rateLimitHeaders.js @@ -1,23 +1,22 @@ import { isObject } from './lib/util.js'; -const RATE_LIMIT_HEADERS = ['ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']; +const LEGACY_RATE_LIMIT_HEADERS = ['ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']; const shouldCheck = (status) => /^2\d\d$/.test(status) || status === '429'; /** * s08 rateLimitHeaders — proxy for guide 8.7: endpoints rate-limited by the - * BB itself MUST declare rate-limit response headers (the v0.1 three-header - * form: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset), and 429 - * responses MUST additionally declare Retry-After. + * BB itself MUST declare the structured RateLimit response header, and 429 + * responses MUST additionally declare Retry-After. Legacy three-header names + * are forbidden in `forbidLegacy` mode. * * Proxy signal: an operation that declares a 429 response is treated as * "rate-limited by the BB itself" (the delegated-to-gateway exception, * which requires only a prose statement, is not mechanically checkable). - * The RateLimit-* trio is then required on that operation's 2xx and 429 - * responses. + * RateLimit is then required on that operation's 2xx and 429 responses. * * Does NOT verify: that rate limiting is actually implemented, the - * delegated-to-gateway prose exception, or the newer structured-field draft - * form (only the three-header v0.1 form is checked). + * delegated-to-gateway prose exception, or the header's runtime field + * values. * * `given` should select an operation's `responses`, e.g. * `$.paths[*][get,put,post,delete,patch].responses`. @@ -33,6 +32,22 @@ export default function rateLimitHeaders(targetVal, options, context) { const results = []; const statuses = Object.keys(targetVal); + if (options?.forbidLegacy === true) { + for (const [status, response] of Object.entries(targetVal)) { + const declared = isObject(response) && isObject(response.headers) + ? Object.keys(response.headers).map((header) => header.toLowerCase()) + : []; + const legacy = LEGACY_RATE_LIMIT_HEADERS.filter((header) => declared.includes(header)); + if (legacy.length) { + results.push({ + message: `${status} response declares legacy rate-limit headers: ${legacy.join(', ')}`, + path: [...base, status, 'headers'], + }); + } + } + return results.length ? results : undefined; + } + if (!statuses.includes('429')) return; const response429 = targetVal['429']; @@ -47,10 +62,9 @@ export default function rateLimitHeaders(targetVal, options, context) { const response = targetVal[status]; if (!isObject(response)) continue; const declared = isObject(response.headers) ? Object.keys(response.headers).map((h) => h.toLowerCase()) : []; - const missing = RATE_LIMIT_HEADERS.filter((h) => !declared.includes(h)); - if (missing.length) { + if (!declared.includes('ratelimit')) { results.push({ - message: `${status} response must declare rate-limit headers: ${missing.join(', ')}`, + message: `${status} response must declare the structured "RateLimit" header`, path: [...base, status], }); } diff --git a/api-design-guide/linter/functions/s08-requestIdCorrelation.js b/api-design-guide/linter/functions/s08-requestIdCorrelation.js deleted file mode 100644 index e6dfb61..0000000 --- a/api-design-guide/linter/functions/s08-requestIdCorrelation.js +++ /dev/null @@ -1,58 +0,0 @@ -import { isObject } from './lib/util.js'; - -/** - * s08 requestIdCorrelation — proxy for guide 8.4: a request SHOULD carry an - * X-Request-Id header, and the server MUST echo it in the response (or - * generate one if absent from the request). - * - * Checks, per operation: - * - it SHOULD declare an X-Request-Id header parameter (in: header); - * - every 2xx response MUST declare an X-Request-Id response header - * (mechanically, presence in the spec stands in for "echoed or - * generated" — the runtime behaviour is out of scope). - * - * Does NOT verify: actual runtime echo/generate behaviour, distinctness - * from the error-envelope `traceId` (§11.3, explicitly a BB's choice), or - * path-item-level (shared) parameters — only operation-level parameters are - * inspected. - * - * `given` should select an operation, e.g. - * `$.paths[*][get,put,post,delete,patch]`. - * - * @param {unknown} targetVal - an operation object. - * @param {object} options - * @param {{path?: (string|number)[]}} [context] - * @returns {{message:string, path:(string|number)[]}[]|undefined} - */ -export default function requestIdCorrelation(targetVal, options, context) { - if (!isObject(targetVal)) return; - const base = context && Array.isArray(context.path) ? context.path : []; - const results = []; - - const params = Array.isArray(targetVal.parameters) ? targetVal.parameters : []; - const hasParam = params.some( - (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === 'x-request-id', - ); - if (!hasParam) { - results.push({ - message: 'operation should declare an X-Request-Id header parameter for correlation', - path: [...base, 'parameters'], - }); - } - - const responses = targetVal.responses; - if (isObject(responses)) { - for (const [status, response] of Object.entries(responses)) { - if (status[0] !== '2' || !isObject(response)) continue; - const declared = isObject(response.headers) ? Object.keys(response.headers).map((h) => h.toLowerCase()) : []; - if (!declared.includes('x-request-id')) { - results.push({ - message: `${status} response must declare an X-Request-Id header (echoed or server-generated)`, - path: [...base, 'responses', status], - }); - } - } - } - - return results.length ? results : undefined; -} diff --git a/api-design-guide/linter/functions/s08-traceContext.js b/api-design-guide/linter/functions/s08-traceContext.js new file mode 100644 index 0000000..93aee30 --- /dev/null +++ b/api-design-guide/linter/functions/s08-traceContext.js @@ -0,0 +1,40 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** Advisory cross-service proxy: effective nonempty security requires traceparent. */ +export default function traceContext(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const rootSecurity = Array.isArray(targetVal.security) ? targetVal.security : null; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const results = []; + + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation)) continue; + const effectiveSecurity = Array.isArray(operation.security) ? operation.security : rootSecurity; + if (!Array.isArray(effectiveSecurity) || effectiveSecurity.length === 0) continue; + const parameters = [ + ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []), + ...(Array.isArray(operation.parameters) ? operation.parameters : []), + ]; + const hasTraceparent = parameters.some( + (parameter) => + isObject(parameter) && + parameter.in === 'header' && + typeof parameter.name === 'string' && + parameter.name.toLowerCase() === 'traceparent', + ); + if (!hasTraceparent) { + results.push({ + message: `secured operation ${method.toUpperCase()} ${pathKey} should declare the W3C traceparent request header`, + path: [...base, 'paths', pathKey, method, 'parameters'], + }); + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-bbCode.js b/api-design-guide/linter/functions/s09-bbCode.js index 64e70e1..87d218c 100644 --- a/api-design-guide/linter/functions/s09-bbCode.js +++ b/api-design-guide/linter/functions/s09-bbCode.js @@ -7,13 +7,13 @@ import { isObject } from './lib/util.js'; * * 1. every extracted BB code matches `^[a-z][a-z0-9-]{1,30}$`; * 2. all extracted BB codes are identical (the same BB uses one code across - * OAuth scopes, error codes, event types and channel addresses). + * OAuth scopes, error codes, event types and logical channel IDs). * * Extraction runs over every object key and string value (skipping free-text * prose keys) using the two documented shapes: * - OAuth scope: `bb:{bb-code}:{resource}:{action}` * - reverse-DNS: `org.govstack.{bb-code}....` (error codes, event types, - * channel addresses, problem-type URIs) + * logical channel IDs, problem-type URIs) * The segment `common` is reserved (§11.7) and excluded from the identity check. * * It does NOT verify ecosystem-wide uniqueness of the BB code — that needs the @@ -34,7 +34,7 @@ const SCOPE_RE = /^bb:([^:\s]+):/; const RDNS_RE = /org\.govstack\.([^.\s]+)\./gi; const BB_CODE_RE = /^[a-z][a-z0-9-]{1,30}$/; const RESERVED = 'common'; -const DEFAULT_SKIP_KEYS = ['description', 'summary', 'title', 'externalDocs']; +const DEFAULT_SKIP_KEYS = ['description', 'summary', 'title', 'externalDocs', 'address']; function truncate(s) { return s.length > 60 ? `${s.slice(0, 57)}...` : s; @@ -100,7 +100,7 @@ export default function s09BbCode(targetVal, options, context) { results.push({ message: `document uses ${codes.size} distinct BB codes (${list.join(', ')}); a BB must use its single ` + - `registered code identically across error codes, scopes, event types and channel addresses (§9.11)`, + `registered code identically across error codes, scopes, event types and logical channel IDs (§9.11)`, path: base, }); } diff --git a/api-design-guide/linter/functions/s17-channelIds.js b/api-design-guide/linter/functions/s17-channelIds.js new file mode 100644 index 0000000..d9e94a3 --- /dev/null +++ b/api-design-guide/linter/functions/s17-channelIds.js @@ -0,0 +1,34 @@ +import { isObject } from './lib/util.js'; + +const LOGICAL_CHANNEL_ID_RE = + /^org\.govstack\.[a-z][a-z0-9-]{1,30}\.v[0-9]+(?:\.(?:[a-z][a-zA-Z0-9-]*|\{[a-zA-Z0-9_]+\})){2,}$/; + +/** + * Validate the stable logical IDs used as keys of an AsyncAPI channels map. + * Channel Object `address` values intentionally are not inspected here because + * they use protocol-native destination syntax (§17.2). + * + * @param {unknown} targetVal - the AsyncAPI channels map. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function channelIds(targetVal, _options, context) { + if (!isObject(targetVal)) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const logicalId of Object.keys(targetVal)) { + if (LOGICAL_CHANNEL_ID_RE.test(logicalId)) continue; + + results.push({ + message: + `logical channel ID "${logicalId}" must match ` + + 'org.govstack.{bb-code}.v{major}.{resource}.{event}', + path: [...base, logicalId], + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s18-versionMajorConsistency.js b/api-design-guide/linter/functions/s18-versionMajorConsistency.js index 76cf072..925f496 100644 --- a/api-design-guide/linter/functions/s18-versionMajorConsistency.js +++ b/api-design-guide/linter/functions/s18-versionMajorConsistency.js @@ -5,7 +5,7 @@ const ASYNCAPI_VERSION_SEG = /(?:^|\.)v(\d+)(?:\.|$)/; /** * s18-versionMajorConsistency — guide 18.2: a major version increment MUST be - * reflected in the OpenAPI URL path (`/v2/`) or the AsyncAPI channel address, + * reflected in the OpenAPI URL path (`/v2/`) or AsyncAPI logical channel ID, * and that reflected major MUST match `info.version`'s major segment. * * `given` should be `$` (the whole document), so the function can read @@ -16,7 +16,7 @@ const ASYNCAPI_VERSION_SEG = /(?:^|\.)v(\d+)(?:\.|$)/; * "openapi" - every `paths` key that already carries a `/v{N}/` prefix * must have N == info.version's major. Paths with no version * prefix at all are guide 5.1's concern, not this rule's. - * "asyncapi" - every `channels` entry's address must embed a `.v{N}.` + * "asyncapi" - every logical ID (key under `channels`) must embed a `.v{N}.` * segment matching info.version's major. Does NOT verify the * text's alternative "equivalent machine-readable version * field documented in govstack-asyncapi-common.yaml" (that @@ -59,20 +59,18 @@ export default function versionMajorConsistency(targetVal, options, context) { } else if (opts.surface === 'asyncapi') { const channels = targetVal.channels; if (!isObject(channels)) return; - for (const [key, channel] of Object.entries(channels)) { - if (!isObject(channel)) continue; - const address = typeof channel.address === 'string' ? channel.address : key; - const m = address.match(ASYNCAPI_VERSION_SEG); + for (const key of Object.keys(channels)) { + const m = key.match(ASYNCAPI_VERSION_SEG); if (!m) { results.push({ - message: `channel "${key}" address "${address}" does not include a major version segment (e.g. ".v${infoMajor}."); AsyncAPI channels must include the major version in the channel address`, + message: `logical channel ID "${key}" does not include a major version segment (e.g. ".v${infoMajor}.")`, path: [...base, 'channels', key], }); continue; } if (m[1] !== infoMajor) { results.push({ - message: `channel "${key}" address "${address}" declares version v${m[1]} but info.version is "${version}" (major ${infoMajor}); the channel's major version must match info.version's major`, + message: `logical channel ID "${key}" declares version v${m[1]} but info.version is "${version}" (major ${infoMajor}); the channel ID's major version must match info.version's major`, path: [...base, 'channels', key], }); } diff --git a/api-design-guide/linter/package-lock.json b/api-design-guide/linter/package-lock.json index 816eabc..38413ac 100644 --- a/api-design-guide/linter/package-lock.json +++ b/api-design-guide/linter/package-lock.json @@ -1,12 +1,12 @@ { "name": "govstack-api-lint", - "version": "0.1.0", + "version": "0.2.0-draft", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "govstack-api-lint", - "version": "0.1.0", + "version": "0.2.0-draft", "dependencies": { "@stoplight/spectral-cli": "^6.16.1", "@stoplight/spectral-core": "^1.23.0", diff --git a/api-design-guide/linter/package.json b/api-design-guide/linter/package.json index 92921eb..28b4566 100644 --- a/api-design-guide/linter/package.json +++ b/api-design-guide/linter/package.json @@ -1,6 +1,6 @@ { "name": "govstack-api-lint", - "version": "0.1.0", + "version": "0.2.0-draft", "private": true, "description": "GovStack Spectral ruleset and lint driver for the Cross-BB API Design Guide", "type": "module", @@ -9,7 +9,7 @@ }, "scripts": { "lint": "node cli.mjs", - "test": "node --test \"tests/*.test.mjs\"" + "test": "COVERAGE_ENFORCE=1 node --test \"tests/*.test.mjs\"" }, "dependencies": { "@stoplight/spectral-cli": "^6.16.1", diff --git a/api-design-guide/linter/rulesets/s02.yaml b/api-design-guide/linter/rulesets/s02.yaml index 3c2b651..64e622c 100644 --- a/api-design-guide/linter/rulesets/s02.yaml +++ b/api-design-guide/linter/rulesets/s02.yaml @@ -10,12 +10,11 @@ functionsDir: "../functions" functions: - valuePattern rules: - # 2.1 [M] — the spec MUST declare openapi: 3.1.0; earlier versions MUST NOT. - # formats [oas2, oas3] so a 2.x/3.0.x document is still told to move to 3.1.0. + # 2.1 [M] — this ruleset qualifies the published 3.1.0-3.1.2 patches only. govstack-2.1: - description: "OpenAPI version must be exactly 3.1.0 (guide 2.1, [M])." + description: "OpenAPI version must be a qualified 3.1 patch (3.1.0-3.1.2; guide 2.1, [M])." message: "[2.1][M] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#21-openapi-310-required + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#21-openapi-31-required severity: error formats: [oas2, oas3] given: $ @@ -27,7 +26,7 @@ rules: required: [openapi] properties: openapi: - const: "3.1.0" + enum: ["3.1.0", "3.1.1", "3.1.2"] # 2.5 [M] — info MUST include title, version, description, contact. # Split per the documented suffix convention: presence here, SemVer below. @@ -65,16 +64,16 @@ rules: name: info.version match: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$' - # 2.6 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. - # Verifies: servers is non-empty AND no server URL points at localhost/loopback. + # 2.6 [M+R] — deterministic MUST clauses are blocking: servers is non-empty, + # URLs use HTTPS, avoid loopback, and do not duplicate /vN from path keys. # Does NOT verify: that URLs are "meaningful"/not fake production domains, the # SHOULD to use parameterised template URLs, or the MAY to label example.org # defaults as non-production. Those are not mechanically decidable. govstack-2.6: - description: "servers must be non-empty and not point at localhost/loopback (guide 2.6, [M+R], proxy)." + description: "servers must be non-empty HTTPS URLs without loopback or duplicate /vN (guide 2.6, [M+R])." message: "[2.6][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#26-meaningful-servers-block - severity: warn + severity: error formats: [oas3_1] given: $ then: @@ -89,11 +88,16 @@ rules: minItems: 1 items: type: object + required: [url] properties: url: type: string - not: - pattern: 'localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?' + allOf: + - pattern: '^https://' + - not: + pattern: 'localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?' + - not: + pattern: '/v[0-9]+(?:/|$)' # 2.7 [M+R] — every operation MUST include operationId (camelCase), summary, # description, and >=1 tag. The verb-noun convention for operationId is not diff --git a/api-design-guide/linter/rulesets/s07.yaml b/api-design-guide/linter/rulesets/s07.yaml index a4f7d2b..01c07cf 100644 --- a/api-design-guide/linter/rulesets/s07.yaml +++ b/api-design-guide/linter/rulesets/s07.yaml @@ -11,6 +11,9 @@ functions: - responseHeaderRequired - operationResponses - s07-noStoreOnProblemJson + - s07-creationResponses + - s07-baselineResponses + - s07-successResponseSchema rules: # 7.2 [M] — every 201 response MUST include a Location header pointing at # the created resource. Presence-only: does not verify the header's value @@ -95,6 +98,32 @@ rules: minCount: 2 minNonSuccess: 1 + # 7.14 [M] — ADVISORY PROXY. A creation-like POST is identified by a + # create/register/add/submit/start/initiate operationId prefix and should + # declare either synchronous 201 or asynchronous 202 semantics. + govstack-7.14-creation-status: + description: "creation-like POST operations should declare 201 or 202 (guide 7.14, proxy)." + message: "[7.14][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#714-all-status-codes-declared + severity: warn + formats: [oas3_1] + given: $ + then: + function: s07-creationResponses + + # 7.14 [M] — deterministic structural baseline. Applicability comes from + # explicit OAS shape: nonempty security requires 401, scoped security 403, + # request bodies or path/query parameters 400, and templated paths 404. + govstack-7.14-baseline-errors: + description: "operations should declare mechanically applicable baseline errors (guide 7.14, proxy)." + message: "[7.14][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#714-all-status-codes-declared + severity: error + formats: [oas3_1] + given: $ + then: + function: s07-baselineResponses + # 7.16 [M+R] — PROXY (bucket B), notched one step: SHOULD -> info. # Verifies: every GET operation's 200 response declares an ETag header AND # the operation also declares a 304 response. Does NOT distinguish @@ -199,3 +228,15 @@ rules: given: "$.paths[*][get,put,post,delete,patch].responses[*]" then: function: s07-noStoreOnProblemJson + + # 7.21 [M] — every declared 2xx response media type has a concrete schema; + # 204 responses do not declare content. + govstack-7.21: + description: "successful response bodies must declare non-empty schemas (guide 7.21, [M])." + message: "[7.21][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies + severity: error + formats: [oas3_1] + given: $ + then: + function: s07-successResponseSchema diff --git a/api-design-guide/linter/rulesets/s08.yaml b/api-design-guide/linter/rulesets/s08.yaml index a5c59dc..7ea5859 100644 --- a/api-design-guide/linter/rulesets/s08.yaml +++ b/api-design-guide/linter/rulesets/s08.yaml @@ -12,7 +12,7 @@ functions: - s08-credentialsInUrl - s08-headerEcho - s08-idempotencyKeyRequired - - s08-requestIdCorrelation + - s08-traceContext - s08-noXHeaders - s08-rateLimitHeaders rules: @@ -66,27 +66,23 @@ rules: then: function: s08-idempotencyKeyRequired - # 8.4 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. - # Verifies: an operation SHOULD declare an X-Request-Id header parameter, - # and its 2xx responses MUST declare an X-Request-Id response header. - # Does NOT verify: actual runtime echo/generate behaviour, distinctness - # from the error-envelope traceId (§11.3, a BB's choice), or path-item- - # level (shared) parameters. + # 8.4 [M+R] — cross-service proxy: operations with effective nonempty + # security declare the W3C traceparent request header. Explicit security: [] + # operations such as /health are excluded. Runtime propagation is review/test. govstack-8.4: - description: "requests should carry X-Request-Id and responses must echo it (guide 8.4, [M+R], proxy)." + description: "secured cross-service operations should declare traceparent (guide 8.4, [M+R], proxy)." message: "[8.4][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#84-x-request-id-correlation + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#84-w3c-trace-context-correlation severity: warn formats: [oas3_1] - given: "$.paths[*][get,put,post,delete,patch]" + given: $ then: - function: s08-requestIdCorrelation + function: s08-traceContext # 8.5 [M] — implemented (bucket A). New custom headers (parameters and - # response headers) must not use the X- prefix, except the legacy - # X-Request-Id (§8.4, pending [OPEN-7-A]). + # response headers) must not use the X- prefix. govstack-8.5: - description: "new custom headers must not use the X- prefix, except X-Request-Id (guide 8.5, [M])." + description: "new custom headers must not use the X- prefix (guide 8.5, [M])." message: "[8.5][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#85-no-new-x--prefixed-headers severity: error @@ -97,13 +93,12 @@ rules: # 8.7 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. # Verifies: an operation declaring a 429 response (proxy for "rate-limited - # by the BB itself") declares Retry-After on 429 and the RateLimit-Limit / - # RateLimit-Remaining / RateLimit-Reset trio on its 2xx and 429 responses. + # by the BB itself") declares Retry-After on 429 and the structured + # RateLimit header on its 2xx and 429 responses. # Does NOT verify: that rate limiting is actually implemented, the - # delegated-to-gateway prose exception, or the newer structured-field - # draft form (only the three-header v0.1 form is checked). + # delegated-to-gateway prose exception or the header's runtime value. govstack-8.7: - description: "rate-limited endpoints must declare RateLimit-* and 429 responses must declare Retry-After (guide 8.7, [M+R], proxy)." + description: "rate-limited endpoints must declare structured RateLimit and 429 Retry-After (guide 8.7, [M+R], proxy)." message: "[8.7][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#87-rate-limit-headers-declared severity: warn @@ -111,3 +106,17 @@ rules: given: "$.paths[*][get,put,post,delete,patch].responses" then: function: s08-rateLimitHeaders + + # 8.7 [M+R] — deterministic MUST NOT: the legacy three-header variant is + # not conformant to the pinned structured-field draft. + govstack-8.7-no-legacy: + description: "legacy RateLimit-Limit/Remaining/Reset headers are forbidden (guide 8.7, [M+R])." + message: "[8.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#87-rate-limit-headers-declared + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: s08-rateLimitHeaders + functionOptions: + forbidLegacy: true diff --git a/api-design-guide/linter/rulesets/s09.yaml b/api-design-guide/linter/rulesets/s09.yaml index efa619f..fc4ced2 100644 --- a/api-design-guide/linter/rulesets/s09.yaml +++ b/api-design-guide/linter/rulesets/s09.yaml @@ -14,6 +14,21 @@ functions: - s09-enumCasing - s09-closedEnum - s09-bbCode +aliases: + DataSchemas: + description: "Reusable and inline OpenAPI body schemas plus AsyncAPI message payload schemas." + targets: + - formats: [oas3_1] + given: + - $.components.schemas[*] + - "$.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema" + - "$.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema" + - "$.webhooks[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema" + - "$.webhooks[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema" + - formats: [aas3] + given: + - $.components.messages[*].payload + - $.channels[*].messages[*].payload rules: # 9.1 [M+R] — PROXY (bucket B), MUST -> warn. Every response body should offer # a JSON media type (application/json or a +json family type, e.g. @@ -38,8 +53,8 @@ rules: message: "[9.2][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#92-camelcase-field-names severity: error - formats: [oas3_1] - given: $.components.schemas[*] + formats: [oas3_1, aas3] + given: "#DataSchemas" then: function: schemaPropertyNames functionOptions: @@ -59,15 +74,16 @@ rules: then: function: s09-booleanStrings - # 9.4 [M] — PROXY (bucket B), MUST -> warn. Flags OpenAPI 3.0-style - # `nullable`, which is not a 3.1 keyword; nullability must be expressed as - # `type: [..., "null"]`. Restricted to 3.1 docs (formats: oas3_1). Does NOT - # verify that every genuinely-nullable field declares the null type. + # 9.4 [M] — the presence of the removed OpenAPI 3.0 `nullable` keyword is a + # deterministic violation: `nullable` is not a 3.1 keyword; nullability must + # be expressed as `type: [..., "null"]`. Restricted to 3.1 docs (formats: + # oas3_1). Does NOT verify that every genuinely-nullable field declares the + # null type. govstack-9.4: description: "Nullability must be explicit via type: [..., null], not 3.0 nullable (guide 9.4, [M], proxy)." message: "[9.4][M] Do not use OpenAPI 3.0 'nullable'; express nullability as type: [..., \"null\"] (§9.4)." documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#94-explicit-nullability - severity: warn + severity: error formats: [oas3_1] given: $..nullable then: @@ -79,8 +95,8 @@ rules: message: "[9.5][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names severity: error - formats: [oas3_1] - given: $.components.schemas[*] + formats: [oas3_1, aas3] + given: "#DataSchemas" then: function: schemaPropertyNames functionOptions: diff --git a/api-design-guide/linter/rulesets/s13.yaml b/api-design-guide/linter/rulesets/s13.yaml index a462489..30d2c32 100644 --- a/api-design-guide/linter/rulesets/s13.yaml +++ b/api-design-guide/linter/rulesets/s13.yaml @@ -48,6 +48,25 @@ rules: types: [openIdConnect, oauth2] label: "OAuth 2.0 / OpenID Connect" + # 13.2 [M+R] — deterministic security baseline: resource-owner password and + # implicit grants MUST NOT be declared. + govstack-13.2-forbidden-flows: + description: "OAuth2 schemes must not declare password or implicit flows (guide 13.2, [M+R])." + message: "[13.2][M+R] OAuth2 password and implicit flows are forbidden." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations + severity: error + formats: [oas3_1] + given: $.components.securitySchemes[*].flows + then: + function: schema + functionOptions: + schema: + type: object + not: + anyOf: + - required: [password] + - required: [implicit] + # 13.3 [M+R] — PROXY (bucket B), MUST notched to warn. # Verifies: a service-to-service scheme is declared (mutualTLS, or oauth2 with # a clientCredentials flow). diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml index 202a2d4..81b0d99 100644 --- a/api-design-guide/linter/rulesets/s17.yaml +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -9,6 +9,7 @@ functionsDir: "../functions" functions: - valuePattern + - s17-channelIds - schemaPropertyNames - extensionShape - s17-channelParameters @@ -38,21 +39,18 @@ rules: action: enum: [send, receive] - # 17.2 [M] — channel addresses MUST follow reverse-DNS - # org.govstack.{bb-code}.v{major}.{resource}.{event}. bb-code per §9.11 - # (^[a-z][a-z0-9-]{1,30}$). Variable segments may themselves be {param} tokens. + # 17.2 [M+R] — logical channel IDs (the keys under `channels`) MUST follow + # reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event}. Channel + # Object addresses intentionally retain protocol-native destination syntax. govstack-17.2: - description: "Channel addresses must follow reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event} (guide 17.2, [M])." - message: "[17.2][M] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#172-reverse-dns-channel-addresses + description: "Logical channel IDs must follow reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event} (guide 17.2, [M+R])." + message: "[17.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses severity: error formats: [aas3] - given: $.channels[*].address + given: $.channels then: - function: valuePattern - functionOptions: - name: channel address - match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.v[0-9]+(?:\.(?:[a-z][a-zA-Z0-9-]*|\{[a-zA-Z0-9_]+\})){2,}$' + function: s17-channelIds # 17.4 [M+R] — every {param} in a channel address MUST be declared under the # channel parameters object and documented (non-empty description). AsyncAPI diff --git a/api-design-guide/linter/rulesets/s18.yaml b/api-design-guide/linter/rulesets/s18.yaml index 1f4a6d6..e5e115c 100644 --- a/api-design-guide/linter/rulesets/s18.yaml +++ b/api-design-guide/linter/rulesets/s18.yaml @@ -53,13 +53,13 @@ rules: functionOptions: surface: openapi - # 18.2 [M] AsyncAPI half — a channel address MUST include the major version, + # 18.2 [M] AsyncAPI half — a logical channel ID MUST include the major version, # consistent with info.version's major segment. Does NOT verify the text's # alternative "equivalent machine-readable version field documented in # govstack-asyncapi-common.yaml" (needs the vendored common file, out of - # scope here); only the address-embedded convention is checked. + # scope here); only the logical-ID convention is checked. govstack-18.2-asyncapi: - description: "AsyncAPI channel addresses must include a major version matching info.version's major segment (guide 18.2, [M])." + description: "AsyncAPI logical channel IDs must include a major version matching info.version's major segment (guide 18.2, [M])." message: "[18.2][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel severity: error diff --git a/api-design-guide/linter/rulesets/s20.yaml b/api-design-guide/linter/rulesets/s20.yaml index 6588352..5d6dd12 100644 --- a/api-design-guide/linter/rulesets/s20.yaml +++ b/api-design-guide/linter/rulesets/s20.yaml @@ -8,12 +8,13 @@ functionsDir: "../functions" functions: - extensionShape rules: - # 20.3 [M] — info.x-govstack-api-guide MUST be an object with a SemVer - # `version`. Surface: Universal, so this applies to both OpenAPI and + # 20.3 [M] — info.x-govstack-api-guide MUST be an object with exact-target + # SemVer `version` and `rulesetVersion`. Exact supported-version matching is + # enforced by the repository driver. Surface: Universal, so this applies to both OpenAPI and # AsyncAPI canonical files' info block; formats widened like the other # info-level rules so it still fires on a wrong-spec-version doc. govstack-20.3: - description: "info.x-govstack-api-guide must declare a SemVer version (guide 20.3, [M])." + description: "info.x-govstack-api-guide must declare SemVer version and rulesetVersion (guide 20.3, [M])." message: "[20.3][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version severity: error @@ -24,15 +25,12 @@ rules: functionOptions: extension: x-govstack-api-guide valueType: object - requiredKeys: [version] - semverKeys: [version] + requiredKeys: [version, rulesetVersion] + semverKeys: [version, rulesetVersion] - # 20.3 [M] — the optional `exceptions` list, when present, must be an array - # of objects, not bare rule-id strings: the guide text requires each entry - # carry both a rule ID and a reference to its approved exception record, - # which a plain string cannot. `[OPEN-20-A]` leaves the exact per-entry key - # names undefined, so only the "array of objects" shape is enforced here, - # not specific field names. + # 20.3 [M] — exceptions are reviewable, expiring records. The repository + # driver additionally verifies that `rule` is a known guide rule and that the + # exception is not expired before it suppresses any finding. govstack-20.3-exceptions: description: "x-govstack-api-guide.exceptions entries must be objects referencing an approved exception record (guide 20.3, [M])." message: "[20.3][M] {{error}}" @@ -53,4 +51,27 @@ rules: type: array items: type: object - minProperties: 1 + additionalProperties: false + required: [rule, scope, rationale, record, reviewedBy, reviewedAt, expiresAt] + properties: + rule: + type: string + pattern: '^\d+\.\d+$' + scope: + type: string + pattern: '^(?:/(?:[^~]|~[01])*)*$' + rationale: + type: string + minLength: 10 + record: + type: string + pattern: '^https://' + reviewedBy: + type: string + minLength: 2 + reviewedAt: + type: string + pattern: '^\d{4}-\d{2}-\d{2}$' + expiresAt: + type: string + pattern: '^\d{4}-\d{2}-\d{2}$' diff --git a/api-design-guide/linter/tests/driver.test.mjs b/api-design-guide/linter/tests/driver.test.mjs index 93c4dfa..0ec2b59 100644 --- a/api-design-guide/linter/tests/driver.test.mjs +++ b/api-design-guide/linter/tests/driver.test.mjs @@ -1,8 +1,4 @@ -// End-to-end tests for the lint driver (cli.mjs). Each test builds a crafted temp repo -// under os.tmpdir() and runs the CLI as a child process. Tests always pass -// --skip-validators (external validators may be absent) except the one that deliberately -// exercises the validator-missing NOTICE path, and use a tiny self-contained mini-ruleset -// so they do not depend on the real ruleset being finished. +// End-to-end tests for the repository-level conformance driver. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -15,8 +11,9 @@ import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const CLI = path.join(HERE, '..', 'cli.mjs'); const MINI_RULESET = path.join(HERE, 'driver-fixtures', 'mini-ruleset.yaml'); +const GUIDE_VERSION = '0.2.0-draft'; +const ADVISORY = ['--mode', 'advisory', '--skip-validators']; -// Build a temp repo from a { relativePath: contents } map. Returns the repo dir. function makeRepo(files) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'govstack-driver-')); fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); @@ -26,6 +23,19 @@ function makeRepo(files) { fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, contents); } + if (files['api/coverage.yaml'] && !files['spec/requirements.md']) { + const ids = [ + ...files['api/coverage.yaml'].matchAll(/\bid:\s*["']?([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)/g), + ].map((match) => match[1]); + if (ids.length) { + const requirements = [...new Set(ids)] + .map((id) => `- **${id}** **REQUIRED**: Test requirement ${id}.`) + .join('\n'); + const abs = path.join(dir, 'spec', 'requirements.md'); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `${requirements}\n`); + } + } return dir; } @@ -33,7 +43,6 @@ function cleanup(dir) { fs.rmSync(dir, { recursive: true, force: true }); } -// Run the CLI. `extraArgs` are appended; `env` overrides process env when provided. function runCli(args, { env } = {}) { const result = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', @@ -44,277 +53,634 @@ function runCli(args, { env } = {}) { function runCliJson(args, opts) { const r = runCli(['--format', 'json', ...args], opts); - let json; + let json = null; try { json = JSON.parse(r.stdout); } catch { - json = null; + // Assertions report stdout/stderr where JSON is expected. } return { ...r, json }; } -const CLEAN_OPENAPI = `openapi: 3.1.0 +function fakeOpenapiValidator(dir) { + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + const validator = path.join(bin, 'openapi-spec-validator'); + fs.writeFileSync(validator, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(validator, 0o755); + return bin; +} + +const VALID_COVERAGE = `version: 1 +requirements: + - id: REQ-TEST-001 + disposition: external + reference: https://example.org/requirements/test +`; + +const NO_API_INDEX = `version: 1 +noApi: true +reason: This building block exposes no API surface. +`; + +const apiGuide = (version = GUIDE_VERSION, exceptions = '') => ` + x-govstack-api-guide: + version: ${version} + rulesetVersion: ${version}${exceptions}`; + +const cleanOpenapi = (version = GUIDE_VERSION) => `openapi: 3.1.0 info: title: Demo version: 1.0.0 description: A demo API contact: - name: Team -paths: {} + name: Team${apiGuide(version)} +paths: + /v1/things: + get: + operationId: listThings + responses: + '200': { description: ok } `; -const OPENAPI_MISSING_CONTACT = (guideVersion, extraGuide = '') => `openapi: 3.1.0 +const openapiMissingContact = (exceptions = '', version = GUIDE_VERSION) => `openapi: 3.1.0 info: title: Demo - version: ${guideVersion} - description: A demo API - x-govstack-api-guide: - version: ${guideVersion}${extraGuide} + version: 1.0.0 + description: A demo API${apiGuide(version, exceptions)} paths: {} `; -test('no spec files at all -> prominent notice, exit 0', () => { +const cleanAsyncapi = (operation = 'receiveThing', message = 'ThingReceived') => `asyncapi: 3.0.0 +info: + title: Events + version: 1.0.0 + description: Event API + contact: + name: Team${apiGuide()} +channels: + things: + address: things + messages: + ${message}: + $ref: '#/components/messages/${message}' +operations: + ${operation}: + action: receive + channel: { $ref: '#/channels/things' } +components: + messages: + ${message}: + payload: { type: object } +`; + +function codes(report) { + return report.files.flatMap((file) => file.findings.map((finding) => finding.code)); +} + +test('conformance requires an API declaration or explicit noApi', () => { + const missing = makeRepo({}); + const explicit = makeRepo({ 'api/index.yaml': NO_API_INDEX }); + try { + const fail = runCliJson(['--repo-root', missing, '--ruleset', MINI_RULESET]); + assert.equal(fail.status, 1, fail.stderr); + assert.ok(codes(fail.json).includes('api-declaration-required')); + + const pass = runCliJson(['--repo-root', explicit, '--ruleset', MINI_RULESET]); + assert.equal(pass.status, 0, pass.stderr); + assert.equal(pass.json.summary.filesLinted, 0); + assert.match(pass.json.notices.join('\n'), /explicitly declares/); + } finally { + cleanup(missing); + cleanup(explicit); + } +}); + +test('advisory mode permits discovery work without an API declaration', () => { const dir = makeRepo({}); try { - const r = runCli(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); - assert.equal(r.status, 0); - assert.match(r.stdout, /no API spec files found to lint/i); + const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /No API declaration found/); } finally { cleanup(dir); } }); -test('non-empty legacy swagger.yaml -> file-canonical-name error, exit 1', () => { - const dir = makeRepo({ 'api/swagger.yaml': 'openapi: 3.1.0\ninfo: {title: X}\n' }); +test('api/index.yaml supports multiple declared OpenAPI and AsyncAPI surfaces', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: openapi + path: api/public.yaml + - type: asyncapi + path: api/events.yaml +`, + 'api/public.yaml': cleanOpenapi(), + 'api/events.yaml': cleanAsyncapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: REQ-API-001 + disposition: operation + operations: [listThings, receiveThing] + - id: REQ-API-002 + disposition: message + messages: [ThingReceived] +`, + }); try { - const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + assert.equal(r.json.summary.filesLinted, 2); + } finally { + cleanup(dir); + } +}); + +test('missing and empty index-declared specs fail', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - { type: openapi, path: api/missing.yaml } + - { type: asyncapi, path: api/empty.yaml } +`, + 'api/empty.yaml': ' \n', + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); assert.equal(r.status, 1); - const codes = r.json.files.flatMap((f) => f.findings.map((x) => x.code)); - assert.ok(codes.includes('file-canonical-name')); - const finding = r.json.files - .flatMap((f) => f.findings) - .find((x) => x.code === 'file-canonical-name'); - assert.equal(finding.guideRule, '2.2'); - assert.equal(finding.severity, 'error'); - assert.equal(r.json.failed, true); + assert.ok(codes(r.json).includes('declared-spec-missing')); + assert.ok(codes(r.json).includes('declared-spec-empty')); } finally { cleanup(dir); } }); -test('empty legacy placeholder -> notice only, exit 0', () => { - const dir = makeRepo({ 'api/swagger.yaml': ' \n', 'api/swagger.json': '' }); +test('legacy swagger files fail whether populated or empty', () => { + const dir = makeRepo({ + 'api/index.yaml': NO_API_INDEX, + 'api/swagger.yaml': '', + 'api/swagger.json': '{"openapi":"3.1.0"}', + }); try { - const r = runCli(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); - assert.equal(r.status, 0); - assert.match(r.stdout, /Empty legacy placeholder api\/swagger\.yaml/); - assert.match(r.stdout, /Empty legacy placeholder api\/swagger\.json/); - assert.doesNotMatch(r.stdout, /file-canonical-name/); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET]); + assert.equal(r.status, 1); + assert.equal(codes(r.json).filter((code) => code === 'file-canonical-name').length, 2); } finally { cleanup(dir); } }); -test('divergent copy detection honours the exclusion list', () => { +test('undeclared parsed specs in api/ and spec assets are blocking duplicates', () => { const dir = makeRepo({ - 'api/openapi.yaml': CLEAN_OPENAPI, - 'docs/copy.yaml': 'openapi: 3.1.0\ninfo: {title: dup}\n', - 'other.json': 'asyncapi: 3.0.0\ninfo: {title: ev}\n', - 'random.yaml': 'name: ci\njobs: {}\n', - // Excluded directories: must NOT be flagged. - 'spec/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', - 'examples/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', - 'test/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', - 'node_modules/foo/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', - 'api-design-guide/fixtures/excluded.yaml': 'openapi: 3.1.0\ninfo: {t: x}\n', + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + 'api/copy.yaml': cleanOpenapi(), + 'spec/.gitbook/assets/copy.json': '{"asyncapi":"3.0.0","info":{"title":"copy"}}', + 'examples/ignored.yaml': cleanOpenapi(), }); try { - const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); - // Warn-level findings do not fail the default (fail-on error) run. - assert.equal(r.status, 0); - const divergent = r.json.files - .flatMap((f) => f.findings) - .filter((x) => x.code === 'file-divergent-copies'); - const flaggedPaths = r.json.files - .filter((f) => f.findings.some((x) => x.code === 'file-divergent-copies')) - .map((f) => f.path) + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + const paths = r.json.files + .filter((file) => file.findings.some((finding) => finding.code === 'file-undeclared-spec')) + .map((file) => file.path) .sort(); - assert.deepEqual(flaggedPaths, ['docs/copy.yaml', 'other.json']); - // The AsyncAPI copy maps to guide rule 3.3, the OpenAPI copy to 2.3. - const byPath = Object.fromEntries( - r.json.files.map((f) => [f.path, f.findings.find((x) => x.code === 'file-divergent-copies')]), - ); - assert.equal(byPath['docs/copy.yaml'].guideRule, '2.3'); - assert.equal(byPath['other.json'].guideRule, '3.3'); - assert.ok(divergent.every((x) => x.severity === 'warn')); + assert.deepEqual(paths, ['api/copy.yaml', 'spec/.gitbook/assets/copy.json']); } finally { cleanup(dir); } }); -test('exceptions suppress a firing rule end-to-end (object form), exit 0', () => { +test('operation-free component libraries under api/common are not API surfaces', () => { const dir = makeRepo({ - 'api/openapi.yaml': OPENAPI_MISSING_CONTACT( - '0.2.0', - '\n exceptions:\n - rule: "2.5"\n record: "EXC-2024-001"', - ), + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + 'api/common/openapi-components.yaml': `openapi: 3.1.0 +info: { title: Common OpenAPI components, version: 1.0.0 } +paths: {} +components: + schemas: + Identifier: { type: string } +`, + 'api/common/asyncapi-components.yaml': `asyncapi: 3.0.0 +info: { title: Common AsyncAPI components, version: 1.0.0 } +channels: {} +operations: {} +components: + schemas: + Identifier: { type: string } +`, + 'api/common/hidden-surface.yaml': cleanOpenapi(), }); try { - const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); - assert.equal(r.status, 0); - const active = r.json.files.flatMap((f) => f.findings); - assert.ok(!active.some((x) => x.code === 'govstack-2.5-contact')); - const suppressed = r.json.files.flatMap((f) => f.suppressed); - const s = suppressed.find((x) => x.code === 'govstack-2.5-contact'); - assert.ok(s, 'contact finding should be suppressed'); - assert.equal(s.guideRule, '2.5'); - assert.equal(s.exceptionRecord, 'EXC-2024-001'); - assert.equal(r.json.summary.suppressed, 1); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + const paths = r.json.files + .filter((file) => file.findings.some((finding) => finding.code === 'file-undeclared-spec')) + .map((file) => file.path); + assert.deepEqual(paths, ['api/common/hidden-surface.yaml']); } finally { cleanup(dir); } }); -test('exceptions accept the plain-string form', () => { +test('coverage requires stable unique IDs and valid dispositions', () => { const dir = makeRepo({ - 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.0', '\n exceptions:\n - "2.5"'), + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - { id: bad, disposition: operation, operations: [missingOperation] } + - { id: bad, disposition: planned } +`, }); try { - const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); - assert.equal(r.status, 0); - const suppressed = r.json.files.flatMap((f) => f.suppressed); - const s = suppressed.find((x) => x.code === 'govstack-2.5-contact'); - assert.ok(s, 'contact finding should be suppressed via string exception'); - assert.equal(s.exceptionRecord, null); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('coverage-invalid')); + assert.ok(codes(r.json).includes('coverage-missing-reference')); } finally { cleanup(dir); } }); -test('guide version major.minor mismatch emits a notice; matching version does not', () => { - const mismatchDir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.1.0') }); - const matchDir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.5') }); +test('coverage rejects ambiguous operation and message names across surfaces', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - { type: asyncapi, path: api/a.yaml } + - { type: asyncapi, path: api/b.yaml } +`, + 'api/a.yaml': cleanAsyncapi('sameOperation', 'SameMessage'), + 'api/b.yaml': cleanAsyncapi('sameOperation', 'SameMessage'), + 'api/coverage.yaml': `version: 1 +requirements: + - { id: REQ-API-001, disposition: operation, operations: [sameOperation] } +`, + }); try { - const mm = runCliJson([ - '--repo-root', mismatchDir, '--skip-validators', '--ruleset', MINI_RULESET, - ]); - assert.ok(mm.json.notices.some((n) => /major\.minor mismatch/.test(n))); - - const ok = runCliJson([ - '--repo-root', matchDir, '--skip-validators', '--ruleset', MINI_RULESET, - ]); - assert.ok(!ok.json.notices.some((n) => /major\.minor mismatch/.test(n))); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.equal(codes(r.json).filter((code) => code === 'coverage-ambiguous-reference').length, 2); } finally { - cleanup(mismatchDir); - cleanup(matchDir); + cleanup(dir); } }); -test('--format json has the documented shape', () => { - const dir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.0') }); +test('coverage is an exact projection of keyed Markdown requirements and flags legacy lines', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - { id: REQ-SPEC-001, disposition: external, reference: https://example.org/one } + - { id: REQ-EXTRA-001, disposition: external, reference: https://example.org/extra } +`, + 'spec/requirements.md': `# Requirements + +- **REQ-SPEC-001** **REQUIRED**: The API exposes the first contract. +- **REQ-SPEC-002** **RECOMMENDED**: The API exposes the second contract. +- Old prose requirement (REQUIRED) +`, + }); try { - const r = runCliJson(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); - assert.ok(r.json, 'stdout must be valid JSON'); - assert.deepEqual( - Object.keys(r.json).sort(), - ['failOn', 'failed', 'files', 'notices', 'summary'], - ); - assert.ok(Array.isArray(r.json.files)); - const file = r.json.files[0]; - assert.deepEqual(Object.keys(file).sort(), ['findings', 'path', 'suppressed']); - const finding = file.findings[0]; - assert.deepEqual( - Object.keys(finding).sort(), - ['code', 'documentationUrl', 'guideRule', 'message', 'path', 'range', 'severity'], - ); - assert.deepEqual( - Object.keys(r.json.summary).sort(), - ['errors', 'filesLinted', 'info', 'suppressed', 'warnings'], - ); - assert.equal(r.json.failOn, 'error'); - assert.equal(typeof r.json.failed, 'boolean'); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('coverage-unknown-requirement')); + assert.ok(codes(r.json).includes('coverage-missing-requirement')); + assert.ok(codes(r.json).includes('requirements-unkeyed')); } finally { cleanup(dir); } }); -test('--fail-on never exits 0 even with error findings', () => { - const dir = makeRepo({ 'api/openapi.yaml': OPENAPI_MISSING_CONTACT('0.2.0') }); +test('planned coverage is blocking in conformance and advisory-only in advisory mode', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: REQ-PLAN-001 + disposition: planned + issue: https://example.org/issues/123 +`, + }); try { - const r = runCliJson([ - '--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET, '--fail-on', 'never', - ]); - assert.equal(r.status, 0); - assert.equal(r.json.failed, false); - // The error finding is still reported, just not fatal. - assert.ok(r.json.summary.errors >= 1); + const bin = fakeOpenapiValidator(dir); + const conform = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: bin }, + }); + assert.equal(conform.status, 1); + const conformanceFinding = conform.json.files + .flatMap((file) => file.findings) + .find((finding) => finding.code === 'coverage-planned'); + assert.equal(conformanceFinding.severity, 'error'); + + const advisory = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(advisory.status, 0, `${advisory.stderr}\n${advisory.stdout}`); + const advisoryFinding = advisory.json.files + .flatMap((file) => file.findings) + .find((finding) => finding.code === 'coverage-planned'); + assert.equal(advisoryFinding.severity, 'warn'); } finally { cleanup(dir); } }); -test('unparseable spec -> exit 2 naming the file', () => { - const dir = makeRepo({ 'api/openapi.yaml': 'openapi: 3.1.0\ninfo: {title: [oops\n bad: : :\n' }); +test('coverage rejects disposition-incompatible extra fields', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: REQ-EXTERNAL-001 + disposition: external + reference: https://example.org/requirement + operations: [listThings] +`, + }); try { - const r = runCli(['--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET]); - assert.equal(r.status, 2); - assert.match(r.stderr, /Cannot parse spec file .*api\/openapi\.yaml/); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('coverage-invalid')); } finally { cleanup(dir); } }); -test('bad flag value -> exit 2', () => { - const dir = makeRepo({}); +test('coverage cannot mark a REQUIRED requirement not-applicable', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: REQ-NA-001 + disposition: not-applicable + rationale: This is incorrectly excluded. +`, + }); try { - const r = runCli([ - '--repo-root', dir, '--skip-validators', '--ruleset', MINI_RULESET, '--fail-on', 'bogus', - ]); - assert.equal(r.status, 2); - assert.match(r.stderr, /Invalid --fail-on/); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('coverage-required-not-applicable')); } finally { cleanup(dir); } }); -test('missing ruleset -> exit 2 when there is a spec to lint', () => { - const dir = makeRepo({ 'api/openapi.yaml': CLEAN_OPENAPI }); +test('valid reviewed, unexpired exception suppresses only its known guide rule', () => { + const exception = ` + exceptions: + - rule: "2.5" + scope: "/info" + rationale: "Temporary compatibility with the approved legacy owner." + record: "https://example.org/exceptions/EXC-001" + reviewedBy: "API Working Group" + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31"`; + const dir = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(exception), + 'api/coverage.yaml': VALID_COVERAGE, + }); try { - const r = runCli([ - '--repo-root', dir, '--skip-validators', '--ruleset', path.join(dir, 'does-not-exist.yaml'), - ]); - assert.equal(r.status, 2); - assert.match(r.stderr, /Ruleset not found/); + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + assert.ok(!codes(r.json).includes('govstack-2.5-contact')); + const suppressed = r.json.files.flatMap((file) => file.suppressed); + assert.equal(suppressed[0].exceptionRecord, 'https://example.org/exceptions/EXC-001'); } finally { cleanup(dir); } }); -test('validator-missing NOTICE path (empty PATH) does not crash, exit 0', () => { +test('bare, unknown, or expired exceptions never suppress findings', () => { + const exception = ` + exceptions: + - "2.5" + - rule: "999.1" + scope: "/info" + rationale: "This rule does not exist and cannot be excepted." + record: "https://example.org/exceptions/EXC-002" + reviewedBy: "API Working Group" + reviewedAt: "2020-01-01" + expiresAt: "2020-12-31"`; const dir = makeRepo({ - 'api/openapi.yaml': CLEAN_OPENAPI, - 'api/asyncapi.yaml': `asyncapi: 3.0.0 + 'api/openapi.yaml': openapiMissingContact(exception), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('guide-exception')); + assert.ok(codes(r.json).includes('govstack-2.5-contact')); + assert.equal(r.json.summary.suppressed, 0); + } finally { + cleanup(dir); + } +}); + +test('exception scope must contain the finding path; root scope contains all descendants', () => { + const exception = (scope) => ` + exceptions: + - rule: "2.5" + scope: ${JSON.stringify(scope)} + rationale: "Temporary compatibility with the approved legacy owner." + record: "https://example.org/exceptions/EXC-SCOPE" + reviewedBy: "API Working Group" + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31"`; + const wrong = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(exception('/paths')), + 'api/coverage.yaml': VALID_COVERAGE, + }); + const root = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(exception('')), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const wrongResult = runCliJson(['--repo-root', wrong, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(wrongResult.status, 1); + assert.ok(codes(wrongResult.json).includes('govstack-2.5-contact')); + + const rootResult = runCliJson(['--repo-root', root, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(rootResult.status, 0, `${rootResult.stderr}\n${rootResult.stdout}`); + assert.equal(rootResult.json.summary.suppressed, 1); + } finally { + cleanup(wrong); + cleanup(root); + } +}); + +test('HTTP exception records and missing rulesetVersion fail conformance', () => { + const insecureException = ` + exceptions: + - rule: "2.5" + scope: "/info" + rationale: "Temporary compatibility with the approved legacy owner." + record: "http://example.org/exceptions/EXC-HTTP" + reviewedBy: "API Working Group" + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31"`; + const insecure = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(insecureException), + 'api/coverage.yaml': VALID_COVERAGE, + }); + const missingRuleset = makeRepo({ + 'api/openapi.yaml': `openapi: 3.1.0 info: - title: Ev + title: Demo version: 1.0.0 - description: events - contact: - name: Team -operations: {} + description: Demo + contact: { name: Team } + x-govstack-api-guide: + version: ${GUIDE_VERSION} +paths: {} `, + 'api/coverage.yaml': VALID_COVERAGE, }); try { - // Empty PATH so spawned validators (openapi-spec-validator, npx/asyncapi) ENOENT. - // node itself is invoked by absolute path, so the CLI still runs. - const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET], { + const insecureResult = runCliJson(['--repo-root', insecure, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(insecureResult.status, 1); + assert.ok(codes(insecureResult.json).includes('guide-exception')); + assert.ok(codes(insecureResult.json).includes('govstack-2.5-contact')); + + const bin = fakeOpenapiValidator(missingRuleset); + const versionResult = runCliJson(['--repo-root', missingRuleset, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: bin }, + }); + assert.equal(versionResult.status, 1); + assert.ok(codes(versionResult.json).includes('guide-version')); + } finally { + cleanup(insecure); + cleanup(missingRuleset); + } +}); + +test('conformance fails an unsupported guide version and accepts the exact draft version', () => { + const mismatch = makeRepo({ + 'api/openapi.yaml': cleanOpenapi('0.2.0'), + 'api/coverage.yaml': VALID_COVERAGE, + }); + const exact = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const mismatchBin = fakeOpenapiValidator(mismatch); + const exactBin = fakeOpenapiValidator(exact); + const fail = runCliJson(['--repo-root', mismatch, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: mismatchBin }, + }); + assert.equal(fail.status, 1, fail.stderr); + assert.ok(codes(fail.json).includes('guide-version')); + + const pass = runCliJson(['--repo-root', exact, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: exactBin }, + }); + assert.equal(pass.status, 0, `${pass.stderr}\n${pass.stdout}`); + } finally { + cleanup(mismatch); + cleanup(exact); + } +}); + +test('missing base validator fails conformance but is an advisory notice', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const conform = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET], { env: { ...process.env, PATH: '' }, }); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /openapi-spec-validator not found/); - assert.match(r.stdout, /AsyncAPI CLI not found/); - assert.match(r.stdout, /pip install openapi-spec-validator/); - assert.match(r.stdout, /npm i -g @asyncapi\/cli/); + assert.equal(conform.status, 1, conform.stderr); + assert.ok(codes(conform.json).includes('base-validator-unavailable')); + + const advisory = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, '--mode', 'advisory'], { + env: { ...process.env, PATH: '' }, + }); + assert.equal(advisory.status, 0, advisory.stderr); + assert.match(advisory.json.notices.join('\n'), /openapi-spec-validator not found/); } finally { cleanup(dir); } }); + +test('conformance rejects --skip-validators', () => { + const dir = makeRepo({}); + try { + const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET, '--skip-validators']); + assert.equal(r.status, 2); + assert.match(r.stderr, /only available with --mode advisory/); + } finally { + cleanup(dir); + } +}); + +test('--format json includes mode and stable report fields', () => { + const dir = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.ok(r.json, `${r.stderr}\n${r.stdout}`); + assert.deepEqual( + Object.keys(r.json).sort(), + ['failOn', 'failed', 'files', 'mode', 'notices', 'summary'], + ); + assert.equal(r.json.mode, 'advisory'); + assert.equal(r.json.failOn, 'error'); + } finally { + cleanup(dir); + } +}); + +test('--fail-on never reports deterministic findings without failing', () => { + const dir = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson([ + '--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY, '--fail-on', 'never', + ]); + assert.equal(r.status, 0); + assert.equal(r.json.failed, false); + assert.ok(r.json.summary.errors >= 1); + } finally { + cleanup(dir); + } +}); + +test('unparseable declared spec is an operational error naming the file', () => { + const dir = makeRepo({ + 'api/openapi.yaml': 'openapi: 3.1.0\ninfo: {title: [oops\n', + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 2); + assert.match(r.stderr, /Cannot parse spec file api\/openapi\.yaml/); + } finally { + cleanup(dir); + } +}); + +test('bad flag and missing ruleset are operational errors', () => { + const empty = makeRepo({}); + const spec = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const bad = runCli(['--repo-root', empty, '--mode', 'bogus']); + assert.equal(bad.status, 2); + assert.match(bad.stderr, /Invalid --mode/); + + const missing = runCli([ + '--repo-root', spec, '--ruleset', path.join(spec, 'missing.yaml'), ...ADVISORY, + ]); + assert.equal(missing.status, 2); + assert.match(missing.stderr, /Ruleset not found/); + } finally { + cleanup(empty); + cleanup(spec); + } +}); diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/fail.yaml new file mode 100644 index 0000000..8c9bd7b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Forbidden OAuth flows fail, version: 1.0.0 } +paths: {} +components: + securitySchemes: + citizenOAuth: + type: oauth2 + flows: + password: + tokenUrl: https://identity.example.org/token + scopes: {} + implicit: + authorizationUrl: https://identity.example.org/authorize + scopes: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/pass.yaml new file mode 100644 index 0000000..c60648c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: { title: Safe OAuth flows pass, version: 1.0.0 } +paths: {} +components: + securitySchemes: + citizenOAuth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://identity.example.org/authorize + tokenUrl: https://identity.example.org/token + scopes: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml index 53d50e5..4434f9b 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml @@ -4,8 +4,8 @@ info: version: 1.0.0 channels: personCreated: - # 17.2: not reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event}. - address: com.example.person.created + # 17.2: the logical ID is not reverse-DNS, even though the native address is valid. + address: registrants/{tenant}/created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml index 0994788..23d880c 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml @@ -3,8 +3,8 @@ info: title: Registry events version: 1.0.0 channels: - personCreated: - address: org.govstack.reg.v1.person.created + org.govstack.reg.v1.person.created: + address: registrants/{tenant}/created messages: evt: $ref: '#/components/messages/PersonCreated' @@ -12,9 +12,9 @@ operations: onPersonCreated: action: receive channel: - $ref: '#/channels/personCreated' + $ref: '#/channels/org.govstack.reg.v1.person.created' messages: - - $ref: '#/channels/personCreated/messages/evt' + - $ref: '#/channels/org.govstack.reg.v1.person.created/messages/evt' components: messages: PersonCreated: diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml index cb56e2e..3c697e9 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml @@ -1,4 +1,4 @@ -# Fixture for govstack-18.2-asyncapi — channel address major (v1) does not +# Fixture for govstack-18.2-asyncapi — logical channel ID major (v1) does not # match info.version's major (2). asyncapi: 3.0.0 info: @@ -16,8 +16,8 @@ servers: brokerHost: default: broker.example.org channels: - userSignedUp: - address: org.govstack.identity.v1.user.signedup + org.govstack.identity.v1.user.signedup: + address: users/signed-up messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' @@ -29,9 +29,9 @@ operations: tags: - name: user channel: - $ref: '#/channels/userSignedUp' + $ref: '#/channels/org.govstack.identity.v1.user.signedup' messages: - - $ref: '#/channels/userSignedUp/messages/userSignedUp' + - $ref: '#/channels/org.govstack.identity.v1.user.signedup/messages/userSignedUp' components: messages: UserSignedUp: diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml index 8860194..0103500 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml @@ -1,4 +1,4 @@ -# Fixture for govstack-18.2-asyncapi — channel address major (v2) matches +# Fixture for govstack-18.2-asyncapi — logical channel ID major (v2) matches # info.version's major (2). asyncapi: 3.0.0 info: @@ -16,8 +16,8 @@ servers: brokerHost: default: broker.example.org channels: - userSignedUp: - address: org.govstack.identity.v2.user.signedup + org.govstack.identity.v2.user.signedup: + address: users/signed-up messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' @@ -29,9 +29,9 @@ operations: tags: - name: user channel: - $ref: '#/channels/userSignedUp' + $ref: '#/channels/org.govstack.identity.v2.user.signedup' messages: - - $ref: '#/channels/userSignedUp/messages/userSignedUp' + - $ref: '#/channels/org.govstack.identity.v2.user.signedup/messages/userSignedUp' components: messages: UserSignedUp: diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml index 099abe4..153b9b2 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml @@ -1,8 +1,8 @@ -openapi: 3.0.3 +openapi: 3.2.0 info: title: Sample API version: 1.0.0 - description: A sample API on the wrong OpenAPI version. + description: A sample API on an unqualified later OpenAPI minor version. contact: name: Sample BB Team url: https://example.org/contact diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml index c6ce592..a62d2fa 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml @@ -1,8 +1,8 @@ -openapi: 3.1.0 +openapi: 3.1.2 info: title: Sample API version: 1.0.0 - description: A sample API on the correct OpenAPI version. + description: A sample API on a qualified OpenAPI 3.1 patch version. contact: name: Sample BB Team url: https://example.org/contact diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml index f3df1f5..fb22e1a 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml @@ -7,7 +7,7 @@ info: name: Sample BB Team url: https://example.org/contact servers: - - url: https://{gatewayHost}/sample/v1 + - url: https://{gatewayHost}/sample description: Non-production reference deployment. variables: gatewayHost: diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml index 6f8ec09..e1be931 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml @@ -7,7 +7,8 @@ info: name: Sample BB Team url: https://example.org/contact x-govstack-api-guide: - version: 0.2.0 + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft exceptions: - RULE-9.3 - RULE-11.2 diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml index d9ae70b..42a0958 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml @@ -7,10 +7,21 @@ info: name: Sample BB Team url: https://example.org/contact x-govstack-api-guide: - version: 0.2.0 + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft exceptions: - - rule: RULE-9.3 - approvedBy: https://example.org/exceptions/RULE-9.3 - - rule: RULE-11.2 - approvedBy: https://example.org/exceptions/RULE-11.2 + - rule: "9.3" + scope: /components/schemas/Legacy/properties/enabled + rationale: Temporary compatibility exception for a reviewed legacy boolean field. + record: https://example.org/exceptions/RULE-9.3 + reviewedBy: API Working Group + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31" + - rule: "11.2" + scope: /paths/~1v1~1things/get/responses/400 + rationale: Temporary compatibility exception for a reviewed problem-details field. + record: https://example.org/exceptions/RULE-11.2 + reviewedBy: API Working Group + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31" paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml index 6206af9..63868a2 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml @@ -7,5 +7,6 @@ info: name: Sample BB Team url: https://example.org/contact x-govstack-api-guide: - version: 0.2.0 + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/fail.yaml new file mode 100644 index 0000000..969f879 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Baseline errors fail, version: 1.0.0 } +security: + - oauth: [] +paths: + /v1/things/{thingId}: + post: + operationId: updateThing + requestBody: + content: + application/json: + schema: { type: object } + responses: + "200": { description: Updated. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/pass.yaml new file mode 100644 index 0000000..9dd9bbe --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/pass.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: { title: Baseline errors pass, version: 1.0.0 } +security: + - oauth: [] +paths: + /v1/things/{thingId}: + post: + operationId: updateThing + requestBody: + content: + application/json: + schema: { type: object } + responses: + "200": { description: Updated. } + "400": { description: Bad request. } + "401": { description: Unauthenticated. } + "404": { description: Not found. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/fail.yaml new file mode 100644 index 0000000..e406701 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/fail.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: { title: Creation status fail, version: 1.0.0 } +paths: + /v1/things: + post: + operationId: createThing + responses: + "200": { description: Incorrect creation response. } + "500": { description: Error. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/pass.yaml new file mode 100644 index 0000000..6168dd9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/pass.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: { title: Creation status pass, version: 1.0.0 } +paths: + /v1/things: + post: + operationId: createThing + responses: + "201": { description: Created. } + "500": { description: Error. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.21/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.21/fail.yaml new file mode 100644 index 0000000..9595fa0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.21/fail.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: { title: Success schema fail, version: 1.0.0 } +paths: + /v1/things: + get: + responses: + "200": + description: Thing response. + content: + application/json: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.21/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.21/pass.yaml new file mode 100644 index 0000000..765d9a4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.21/pass.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: { title: Success schema pass, version: 1.0.0 } +paths: + /v1/things: + get: + responses: + "200": + description: Thing response. + content: + application/json: + schema: + type: object + description: A thing. diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml index 31ffdd1..3c70ea1 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Sample API version: 1.0.0 - description: No X-Request-Id correlation at all. + description: Secured operation missing W3C traceparent. contact: name: Sample BB Team url: https://example.org/contact @@ -13,6 +13,8 @@ paths: summary: List items description: List items. tags: [items] + security: + - serviceAuth: [] responses: "200": description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml index b28331e..4009f38 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Sample API version: 1.0.0 - description: Accepts and echoes X-Request-Id. + description: Secured operation accepts W3C traceparent. contact: name: Sample BB Team url: https://example.org/contact @@ -13,8 +13,10 @@ paths: summary: List items description: List items. tags: [items] + security: + - serviceAuth: [] parameters: - - name: X-Request-Id + - name: traceparent in: header required: false schema: @@ -22,7 +24,3 @@ paths: responses: "200": description: OK - headers: - X-Request-Id: - schema: - type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml index 50fa999..706591a 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Sample API version: 1.0.0 - description: No new X- prefixed headers; the legacy X-Request-Id is still allowed. + description: No X- prefixed headers; W3C Trace Context is used. contact: name: Sample BB Team url: https://example.org/contact @@ -14,7 +14,7 @@ paths: description: List items. tags: [items] parameters: - - name: X-Request-Id + - name: traceparent in: header required: false schema: diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/fail.yaml new file mode 100644 index 0000000..e64f95b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/fail.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: { title: Legacy RateLimit fail, version: 1.0.0 } +paths: + /items: + get: + responses: + "200": + description: OK + headers: + RateLimit-Limit: { schema: { type: integer } } + RateLimit-Remaining: { schema: { type: integer } } + RateLimit-Reset: { schema: { type: integer } } diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/pass.yaml new file mode 100644 index 0000000..079c325 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/pass.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: { title: Structured RateLimit pass, version: 1.0.0 } +paths: + /items: + get: + responses: + "200": + description: OK + headers: + RateLimit: { schema: { type: string } } diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml index c183a5c..d316953 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml @@ -17,27 +17,15 @@ paths: "200": description: OK headers: - RateLimit-Limit: + RateLimit: schema: - type: integer - RateLimit-Remaining: - schema: - type: integer - RateLimit-Reset: - schema: - type: integer + type: string "429": description: Too Many Requests headers: Retry-After: schema: type: integer - RateLimit-Limit: + RateLimit: schema: - type: integer - RateLimit-Remaining: - schema: - type: integer - RateLimit-Reset: - schema: - type: integer + type: string diff --git a/api-design-guide/linter/tests/functions.test.mjs b/api-design-guide/linter/tests/functions.test.mjs index 9171b0c..4f82d59 100644 --- a/api-design-guide/linter/tests/functions.test.mjs +++ b/api-design-guide/linter/tests/functions.test.mjs @@ -16,6 +16,9 @@ import securityCoverage from '../functions/securityCoverage.js'; import schemaFieldFormat from '../functions/schemaFieldFormat.js'; import extensionShape from '../functions/extensionShape.js'; import mediaTypeExpected from '../functions/mediaTypeExpected.js'; +import successResponseSchema from '../functions/s07-successResponseSchema.js'; +import creationResponses from '../functions/s07-creationResponses.js'; +import baselineResponses from '../functions/s07-baselineResponses.js'; import { walkSchema } from '../functions/lib/schemaWalk.js'; const count = (r) => (r === undefined ? 0 : r.length); @@ -33,6 +36,9 @@ const ALL = { schemaFieldFormat, extensionShape, mediaTypeExpected, + successResponseSchema, + creationResponses, + baselineResponses, }; test('all functions return undefined on bad input, never throw', () => { for (const [name, fn] of Object.entries(ALL)) { @@ -208,6 +214,39 @@ test('mediaTypeExpected: require / requireOneOf / forbid', () => { assert.equal(count(mediaTypeExpected({ 'text/plain': {} }, { forbid: ['text/plain'] })), 1); }); +test('successResponseSchema: declared success content requires schemas', () => { + const bad = { + paths: { + '/v1/things': { + get: { responses: { 200: { content: { 'application/json': {} } }, 204: {} } }, + }, + }, + }; + assert.equal(count(successResponseSchema(bad)), 1); + bad.paths['/v1/things'].get.responses[200].content['application/json'].schema = { type: 'object' }; + assert.equal(count(successResponseSchema(bad)), 0); +}); + +test('creationResponses and baselineResponses use visible operation shape', () => { + const doc = { + security: [{ oauth: [] }], + paths: { + '/v1/things/{thingId}': { + post: { + operationId: 'createThing', + requestBody: { content: {} }, + responses: { 200: {} }, + }, + }, + }, + }; + assert.equal(count(creationResponses(doc)), 1); + assert.equal(count(baselineResponses(doc)), 3); + doc.paths['/v1/things/{thingId}'].post.responses = { 201: {}, 400: {}, 401: {}, 404: {} }; + assert.equal(count(creationResponses(doc)), 0); + assert.equal(count(baselineResponses(doc)), 0); +}); + test('walkSchema: visits combinators and is cycle-safe', () => { const seen = []; const schema = { diff --git a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml index 6ce384c..2c7c8dd 100644 --- a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml +++ b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml @@ -5,8 +5,9 @@ # bundled ruleset simultaneously and to double as a copy-able reference for BB # spec editors. # -# It exercises the guide's event-driven shapes: reverse-DNS channel addresses with -# the major version (§17.2) and declared+described parameters (§17.4), operations +# It exercises the guide's event-driven shapes: reverse-DNS logical channel IDs +# with the major version (§17.2), native addresses, and declared+described +# parameters (§17.4), operations # with send/receive perspective and complete metadata (§3.7/§17.1), CloudEvents # JSON payloads (§17.6) with camelCase properties (§3.9) and camelCase headers # (§17.8), message examples (§17.20), security covering every operation via the @@ -35,7 +36,8 @@ info: name: CC-BY-4.0 url: https://creativecommons.org/licenses/by/4.0/ x-govstack-api-guide: - version: 0.2.0 + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft servers: production: host: kafka.example.gov:9092 @@ -45,8 +47,8 @@ servers: - $ref: '#/components/securitySchemes/registryOAuth' defaultContentType: application/json channels: - registrantRegistered: - address: org.govstack.registry.v1.registrant.registered + org.govstack.registry.v1.registrant.registered: + address: registry.registrant.registered description: Registrant lifecycle events published by the Registry BB. servers: - $ref: '#/servers/production' @@ -56,8 +58,8 @@ channels: bindings: kafka: bindingVersion: '0.5.0' - registrantCommands: - address: org.govstack.registry.v1.registrant.{registrantId}.commands + org.govstack.registry.v1.registrant.commands: + address: registry.registrant.{registrantId}.commands description: Commands directed at a specific registrant, consumed by the Registry BB. parameters: registrantId: @@ -70,8 +72,8 @@ channels: bindings: kafka: bindingVersion: '0.5.0' - registrantCommandReplies: - address: org.govstack.registry.v1.registrant.command-replies + org.govstack.registry.v1.registrant.command-replies: + address: registry.registrant.command-replies description: Replies and rejections for registrant commands. servers: - $ref: '#/servers/production' @@ -94,9 +96,9 @@ operations: tags: - name: Registrants channel: - $ref: '#/channels/registrantRegistered' + $ref: '#/channels/org.govstack.registry.v1.registrant.registered' messages: - - $ref: '#/channels/registrantRegistered/messages/registered' + - $ref: '#/channels/org.govstack.registry.v1.registrant.registered/messages/registered' x-govstack-delivery: atLeastOnce x-govstack-ordering: scope: partitionKey @@ -116,14 +118,14 @@ operations: tags: - name: Registrants channel: - $ref: '#/channels/registrantCommands' + $ref: '#/channels/org.govstack.registry.v1.registrant.commands' messages: - - $ref: '#/channels/registrantCommands/messages/deregister' + - $ref: '#/channels/org.govstack.registry.v1.registrant.commands/messages/deregister' reply: channel: - $ref: '#/channels/registrantCommandReplies' + $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies' messages: - - $ref: '#/channels/registrantCommandReplies/messages/result' + - $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies/messages/result' x-govstack-delivery: atLeastOnce x-govstack-ordering: scope: partitionKey @@ -142,9 +144,9 @@ operations: tags: - name: Registrants channel: - $ref: '#/channels/registrantCommandReplies' + $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies' messages: - - $ref: '#/channels/registrantCommandReplies/messages/rejected' + - $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies/messages/rejected' x-govstack-delivery: atLeastOnce x-govstack-ordering: none x-govstack-redelivery: supported diff --git a/api-design-guide/linter/tests/golden/openapi-golden.yaml b/api-design-guide/linter/tests/golden/openapi-golden.yaml index ceec728..eb6c808 100644 --- a/api-design-guide/linter/tests/golden/openapi-golden.yaml +++ b/api-design-guide/linter/tests/golden/openapi-golden.yaml @@ -9,8 +9,8 @@ # params/properties, described schemas (§4.1) with examples (§4.2), the §5.9 # health endpoint family, RFC 9457 problem+json error envelopes with the # GovStack extension fields and field-level errors[] (§11), cursor pagination -# (§12), a creating POST with Idempotency-Key + Location (§8.3/§14.1), X-Request-Id -# correlation (§8.4), ETag/If-Match optimistic concurrency (§7.16/§7.17), merge- +# (§12), a creating POST with Idempotency-Key + Location (§8.3/§14.1), traceparent +# Trace Context correlation (§8.4), ETag/If-Match optimistic concurrency (§7.16/§7.17), merge- # patch PATCH (§6.4), async long-running Operations (§15), CloudEvents webhooks + # subscription control plane (§16), OAuth2/OIDC/mTLS security with namespaced # scopes (§13), and the §20.3 conformance declaration. @@ -42,7 +42,8 @@ info: name: CC-BY-4.0 url: https://creativecommons.org/licenses/by/4.0/ x-govstack-api-guide: - version: 0.2.0 + version: 0.2.0-draft + rulesetVersion: 0.2.0-draft servers: - url: https://api.example.gov/registry description: Production gateway for the Registry Building Block. @@ -72,13 +73,13 @@ paths: tags: [Health] security: [] parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: The service is alive; body reports the aggregate status. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' content: application/health+json: schema: @@ -99,7 +100,7 @@ paths: updated first. Supports simple equality filtering and sorting. tags: [Registrants] parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' - $ref: '#/components/parameters/PageSize' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Sort' @@ -109,8 +110,8 @@ paths: '200': description: A page of registrants. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' ETag: $ref: '#/components/headers/ETag' content: @@ -123,6 +124,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/ServerError' post: @@ -136,7 +139,7 @@ paths: - registryOAuth: - bb:registry:registrant:write parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true @@ -149,8 +152,8 @@ paths: '201': description: The registrant was created. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' Location: $ref: '#/components/headers/Location' ETag: @@ -163,6 +166,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '500': @@ -176,13 +181,13 @@ paths: description: Returns a single registrant record by its opaque identifier. tags: [Registrants] parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: The registrant record. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' ETag: $ref: '#/components/headers/ETag' content: @@ -191,8 +196,12 @@ paths: $ref: '#/components/schemas/Registrant' '304': $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': @@ -208,7 +217,7 @@ paths: - registryOAuth: - bb:registry:registrant:write parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' - $ref: '#/components/parameters/IfMatch' requestBody: required: true @@ -221,8 +230,8 @@ paths: '200': description: The registrant was replaced. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' ETag: $ref: '#/components/headers/ETag' content: @@ -233,6 +242,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': @@ -252,7 +263,7 @@ paths: - registryOAuth: - bb:registry:registrant:write parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' - $ref: '#/components/parameters/IfMatch' requestBody: required: true @@ -265,8 +276,8 @@ paths: '200': description: The registrant was updated. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' ETag: $ref: '#/components/headers/ETag' content: @@ -277,6 +288,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': @@ -296,15 +309,19 @@ paths: - registryOAuth: - bb:registry:registrant:write parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' responses: '204': description: The registrant was deleted; no body is returned. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': @@ -319,7 +336,7 @@ paths: request body per guide §6.6/§12.9. tags: [Registrants] parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' requestBody: required: true description: The search filter plus cursor pagination controls. @@ -331,8 +348,8 @@ paths: '200': description: A page of matching registrants. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' content: application/json: schema: @@ -341,6 +358,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/ServerError' /v1/bulk-imports: @@ -356,7 +375,7 @@ paths: - registryOAuth: - bb:registry:registrant:write parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true @@ -369,8 +388,8 @@ paths: '202': description: The import was accepted; poll the Operation for progress. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' Location: $ref: '#/components/headers/Location' content: @@ -381,6 +400,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/ServerError' /v1/operations/{operationId}: @@ -394,13 +415,13 @@ paths: for completion, per guide §15.4. tags: [Operations] parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: The current Operation state. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' ETag: $ref: '#/components/headers/ETag' content: @@ -409,8 +430,12 @@ paths: $ref: '#/components/schemas/Operation' '304': $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': @@ -429,19 +454,23 @@ paths: - registryOAuth: - bb:registry:registrant:write parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: Cancellation was requested; the Operation state is returned. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' content: application/json: schema: $ref: '#/components/schemas/Operation' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': @@ -458,15 +487,15 @@ paths: - registryOAuth: - bb:registry:subscription:manage parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' - $ref: '#/components/parameters/PageSize' - $ref: '#/components/parameters/Cursor' responses: '200': description: A page of subscriptions. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' ETag: $ref: '#/components/headers/ETag' content: @@ -475,8 +504,12 @@ paths: $ref: '#/components/schemas/SubscriptionPage' '304': $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/ServerError' post: @@ -491,7 +524,7 @@ paths: - registryOAuth: - bb:registry:subscription:manage parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true @@ -504,8 +537,8 @@ paths: '201': description: The subscription was created. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' Location: $ref: '#/components/headers/Location' content: @@ -516,6 +549,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '500': $ref: '#/components/responses/ServerError' /v1/subscriptions/{subscriptionId}: @@ -530,15 +565,19 @@ paths: - registryOAuth: - bb:registry:subscription:manage parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' responses: '204': description: The subscription was deleted; no body is returned. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': @@ -557,19 +596,23 @@ paths: - registryOAuth: - bb:registry:subscription:manage parameters: - - $ref: '#/components/parameters/XRequestId' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: The secret was rotated; the new secret is returned once. headers: - X-Request-Id: - $ref: '#/components/headers/XRequestId' + traceparent: + $ref: '#/components/headers/Traceparent' content: application/json: schema: $ref: '#/components/schemas/SubscriptionSecret' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': @@ -632,16 +675,16 @@ components: type: mutualTLS description: Mutual TLS for high-assurance service-to-service calls. parameters: - XRequestId: - name: X-Request-Id + Traceparent: + name: traceparent in: header required: false description: >- - Client-supplied correlation identifier (§8.4). Echoed on the response; if - omitted the server generates one. + W3C Trace Context traceparent (§8.4). The service propagates a valid + received context or creates a valid new context when absent or invalid. schema: type: string - format: uuid + pattern: '^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$' IdempotencyKey: name: Idempotency-Key in: header @@ -740,11 +783,11 @@ components: type: string pattern: '^[A-Z]{2}$' headers: - XRequestId: - description: Correlation identifier echoed from the request or generated by the server. + Traceparent: + description: Optional W3C Trace Context returned for diagnostic convenience. schema: type: string - format: uuid + pattern: '^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$' Location: description: Absolute URL of the created or referenced resource. schema: @@ -788,6 +831,15 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + Forbidden: + description: The authenticated principal lacks a required OAuth scope. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' NotFound: description: The requested resource does not exist. headers: From 071dd12c1b02630732620e3ab1a985c71ebcd5c1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:15:29 +0700 Subject: [PATCH 06/19] docs: rewrite BB spec template around a reference example Replaces the prose spec template with a generic reference domain carrying stable BB-TPL-* requirement identifiers, so the traceability and conformance structure is demonstrated rather than described. This commit is deliberately last and touches only committee-owned template content. Drop it to ship the API design guide work on its own. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- README.md | 63 +- .../assets/Govstack_scheduler_BB_APIs.json | 3755 ----------------- .../Screen Shot 2023-04-07 at 11.59.49 AM.png | Bin 143890 -> 0 bytes spec/1-version-history.md | 11 +- spec/10-other-resources.md | 24 +- spec/2-description.md | 21 +- spec/3-terminology.md | 14 +- spec/4-key-digital-functionalities.md | 28 +- spec/5-cross-cutting-requirements.md | 50 +- spec/6-functional-requirements.md | 50 +- spec/7-data-structures.md | 55 +- spec/8-service-apis.md | 60 +- spec/9-workflows.md | 64 +- spec/README.md | 17 +- spec/SUMMARY.md | 2 +- test/plan.md | 52 +- 16 files changed, 230 insertions(+), 4036 deletions(-) delete mode 100644 spec/.gitbook/assets/Govstack_scheduler_BB_APIs.json delete mode 100644 spec/.gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png diff --git a/README.md b/README.md index 94c490e..2d7e4c7 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,40 @@ # GovStack Building Block Template -This template is intended to be used by the various GovStack building block -repos. Each building block repo will have at least 4 main sections, outlined in -the directory structure below. - -## Gitbook and the published "Building Block Specifications" document - -Note that pushes to the `main` branch will automatically trigger a Gitbook build -and deployment from the `/spec` directory. - -## Repo Structure - -```sh -README.md -/spec # the markdown files which are used to build the specification in GitBook -/api-design-guide # the GovStack Cross-BB API Design Guide (its own GitBook space; see api-design-guide/README.md) -/api # the openapi specification -/test # the test plan and tests - plan.md -/examples # examples for deploying, configuring, and testing applications which implement the behaviors specified by this building block - /application-a - README.md # instructions for deployment/testing - docker-compose.yaml # example deployment file - db - web - adaptor - security-server - Caddyfile # example config for "adaptor" - Dockerfile # dockerfile to build "adaptor" - /application-b - /application-c +This repository is the starting point for a GovStack Building Block (BB) +specification. Replace the generic reference domain with the BB's real +requirements while preserving its traceability and conformance structure. + +## Start a BB specification + +1. Give every normative requirement a stable identifier based on the BB code, + such as `REGISTRY-FR-001`. Never reuse an identifier for a different + requirement. +2. Replace the reference contract at `api/openapi.yaml`. Keep `api/index.yaml` + as the canonical API registry. Keep the shared files under `api/common/` + pinned to their recorded upstream version and revision. +3. Map every normative interface requirement in `api/coverage.yaml`. +4. Run the checks in `test/plan.md` before requesting specification review. + +The reference API is intentionally small. It demonstrates synchronous creation, +pagination, long-running operations, standard errors, trace context, and OAuth +2.0 without prescribing a domain model for real BBs. + +## Repository structure + +```text +spec/ GitBook specification and stable requirements +api/index.yaml registry of canonical API documents +api/openapi.yaml canonical OpenAPI 3.1 reference contract +api/coverage.yaml authoritative requirement-to-interface mapping +api/common/ pinned, vendored cross-BB contract components +api-design-guide/ cross-BB API design rules and lint tooling +test/plan.md specification and implementation conformance plan +examples/ deployable implementation examples ``` +Pushes to `main` publish the GitBook content under `spec/`. The API contract in +`api/` remains the machine-readable source of truth for operations and schemas. + ## ORB setup Documentation for ORB setup is available here: diff --git a/spec/.gitbook/assets/Govstack_scheduler_BB_APIs.json b/spec/.gitbook/assets/Govstack_scheduler_BB_APIs.json deleted file mode 100644 index 30a508a..0000000 --- a/spec/.gitbook/assets/Govstack_scheduler_BB_APIs.json +++ /dev/null @@ -1,3755 +0,0 @@ -{ - "openapi" : "3.0.1", - "info" : { - "title" : "Govstack Scheduler API", - "description" : "Interfaces to services rendered by Scheduler building block", - "termsOfService" : "TBD", - "contact" : { - "email" : "psramkumar2@gmail.com" - }, - "license" : { - "name" : "Apache 2.0", - "url" : "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "version" : "1.0.0" - }, - "externalDocs" : { - "description" : "Find out more about scheduler", - "url" : "https://www.govstack.global/" - }, - "tags" : [ { - "name" : "event", - "description" : "event management API" - }, { - "name" : "entity", - "description" : "entity management API" - }, { - "name" : "alertSchedule", - "description" : "alertSchedule management API" - }, { - "name" : "resource", - "description" : "resource management API" - }, { - "name" : "subscribers", - "description" : "subscriber management API" - }, { - "name" : "message", - "description" : "message management API" - }, { - "name" : "log", - "description" : "log management API" - }, { - "name" : "affiliation", - "description" : "affiliation management API" - }, { - "name" : "appointment", - "description" : "appointment management API" - } ], - "paths" : { - "/event/new" : { - "post" : { - "tags" : [ "event" ], - "summary" : "create a new event, in eventList with given eventDetails, returns new event id or failure error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/eventNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created event", - "example" : "eventId:12345" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/event/modifications" : { - "put" : { - "tags" : [ "event" ], - "summary" : "changes values of specific details (as given by eventDetails) of given event (selected by eventFilter) in eventList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "eventId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/eventModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, event updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/event" : { - "delete" : { - "tags" : [ "event" ], - "summary" : "delete a predefined event in eventList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "eventId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - } ], - "responses" : { - "200" : { - "description" : "success, event cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/event/listDetails" : { - "get" : { - "tags" : [ "event" ], - "summary" : "get list of events filetered by criteria in eventFilter and return info specified by eventDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/eventGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,eventList", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/eventList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment/new" : { - "post" : { - "tags" : [ "appointment" ], - "summary" : "create a new appointment, in appointmentList with given appointmentDetails, returns new appointment id or failure error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/appointmentNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created appointment", - "example" : "[eventId:12345,appointmentId:1]" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment/modifications" : { - "put" : { - "tags" : [ "appointment" ], - "summary" : "changes values of specific details (as given by appointmentDetails) of given appointment (selected by appointmentFilter)in appointmentList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "appointmentId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/appointmentModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, appointment updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment" : { - "delete" : { - "tags" : [ "appointment" ], - "summary" : "delete a predefined appointment in appointmentList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "appointmentId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, appointment cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment/listDetails" : { - "get" : { - "tags" : [ "appointment" ], - "summary" : "get list of appointments filetered by criteria in appointmentFilter and return info specified by appointmentDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/appointmentGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,appointmentList", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/appointmentList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity/new" : { - "post" : { - "tags" : [ "entity" ], - "summary" : "create a new entity, in entityList with given entityDetails, returns new entity id or entity error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/entityNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created entity", - "example" : "entityName:xyz hospital,entityId:67890" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity/modifications" : { - "put" : { - "tags" : [ "entity" ], - "summary" : "changes values of specific details (as given by entityDetails) of given entity (selected by entityFilter)in entityList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "entityId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "67890" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/entityModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, entity updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity" : { - "delete" : { - "tags" : [ "entity" ], - "summary" : "delete a predefined entity in entityList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "entityId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "67890" - } - } ], - "responses" : { - "200" : { - "description" : "success, entity cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity/listDetails" : { - "get" : { - "tags" : [ "entity" ], - "summary" : "get list of entities filetered by criteria in entityFilter and return info specified by entityDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/entityGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,entity list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/entityList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/new" : { - "post" : { - "tags" : [ "resource" ], - "summary" : "create a new resource, in resourceList with given resourceDetails, returns new resource id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created resource", - "example" : "resourceName:xyz hospital,resourceId:54321" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/modifications" : { - "put" : { - "tags" : [ "resource" ], - "summary" : "changes values of specific details (as given by resourceDetails) of given resource (selected by resourceFilter)in resourceList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "resourceId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "54321" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, resource updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource" : { - "delete" : { - "tags" : [ "resource" ], - "summary" : "delete a predefined resource in resourceList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "resourceId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "54321" - } - } ], - "responses" : { - "200" : { - "description" : "success, resource cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/listDetails" : { - "get" : { - "tags" : [ "resource" ], - "summary" : "get list of entities filetered by criteria in resourceFilter and return info specified by resourceDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,resource list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/resourceList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/availability" : { - "get" : { - "tags" : [ "resource" ], - "summary" : "get details of resources matching criteria given by freeResourceFilter, that are free(unallocated) in a given date range and entity and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceGetAvailabilityQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,resource list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/freeResourceList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber/new" : { - "post" : { - "tags" : [ "subscriber" ], - "summary" : "create a new subscriber, in subscriberList with given subscriberDetails, returns new subscriber id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/subscriberNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created subscriber", - "example" : "subscriberName:xyz hospital,subscriberId:12345" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber/modifications" : { - "put" : { - "tags" : [ "subscriber" ], - "summary" : "changes values of specific details (as given by subscriberDetails) of given subscriber (selected by subscriberFilter)in subscriberList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "subscriberId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/subscriberModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, subscriber updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber" : { - "delete" : { - "tags" : [ "subscriber" ], - "summary" : "delete a predefined subscriber in subscriberList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "subscriberId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - } ], - "responses" : { - "200" : { - "description" : "success, subscriber cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber/listDetails" : { - "get" : { - "tags" : [ "subscriber" ], - "summary" : "get list of entities filetered by criteria in subscriberFilter and return info specified by subscriberDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/subscriberGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,message list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/subscriberList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message/new" : { - "post" : { - "tags" : [ "message" ], - "summary" : "create a new message, in messageList with given messageDetails, returns new message id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/messageNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created message", - "example" : "messageName:xyz hospital,messageId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message/modifications" : { - "put" : { - "tags" : [ "message" ], - "summary" : "changes values of specific details (as given by messageDetails) of given message (selected by messageFilter)in messageList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "messageId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/messageModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, message updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message" : { - "delete" : { - "tags" : [ "message" ], - "summary" : "delete a predefined message in messageList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "messageId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, message cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message/listDetails" : { - "get" : { - "tags" : [ "message" ], - "summary" : "get list of entities filetered by criteria in messageFilter and return info specified by messageDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/messageGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,message list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/messageList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation/new" : { - "post" : { - "tags" : [ "affiliation" ], - "summary" : "create a new affiliation, in affiliationList with given affiliationDetails, returns new affiliation id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/affiliationNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created affiliation", - "example" : "affiliationName:xyz hospital,affiliationId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation/modifications" : { - "put" : { - "tags" : [ "affiliation" ], - "summary" : "changes values of specific details (as given by affiliationDetails) of given affiliation (selected by affiliationFilter)in affiliationList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "affiliationId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/affiliationModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, affiliation updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation" : { - "delete" : { - "tags" : [ "affiliation" ], - "summary" : "delete a predefined affiliation in affiliationList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "affiliationId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, affiliation cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation/listDetails" : { - "get" : { - "tags" : [ "affiliation" ], - "summary" : "get list of entities filetered by criteria in affiliationFilter and return info specified by affiliationDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/affiliationGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,affiliation list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/affiliationList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule/new" : { - "post" : { - "tags" : [ "alertSchedule" ], - "summary" : "create a new alertSchedule, in alertScheduleList with given alertScheduleDetails, returns new alertSchedule id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/alertScheduleNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created alertSchedule", - "example" : "alertScheduleName:xyz hospital,alertScheduleId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule/modifications" : { - "put" : { - "tags" : [ "alertSchedule" ], - "summary" : "changes values of specific details (as given by alertScheduleDetails) of given alertSchedule (selected by alertScheduleFilter)in alertScheduleList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string" - }, - "example" : "Organizer" - }, { - "name" : "alertScheduleId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/alertScheduleModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, alertSchedule updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule" : { - "delete" : { - "tags" : [ "alertSchedule" ], - "summary" : "delete a predefined alertSchedule in alertScheduleList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "alertScheduleId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, alertSchedule cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule/listDetails" : { - "get" : { - "tags" : [ "alertSchedule" ], - "summary" : "get list of entities filetered by criteria in alertScheduleFilter and return info specified by alertScheduleDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/alertScheduleGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,alertSchedule list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/alertScheduleList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log/new" : { - "post" : { - "tags" : [ "log" ], - "summary" : "create a new log, in logList with given logDetails, returns new logId or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/logNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created log", - "example" : "logName:xyz hospital,logId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log/modifications" : { - "put" : { - "tags" : [ "log" ], - "summary" : "changes values of specific details (as given by logDetails) of given log (selected by logFilter)in logList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "logId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/logModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, log updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log" : { - "delete" : { - "tags" : [ "log" ], - "summary" : "delete a predefined log in logList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "logId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, log cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log/listDetails" : { - "get" : { - "tags" : [ "log" ], - "summary" : "get list of entities filetered by criteria in logFilter and return info specified by logDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/logGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,log list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/logList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - } - }, - "components" : { - "schemas" : { - "entityList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "details" : { - "$ref" : "#/components/schemas/entityDetails" - } - } - } - }, - "entityDetails" : { - "type" : "object", - "properties" : { - "category" : { - "type" : "string", - "example" : "hospital" - }, - "name" : { - "type" : "string", - "example" : "abc" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "info@xyz.com" - }, - "website" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "entityFilter" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[67890,12121]" - }, - "category" : { - "type" : "string", - "example" : "hospital" - }, - "name" : { - "type" : "string", - "example" : "abc" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "info@xyz.com" - }, - "website" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "entityDetailsRequired" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "boolean", - "example" : false - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "phone" : { - "type" : "boolean", - "example" : true - }, - "email" : { - "type" : "boolean", - "example" : true - }, - "website" : { - "type" : "boolean", - "example" : true - } - } - }, - "appointmentCreationDetails" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "eventIds" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1]" - }, - "participantType" : { - "type" : "string", - "example" : "subscriber" - }, - "participantId" : { - "type" : "string", - "example" : "12345" - }, - "participantEntityId" : { - "type" : "string", - "example" : "67890" - } - } - }, - "appointmentList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "appointmentId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/appointmentDetails" - } - } - } - }, - "appointmentDetails" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "eventId" : { - "type" : "string", - "example" : "1" - }, - "participantType" : { - "type" : "string", - "example" : "subscriber" - }, - "participantId" : { - "type" : "string", - "example" : "12345" - }, - "statusId" : { - "type" : "string", - "example" : "confirmed" - }, - "participantEntityId" : { - "type" : "string", - "example" : "67890" - } - } - }, - "appointmentFilter" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "appointmentId" : { - "type" : "string", - "example" : "1" - }, - "participantType" : { - "type" : "string", - "example" : "subscriber" - }, - "participantId" : { - "type" : "string", - "example" : "12345" - }, - "participantEntityId" : { - "type" : "string", - "example" : "67890" - }, - "status" : { - "type" : "string", - "example" : "confirmed" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T17:00:00" - } - } - }, - "appointmentDetailsRequired" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "appointmentId" : { - "type" : "boolean", - "example" : true - }, - "eventDetails" : { - "type" : "boolean", - "example" : true - }, - "participantType" : { - "type" : "boolean", - "example" : true - }, - "participantId" : { - "type" : "boolean", - "example" : true - }, - "status" : { - "type" : "boolean", - "example" : true - }, - "participantEntityId" : { - "type" : "boolean", - "example" : true - } - } - }, - "resourceList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "string", - "example" : "54321" - }, - "details" : { - "$ref" : "#/components/schemas/resourceDetails" - } - } - } - }, - "resourceDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "psrk" - }, - "category" : { - "type" : "string", - "example" : "doctor" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "doctor1@xyz.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "psrk@gmail.com" - }, - "alertPreference" : { - "type" : "string", - "example" : "phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.doctor1.com" - } - } - }, - "resourceFilter" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[54321,31313]" - }, - "name" : { - "type" : "string", - "example" : "psrk" - }, - "category" : { - "type" : "string", - "example" : "doctor" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "abc@gmail.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.doctor1.com" - }, - "alertPreference" : { - "type" : "string", - "example" : " phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.doctor1.com" - } - } - }, - "resourceDetailsRequired" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "phone" : { - "type" : "boolean", - "example" : true - }, - "email" : { - "type" : "boolean", - "example" : true - }, - "alertUrl" : { - "type" : "boolean", - "example" : true - }, - "alertPreference" : { - "type" : "boolean", - "example" : true - }, - "statusPollUrl" : { - "type" : "boolean", - "example" : true - } - } - }, - "subscriberList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "subscriberId" : { - "type" : "string", - "example" : "12345" - }, - "details" : { - "$ref" : "#/components/schemas/subscriberDetails" - } - } - } - }, - "subscriberDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "abc" - }, - "category" : { - "type" : "string", - "example" : "patient" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "abc@gmail.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - }, - "alertPreference" : { - "type" : "string", - "example" : "phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "subscriberFilter" : { - "type" : "object", - "properties" : { - "subscriberId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[12345, 41414]" - }, - "category" : { - "type" : "string", - "example" : "patient" - }, - "name" : { - "type" : "string", - "example" : "abc" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "abc@gmail.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - }, - "alertPreference" : { - "type" : "string", - "example" : " phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "subscriberDetailsRequired" : { - "type" : "object", - "properties" : { - "subscriberId" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "phone" : { - "type" : "boolean", - "example" : true - }, - "email" : { - "type" : "boolean", - "example" : true - }, - "alertUrl" : { - "type" : "boolean", - "example" : true - }, - "alertPreference" : { - "type" : "boolean", - "example" : true - }, - "statusPollUrl" : { - "type" : "boolean", - "example" : true - } - } - }, - "eventCreationDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "abc medical camp" - }, - "description" : { - "type" : "string", - "example" : "medical camp for senior citizens" - }, - "category" : { - "type" : "string", - "example" : "doctor_consultation" - }, - "hostEntityId" : { - "type" : "string", - "example" : "67890" - }, - "slots" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:30:00" - } - } - } - }, - "deadline" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:10:00" - }, - "subscriberLimit" : { - "type" : "string", - "example" : "1" - }, - "terms" : { - "type" : "string", - "example" : "non refundable" - }, - "status" : { - "type" : "string", - "example" : "open" - }, - "venue" : { - "$ref" : "#/components/schemas/venue" - } - } - }, - "eventList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "string", - "example" : "12345" - }, - "details" : { - "$ref" : "#/components/schemas/eventDetails" - } - } - } - }, - "eventDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "abc medical camp" - }, - "description" : { - "type" : "string", - "example" : "medical camp for senior citizens" - }, - "category" : { - "type" : "string", - "example" : "doctor_consultation" - }, - "hostEntityId" : { - "type" : "string", - "example" : "67890" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:30:00" - }, - "deadline" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:10:00" - }, - "subscriberLimit" : { - "type" : "string", - "example" : "1" - }, - "terms" : { - "type" : "string", - "example" : "non refundable" - }, - "status" : { - "type" : "string", - "example" : "open" - }, - "venue" : { - "$ref" : "#/components/schemas/venue" - } - } - }, - "eventFilter" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[12345,51515]" - }, - "description" : { - "type" : "string", - "example" : "medical camp for senior citizens" - }, - "name" : { - "type" : "string", - "example" : "doctor_consultation" - }, - "category" : { - "type" : "string", - "example" : "opd_physician_consultation" - }, - "hostEntityId" : { - "type" : "string", - "example" : "67890" - }, - "subscriberLimit" : { - "type" : "string", - "example" : "1" - }, - "terms" : { - "type" : "string", - "example" : "non refundable" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T17:00:00" - }, - "venue" : { - "$ref" : "#/components/schemas/venue" - } - } - }, - "eventDetailsRequired" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "boolean", - "example" : true - }, - "description" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "hostEntityId" : { - "type" : "boolean", - "example" : true - }, - "period" : { - "type" : "boolean", - "example" : true - }, - "venue" : { - "type" : "boolean", - "example" : true - }, - "deadline" : { - "type" : "boolean", - "example" : true - }, - "subscriberLimit" : { - "type" : "boolean", - "example" : true - }, - "terms" : { - "type" : "boolean", - "example" : true - }, - "status" : { - "type" : "boolean", - "example" : true - } - } - }, - "affiliationList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "affiliationId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/affiliationDetails" - } - } - } - }, - "affiliationDetails" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "string", - "example" : "12345" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "resourceCategory" : { - "type" : "string", - "example" : "physician" - }, - "workDaysHours" : { - "$ref" : "#/components/schemas/daysHours" - } - } - }, - "daysHours" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "dayOfWeek" : { - "type" : "string", - "example" : "[monday" - }, - "startTime" : { - "type" : "string", - "example" : "09:00:00" - }, - "endTime" : { - "type" : "string", - "example" : "17:00:00" - } - } - } - }, - "affiliationFilter" : { - "type" : "object", - "properties" : { - "affiliationId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "resourceId" : { - "type" : "string", - "example" : "12345" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "physician" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T17:00:00" - } - } - }, - "affiliationDetailsRequired" : { - "type" : "object", - "properties" : { - "affiliationId" : { - "type" : "boolean", - "example" : true - }, - "resourceId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "workDaysHours" : { - "type" : "boolean", - "example" : true - } - } - }, - "alertScheduleList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "alertScheduleId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/alertScheduleDetails" - } - } - } - }, - "alertScheduleDetails" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "string", - "example" : "12345" - }, - "targetCategory" : { - "type" : "string", - "example" : "subscriber" - }, - "messageId" : { - "type" : "string", - "example" : "1" - }, - "alertDatetime" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - } - } - }, - "alertScheduleFilter" : { - "type" : "object", - "properties" : { - "alertScheduleId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "targetCategory" : { - "type" : "string", - "example" : "subscriber" - }, - "messageId" : { - "type" : "string", - "example" : "1" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T13:30:00" - } - } - }, - "alertScheduleDetailsRequired" : { - "type" : "object", - "properties" : { - "alertScheduleId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "messageId" : { - "type" : "boolean", - "example" : true - }, - "alertDatetime" : { - "type" : "boolean", - "example" : true - } - } - }, - "messageList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "messageId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/messageDetails" - } - } - } - }, - "messageDetails" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "reminder" - }, - "messageBody" : { - "type" : "string", - "example" : "you have doctor consultation today" - } - } - }, - "messageFilter" : { - "type" : "object", - "properties" : { - "messageId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "reminder" - }, - "messageBody" : { - "type" : "string", - "example" : "you have doctor consultation today" - } - } - }, - "messageDetailsRequired" : { - "type" : "object", - "properties" : { - "messageId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "messageBody" : { - "type" : "boolean", - "example" : true - } - } - }, - "logList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "logId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/logDetails" - } - } - } - }, - "logDetails" : { - "type" : "object", - "properties" : { - "loggerRole" : { - "type" : "string", - "example" : "resource" - }, - "loggerId" : { - "type" : "string", - "example" : "1" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "logCategory" : { - "type" : "string", - "example" : "attendance" - }, - "datetime" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "logData" : { - "type" : "string", - "example" : "eventId:12345,subscriberId:1,token:a2s3x2fer,status:attended" - } - } - }, - "logFilter" : { - "type" : "object", - "properties" : { - "logId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "attendance" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:30:00" - } - } - }, - "logDetailsRequired" : { - "type" : "object", - "properties" : { - "logId" : { - "type" : "boolean", - "example" : true - }, - "loggerCategory" : { - "type" : "boolean", - "example" : true - }, - "loggerId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "logCategory" : { - "type" : "boolean", - "example" : true - }, - "datetime" : { - "type" : "boolean", - "example" : true - }, - "logData" : { - "type" : "boolean", - "example" : true - } - } - }, - "freeResourceList" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/freeResourceDetails" - } - }, - "freeResourceDetails" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "string", - "example" : "1" - }, - "resourceName" : { - "type" : "string", - "example" : "abc" - }, - "freeSlots" : { - "type" : "array", - "items" : { - "type" : "string", - "example" : "{[2018-02-15T11:00:00to2018-02-15T11:09:00:00],[2018-02-22T11:00:00to2018-02-22T11:17:00:00]}" - } - } - } - }, - "freeResourceFilter" : { - "type" : "object", - "properties" : { - "category" : { - "type" : "string", - "example" : "doctor" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-14T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-18T13:30:00" - }, - "resourceId" : { - "type" : "string", - "example" : "1" - } - } - }, - "venue" : { - "type" : "object", - "properties" : { - "building" : { - "type" : "string", - "example" : "xyz" - }, - "street" : { - "type" : "string", - "example" : "7th main" - }, - "area" : { - "type" : "string", - "example" : "wilson garden" - }, - "city" : { - "type" : "string", - "example" : "bangalore" - }, - "state" : { - "type" : "string", - "example" : "karnataka" - }, - "country" : { - "type" : "string", - "example" : "india" - }, - "lat" : { - "type" : "string", - "example" : "0.001" - }, - "long" : { - "type" : "string", - "example" : "0.002" - } - } - }, - "eventNewQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/eventCreationDetails" - } - } - }, - "eventModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/eventDetails" - } - } - }, - "eventGetDetailsQuery" : { - "type" : "object", - "properties" : { - "eventFilter" : { - "$ref" : "#/components/schemas/eventFilter" - }, - "eventDetailsRequired" : { - "$ref" : "#/components/schemas/eventDetailsRequired" - } - } - }, - "entityNewQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/entityDetails" - } - } - }, - "entityModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/entityDetails" - } - } - }, - "entityGetDetailsQuery" : { - "type" : "object", - "properties" : { - "entityFilter" : { - "$ref" : "#/components/schemas/entityFilter" - }, - "entityDetailsRequired" : { - "$ref" : "#/components/schemas/entityDetailsRequired" - } - } - }, - "resourceNewQuery" : { - "type" : "object", - "properties" : { - "resourceDetails" : { - "$ref" : "#/components/schemas/resourceDetails" - } - } - }, - "resourceModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/resourceDetails" - } - } - }, - "resourceGetDetailsQuery" : { - "type" : "object", - "properties" : { - "resourceFilter" : { - "$ref" : "#/components/schemas/resourceFilter" - }, - "resourceDetailsRequired" : { - "$ref" : "#/components/schemas/resourceDetailsRequired" - } - } - }, - "resourceGetAvailabilityQuery" : { - "type" : "object", - "properties" : { - "freeResourceFilter" : { - "$ref" : "#/components/schemas/freeResourceFilter" - } - } - }, - "subscriberNewQuery" : { - "type" : "object", - "properties" : { - "subscriberDetails" : { - "$ref" : "#/components/schemas/subscriberDetails" - } - } - }, - "subscriberModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/subscriberDetails" - } - } - }, - "subscriberGetDetailsQuery" : { - "type" : "object", - "properties" : { - "subscriberFilter" : { - "$ref" : "#/components/schemas/subscriberFilter" - }, - "subscriberDetailsRequired" : { - "$ref" : "#/components/schemas/subscriberDetailsRequired" - } - } - }, - "messageNewQuery" : { - "type" : "object", - "properties" : { - "messageDetails" : { - "$ref" : "#/components/schemas/messageDetails" - } - } - }, - "messageModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/messageDetails" - } - } - }, - "messageGetDetailsQuery" : { - "type" : "object", - "properties" : { - "messageFilter" : { - "$ref" : "#/components/schemas/messageFilter" - }, - "messageDetailsRequired" : { - "$ref" : "#/components/schemas/messageDetailsRequired" - } - } - }, - "affiliationNewQuery" : { - "type" : "object", - "properties" : { - "affiliationDetails" : { - "$ref" : "#/components/schemas/affiliationDetails" - } - } - }, - "affiliationModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/affiliationDetails" - } - } - }, - "affiliationGetDetailsQuery" : { - "type" : "object", - "properties" : { - "affiliationFilter" : { - "$ref" : "#/components/schemas/affiliationFilter" - }, - "affiliationDetailsRequired" : { - "$ref" : "#/components/schemas/affiliationDetailsRequired" - } - } - }, - "alertScheduleNewQuery" : { - "type" : "object", - "properties" : { - "alertScheduleDetails" : { - "$ref" : "#/components/schemas/alertScheduleDetails" - } - } - }, - "alertScheduleModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/alertScheduleDetails" - } - } - }, - "alertScheduleGetDetailsQuery" : { - "type" : "object", - "properties" : { - "alertScheduleFilter" : { - "$ref" : "#/components/schemas/alertScheduleFilter" - }, - "alertScheduleDetailsRequired" : { - "$ref" : "#/components/schemas/alertScheduleDetailsRequired" - } - } - }, - "logNewQuery" : { - "type" : "object", - "properties" : { - "logDetails" : { - "$ref" : "#/components/schemas/logDetails" - } - } - }, - "logModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/logDetails" - } - } - }, - "logGetDetailsQuery" : { - "type" : "object", - "properties" : { - "logFilter" : { - "$ref" : "#/components/schemas/logFilter" - }, - "logDetailsRequired" : { - "$ref" : "#/components/schemas/logDetailsRequired" - } - } - }, - "appointmentNewQuery" : { - "type" : "object", - "properties" : { - "appointmentDetails" : { - "$ref" : "#/components/schemas/appointmentCreationDetails" - } - } - }, - "appointmentModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/appointmentDetails" - } - } - }, - "appointmentGetDetailsQuery" : { - "type" : "object", - "properties" : { - "appointmentFilter" : { - "$ref" : "#/components/schemas/appointmentFilter" - }, - "appointmentDetailsRequired" : { - "$ref" : "#/components/schemas/appointmentDetailsRequired" - } - } - } - } - } -} diff --git a/spec/.gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png b/spec/.gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png deleted file mode 100644 index 9a2c484ba2621c2f8b4ef5768b4059f3381715ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 143890 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 YfBXBKoLl_!dw`#bk%eKU!QIFI1!rdcV*mgE diff --git a/spec/1-version-history.md b/spec/1-version-history.md index 87291b5..197301d 100644 --- a/spec/1-version-history.md +++ b/spec/1-version-history.md @@ -1,9 +1,8 @@ # 1 Version History -{% hint style="success" %} -Add a line to the version history table describing the major changes to the specifications between _published_ versions. Ideally, include links to issues and authors on GitHub. -{% endhint %} +Record published specification versions only. Describe material requirement and +interface changes and link the approval record or pull request. -_\<Example Version history>_ - -<table><thead><tr><th width="138.33333333333331">Version</th><th width="274">Authors</th><th>Comment</th></tr></thead><tbody><tr><td>0.7</td><td>Steve Conrad</td><td>Initial Revision</td></tr><tr><td>0.8</td><td>Max Carlson, Steve Conrad, Dr. Ramkumar, Trevor Kinsey from the Architecture Group</td><td>Applied document standards to template</td></tr><tr><td>0.9</td><td>Architecture Team</td><td>Added sections for review comments, updated to match formatting numbering/fonts/etc. scheme agreed upon</td></tr><tr><td>1.0</td><td>Steve Conrad, GovStack Technical Committee</td><td>Update format for GitBook, revisions for GovStack 1.0 release</td></tr></tbody></table> +| Version | Date | Editors | Change and approval reference | +|---|---|---|---| +| 0.1.0 | 2026-07-10 | GovStack template maintainers | Introduced the generic, traceable reference specification. | diff --git a/spec/10-other-resources.md b/spec/10-other-resources.md index 0ed32a3..796ee07 100644 --- a/spec/10-other-resources.md +++ b/spec/10-other-resources.md @@ -1,21 +1,19 @@ # 10 Other Resources -{% hint style="success" %} -This section can be used to link to any external documents that may be relevant, such as standards documents or other descriptions of this building block that may be useful +Link only maintained resources that help implementers interpret the normative +specification. Explain the authority and version of each external document. -This section should contain at minimum, links to the Cross-BB Workflows that have been defined for this BB, the Key Decision Log (Confluence), and Future Considerations (Confluence) -{% endhint %} +## 10.1 Cross-Building Block workflows -_\<Example Other Resources>_ +List approved workflows that depend on this BB and identify the requirements +they exercise. -## 10.1 Example Cross-Building Block Workflows +## 10.2 Key decision log -Some common workflows that leverage the Consent Building Block can be found here: [https://govstack.gitbook.io/workflows-capabilities/consent](https://govstack.gitbook.io/workflows-capabilities/consent) +Link the working group's durable decision log. API design exceptions should also +be declared in the canonical API document. -## 10.2 Key Decision Log +## 10.3 Future considerations -A historical log of key decisions regarding this Building Block can be found here: [https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183238674/Key+Decision+Log+Consent](https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183238674/Key+Decision+Log+Consent) - -## 10.3 Future Considerations - -A list of topics that may be relevant to future versions of this Building Block are documented here: [https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183205908/Future+Considerations+Consent](https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183205908/Future+Considerations+Consent) +Link proposed future capabilities without presenting them as current normative +requirements. Planned interface gaps still belong in `api/coverage.yaml`. diff --git a/spec/2-description.md b/spec/2-description.md index 24bf272..e6c99a1 100644 --- a/spec/2-description.md +++ b/spec/2-description.md @@ -1,11 +1,14 @@ # 2 Description -{% hint style="success" %} -Set the context of the Building Block for the reader. The description should not assume that the reader has any experience of the GovStack system other than that found on the GovStack website. - -If there are assumptions or context for this building block that may be needed for the reader, it can be provided in this section. -{% endhint %} - -_\<Example Description below>_ - -Registration services attribute a unique functional ID to a person, place or other entity to identify and access information about it. According to the World Bank, functional IDs are those that evolve out of a single use-case, such as voter IDs, health records, or bank cards, and are created with a specific purpose in mind, differing from foundational IDs which are created with a general purpose in mind. Registration services can also use the foundational ID or map it to the functional ID where such an identity exists. Examples of specific registration services include immunization, disease and citizenship records, as well as birth and death registration. The ensemble of utilities for capturing, recording, profiling, searching, retrieving and verifying this identity information is encapsulated as registration services. The information itself will be deposited into and retrieved from corresponding functional registries (see the Registries ICT Building Blocks). Registration services help profile entities by enabling the registration of different categories or groups and documenting their access to various services. These services also onboard users into a programme or service offered by an organization (eg rural advisory service), capturing related demography, profile and citizen ID information. +Describe the public-purpose problem this BB solves in language understandable to +a reader who is new to GovStack. Cover: + +- the outcome and users of the BB; +- its responsibilities and explicit non-responsibilities; +- the systems or BBs it depends on; +- the trust boundaries and sensitive data it handles; +- assumptions that affect country adoption. + +Do not describe an implementation product as the standard. Link each claimed +capability to a Key Digital Functionality in Section 4 and a testable functional +requirement in Section 6. diff --git a/spec/3-terminology.md b/spec/3-terminology.md index ab5f0c8..7605c49 100644 --- a/spec/3-terminology.md +++ b/spec/3-terminology.md @@ -1,9 +1,11 @@ # 3 Terminology -{% hint style="success" %} -Terminology/glossary used within the specification. The terms can be laid out in a table format -{% endhint %} +Define domain terms that a reader needs to interpret requirements and API +fields. Reuse terminology from adopted standards and cite the source. Avoid +giving a familiar term a BB-specific meaning without saying so. -_\<Example Terminology>_ - -<table data-header-hidden><thead><tr><th width="234"></th><th></th></tr></thead><tbody><tr><td><strong>Term</strong></td><td><strong>Description</strong></td></tr><tr><td><strong>Configuration</strong></td><td>technical implementation of all the content and process conditions as defined by the Data Policy for Consent Agreement creation, reading, updating and deletion, as well as for providing all necessary actors with the required operations</td></tr><tr><td><strong>Consent Agreement</strong></td><td>is the agreement to be signed by the Individual and the Data Controller as prescribed by Data Policy, based on which the Data Providing System may transmit the data to the Data Consuming System for the purposes described in the Consent Agreement.</td></tr><tr><td><strong>Consent Record</strong></td><td>is created when an individual signs a consent agreement. It represents a signed consent agreement.</td></tr><tr><td><strong>Consent Reference</strong></td><td>a unique identifier used to locate and verify the validity of the Consent Agreement.</td></tr><tr><td><strong>Data Providers</strong></td><td>is a legal entity that stores and provides access to an Individual's data, which requires the Individual's consent for processing (outside of its primary purpose/location).</td></tr><tr><td><strong>Data Consumers</strong></td><td>is a legal entity that requires the Individual's data from the Data Providers according to the consent of the Individual.</td></tr><tr><td><strong>Data Disclosure Agreements</strong></td><td>A Data Disclosure Agreement (DDA) exists between two organisations where one organisation acts as a Data Provider and the other as a Data Consumer. The DDA captures how data is shared between the two organisations and what role and obligation each party has.</td></tr><tr><td><strong>Data Policy</strong></td><td>is a formal description of the purpose, nature and extent of consent-based personal data processing, covering the configuration needs by Data Providing System and Data Consuming System and the conditions defined by law.</td></tr><tr><td><strong>Data Processing Auditor</strong></td><td>is an entity (a person or an organisation) verifying the legitimacy of personal data processing by Data Controllers and Data Processors based on the Data Policies and performed tasks. The entity is not to be confused with a data policy auditor that is independent of the actors involved in the operations of consent management and can engage directly with the Consent Management service operator.</td></tr><tr><td><strong>Delegate</strong></td><td>the person giving consent (signing Consent Agreement); on behalf of the Individual,</td></tr><tr><td><strong>Individual</strong></td><td>is a person about whom the personal data is stored in an information system (a.k.a. “Data Subject”) and who agrees or not with the use of this data outside of its primary purpose/location.</td></tr><tr><td><strong>Legal Entity</strong></td><td>is an organisation (public or private) ​that has the rights and obligations to define standards for personal data processing. E.g. a public health authority</td></tr><tr><td><strong>Personal data</strong></td><td>Is any information that (a) can be used to identify the Individual to whom such information relates, or (b) is or might be directly or indirectly linked to the Individual (ISO(IEC 29100:2011)</td></tr><tr><td><strong>Regulations</strong></td><td>are broadly defined as rules followed by any system: could be laws, bylaws, ​norms or architectures (Defintion inspired by Lessig’s modalities of regulation: https://lessig.org/images/resources/1999-Code.pdf) that ​ regulates a given system.</td></tr></tbody></table> +| Term | Definition | Source or note | +|---|---|---| +| Building Block (BB) | An independently useful, reusable GovStack capability with specified interfaces. | GovStack terminology | +| Conformance | Satisfaction of the normative requirements and interface contracts identified by this specification. | Verify through Section 5 and `test/plan.md`. | +| Reference record | A deliberately generic resource used only to demonstrate the template API pattern. | Replace with the BB's domain resource. | diff --git a/spec/4-key-digital-functionalities.md b/spec/4-key-digital-functionalities.md index 0de665b..e8451e8 100644 --- a/spec/4-key-digital-functionalities.md +++ b/spec/4-key-digital-functionalities.md @@ -1,23 +1,19 @@ # 4 Key Digital Functionalities -{% hint style="success" %} -The Key Digital Functionalities (KDFs) describe the core (required) functions that this building block must be able to perform. These functionalities should be described as business processes as opposed to technical specifications or API definitions. +Key Digital Functionalities describe public-purpose capabilities, not endpoints +or product features. Give each KDF a stable ID of the form +`{bb-code}-KDF-{number}` and do not renumber published IDs. Normative, testable +obligations derived from each KDF belong in Section 6. -The KDFs provides an overview of functionality that should be provided by the Building Block. These KDFs should be organized by area of functionality and should be numbered so that they can be referenced in other sections. +The following KDFs belong to the template reference domain. Replace them when +creating a real BB. -Note, any assumptions or context that are needed for this Building Block should be provided in Section 2 (Description). -{% endhint %} +## BB-TPL-KDF-001 Manage reference records -_\<Example Key Digital Functionalities (based on Consent Building Block)>_ +An authorised actor can discover, create, and retrieve reference records. This +demonstrates a conventional resource lifecycle without imposing a domain model. -The functionalities are derived from the [consent agreement lifecycle](broken-reference) and categorised according to the [Actors](broken-reference) described above. While the consenting workflows (as described above) are implicitly considered the centerpiece of the Consent Building Block, it is important to realise that the integrity of consent management can only be achieved if robust configuration before and auditing after the Consent Agreement signing and Consent Record verification activities are in place. +## BB-TPL-KDF-002 Run long-running work -### 4.1 Administration of Consent Agreements - -The Consent Building block should allow for the administration of Consent Agreements based on the Data Policy agreements that have been defined. - -### 4.2 User Consent - -An individual user must be able to view a consent agreement and provide or withdraw consent for that agreement. The Consent Building Block should allow the user to determine the time period for which the consent is valid - -### +An authorised system can request work that completes asynchronously and can +observe or cancel that work through a standard Operation resource. diff --git a/spec/5-cross-cutting-requirements.md b/spec/5-cross-cutting-requirements.md index 62efb1b..aaaf0da 100644 --- a/spec/5-cross-cutting-requirements.md +++ b/spec/5-cross-cutting-requirements.md @@ -1,47 +1,23 @@ # 5 Cross-Cutting Requirements -{% hint style="success" %} -The Cross-cutting requirements described in this section are an extension of the cross-cutting requirements defined in the architecture blueprint and nonfunctional requirements document. This section will describe any additional cross-cutting requirements that apply to this building block, or any requirements that are defined in the non-functional requirements document that are NOT applicable to this Building Block. - -Cross-cutting requirements will use the same language (REQUIRED, RECOMMENDED or OPTIONAL) as specified in the architecture document. - -Note: this section will contain 3 parts. The first is a list of Requirements, followed by any exceptions to the cross-cutting requirements for this building block (this section may be skipped if not needed). The third part is a list of relevant standards to this domain that should be used for any Building Block implementation. -{% endhint %} - -_\<Example Cross-Cutting Requirements from Payments Building Block>_ +List requirements that apply across functional areas. Use +`{bb-code}-XR-{number}` IDs, RFC 2119 language, and a verification method. ## 5.1 Requirements -### 5.1.1 Follow all Statutory and Operational Requirements (REQUIRED) - -The Payments Building Block assumes that the statutory and operational requirements around accounts (i.e. know your customer/anti-money laundering/counter-terrorist financing) must have been completed by an outside system, which is capable of communicating that status in appropriate timeframes. - -### 5.1.2 All Participants should be previously registered (RECOMMENDED) - -The Payment System or Scheme in a country may require that participating payor or payee entities, whether health clinics, ministries, or individuals must have been registered with a regulated banking or non-banking entity prior to the use of the Payments Building Block. - -## 5.2 Exceptions to Architectural Cross-Cutting Specifications - -Cross-Cutting specifications for all Building Blocks are detailed in the [GovStack non-functional requirements document](https://govstack.gitbook.io/specification/architecture-and-nonfunctional-requirements/5-cross-cutting-requirements). However, for this Building Block the following Cross-Cutting Specifications are not required: - -**5.17 Databases should not Include Business Logic or Stored Procedures** - -Several mundane localized operations on data such as searching, filtering, and format transformations may find a better performance by being collocated with the database itself in form of stored procedures in typical SQL databases. Such procedures must be configured to handle the concurrent processing of multiple requests, with an appropriate mechanism(e.g. SQL agents/SSIS packages/service brokers/etc.). Since data is collocated with the code, when scaled up to clusters of multiple instances of database servers, each instance will utilize local Safeguard for Privileged Sessions. However, this will create an additional burden on maintenance and update of source code as applications may have part of logic in backend code and partially embedded in database servers. To host complex queries related to data from different databases it is recommended to implement it in business logic rather than stored procedures. In this case, scalability must be ensured by suitable application infrastructure scaling mechanisms such as Virtual Machine-level scaling and automatic elastic frameworks. +- **BB-TPL-XR-001** **REQUIRED**: The BB MUST expose the unauthenticated `/health` contract defined in the canonical OpenAPI document without returning internal system detail. +- **BB-TPL-XR-002** **REQUIRED**: The canonical API documents MUST pass their base schema validators and the GovStack API Design Guide ruleset targeted by the documents. +- **BB-TPL-XR-003** **REQUIRED**: Non-operational operations MUST declare OAuth 2.0 security and W3C Trace Context as defined by the canonical API contract. +## 5.2 Exceptions to architectural cross-cutting requirements +State each exception, its rationale, approver, and expiry or review date. The +template declares no exceptions. ## 5.3 Standards -The following standards are applicable to data structures in the Workflow Building Block: - -### 5.3.1 BPMN (REQUIRED) - -The workflow Building Block should leverage [BPMN v2.0.2 - Business Process Model and Notation](https://www.omg.org/spec/BPMN/) - -### 5.3.2 OpenAPI - -[OpenAPI](https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md) - -### 5.3.3 REST APIs - -Rest APIs should use JSON payloads. Note that we are not using XML. +- [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) for synchronous HTTP APIs. +- [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) for HTTP problem details. +- [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749) for authorised API access. +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) for distributed tracing. +- The [GovStack Cross-BB API Design Guide](../api-design-guide/README.md) for cross-BB conventions. diff --git a/spec/6-functional-requirements.md b/spec/6-functional-requirements.md index a740632..068644e 100644 --- a/spec/6-functional-requirements.md +++ b/spec/6-functional-requirements.md @@ -1,44 +1,24 @@ # 6 Functional Requirements -{% hint style="success" %} -The functional requirements section lists the technical capabilities that this building block should have. These requirements should be sufficient to deliver all functionality that is listed in the Key Digital Functionalities section. +Functional requirements state observable capabilities and remain independent of +a specific product. Use stable IDs of the form `{bb-code}-FR-{number}`, identify +the related KDF, state REQUIRED, RECOMMENDED, or OPTIONAL, and define acceptance +evidence. Never silently delete or reuse a published ID. -These functional requirements do not define specific APIs - they provide a list of information about functionality that must be implemented within the building block. These requirements should be defined by subject-matter experts and don’t have to be highly technical in this section. +The reference requirements below are implemented by `api/openapi.yaml` and +mapped in `api/coverage.yaml`. Replace them for a real BB. -This section should contain 2 parts. The first provides the functional requirements for each functional area that is defined for the Building Block (described in Section 4). The functional requirements for each component should have its own sub-section. +## 6.1 Reference record lifecycle -The second section outlines the any components that make up the Building Block. Many Building Blocks are made up of multiple components. These can be described (and diagrams provided where appropriate) in this section. -{% endhint %} +- **BB-TPL-FR-001** **REQUIRED**: To support `BB-TPL-KDF-001`, an authorised caller MUST be able to retrieve a bounded, cursor-paginated collection of reference records. +- **BB-TPL-FR-002** **REQUIRED**: To support `BB-TPL-KDF-001`, an authorised caller MUST be able to create a record synchronously and retrieve it by its opaque identifier; successful creation MUST identify the created resource. -_\<Example Functional Requirements>_ +## 6.2 Long-running work -The following functionalities must be provided by the Consent Building Block. These functional requirements are linked to the Key Digital Functionalities in Section 4. +- **BB-TPL-FR-003** **REQUIRED**: To support `BB-TPL-KDF-002`, an authorised service MUST be able to request an asynchronous record export, poll the returned Operation, and request cancellation. -### 6.1 Consent Agreements +## 6.3 Components -* An administrative user can create, update, and delete Consent Agreements (REQUIRED) -* Notifications should be provided to all parties when changes are made to a Consent Agreement (RECOMMENDED) - -### 6.2 User Consent - -* A user can view a consent agreement and give consent for that agreement (REQUIRED) -* A user can withdraw consent from an agreement that he/she has previously given consent to (REQUIRED) -* An audit log of all user consent given or withdrawn must be provided (REQUIRED) - - - -## Building Block Components - -Within the scope of Consent Building Block version 1.0, the required components are as given: - -<figure><img src=".gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png" alt=""><figcaption></figcaption></figure> - -**Consent Agreement Configuration Handler** - handles the creation, updation & deletion of consent agreements for organisations. Organisations can be Data Providers or Data Consumers. - -**Consent Record Handler** - enables Individuals to view data usage and consent record. - -**Notification Handler** - Handles all notification configurations and notifications requested by different subscribers. - -**Administrative User Interface and client Software Development Kit** - These are readily available components that can configure and use the services offered, making integration easy and low code. - -**RESTful APIs**: All APIs are exposed as RESTful APIs. These are categorised into Organisation APIs, Individual APIs, and Auditing APIs. +Describe logical components only when they clarify responsibility or trust +boundaries. Do not require a deployer to reproduce an illustrative component +diagram or a particular internal architecture. diff --git a/spec/7-data-structures.md b/spec/7-data-structures.md index d65e061..b8a22ee 100644 --- a/spec/7-data-structures.md +++ b/spec/7-data-structures.md @@ -1,46 +1,23 @@ # 7 Data Structures -{% hint style="success" %} -This section provides information on the core data structures/data models that are used by a Building Block. These data structures describe information that is exchanged between building blocks - they do not dictate internal data structures for a particular implementation. These data structures should also describe the _minimum_ set of information that should be passed in an API call. The data structures can be extended for particular use cases. +Describe only information exchanged across the BB boundary. The OpenAPI or +AsyncAPI schema is normative when prose and machine-readable definitions differ. +Include a diagram when relationships cannot be expressed clearly in a table. -Data Structures should consist of two sections. The first section should provide an overall resource model that shows the various data structures that are used by the Building Block and how these structures are inter-related. +## 7.1 Resource model -The second section provides a more detailed breakdown of each data model. For each data model, the following information should be provided: +The template reference model has a `Record`, a paginated `RecordCollection`, and +an `Operation` representing long-running work. -* Name -* Description -* Fields - the various fields in this data structure. Each field definition should contain the following: - * Name - * Type (string, Boolean, number, date, etc) - * Description - * You can also reference any standards that must be adhered to (ie. UTC standard for date/times) - * Comments (any notes about this field) +## 7.2 Reference record -Note that complete data structure definitions will be provided by the services APIs. -{% endhint %} +| Field | Type | Required | Meaning | +|---|---|---|---| +| `id` | UUID string | Yes in responses | Opaque server-generated record identifier. | +| `name` | string | Yes | Human-readable label without personal data. | +| `status` | enum | Yes | `ACTIVE` or `ARCHIVED`; clients tolerate future values. | +| `createdAt` | RFC 3339 timestamp | Yes in responses | Time at which the record was created. | +| `updatedAt` | RFC 3339 timestamp | Yes in responses | Time of the latest change. | -_\<Example Data Elements>_ - -## 7.1 Resource Model - -_Note: Recommend using_ [_https://app.diagrams.net/_](https://app.diagrams.net/) _to create the resource model and store in BuildingBlock repository_ - - _\<Example Resource Model>_ - -<figure><img src="https://files.gitbook.com/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdXe8NbIMZIv5sydPBf6%2Fuploads%2Fgit-blob-736cd906cff209af7113b298653cb11a8b5935b6%2Fdata-structures.png?alt=media" alt=""><figcaption></figcaption></figure> - - - -## 7.2 Data Structures: <a href="#worklist-data-structure" id="worklist-data-structure"></a> - -_Note: any relevant standards that are applicable to the fields of the data structures can be defined here._ - -### 7.2.1 Worklist - -**Description:** The WorkList data structure is used to track a list of subscribers to a particular session or event. - -**Fields:** - -<table data-header-hidden><thead><tr><th width="115"></th><th width="115"></th><th width="242"></th><th></th><th data-hidden></th><th data-hidden></th><th data-hidden></th></tr></thead><tbody><tr><td><strong>Name</strong></td><td><strong>Type</strong></td><td><strong>Description</strong></td><td><strong>Notes</strong></td><td><strong>Foreign Key</strong></td><td><strong>Constraints</strong></td><td><strong>Required</strong></td></tr><tr><td>id</td><td>int</td><td>Unique identifier for this WorkList</td><td>Generated by building block on creation</td><td> </td><td>PK</td><td>Y</td></tr><tr><td>name</td><td>string</td><td>Name for this WorkList</td><td></td><td> </td><td>Uniq</td><td>Y</td></tr><tr><td>status</td><td>enum</td><td>Status of the WorkList</td><td>enum that is defined with the following fields: ACTIVE, SUSPENDED, CANCELED</td><td> </td><td> </td><td>Y</td></tr><tr><td>start_time</td><td>date</td><td>Start date/time for the WorkList</td><td></td><td> </td><td> </td><td>Y</td></tr><tr><td>end_time</td><td>date</td><td>End date/time for the WorkList</td><td></td><td> </td><td> </td><td>Y</td></tr><tr><td>alerts</td><td>integer array</td><td>Array of assigned alerts for this WorkList</td><td></td><td>Alert</td><td>FK</td><td>N</td></tr></tbody></table> - -## +See [`api/openapi.yaml`](../api/openapi.yaml) for constraints, examples, error +schemas, pagination metadata, and the Operation resource. diff --git a/spec/8-service-apis.md b/spec/8-service-apis.md index 7ce47c9..1505dbb 100644 --- a/spec/8-service-apis.md +++ b/spec/8-service-apis.md @@ -1,31 +1,33 @@ # 8 Service APIs -This section provides a reference for APIs that should be implemented by this Building Block. The APIs defined here establish a blueprint for how the Building Block will interact with other Building Blocks. Additional APIs may be implemented by the Building Block, but the listed APIs define a minimal set of functionality that should be provided by any implementation of this Building Block. - -The [GovStack non-functional requirements document](https://govstack.gitbook.io/specification/architecture-and-nonfunctional-requirements/6-onboarding) provides additional information on how 'adaptors' may be used to translate an existing API to the patterns described here. This section also provides guidance on how candidate products are tested and how GovStack validates a product's API against the API specifications defined here. - -{% hint style="success" %} -All APIs will be defined using the OpenAPI (Swagger) standard. The API definitions will be hosted outside of this document. This section may provide a brief description of required APIs. - -This section will primarily contain links to the GitHub repository for OpenAPI definition (yaml) files as well as to a website hosted by GovStack that provides a live API documentation portal. - -Note that APIs should be grouped by functional area (from sections 4 and 6) where appropriate. - -OpenAPI links to the GitHub repository can be made in an interactive way using the GitBook OpenAPI widget, linking to the GitHub repo version of the .yaml file, remembering to link to the “raw” url. An example from the Registries BB is shown below and can be replaced. -{% endhint %} - -## 8.1 Administrative APIs - -{% swagger src=".gitbook/assets/Govstack_scheduler_BB_APIs.json" path="/event/new" method="post" %} -[Govstack_scheduler_BB_APIs.json](.gitbook/assets/Govstack_scheduler_BB_APIs.json) -{% endswagger %} - -{% swagger src=".gitbook/assets/Govstack_scheduler_BB_APIs.json" path="/event/modifications" method="put" %} -[Govstack_scheduler_BB_APIs.json](.gitbook/assets/Govstack_scheduler_BB_APIs.json) -{% endswagger %} - -{% swagger src=".gitbook/assets/Govstack_scheduler_BB_APIs.json" path="/event" method="delete" %} -[Govstack_scheduler_BB_APIs.json](.gitbook/assets/Govstack_scheduler_BB_APIs.json) -{% endswagger %} - -## 8.2 User APIs +Machine-readable API documents are normative. Keep one canonical entrypoint per +surface and enumerate it in [`api/index.yaml`](../api/index.yaml). The reference +REST contract is [`api/openapi.yaml`](../api/openapi.yaml). + +## 8.1 Requirement traceability + +Every normative interface requirement in Sections 5 and 6 has exactly one +disposition in [`api/coverage.yaml`](../api/coverage.yaml). The coverage file is +the single authoritative requirement-to-interface mapping. A BB records a +planned, external-standard, or non-applicable interface explicitly rather than +silently omitting it. + +The template reference maps: + +| Requirement | Canonical operations | +|---|---| +| `BB-TPL-FR-001` | `listRecords` | +| `BB-TPL-FR-002` | `createRecord`, `getRecord` | +| `BB-TPL-FR-003` | `requestRecordExport`, `getOperation`, `cancelOperation` | +| `BB-TPL-XR-001` | `getHealth` | +| `BB-TPL-XR-002` | All reference operations through schema and guide validation | +| `BB-TPL-XR-003` | All non-health operations | + +## 8.2 Contract ownership + +- API paths, parameters, schemas, responses, and examples belong in the + canonical OpenAPI or AsyncAPI file, not copied into Markdown. +- Review `api/coverage.yaml` whenever requirements or operations change. +- Pin shared cross-BB shapes from `api/common/` and record their upstream + revision in [`api/common/README.md`](../api/common/README.md); keep domain + schemas in the BB's canonical API document. diff --git a/spec/9-workflows.md b/spec/9-workflows.md index b082414..bf5bbbe 100644 --- a/spec/9-workflows.md +++ b/spec/9-workflows.md @@ -1,57 +1,35 @@ # 9 Internal Workflows -{% hint style="success" %} -This section describes standard _internal_ workflows that a building block should support. Each internal workflow must be linked to one of the Functional Requirements defined in section 6. +Describe externally observable sequences needed to understand a requirement. +Internal implementation steps are informative unless a requirement makes them +observable. Name the requirement IDs exercised by each workflow. -An internal workflow describes the internal processes that a Building Block needs to execute to complete a request from an external application or Building Block to fulfull the functional requirement -{% endhint %} +## 9.1 Create and retrieve a record -_\<Example Internal Workflows>_ - -### 9.1 Start a workflow process via API. - -This internal workflow is used by the Workflow Building Block to initiate a workflow process. An external application (Building Block) calls an API in the Workflow Building Block which will launch a workflow process. This functional requirement must also support submission of data payload through variables in the same API call. - -Examples: - -* [PostPartum and Infant Care Use Case, Payment Step](https://govstack-global.atlassian.net/wiki/spaces/GH/pages/49381394/PostPartum-01-Example+Implementation+Original+-+multiple+steps): Validate the mother has completed all steps (visited a pediatrician, procured medicine and nutrition supplies, and visited the therapy center) by connecting to MCTS registry -* [Unconditional Social Cash Transfer, Elibility Determination](https://govstack.gitbook.io/product-use-cases/product-use-case/inst-1-unconditional-social-cash-transfer): Send beneficiary data from Registration BB to Workflow BB +Requirement: `BB-TPL-FR-002`. ```mermaid sequenceDiagram - -External BB-->>Workflow BB: Call API to start workflow process -Workflow BB-->>Workflow BB: Launch process -Workflow BB-->>External BB: Return Process ID - + participant Client + participant BB + Client->>BB: POST /v1/records with OAuth and Idempotency-Key + BB-->>Client: 201 Created with Location + Client->>BB: GET Location + BB-->>Client: 200 Record ``` +## 9.2 Request long-running work - -### 9.2 Booking an appointment - -The first and somewhat unique use-case is related to the need for consent when the Individual is not yet provisioned in the System processing the data. In such cases, the workflow requires the creation of a valid and trusted Foundational ID to be linked with the Consent Record. Below is shown how a pre-registration use of consent workflow works. - -Examples: - -* Postpartum Use Case, Appointment scheduling step: In this case, a health care worker will book an appointment into a specific slot. The Scheduler Building Block will leverage the Messaging Building Block to send a message to the patient with an appointment confirmation. +Requirement: `BB-TPL-FR-003`. ```mermaid sequenceDiagram - -HCworker->>PPCP_APP: Request appointment<br />for consultation session<br /> with preferences<br />(date-time range,\nclinics, doctors,etc) -PPCP_APP->>Scheduler [planner]: Find unbooked session<br />slots in consultation event<br />for given preferrences -Scheduler [planner]->>PPCP_APP: Report available<br />session slots\n with terms of service -opt: - HCworker ->>PPCP_APP:Pay fee, if any - PPCP_APP->HCworker:payment receipt -end -HCworker->>PPCP_APP:Confirm slot -PPCP_APP->>Scheduler [planner]: Book appointment<br />for consultation session -Scheduler [planner]->>Scheduler [Worklist]: Update in<br />consultant's worklist -Scheduler [planner]->>PPCP_APP:Confirm booking of \n appointment -PPCP_APP->>HCworker:Publish booking details -Scheduler [planner]->>Messaging BB: Appointment confirmation message -Messaging BB->>Subscriber: Deliver message \n to Subscriber -Messaging BB->>Scheduler [planner]: delivery confirmation + participant Service + participant BB + Service->>BB: POST /v1/exports with client credentials + BB-->>Service: 202 Accepted with Operation Location + loop Until terminal status + Service->>BB: GET Operation Location + BB-->>Service: 200 Operation + end ``` diff --git a/spec/README.md b/spec/README.md index b8f32b4..0b1cb4b 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,10 +1,13 @@ -# \<name of building block> +# Building Block Specification Template -{% hint style="success" %} -Throughout this template are a series of these info callouts. They are designed to guide the type of content to add in each section and often contain example content from other building blocks. Delete them as no longer required. -{% endhint %} +Use this book to define one GovStack Building Block. Replace the reference +examples with domain-specific content before publication and remove authoring +instructions that no longer apply. +The normative specification consists of the requirements in Sections 5 and 6, +the interface data in Sections 7 and 8, and the workflows in Section 9. Every +normative requirement has a stable ID and exactly one disposition in +[`api/coverage.yaml`](../api/coverage.yaml). - -\ -Developed by: `<Names and organization affiliations of working group members>` in cooperation with GIZ, ITU, DIAL, and the Government of Estonia +Record the editors and their organisational affiliations here when the BB +working group is established. diff --git a/spec/SUMMARY.md b/spec/SUMMARY.md index 83dc68b..d2a5715 100644 --- a/spec/SUMMARY.md +++ b/spec/SUMMARY.md @@ -1,6 +1,6 @@ # Table of contents -* [\<name of building block>](README.md) +* [Building Block Specification Template](README.md) * [1 Version History](1-version-history.md) * [2 Description](2-description.md) * [3 Terminology](3-terminology.md) diff --git a/test/plan.md b/test/plan.md index b8746b2..1c54d2e 100644 --- a/test/plan.md +++ b/test/plan.md @@ -1,14 +1,46 @@ -# Test plan for the _____________ building block. +# Building Block conformance test plan -1. a -2. b -3. c +This plan covers the specification and externally observable implementation +contract. A BB may add domain and deployment tests, but must not remove checks +for its REQUIRED requirements. -## Notes +## 1. Specification checks -At least three levels of testing... +1. Validate every document listed in `api/index.yaml` with its base schema + validator. +2. Run `node api-design-guide/linter/cli.mjs --repo-root . --fail-on error` + (install the base validators first; see + `api-design-guide/guides/validating-your-spec.md`). +3. Confirm every normative requirement ID is unique and has exactly one valid + disposition in `api/coverage.yaml`. +4. Confirm every `operation` disposition names existing, unique `operationId` + values. +5. Resolve every local `$ref` without network access. -1. can the BB be deployed via docker-compose? -2. can the BB interact with the IM? -3. can an adaptor be deployed alongside it to test API compliance? -4. do the required APIs respond to the required inputs and provide the required responses? +## 2. Reference contract tests + +| Requirement | Test | +|---|---| +| `BB-TPL-FR-001` | List records with no cursor, follow `nextCursor`, enforce the `pageSize` maximum, and treat the integrity-protected cursor as opaque. | +| `BB-TPL-FR-002` | Create with a new `Idempotency-Key`, verify `201` and `Location`, retrieve the record, then replay the same request and receive the original result. | +| `BB-TPL-FR-003` | Request an export, verify `202` and Operation `Location`, poll to a terminal status, and exercise cancellation. | +| `BB-TPL-XR-001` | Call `/health` without credentials and verify `application/health+json` without internal details. | +| `BB-TPL-XR-003` | Reject missing or insufficient OAuth access tokens, propagate `traceparent`, and make an error `traceId` equal the effective W3C trace-id. | + +## 3. Error and resilience tests + +- Verify every documented 4xx and 5xx response uses + `application/problem+json`, has the RFC 9457 fields plus `code`, `traceId`, + and `timestamp`, and declares `Cache-Control: no-store`. +- Verify malformed input produces field-level JSON Pointer errors. +- Verify a reused idempotency key with a different body is rejected. +- Verify no credentials or personal data appear in URLs or logs collected as + test evidence. + +## 4. Integration and deployment evidence + +Record the implementation version, test environment, commands, timestamps, and +result artifacts. Where the BB communicates through an interoperability +mediator or adaptor, run the same contract suite through that boundary. A +release is conformant only when all REQUIRED requirement tests pass and no +undeclared interface exception remains. From 2bb011149754fe1a83ccc0659c1b5dfe645069ae Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:20:29 +0700 Subject: [PATCH 07/19] fix: repoint reverse-DNS namespace to global.govstack and docs to govstack.global The guide used org.govstack.* as the reverse-DNS root for event types, channel ids and error identifiers, and docs.govstack.org as the error registry host. Neither matches the project's actual domain: govstack.org is not owned by GovStack and docs.govstack.org does not resolve. Reverse-DNS of govstack.global is global.govstack. docs.govstack.global resolves and serves the documentation site. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/appendix/b-open-questions.md | 4 +- api-design-guide/linter/coverage.yaml | 6 +-- .../linter/functions/s09-bbCode.js | 4 +- .../linter/functions/s13-scopeNames.js | 2 +- .../linter/functions/s17-channelIds.js | 4 +- api-design-guide/linter/rulesets/s11.yaml | 10 ++--- api-design-guide/linter/rulesets/s13.yaml | 2 +- api-design-guide/linter/rulesets/s16.yaml | 8 ++-- api-design-guide/linter/rulesets/s17.yaml | 4 +- .../fixtures/govstack-11.5-enum/pass.yaml | 2 +- .../fixtures/govstack-11.5-example/pass.yaml | 2 +- .../govstack-16.2-recommended/fail.yaml | 2 +- .../govstack-16.2-recommended/pass.yaml | 2 +- .../tests/fixtures/govstack-16.2/pass.yaml | 2 +- .../tests/fixtures/govstack-16.3/fail.yaml | 2 +- .../tests/fixtures/govstack-16.3/pass.yaml | 2 +- .../tests/fixtures/govstack-16.4/fail.yaml | 2 +- .../tests/fixtures/govstack-16.4/pass.yaml | 2 +- .../tests/fixtures/govstack-17.1/fail.yaml | 2 +- .../tests/fixtures/govstack-17.1/pass.yaml | 2 +- .../tests/fixtures/govstack-17.10/fail.yaml | 2 +- .../tests/fixtures/govstack-17.10/pass.yaml | 2 +- .../tests/fixtures/govstack-17.11/fail.yaml | 2 +- .../tests/fixtures/govstack-17.11/pass.yaml | 2 +- .../tests/fixtures/govstack-17.12/fail.yaml | 2 +- .../tests/fixtures/govstack-17.12/pass.yaml | 2 +- .../tests/fixtures/govstack-17.13/fail.yaml | 2 +- .../tests/fixtures/govstack-17.13/pass.yaml | 2 +- .../tests/fixtures/govstack-17.15/fail.yaml | 2 +- .../tests/fixtures/govstack-17.15/pass.yaml | 2 +- .../tests/fixtures/govstack-17.16/fail.yaml | 2 +- .../tests/fixtures/govstack-17.16/pass.yaml | 2 +- .../tests/fixtures/govstack-17.17/fail.yaml | 4 +- .../tests/fixtures/govstack-17.17/pass.yaml | 4 +- .../tests/fixtures/govstack-17.19/fail.yaml | 2 +- .../tests/fixtures/govstack-17.19/pass.yaml | 4 +- .../tests/fixtures/govstack-17.2/pass.yaml | 6 +-- .../tests/fixtures/govstack-17.20/fail.yaml | 2 +- .../tests/fixtures/govstack-17.20/pass.yaml | 2 +- .../tests/fixtures/govstack-17.3/fail.yaml | 2 +- .../tests/fixtures/govstack-17.3/pass.yaml | 2 +- .../tests/fixtures/govstack-17.4/fail.yaml | 2 +- .../tests/fixtures/govstack-17.4/pass.yaml | 2 +- .../tests/fixtures/govstack-17.5/fail.yaml | 2 +- .../tests/fixtures/govstack-17.5/pass.yaml | 2 +- .../tests/fixtures/govstack-17.6/fail.yaml | 2 +- .../tests/fixtures/govstack-17.6/pass.yaml | 6 +-- .../tests/fixtures/govstack-17.8/fail.yaml | 2 +- .../tests/fixtures/govstack-17.8/pass.yaml | 2 +- .../tests/fixtures/govstack-17.9/fail.yaml | 2 +- .../tests/fixtures/govstack-17.9/pass.yaml | 2 +- .../fixtures/govstack-18.2-asyncapi/fail.yaml | 6 +-- .../fixtures/govstack-18.2-asyncapi/pass.yaml | 6 +-- .../govstack-18.7-description/fail.yaml | 2 +- .../govstack-18.7-description/pass.yaml | 2 +- .../tests/fixtures/govstack-18.7/fail.yaml | 2 +- .../tests/fixtures/govstack-18.7/pass.yaml | 2 +- .../fixtures/govstack-19.4-asyncapi/fail.yaml | 2 +- .../fixtures/govstack-19.4-asyncapi/pass.yaml | 2 +- .../tests/fixtures/govstack-3.1/pass.yaml | 2 +- .../fixtures/govstack-3.5-semver/fail.yaml | 2 +- .../fixtures/govstack-3.5-semver/pass.yaml | 2 +- .../tests/fixtures/govstack-3.5/fail.yaml | 2 +- .../tests/fixtures/govstack-3.5/pass.yaml | 2 +- .../fixtures/govstack-3.6-host/fail.yaml | 2 +- .../fixtures/govstack-3.6-host/pass.yaml | 2 +- .../tests/fixtures/govstack-3.6/fail.yaml | 2 +- .../tests/fixtures/govstack-3.6/pass.yaml | 2 +- .../tests/fixtures/govstack-3.7/fail.yaml | 2 +- .../tests/fixtures/govstack-3.7/pass.yaml | 2 +- .../tests/fixtures/govstack-3.9/fail.yaml | 2 +- .../tests/fixtures/govstack-3.9/pass.yaml | 2 +- .../linter/tests/golden/asyncapi-golden.yaml | 36 +++++++++--------- .../linter/tests/golden/openapi-golden.yaml | 22 +++++------ api-design-guide/part-c/11-errors.md | 14 +++---- .../13-authentication-and-authorisation.md | 2 +- .../part-d/16-cloudevents-and-webhooks.md | 4 +- .../part-d/17-asyncapi-channel-rules.md | 2 +- .../part-e/20-conformance-and-validation.md | 2 +- api-design-guide/rules.yaml | 12 +++--- api/common/govstack-asyncapi-common.yaml | 10 ++--- api/common/govstack-openapi-common.yaml | 38 +++++++++---------- 82 files changed, 167 insertions(+), 167 deletions(-) diff --git a/api-design-guide/appendix/b-open-questions.md b/api-design-guide/appendix/b-open-questions.md index 0cf3a1d..ff3c211 100644 --- a/api-design-guide/appendix/b-open-questions.md +++ b/api-design-guide/appendix/b-open-questions.md @@ -14,11 +14,11 @@ The **Blocks v1.0?** column marks the questions whose answers shape the shared ` | OPEN-4-B | Health endpoint shape: align with `draft-inadarei-api-health-check`, or use a simpler local shape | Align with the draft | [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | No | | OPEN-4-C | Path nesting depth: soft cap of two levels under `/v{N}/` | Keep as SHOULD with the soft cap | [§5.4](../part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | No | | OPEN-6-A | 400 vs 422 boundary | Keep both (400 unparseable, 422 semantic) | [§7](../part-b/7-http-status-codes.md) | No | -| OPEN-10-A | Error code shape: reverse-DNS named code vs reverse-DNS numeric code vs shorter BB-prefixed code | Reverse-DNS named code: `org.govstack.{bb-code}.{error-name}` | [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes) | Yes | +| OPEN-10-A | Error code shape: reverse-DNS named code vs reverse-DNS numeric code vs shorter BB-prefixed code | Reverse-DNS named code: `global.govstack.{bb-code}.{error-name}` | [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes) | Yes | | OPEN-10-B | Common error catalogue: which canonical errors to include | The `google.rpc.Code` set mapped to reverse-DNS GovStack codes | [§11.7](../part-c/11-errors.md#117-common-error-catalogue) | Yes | | OPEN-12-A | OAuth scope syntax: `bb:{bb-code}:{resource}:{action}` vs reverse-DNS vs `resource.action` | `bb:` prefix for namespacing; reverse-DNS is the alternative | [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | Yes | | OPEN-14-A | Operation resource: GovStack-local shape vs strict Google AIP-151 mirror | AIP-151-aligned hybrid; strict AIP-151 is the alternative | [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | Yes | -| OPEN-15-B | Event `type` naming convention | `org.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | +| OPEN-15-B | Event `type` naming convention | `global.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | | OPEN-15-E | CloudEvents binding style for AsyncAPI | Structured CloudEvents JSON payload | [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | No | | OPEN-15-F | AsyncAPI protocol-binding depth | Require bindings where they affect interoperability; future profiles may add deeper broker-specific rules | [§17.19](../part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | No | | OPEN-15-G | GovStack AsyncAPI extension names and schemas | Define in `govstack-asyncapi-common.yaml` | [§17.15](../part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions) | No | diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 965853d..2a87606 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -478,7 +478,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-11.5-enum, govstack-11.5-example] - note: "proxy: where code enum/examples exist, match org.govstack.{bb-code}.{lowerCamel}. OpenAPI surface only." + note: "proxy: where code enum/examples exist, match global.govstack.{bb-code}.{lowerCamel}. OpenAPI surface only." - id: "11.6" class: "R" status: runtime @@ -658,7 +658,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-16.3] - note: "event type const matches reverse-DNS org.govstack.{bb-code}.{resource}.{action}, no version" + note: "event type const matches reverse-DNS global.govstack.{bb-code}.{resource}.{action}, no version" - id: "16.4" class: "M+R" status: partial-proxy @@ -708,7 +708,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-17.2] - note: "logical channel keys match org.govstack.{bb-code}.v{major}.{resource}.{event}; native address syntax and binding mapping require protocol-aware review" + note: "logical channel keys match global.govstack.{bb-code}.v{major}.{resource}.{event}; native address syntax and binding mapping require protocol-aware review" - id: "17.3" class: "R" status: strict-only diff --git a/api-design-guide/linter/functions/s09-bbCode.js b/api-design-guide/linter/functions/s09-bbCode.js index 87d218c..393e6f9 100644 --- a/api-design-guide/linter/functions/s09-bbCode.js +++ b/api-design-guide/linter/functions/s09-bbCode.js @@ -12,7 +12,7 @@ import { isObject } from './lib/util.js'; * Extraction runs over every object key and string value (skipping free-text * prose keys) using the two documented shapes: * - OAuth scope: `bb:{bb-code}:{resource}:{action}` - * - reverse-DNS: `org.govstack.{bb-code}....` (error codes, event types, + * - reverse-DNS: `global.govstack.{bb-code}....` (error codes, event types, * logical channel IDs, problem-type URIs) * The segment `common` is reserved (§11.7) and excluded from the identity check. * @@ -31,7 +31,7 @@ import { isObject } from './lib/util.js'; * @returns {{message:string, path:(string|number)[]}[]|undefined} */ const SCOPE_RE = /^bb:([^:\s]+):/; -const RDNS_RE = /org\.govstack\.([^.\s]+)\./gi; +const RDNS_RE = /global\.govstack\.([^.\s]+)\./gi; const BB_CODE_RE = /^[a-z][a-z0-9-]{1,30}$/; const RESERVED = 'common'; const DEFAULT_SKIP_KEYS = ['description', 'summary', 'title', 'externalDocs', 'address']; diff --git a/api-design-guide/linter/functions/s13-scopeNames.js b/api-design-guide/linter/functions/s13-scopeNames.js index 5d9f848..cd01baa 100644 --- a/api-design-guide/linter/functions/s13-scopeNames.js +++ b/api-design-guide/linter/functions/s13-scopeNames.js @@ -7,7 +7,7 @@ import { isObject, toRegExp } from './lib/util.js'; * * This is a proxy: it validates the *shape* of each scope string against the * default `bb:{bb-code}:{resource}:{action}` and its two documented - * alternatives (reverse-DNS `org.govstack.{bb-code}.{resource}.{action}` and + * alternatives (reverse-DNS `global.govstack.{bb-code}.{resource}.{action}` and * `resource.action`). It does NOT verify that `{bb-code}` is the BB's actual * registered code (§9.11), that scopes are unique ecosystem-wide, that a single * convention is used consistently, or that every operation documents its scopes. diff --git a/api-design-guide/linter/functions/s17-channelIds.js b/api-design-guide/linter/functions/s17-channelIds.js index d9e94a3..a184409 100644 --- a/api-design-guide/linter/functions/s17-channelIds.js +++ b/api-design-guide/linter/functions/s17-channelIds.js @@ -1,7 +1,7 @@ import { isObject } from './lib/util.js'; const LOGICAL_CHANNEL_ID_RE = - /^org\.govstack\.[a-z][a-z0-9-]{1,30}\.v[0-9]+(?:\.(?:[a-z][a-zA-Z0-9-]*|\{[a-zA-Z0-9_]+\})){2,}$/; + /^global\.govstack\.[a-z][a-z0-9-]{1,30}\.v[0-9]+(?:\.(?:[a-z][a-zA-Z0-9-]*|\{[a-zA-Z0-9_]+\})){2,}$/; /** * Validate the stable logical IDs used as keys of an AsyncAPI channels map. @@ -25,7 +25,7 @@ export default function channelIds(targetVal, _options, context) { results.push({ message: `logical channel ID "${logicalId}" must match ` + - 'org.govstack.{bb-code}.v{major}.{resource}.{event}', + 'global.govstack.{bb-code}.v{major}.{resource}.{event}', path: [...base, logicalId], }); } diff --git a/api-design-guide/linter/rulesets/s11.yaml b/api-design-guide/linter/rulesets/s11.yaml index d78e470..f087920 100644 --- a/api-design-guide/linter/rulesets/s11.yaml +++ b/api-design-guide/linter/rulesets/s11.yaml @@ -84,7 +84,7 @@ rules: requiredProperties: [pointer, code, message] # 11.5 [M+R] — PROXY (bucket B, MUST -> warn). Error codes MUST be - # namespaced reverse-DNS `org.govstack.{bb-code}.{error-name}`, bb-code + # namespaced reverse-DNS `global.govstack.{bb-code}.{error-name}`, bb-code # matching the §9.11 pattern `^[a-z][a-z0-9-]{1,30}$`, error-name either # lowerCamelCase or (per the guide's numeric-catalogue MAY) a plain integer. # Only checks literal `code` values that appear as a schema `enum` member or @@ -95,7 +95,7 @@ rules: # nested inside an `allOf` branch (only the schema's own direct # properties.code are inspected). govstack-11.5-enum: - description: "problem+json code enum values must match org.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." + description: "problem+json code enum values must match global.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." message: "[11.5][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#115-namespaced-stable-error-codes severity: warn @@ -105,10 +105,10 @@ rules: function: valuePattern functionOptions: name: "code" - match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' + match: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' govstack-11.5-example: - description: "problem+json code example must match org.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." + description: "problem+json code example must match global.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." message: "[11.5][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#115-namespaced-stable-error-codes severity: warn @@ -118,4 +118,4 @@ rules: function: valuePattern functionOptions: name: "code" - match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' + match: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' diff --git a/api-design-guide/linter/rulesets/s13.yaml b/api-design-guide/linter/rulesets/s13.yaml index 30d2c32..7b1d457 100644 --- a/api-design-guide/linter/rulesets/s13.yaml +++ b/api-design-guide/linter/rulesets/s13.yaml @@ -106,7 +106,7 @@ rules: label: "the GovStack scope naming convention" patterns: - '^bb:[a-z][a-z0-9-]*:[A-Za-z0-9-]+:[A-Za-z0-9-]+$' - - '^org\.govstack\.[a-z][a-z0-9-]*\.[A-Za-z0-9-]+\.[A-Za-z0-9-]+$' + - '^global\.govstack\.[a-z][a-z0-9-]*\.[A-Za-z0-9-]+\.[A-Za-z0-9-]+$' - '^[A-Za-z0-9-]+\.[A-Za-z0-9-]+$' # 13.5 [M+R] — credentials MUST NOT be declared in query parameters or diff --git a/api-design-guide/linter/rulesets/s16.yaml b/api-design-guide/linter/rulesets/s16.yaml index 4c6dc40..e852620 100644 --- a/api-design-guide/linter/rulesets/s16.yaml +++ b/api-design-guide/linter/rulesets/s16.yaml @@ -64,11 +64,11 @@ rules: datacontenttype: {} # 16.3 [M] — the pinned event `type` value MUST follow reverse-DNS - # org.govstack.{bb-code}.{resource}.{action} and MUST NOT carry a version + # global.govstack.{bb-code}.{resource}.{action} and MUST NOT carry a version # segment. Only pinned const/enum values are checked (a free-form `type` is a # 16.2 presence concern, not verifiable here). govstack-16.3: - description: "event type must be reverse-DNS org.govstack.{bb-code}.{resource}.{action} with no version (guide 16.3, [M])." + description: "event type must be reverse-DNS global.govstack.{bb-code}.{resource}.{action} with no version (guide 16.3, [M])." message: "[16.3][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types severity: error @@ -79,8 +79,8 @@ rules: functionOptions: property: type name: event type - expected: "reverse-DNS org.govstack.{bb-code}.{resource}.{action} with no major-version segment" - match: '^org\.govstack\.[a-z][a-z0-9-]{1,30}(?:\.[a-z][a-zA-Z0-9]*){2,}$' + expected: "reverse-DNS global.govstack.{bb-code}.{resource}.{action} with no major-version segment" + match: '^global\.govstack\.[a-z][a-z0-9-]{1,30}(?:\.[a-z][a-zA-Z0-9]*){2,}$' forbidSegments: '^v[0-9]+$' # 16.4 [M+R] — PROXY (bucket B), notched MUST/MUST NOT -> warn. diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml index 81b0d99..a080716 100644 --- a/api-design-guide/linter/rulesets/s17.yaml +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -40,10 +40,10 @@ rules: enum: [send, receive] # 17.2 [M+R] — logical channel IDs (the keys under `channels`) MUST follow - # reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event}. Channel + # reverse-DNS global.govstack.{bb-code}.v{major}.{resource}.{event}. Channel # Object addresses intentionally retain protocol-native destination syntax. govstack-17.2: - description: "Logical channel IDs must follow reverse-DNS org.govstack.{bb-code}.v{major}.{resource}.{event} (guide 17.2, [M+R])." + description: "Logical channel IDs must follow reverse-DNS global.govstack.{bb-code}.v{major}.{resource}.{event} (guide 17.2, [M+R])." message: "[17.2][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses severity: error diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml index 453e6d0..7ebee6f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml @@ -35,6 +35,6 @@ paths: status: { type: integer } code: type: string - enum: [org.govstack.identity.personNotFound] + enum: [global.govstack.identity.personNotFound] traceId: { type: string } timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml index d60e6b4..27f283f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml @@ -35,6 +35,6 @@ paths: status: { type: integer } code: type: string - example: org.govstack.identity.personNotFound + example: global.govstack.identity.personNotFound traceId: { type: string } timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml index dafe949..b048980 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml @@ -26,7 +26,7 @@ webhooks: const: "urn:govstack:bb:payments" type: type: string - const: "org.govstack.payments.payment.completed" + const: "global.govstack.payments.payment.completed" data: type: object responses: diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml index 229f791..1239b00 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml @@ -26,7 +26,7 @@ webhooks: const: "urn:govstack:bb:payments" type: type: string - const: "org.govstack.payments.payment.completed" + const: "global.govstack.payments.payment.completed" time: type: string format: date-time diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml index 3cee2f7..f0d6604 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml @@ -26,7 +26,7 @@ webhooks: const: "urn:govstack:bb:payments" type: type: string - const: "org.govstack.payments.payment.completed" + const: "global.govstack.payments.payment.completed" data: type: object responses: diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml index 89064ca..02f01d4 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml @@ -25,7 +25,7 @@ webhooks: type: string type: type: string - const: "org.govstack.payments.v1.payment.completed" + const: "global.govstack.payments.v1.payment.completed" data: type: object responses: diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml index 89c1d87..d9a06e4 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml @@ -25,7 +25,7 @@ webhooks: type: string type: type: string - const: "org.govstack.payments.payment.completed" + const: "global.govstack.payments.payment.completed" data: type: object responses: diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml index f88bebb..3472a45 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml @@ -27,7 +27,7 @@ webhooks: - "https://pay-prod-eu1.internal:8443/events" type: type: string - const: "org.govstack.payments.payment.completed" + const: "global.govstack.payments.payment.completed" data: type: object responses: diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml index 60074f3..d41374a 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml @@ -26,7 +26,7 @@ webhooks: const: "urn:govstack:bb:payments" type: type: string - const: "org.govstack.payments.payment.completed" + const: "global.govstack.payments.payment.completed" data: type: object responses: diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml index 14d6c21..6e7166e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml index 0994788..8d3259e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml index ac307bb..afaf6e7 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml @@ -9,7 +9,7 @@ servers: # 17.10: no server-level security, and the operation declares none either. channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml index 1de7c3a..9e72559 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml @@ -10,7 +10,7 @@ servers: - $ref: '#/components/securitySchemes/userToken' channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml index 6a9ecd4..3c12e19 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml index e560ff7..0537cb2 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml index 7dcb0b9..5ea3d37 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml index ac97c02..3ce5b1b 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml index 90e72c3..874fc96 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml index 69766a2..2e9ba34 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml index cfba6a7..4e3866e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml index 17a1792..8b01b46 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml index dab3dc9..8a27aea 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: submitPayment: - address: org.govstack.pay.v1.payment.submit + address: global.govstack.pay.v1.payment.submit messages: cmd: $ref: '#/components/messages/SubmitPayment' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml index 8bd6ce0..47fca9b 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: submitPayment: - address: org.govstack.pay.v1.payment.submit + address: global.govstack.pay.v1.payment.submit messages: cmd: $ref: '#/components/messages/SubmitPayment' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml index 756ad39..28f4985 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personQuery: - address: org.govstack.reg.v1.person.query + address: global.govstack.reg.v1.person.query messages: cmd: payload: @@ -13,7 +13,7 @@ channels: - name: q payload: {} personQueryReply: - address: org.govstack.reg.v1.person.query-reply + address: global.govstack.reg.v1.person.query-reply messages: res: # 17.17: no correlationId, and the reply declares no address location. diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml index 6f4af44..668b895 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personQuery: - address: org.govstack.reg.v1.person.query + address: global.govstack.reg.v1.person.query messages: cmd: payload: @@ -13,7 +13,7 @@ channels: - name: q payload: {} personQueryReply: - address: org.govstack.reg.v1.person.query-reply + address: global.govstack.reg.v1.person.query-reply messages: res: correlationId: diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml index 5c1989a..432b71a 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml @@ -10,7 +10,7 @@ channels: personCreated: # 17.19: server speaks kafka, but neither the channel nor its operation # declares kafka bindings. - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml index 0b3d1ce..64211c2 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml @@ -8,10 +8,10 @@ servers: protocol: kafka channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created bindings: kafka: - topic: org.govstack.reg.v1.person.created + topic: global.govstack.reg.v1.person.created bindingVersion: '0.5.0' messages: evt: diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml index 23d880c..da05230 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml @@ -3,7 +3,7 @@ info: title: Registry events version: 1.0.0 channels: - org.govstack.reg.v1.person.created: + global.govstack.reg.v1.person.created: address: registrants/{tenant}/created messages: evt: @@ -12,9 +12,9 @@ operations: onPersonCreated: action: receive channel: - $ref: '#/channels/org.govstack.reg.v1.person.created' + $ref: '#/channels/global.govstack.reg.v1.person.created' messages: - - $ref: '#/channels/org.govstack.reg.v1.person.created/messages/evt' + - $ref: '#/channels/global.govstack.reg.v1.person.created/messages/evt' components: messages: PersonCreated: diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml index cbffe48..425d459 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml index 56d009e..d127965 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml index 1ec68a1..1611bdb 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml @@ -5,7 +5,7 @@ info: channels: personEmail: # 17.3: "email" is a directly-identifying attribute in the channel address. - address: org.govstack.reg.v1.person.email + address: global.govstack.reg.v1.person.email parameters: # 17.3: personal-data token in a channel parameter name. phoneNumber: diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml index 122f260..2a41e5a 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created parameters: tenant: description: tenant routing key diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml index 4a4ecca..9c6e1e1 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.{tenant}.person.created + address: global.govstack.reg.v1.{tenant}.person.created parameters: # 17.4: the {tenant} parameter is declared but not documented (no description). tenant: diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml index ceda514..7b7fd79 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.{tenant}.person.created + address: global.govstack.reg.v1.{tenant}.person.created parameters: tenant: description: Tenant routing key; selects the owning tenant partition. diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml index 3447890..b23804d 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml @@ -5,7 +5,7 @@ info: channels: personCreated: # 17.5: environment token "prod" belongs in servers, not the channel address. - address: org.govstack.reg.v1.prod.person.created + address: global.govstack.reg.v1.prod.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml index 0994788..8d3259e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml index 94bb6bc..d21240c 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml index 2b9dde6..9e65d5d 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' @@ -40,7 +40,7 @@ components: payload: specversion: "1.0" id: e-1 - source: org.govstack.reg - type: org.govstack.reg.v1.person.created + source: global.govstack.reg + type: global.govstack.reg.v1.person.created data: personId: p-1 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml index 052db2e..74de578 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml index 31682f2..e1327ad 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml index 19179fb..e4e532a 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml index 1ab4e5c..14df048 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 channels: personCreated: - address: org.govstack.reg.v1.person.created + address: global.govstack.reg.v1.person.created messages: evt: $ref: '#/components/messages/PersonCreated' diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml index 3c697e9..14f7f4c 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml @@ -16,7 +16,7 @@ servers: brokerHost: default: broker.example.org channels: - org.govstack.identity.v1.user.signedup: + global.govstack.identity.v1.user.signedup: address: users/signed-up messages: userSignedUp: @@ -29,9 +29,9 @@ operations: tags: - name: user channel: - $ref: '#/channels/org.govstack.identity.v1.user.signedup' + $ref: '#/channels/global.govstack.identity.v1.user.signedup' messages: - - $ref: '#/channels/org.govstack.identity.v1.user.signedup/messages/userSignedUp' + - $ref: '#/channels/global.govstack.identity.v1.user.signedup/messages/userSignedUp' components: messages: UserSignedUp: diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml index 0103500..bdd8a77 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml @@ -16,7 +16,7 @@ servers: brokerHost: default: broker.example.org channels: - org.govstack.identity.v2.user.signedup: + global.govstack.identity.v2.user.signedup: address: users/signed-up messages: userSignedUp: @@ -29,9 +29,9 @@ operations: tags: - name: user channel: - $ref: '#/channels/org.govstack.identity.v2.user.signedup' + $ref: '#/channels/global.govstack.identity.v2.user.signedup' messages: - - $ref: '#/channels/org.govstack.identity.v2.user.signedup/messages/userSignedUp' + - $ref: '#/channels/global.govstack.identity.v2.user.signedup/messages/userSignedUp' components: messages: UserSignedUp: diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml index 4889fbe..5548d37 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml @@ -17,7 +17,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml index 58085d4..e00ef7e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml index 9d7fc17..be2b018 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml @@ -18,7 +18,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml index fd1aafa..1498441 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml index 529262b..bab4442 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml @@ -15,7 +15,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: requestMessage: name: RequestMessage diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml index dec4880..93cb275 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml @@ -15,7 +15,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: requestMessage: name: RequestMessage diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml index 3827bcd..0a4d16f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml index bf5a924..57a149d 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml @@ -13,7 +13,7 @@ servers: protocol: kafka channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml index 3827bcd..0a4d16f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml index b55a716..ff19c84 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml @@ -10,7 +10,7 @@ servers: protocol: kafka channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml index 3827bcd..0a4d16f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml index b52d0db..ef8141c 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml @@ -13,7 +13,7 @@ servers: protocol: kafka channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml index 3827bcd..0a4d16f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml index e08c251..aed049f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml @@ -14,7 +14,7 @@ servers: protocol: kafka channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml index 3827bcd..0a4d16f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml index 6f90b5a..9d54a2e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml @@ -14,7 +14,7 @@ servers: protocol: kafka channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml index 3827bcd..0a4d16f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml index 1fa448d..7b98e90 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml @@ -14,7 +14,7 @@ servers: protocol: kafka channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml index 3827bcd..0a4d16f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml @@ -16,7 +16,7 @@ servers: default: broker.example.org channels: userSignedUp: - address: org.govstack.identity.v1.user.signedup + address: global.govstack.identity.v1.user.signedup messages: userSignedUp: $ref: '#/components/messages/UserSignedUp' diff --git a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml index 2c7c8dd..98621be 100644 --- a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml +++ b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml @@ -47,7 +47,7 @@ servers: - $ref: '#/components/securitySchemes/registryOAuth' defaultContentType: application/json channels: - org.govstack.registry.v1.registrant.registered: + global.govstack.registry.v1.registrant.registered: address: registry.registrant.registered description: Registrant lifecycle events published by the Registry BB. servers: @@ -58,7 +58,7 @@ channels: bindings: kafka: bindingVersion: '0.5.0' - org.govstack.registry.v1.registrant.commands: + global.govstack.registry.v1.registrant.commands: address: registry.registrant.{registrantId}.commands description: Commands directed at a specific registrant, consumed by the Registry BB. parameters: @@ -72,7 +72,7 @@ channels: bindings: kafka: bindingVersion: '0.5.0' - org.govstack.registry.v1.registrant.command-replies: + global.govstack.registry.v1.registrant.command-replies: address: registry.registrant.command-replies description: Replies and rejections for registrant commands. servers: @@ -96,9 +96,9 @@ operations: tags: - name: Registrants channel: - $ref: '#/channels/org.govstack.registry.v1.registrant.registered' + $ref: '#/channels/global.govstack.registry.v1.registrant.registered' messages: - - $ref: '#/channels/org.govstack.registry.v1.registrant.registered/messages/registered' + - $ref: '#/channels/global.govstack.registry.v1.registrant.registered/messages/registered' x-govstack-delivery: atLeastOnce x-govstack-ordering: scope: partitionKey @@ -118,14 +118,14 @@ operations: tags: - name: Registrants channel: - $ref: '#/channels/org.govstack.registry.v1.registrant.commands' + $ref: '#/channels/global.govstack.registry.v1.registrant.commands' messages: - - $ref: '#/channels/org.govstack.registry.v1.registrant.commands/messages/deregister' + - $ref: '#/channels/global.govstack.registry.v1.registrant.commands/messages/deregister' reply: channel: - $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies' + $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies' messages: - - $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies/messages/result' + - $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies/messages/result' x-govstack-delivery: atLeastOnce x-govstack-ordering: scope: partitionKey @@ -144,9 +144,9 @@ operations: tags: - name: Registrants channel: - $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies' + $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies' messages: - - $ref: '#/channels/org.govstack.registry.v1.registrant.command-replies/messages/rejected' + - $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies/messages/rejected' x-govstack-delivery: atLeastOnce x-govstack-ordering: none x-govstack-redelivery: supported @@ -200,7 +200,7 @@ components: type: type: string description: Reverse-DNS event type carrying no version segment. - const: org.govstack.registry.registrant.registered + const: global.govstack.registry.registrant.registered time: type: string format: date-time @@ -239,7 +239,7 @@ components: specversion: '1.0' id: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e source: /govstack/registry - type: org.govstack.registry.registrant.registered + type: global.govstack.registry.registrant.registered time: '2026-07-10T12:34:56Z' datacontenttype: application/json subject: 6f9619ff-8b86-d011-b42d-00cf4fc964ff @@ -286,7 +286,7 @@ components: type: type: string description: Reverse-DNS command type carrying no version segment. - const: org.govstack.registry.registrant.deregisterRequested + const: global.govstack.registry.registrant.deregisterRequested time: type: string format: date-time @@ -317,7 +317,7 @@ components: specversion: '1.0' id: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f source: /govstack/registry - type: org.govstack.registry.registrant.deregisterRequested + type: global.govstack.registry.registrant.deregisterRequested time: '2026-07-10T12:40:00Z' datacontenttype: application/json data: @@ -358,7 +358,7 @@ components: type: type: string description: Reverse-DNS event type carrying no version segment. - const: org.govstack.registry.registrant.deregistered + const: global.govstack.registry.registrant.deregistered time: type: string format: date-time @@ -389,7 +389,7 @@ components: specversion: '1.0' id: 3d4e5f6a-7b8c-9d0e-1f2a-3b4c5d6e7f80 source: /govstack/registry - type: org.govstack.registry.registrant.deregistered + type: global.govstack.registry.registrant.deregistered time: '2026-07-10T12:41:00Z' datacontenttype: application/json data: @@ -451,6 +451,6 @@ components: title: Deregistration rejected status: 409 detail: The registrant has an active obligation and cannot be deregistered. - code: org.govstack.registry.deregistrationRejected + code: global.govstack.registry.deregistrationRejected traceId: 9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c timestamp: '2026-07-10T12:41:05Z' diff --git a/api-design-guide/linter/tests/golden/openapi-golden.yaml b/api-design-guide/linter/tests/golden/openapi-golden.yaml index eb6c808..d73dd71 100644 --- a/api-design-guide/linter/tests/golden/openapi-golden.yaml +++ b/api-design-guide/linter/tests/golden/openapi-golden.yaml @@ -914,8 +914,8 @@ components: type: string description: >- Stable, namespaced GovStack error code (§11.5), reverse-DNS - org.govstack.{bb-code}.{errorName}. - example: org.govstack.registry.requestFailed + global.govstack.{bb-code}.{errorName}. + example: global.govstack.registry.requestFailed traceId: type: string description: Distributed-trace identifier correlating this error to server logs. @@ -929,7 +929,7 @@ components: status: 500 detail: The request could not be processed. instance: /v1/registrants/6f9619ff-8b86-d011-b42d-00cf4fc964ff - code: org.govstack.registry.requestFailed + code: global.govstack.registry.requestFailed traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d timestamp: '2026-07-10T12:34:56Z' ValidationProblem: @@ -952,12 +952,12 @@ components: title: Validation failed status: 400 detail: One or more fields are invalid. - code: org.govstack.registry.validationFailed + code: global.govstack.registry.validationFailed traceId: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d timestamp: '2026-07-10T12:34:56Z' errors: - pointer: /emailAddress - code: org.govstack.registry.invalidEmail + code: global.govstack.registry.invalidEmail message: emailAddress must be a valid email address. FieldError: type: object @@ -1243,7 +1243,7 @@ components: - subscriptionId: 9d1f3b2a-6c4e-4a1b-8f0d-2e5c7a9b1d33 callbackUrl: https://partner.example.org/hooks/registry eventTypes: - - org.govstack.registry.registrant.registered + - global.govstack.registry.registrant.registered status: ACTIVE createdAt: '2026-06-01T08:00:00Z' updatedAt: '2026-06-01T08:00:00Z' @@ -1354,7 +1354,7 @@ components: - subscriptionId: 9d1f3b2a-6c4e-4a1b-8f0d-2e5c7a9b1d33 callbackUrl: https://partner.example.org/hooks/registry eventTypes: - - org.govstack.registry.registrant.registered + - global.govstack.registry.registrant.registered status: ACTIVE createdAt: '2026-06-01T08:00:00Z' updatedAt: '2026-06-01T08:00:00Z' @@ -1376,7 +1376,7 @@ components: examples: - callbackUrl: https://partner.example.org/hooks/registry eventTypes: - - org.govstack.registry.registrant.registered + - global.govstack.registry.registrant.registered SubscriptionSecret: type: object description: A freshly rotated subscription signing secret. @@ -1415,9 +1415,9 @@ components: type: type: string description: >- - Reverse-DNS event type, org.govstack.{bb-code}.{resource}.{action}, + Reverse-DNS event type, global.govstack.{bb-code}.{resource}.{action}, carrying no version segment (§16.3). - const: org.govstack.registry.registrant.registered + const: global.govstack.registry.registrant.registered time: type: string format: date-time @@ -1435,7 +1435,7 @@ components: - specversion: '1.0' id: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e source: /govstack/registry - type: org.govstack.registry.registrant.registered + type: global.govstack.registry.registrant.registered time: '2026-07-10T12:34:56Z' datacontenttype: application/json subject: 6f9619ff-8b86-d011-b42d-00cf4fc964ff diff --git a/api-design-guide/part-c/11-errors.md b/api-design-guide/part-c/11-errors.md index c5488e0..d637b25 100644 --- a/api-design-guide/part-c/11-errors.md +++ b/api-design-guide/part-c/11-errors.md @@ -16,7 +16,7 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi ## 11.2 Standard problem fields present <a href="#112-standard-problem-fields-present" id="112-standard-problem-fields-present"></a> -**[M+R]** Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. +**[M+R]** Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.global/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. ## 11.3 GovStack error extension fields <a href="#113-govstack-error-extension-fields" id="113-govstack-error-extension-fields"></a> @@ -30,23 +30,23 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi ```json { - "type": "https://docs.govstack.org/errors/registration/validationFailed", + "type": "https://docs.govstack.global/errors/registration/validationFailed", "title": "Request validation failed", "status": 422, "detail": "Two request fields failed validation.", "instance": "/v1/applications/3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", - "code": "org.govstack.registration.validationFailed", + "code": "global.govstack.registration.validationFailed", "traceId": "6f1c3f0e2a9b4c8d7e6f5a4b3c2d1e0f", "timestamp": "2026-07-10T08:30:00Z", "errors": [ { "pointer": "/applicant/phoneNumber", - "code": "org.govstack.registration.invalidPhoneNumber", + "code": "global.govstack.registration.invalidPhoneNumber", "message": "Phone number must be an E.164 string." }, { "pointer": "/applicant/birthDate", - "code": "org.govstack.registration.invalidDate", + "code": "global.govstack.registration.invalidDate", "message": "Date must be an RFC 3339 calendar date." } ] @@ -55,7 +55,7 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi ## 11.5 Namespaced stable error codes <a href="#115-namespaced-stable-error-codes" id="115-namespaced-stable-error-codes"></a> -**[M+R]** Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` ([§11.2](#112-standard-problem-fields-present)). The default shape is reverse-DNS: `org.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `{error-name}` segment **MUST** use lowerCamelCase, for example `org.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `org.govstack.{bb-code}.{number}`. [`[OPEN-10-A]`](../appendix/b-open-questions.md) +**[M+R]** Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` ([§11.2](#112-standard-problem-fields-present)). The default shape is reverse-DNS: `global.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `{error-name}` segment **MUST** use lowerCamelCase, for example `global.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `global.govstack.{bb-code}.{number}`. [`[OPEN-10-A]`](../appendix/b-open-questions.md) ## 11.6 Stable codes across languages <a href="#116-stable-codes-across-languages" id="116-stable-codes-across-languages"></a> @@ -63,7 +63,7 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi ## 11.7 Common error catalogue <a href="#117-common-error-catalogue" id="117-common-error-catalogue"></a> -**[M+R]** A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `org.govstack.common.unauthenticated`, `org.govstack.common.permissionDenied`, `org.govstack.common.notFound`, `org.govstack.common.invalidArgument`, `org.govstack.common.alreadyExists`, `org.govstack.common.aborted`, `org.govstack.common.resourceExhausted`, `org.govstack.common.internal`, `org.govstack.common.unimplemented`. The final list is [`[OPEN-10-B]`](../appendix/b-open-questions.md). +**[M+R]** A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is [`[OPEN-10-B]`](../appendix/b-open-questions.md). ## 11.8 Transport-neutral asynchronous errors <a href="#118-transport-neutral-asynchronous-errors" id="118-transport-neutral-asynchronous-errors"></a> diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md index 27b1690..f643149 100644 --- a/api-design-guide/part-d/13-authentication-and-authorisation.md +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -24,7 +24,7 @@ description: "Rules for how BB API specs declare security schemes, OAuth scopes, ## 13.4 Namespaced OAuth scopes <a href="#134-namespaced-oauth-scopes" id="134-namespaced-oauth-scopes"></a> -**[M]** OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`org.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-12-A]`](../appendix/b-open-questions.md) +**[M]** OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-12-A]`](../appendix/b-open-questions.md) ## 13.5 Authorization is the credential channel <a href="#135-authorization-is-the-credential-channel" id="135-authorization-is-the-credential-channel"></a> diff --git a/api-design-guide/part-d/16-cloudevents-and-webhooks.md b/api-design-guide/part-d/16-cloudevents-and-webhooks.md index f2e6aa3..f416f4d 100644 --- a/api-design-guide/part-d/16-cloudevents-and-webhooks.md +++ b/api-design-guide/part-d/16-cloudevents-and-webhooks.md @@ -22,7 +22,7 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.3 Reverse-DNS event types <a href="#163-reverse-dns-event-types" id="163-reverse-dns-event-types"></a> -**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-15-B]`](../appendix/b-open-questions.md) +**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-15-B]`](../appendix/b-open-questions.md) ## 16.4 Stable CloudEvents source <a href="#164-stable-cloudevents-source" id="164-stable-cloudevents-source"></a> @@ -35,7 +35,7 @@ description: "Rules governing the CloudEvents envelope, event-type and source na "specversion": "1.0", "id": "5e0c63c2-2b8a-4d3f-9a51-7c6b0d9e8f21", "source": "urn:govstack:bb:registration", - "type": "org.govstack.registration.application.approved", + "type": "global.govstack.registration.application.approved", "time": "2026-07-10T08:30:00Z", "datacontenttype": "application/json", "traceparent": "00-6f1c3f0e2a9b4c8d7e6f5a4b3c2d1e0f-5b1e4d7ca8f01e2d-01", diff --git a/api-design-guide/part-d/17-asyncapi-channel-rules.md b/api-design-guide/part-d/17-asyncapi-channel-rules.md index ac2db57..fcab375 100644 --- a/api-design-guide/part-d/17-asyncapi-channel-rules.md +++ b/api-design-guide/part-d/17-asyncapi-channel-rules.md @@ -16,7 +16,7 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.2 Stable logical channel IDs and native addresses <a href="#172-stable-logical-channel-ids-and-native-addresses" id="172-stable-logical-channel-ids-and-native-addresses"></a> -**[M+R]** Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address. +**[M+R]** Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `global.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address. ## 17.3 No personal data in channels <a href="#173-no-personal-data-in-channels" id="173-no-personal-data-in-channels"></a> diff --git a/api-design-guide/part-e/20-conformance-and-validation.md b/api-design-guide/part-e/20-conformance-and-validation.md index bb9298f..d9499dc 100644 --- a/api-design-guide/part-e/20-conformance-and-validation.md +++ b/api-design-guide/part-e/20-conformance-and-validation.md @@ -33,7 +33,7 @@ info: - rule: "5.2" scope: /paths/~1v1~1status/get rationale: Legacy statutory endpoint name cannot change before v2. - record: https://docs.govstack.org/api-exceptions/registration-2026-004 + record: https://docs.govstack.global/api-exceptions/registration-2026-004 reviewedBy: GovStack API Working Group reviewedAt: "2026-07-10" expiresAt: "2027-01-31" diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 9632cef..1036cd2 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -815,7 +815,7 @@ rules: surface: Universal page: part-c/11-errors.md anchor: 112-standard-problem-fields-present - text: "Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.org/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments." + text: "Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.global/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments." open_questions: [] - id: "11.3" title: "GovStack error extension fields" @@ -842,7 +842,7 @@ rules: surface: Universal page: part-c/11-errors.md anchor: 115-namespaced-stable-error-codes - text: "Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` (§11.2). The default shape is reverse-DNS: `org.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `{error-name}` segment **MUST** use lowerCamelCase, for example `org.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `org.govstack.{bb-code}.{number}`. `[OPEN-10-A]`" + text: "Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` (§11.2). The default shape is reverse-DNS: `global.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `{error-name}` segment **MUST** use lowerCamelCase, for example `global.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `global.govstack.{bb-code}.{number}`. `[OPEN-10-A]`" open_questions: ["OPEN-10-A"] - id: "11.6" title: "Stable codes across languages" @@ -860,7 +860,7 @@ rules: surface: Universal page: part-c/11-errors.md anchor: 117-common-error-catalogue - text: "A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `org.govstack.common.unauthenticated`, `org.govstack.common.permissionDenied`, `org.govstack.common.notFound`, `org.govstack.common.invalidArgument`, `org.govstack.common.alreadyExists`, `org.govstack.common.aborted`, `org.govstack.common.resourceExhausted`, `org.govstack.common.internal`, `org.govstack.common.unimplemented`. The final list is `[OPEN-10-B]`." + text: "A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is `[OPEN-10-B]`." open_questions: ["OPEN-10-B"] - id: "11.8" title: "Transport-neutral asynchronous errors" @@ -995,7 +995,7 @@ rules: surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 134-namespaced-oauth-scopes - text: "OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`org.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-12-A]`" + text: "OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-12-A]`" open_questions: ["OPEN-12-A"] - id: "13.5" title: "Authorization is the credential channel" @@ -1166,7 +1166,7 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 163-reverse-dns-event-types - text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `org.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata (§18.2). `[OPEN-15-B]`" + text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata (§18.2). `[OPEN-15-B]`" open_questions: ["OPEN-15-B"] - id: "16.4" title: "Stable CloudEvents source" @@ -1256,7 +1256,7 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 172-stable-logical-channel-ids-and-native-addresses - text: "Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `org.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from §9.11. The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address." + text: "Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `global.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from §9.11. The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address." open_questions: [] - id: "17.3" title: "No personal data in channels" diff --git a/api/common/govstack-asyncapi-common.yaml b/api/common/govstack-asyncapi-common.yaml index a3a1b29..9254496 100644 --- a/api/common/govstack-asyncapi-common.yaml +++ b/api/common/govstack-asyncapi-common.yaml @@ -94,7 +94,7 @@ components: specversion: '1.0' id: 5e0c63c2-2b8a-4d3f-9a51-7c6b0d9e8f21 source: urn:govstack:bb:template - type: org.govstack.template.record.created + type: global.govstack.template.record.created time: '2026-07-10T12:00:00Z' datacontenttype: application/json traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 @@ -111,14 +111,14 @@ components: - name: rejectedCommand summary: A command rejected because one field is invalid. payload: - type: https://docs.govstack.org/errors/common/invalidArgument + type: https://docs.govstack.global/errors/common/invalidArgument title: Command validation failed - code: org.govstack.common.invalidArgument + code: global.govstack.common.invalidArgument traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' errors: - pointer: /data/name - code: org.govstack.common.invalidArgument + code: global.govstack.common.invalidArgument message: Name must not be blank. schemas: EventHeaders: @@ -157,7 +157,7 @@ components: description: Stable logical URI reference for the publishing BB surface. type: type: string - pattern: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$' + pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$' description: Reverse-DNS semantic event type without an API version segment. time: type: string diff --git a/api/common/govstack-openapi-common.yaml b/api/common/govstack-openapi-common.yaml index e6335a2..1dcbf67 100644 --- a/api/common/govstack-openapi-common.yaml +++ b/api/common/govstack-openapi-common.yaml @@ -159,17 +159,17 @@ components: schema: $ref: '#/components/schemas/ValidationProblem' example: - type: https://docs.govstack.org/errors/common/invalidArgument + type: https://docs.govstack.global/errors/common/invalidArgument title: Request validation failed status: 400 detail: One request field is invalid. instance: /v1/records - code: org.govstack.common.invalidArgument + code: global.govstack.common.invalidArgument traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' errors: - pointer: /name - code: org.govstack.common.invalidArgument + code: global.govstack.common.invalidArgument message: Name must not be blank. Unauthorized: description: Authentication is missing or invalid. @@ -183,10 +183,10 @@ components: schema: $ref: '#/components/schemas/Problem' example: - type: https://docs.govstack.org/errors/common/unauthenticated + type: https://docs.govstack.global/errors/common/unauthenticated title: Authentication required status: 401 - code: org.govstack.common.unauthenticated + code: global.govstack.common.unauthenticated traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' Forbidden: @@ -199,10 +199,10 @@ components: schema: $ref: '#/components/schemas/Problem' example: - type: https://docs.govstack.org/errors/common/permissionDenied + type: https://docs.govstack.global/errors/common/permissionDenied title: Permission denied status: 403 - code: org.govstack.common.permissionDenied + code: global.govstack.common.permissionDenied traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' NotFound: @@ -215,10 +215,10 @@ components: schema: $ref: '#/components/schemas/Problem' example: - type: https://docs.govstack.org/errors/common/notFound + type: https://docs.govstack.global/errors/common/notFound title: Resource not found status: 404 - code: org.govstack.common.notFound + code: global.govstack.common.notFound traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' Conflict: @@ -231,10 +231,10 @@ components: schema: $ref: '#/components/schemas/Problem' example: - type: https://docs.govstack.org/errors/common/aborted + type: https://docs.govstack.global/errors/common/aborted title: State conflict status: 409 - code: org.govstack.common.aborted + code: global.govstack.common.aborted traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' UnprocessableContent: @@ -247,10 +247,10 @@ components: schema: $ref: '#/components/schemas/Problem' example: - type: https://docs.govstack.org/errors/common/invalidArgument + type: https://docs.govstack.global/errors/common/invalidArgument title: Request cannot be processed status: 422 - code: org.govstack.common.invalidArgument + code: global.govstack.common.invalidArgument traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' TooManyRequests: @@ -265,10 +265,10 @@ components: schema: $ref: '#/components/schemas/Problem' example: - type: https://docs.govstack.org/errors/common/resourceExhausted + type: https://docs.govstack.global/errors/common/resourceExhausted title: Request limit exceeded status: 429 - code: org.govstack.common.resourceExhausted + code: global.govstack.common.resourceExhausted traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' InternalError: @@ -281,10 +281,10 @@ components: schema: $ref: '#/components/schemas/Problem' example: - type: https://docs.govstack.org/errors/common/internal + type: https://docs.govstack.global/errors/common/internal title: Internal error status: 500 - code: org.govstack.common.internal + code: global.govstack.common.internal traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' schemas: @@ -322,9 +322,9 @@ components: description: URI reference identifying this problem occurrence. code: type: string - pattern: '^org\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*$' + pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*$' description: Stable namespaced machine-readable error identifier. - example: org.govstack.common.internal + example: global.govstack.common.internal traceId: type: string pattern: '^[\da-f]{32}$' From baed8c0e8be3a564bc5a056b73066b6f2d686090 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:23:09 +0700 Subject: [PATCH 08/19] docs: stop citing an unpublished cross-BB audit as the evidence base Seven passages cited "the 2026 cross-BB audit of all 15 Building Blocks" as a citable artifact and attached counts to it ("at least 9 BBs"). No such document is published, so a reader cannot check any of it. Each passage now states the provenance a reader can verify for themselves (the published BB API specifications) and drops the unverifiable counts. The substantive observations are unchanged. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/1-introduction.md | 2 +- api-design-guide/README.md | 2 +- api-design-guide/part-a/2-openapi-document-standards.md | 2 +- api-design-guide/part-a/4-documentation-requirements.md | 2 +- api-design-guide/part-b/6-http-methods.md | 2 +- api-design-guide/part-c/9-json-conventions-and-naming.md | 2 +- api-design-guide/rules.yaml | 6 +++--- api-design-guide/version-history.md | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md index 375791d..18cccd6 100644 --- a/api-design-guide/1-introduction.md +++ b/api-design-guide/1-introduction.md @@ -23,7 +23,7 @@ The test for inclusion: *would two BB editors writing two different specs need t - The companion files `govstack-openapi-common.yaml` (REST security scheme, error schema, pagination components, common headers, Operation resource) and `govstack-asyncapi-common.yaml` (event envelope, message headers, security schemes, signing metadata, delivery declarations, common error messages) that BBs reference. - The repository-level API inventory (`api/index.yaml`, when the default canonical paths are insufficient) and functional-requirement traceability file (`api/coverage.yaml`). -CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally lighter in v0.1: BBs must declare the relevant bindings where they affect the contract, while detailed broker-operation guidance belongs in the Security & Operations companion or a later protocol profile. The 2026 audit is OpenAPI-centric only because the current BB set is predominantly REST, not because the ecosystem should remain so. +CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally lighter in v0.1: BBs must declare the relevant bindings where they affect the contract, while detailed broker-operation guidance belongs in the Security & Operations companion or a later protocol profile. That lighter coverage reflects the current BB set being predominantly REST, not a judgement that the ecosystem should remain so. The decision tree below summarises which artifact documents which kind of surface (informative). The document-level rules are in [§2](part-a/2-openapi-document-standards.md) and [§3](part-a/3-asyncapi-document-standards.md); the event rules are in [§16](part-d/16-cloudevents-and-webhooks.md) and [§17](part-d/17-asyncapi-channel-rules.md). diff --git a/api-design-guide/README.md b/api-design-guide/README.md index 2c61165..1a7ad3b 100644 --- a/api-design-guide/README.md +++ b/api-design-guide/README.md @@ -18,7 +18,7 @@ description: "The rules every GovStack Building Block API specification must fol ## Executive summary -GovStack has standardised a great deal, but never a single API design guide that every Building Block follows. In its absence each BB team made reasonable local choices that, predictably, diverged. A 2026 cross-BB audit mapped that divergence across all 15 BBs and is the evidence base for every rule below: this guide closes documented gaps, not hypothetical ones. +GovStack has standardised a great deal, but never a single API design guide that every Building Block follows. In its absence each BB team made reasonable local choices that, predictably, diverged. The rules below were drafted against the published Building Block API specifications as they stood in 2026, so this guide closes gaps observed in those specifications, not hypothetical ones. The GovStack Cross-BB API Design Guide defines the rules every Building Block API specification must follow, so that an integrator combining several BBs into a national digital platform sees consistent shapes for authentication, errors, identifiers, pagination, events, and lifecycle. It governs OpenAPI 3.1 REST surfaces, CloudEvents event payloads, OpenAPI webhooks, and AsyncAPI 3.0 documentation for brokered event channels and event streams. Operational behaviour (token validation, key rotation, audit logging) and ecosystem governance (ratification, enforcement, exception lifecycle) are out of scope. diff --git a/api-design-guide/part-a/2-openapi-document-standards.md b/api-design-guide/part-a/2-openapi-document-standards.md index 2a260bd..c7b4c83 100644 --- a/api-design-guide/part-a/2-openapi-document-standards.md +++ b/api-design-guide/part-a/2-openapi-document-standards.md @@ -16,7 +16,7 @@ description: "Rules governing the canonical OpenAPI document: version, location, ## 2.2 One canonical OpenAPI entrypoint <a href="#22-one-canonical-openapi-entrypoint" id="22-one-canonical-openapi-entrypoint"></a> -**[M+R]** In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using [§4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory). Either discovery form **MUST** identify exactly one canonical artifact per surface. The audit's legacy `api/swagger.yaml` and `api/swagger.json` names are not canonical under this guide. +**[M+R]** In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using [§4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory). Either discovery form **MUST** identify exactly one canonical artifact per surface. The legacy `api/swagger.yaml` and `api/swagger.json` names still used by some BBs are not canonical under this guide. ## 2.3 No divergent OpenAPI copies <a href="#23-no-divergent-openapi-copies" id="23-no-divergent-openapi-copies"></a> diff --git a/api-design-guide/part-a/4-documentation-requirements.md b/api-design-guide/part-a/4-documentation-requirements.md index 5f4601c..8ed1173 100644 --- a/api-design-guide/part-a/4-documentation-requirements.md +++ b/api-design-guide/part-a/4-documentation-requirements.md @@ -24,7 +24,7 @@ description: "Documentation requirements for schemas, examples, and operation de ## 4.4 Accurate operation descriptions <a href="#44-accurate-operation-descriptions" id="44-accurate-operation-descriptions"></a> -**[R]** Operation `description` **MUST** describe what the operation actually does. (The audit found at least 9 BBs with cross-endpoint description mismatches from copy-paste.) +**[R]** Operation `description` **MUST** describe what the operation actually does. (Copy-pasted descriptions that document a different endpoint than the one they sit on are a recurring problem in existing BB specifications.) ## 4.5 API surface inventory <a href="#45-api-surface-inventory" id="45-api-surface-inventory"></a> diff --git a/api-design-guide/part-b/6-http-methods.md b/api-design-guide/part-b/6-http-methods.md index 42dd51f..e3a976b 100644 --- a/api-design-guide/part-b/6-http-methods.md +++ b/api-design-guide/part-b/6-http-methods.md @@ -36,4 +36,4 @@ description: "Rules defining the meaning, safety, and idempotency guarantees of ## 6.7 Bulk mutation needs explicit selection <a href="#67-bulk-mutation-needs-explicit-selection" id="67-bulk-mutation-needs-explicit-selection"></a> -**[M+R]** A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (The 2026 audit found mutable audit logs and filter-less bulk update/delete that could rewrite or destroy an entire registry.) +**[M+R]** A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (Mutable audit logs and filter-less bulk update or delete operations that could rewrite or destroy an entire registry have both been observed in existing BB specifications.) diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md index 618c6cf..858cb3b 100644 --- a/api-design-guide/part-c/9-json-conventions-and-naming.md +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -5,7 +5,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec # 9. JSON conventions and naming {% hint style="info" %} -**Intent.** One naming style ecosystem-wide. The audit found camelCase, PascalCase, snake_case, and fields with literal spaces coexisting within single BBs. +**Intent.** One naming style ecosystem-wide. Existing BB specifications mix camelCase, PascalCase, snake_case, and fields with literal spaces, sometimes within a single BB. **Applies to:** Universal. {% endhint %} diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 1036cd2..5245e91 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -23,7 +23,7 @@ rules: surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 22-one-canonical-openapi-entrypoint - text: "In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface. The audit's legacy `api/swagger.yaml` and `api/swagger.json` names are not canonical under this guide." + text: "In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface. The legacy `api/swagger.yaml` and `api/swagger.json` names still used by some BBs are not canonical under this guide." open_questions: [] - id: "2.3" title: "No divergent OpenAPI copies" @@ -194,7 +194,7 @@ rules: surface: Universal page: part-a/4-documentation-requirements.md anchor: 44-accurate-operation-descriptions - text: "Operation `description` **MUST** describe what the operation actually does. (The audit found at least 9 BBs with cross-endpoint description mismatches from copy-paste.)" + text: "Operation `description` **MUST** describe what the operation actually does. (Copy-pasted descriptions that document a different endpoint than the one they sit on are a recurring problem in existing BB specifications.)" open_questions: [] - id: "4.5" title: "API surface inventory" @@ -356,7 +356,7 @@ rules: surface: OpenAPI page: part-b/6-http-methods.md anchor: 67-bulk-mutation-needs-explicit-selection - text: "A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (The 2026 audit found mutable audit logs and filter-less bulk update/delete that could rewrite or destroy an entire registry.)" + text: "A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (Mutable audit logs and filter-less bulk update or delete operations that could rewrite or destroy an entire registry have both been observed in existing BB specifications.)" open_questions: [] - id: "7.1" title: "200 for successful reads" diff --git a/api-design-guide/version-history.md b/api-design-guide/version-history.md index b253566..1939a69 100644 --- a/api-design-guide/version-history.md +++ b/api-design-guide/version-history.md @@ -48,4 +48,4 @@ This edition supersedes the circulated v0.1 document. It restructures the rulebo ## v0.1 (DRAFT, 2026-05-31) -Initial draft circulated to the GovStack committee for feedback: 164 numbered rules in sections 1–18 plus the lettered sections 2A and 15A, with three appendices (companion documents, open questions, normative references). Authored by Jeremi Joslin, drawing on the 2026 cross-BB audit of all 15 Building Blocks. +Initial draft circulated to the GovStack committee for feedback: 164 numbered rules in sections 1–18 plus the lettered sections 2A and 15A, with three appendices (companion documents, open questions, normative references). Authored by Jeremi Joslin, drawing on a review of the published Building Block API specifications. From e0131068ef72e86854c03914161ac524d69e5445 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:28:26 +0700 Subject: [PATCH 09/19] fix: qualify AsyncAPI 3.1.0 alongside 3.0.0 in 3.1 3.1 pinned asyncapi to exactly 3.0.0, rejecting 3.1.0, the current published AsyncAPI 3 version. Linting the golden AsyncAPI spec re-declared as 3.1.0 produces exactly one finding, govstack-3.1 itself: every other rule in the ruleset passes unchanged, so the pin was the only obstacle. 3.1 now mirrors 2.1's forward-qualification wording, naming the versions this guide and ruleset version qualify and requiring a later version to be qualified explicitly before use. The govstack-3.1 pass fixture moves to 3.1.0; 3.0.0 stays covered by tests/golden/asyncapi-golden.yaml. Also narrows govstack-18.2-openapi to oas3_1 and govstack-18.2-asyncapi to aas3. Both are substantive rules, not version gates, so the broad formats they declared did not match the house style used by every other rule of that kind. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/linter/coverage.yaml | 2 +- api-design-guide/linter/rulesets/s03.yaml | 9 +++++---- api-design-guide/linter/rulesets/s18.yaml | 4 ++-- .../linter/tests/fixtures/govstack-3.1/pass.yaml | 5 +++-- api-design-guide/part-a/3-asyncapi-document-standards.md | 2 +- api-design-guide/rules.yaml | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 2a87606..1456c60 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -58,7 +58,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-3.1] - note: "assert asyncapi == \"3.0.0\"" + note: "assert asyncapi is a qualified AsyncAPI 3 version (\"3.0.0\" or \"3.1.0\")" - id: "3.2" class: "M+R" status: driver diff --git a/api-design-guide/linter/rulesets/s03.yaml b/api-design-guide/linter/rulesets/s03.yaml index 3e27c07..8206ac0 100644 --- a/api-design-guide/linter/rulesets/s03.yaml +++ b/api-design-guide/linter/rulesets/s03.yaml @@ -12,10 +12,11 @@ functions: - schemaPropertyNames - s03-asyncOperation rules: - # 3.1 [M] — the spec MUST declare asyncapi: 3.0.0; earlier versions MUST NOT. - # formats [aas2, aas3] so a 2.x document is still told to move to 3.0.0. + # 3.1 [M] — the spec MUST declare an AsyncAPI 3 version qualified by this + # ruleset. 0.2.0-draft qualifies 3.0.0 and 3.1.0; 2.x and earlier MUST NOT. + # formats [aas2, aas3] so a 2.x document is still told to move to AsyncAPI 3. govstack-3.1: - description: "AsyncAPI version must be exactly 3.0.0 (guide 3.1, [M])." + description: "AsyncAPI version must be a qualified AsyncAPI 3 version, 3.0.0 or 3.1.0 (guide 3.1, [M])." message: "[3.1][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required severity: error @@ -29,7 +30,7 @@ rules: required: [asyncapi] properties: asyncapi: - const: "3.0.0" + enum: ["3.0.0", "3.1.0"] # 3.5 [M] — info MUST include title, version, description, contact. # Split per the suffix convention: presence here, SemVer below. diff --git a/api-design-guide/linter/rulesets/s18.yaml b/api-design-guide/linter/rulesets/s18.yaml index e5e115c..475dfec 100644 --- a/api-design-guide/linter/rulesets/s18.yaml +++ b/api-design-guide/linter/rulesets/s18.yaml @@ -46,7 +46,7 @@ rules: message: "[18.2][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel severity: error - formats: [oas3] + formats: [oas3_1] given: $ then: function: s18-versionMajorConsistency @@ -63,7 +63,7 @@ rules: message: "[18.2][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel severity: error - formats: [aas2, aas3] + formats: [aas3] given: $ then: function: s18-versionMajorConsistency diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml index 0a4d16f..1a54b64 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml @@ -1,5 +1,6 @@ -# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. -asyncapi: 3.0.0 +# Valid AsyncAPI 3.1.0 document: no §3 finding should fire. 3.0.0 is the other +# qualified version and is covered by tests/golden/asyncapi-golden.yaml. +asyncapi: 3.1.0 info: title: Identity Events version: 1.2.0 diff --git a/api-design-guide/part-a/3-asyncapi-document-standards.md b/api-design-guide/part-a/3-asyncapi-document-standards.md index 85e3a8e..97656fc 100644 --- a/api-design-guide/part-a/3-asyncapi-document-standards.md +++ b/api-design-guide/part-a/3-asyncapi-document-standards.md @@ -12,7 +12,7 @@ description: "Rules governing the canonical AsyncAPI document: version, location ## 3.1 AsyncAPI 3.0.0 required <a href="#31-asyncapi-300-required" id="31-asyncapi-300-required"></a> -**[M]** An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3.0 and **MUST** declare `asyncapi: 3.0.0`. Earlier versions **MUST NOT** be used for new GovStack event-driven surfaces. +**[M]** An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. ## 3.2 One canonical AsyncAPI entrypoint <a href="#32-one-canonical-asyncapi-entrypoint" id="32-one-canonical-asyncapi-entrypoint"></a> diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 5245e91..88a230e 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -86,7 +86,7 @@ rules: surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 31-asyncapi-300-required - text: "An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3.0 and **MUST** declare `asyncapi: 3.0.0`. Earlier versions **MUST NOT** be used for new GovStack event-driven surfaces." + text: "An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." open_questions: [] - id: "3.2" title: "One canonical AsyncAPI entrypoint" From 98d1146def5bf87988731ca1d5215a42df07986b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:29:42 +0700 Subject: [PATCH 10/19] fix: state 17.4 against the AsyncAPI 3 Parameter Object 17.4 required each channel parameter's "schema" to be documented. The AsyncAPI 3 Parameter Object has exactly default, description, enum, examples and location, with additionalProperties: false, in both 3.0.0 and 3.1.0. A parameter carrying a schema field is therefore not a valid AsyncAPI document, so the rule as written could not be satisfied. 17.4 now requires what the format can express: a non-empty description for routing semantics, enum for closed value sets, examples otherwise. The linter already enforced description as a stand-in; that is now a faithful implementation of the rule rather than a proxy, and the comments saying otherwise are updated. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/linter/coverage.yaml | 2 +- .../linter/functions/s17-channelParameters.js | 10 +++++----- api-design-guide/linter/rulesets/s17.yaml | 6 +++--- api-design-guide/part-d/17-asyncapi-channel-rules.md | 2 +- api-design-guide/rules.yaml | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 1456c60..015a31c 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -718,7 +718,7 @@ rules: class: "M+R" status: implemented spectral_rules: [govstack-17.4] - note: "every {param} in channel address declared under parameters with schema+description. AsyncAPI 3.0 parameters have no schema field; 'schema documented' proxied by required description." + note: "every {param} in channel address declared under parameters with a non-empty description. The rule's enum/examples clauses are [R]: a parameter's value set being closed is not detectable from the document." - id: "17.5" class: "M+R" status: partial-proxy diff --git a/api-design-guide/linter/functions/s17-channelParameters.js b/api-design-guide/linter/functions/s17-channelParameters.js index 7281661..0b57c42 100644 --- a/api-design-guide/linter/functions/s17-channelParameters.js +++ b/api-design-guide/linter/functions/s17-channelParameters.js @@ -10,10 +10,10 @@ import { isObject, isNonEmptyString } from './lib/util.js'; * - the declared parameter documents its routing semantics via a non-empty * `description`. * - * Note on "schema": the AsyncAPI 3.0 Parameter Object has no `schema` field - * (unlike AsyncAPI 2.x); a parameter's allowed values are expressed with `enum`. - * The guide's "its schema ... MUST be documented" is therefore proxied here by - * requiring a non-empty `description`; the value grammar is not further checked. + * The AsyncAPI 3 Parameter Object has no `schema` field (unlike AsyncAPI 2.x); + * a parameter's allowed values are expressed with `enum`. §17.4's enum and + * examples clauses are [R] and are not checked here: whether a parameter's + * value set is closed cannot be determined from the document. * * options: none. * @param {unknown} targetVal - a channel object. @@ -47,7 +47,7 @@ export default function s17ChannelParameters(targetVal, _options, context) { const def = params[name]; if (!isObject(def) || !isNonEmptyString(def.description)) { results.push({ - message: `channel parameter "${name}" must document its schema and routing semantics via a non-empty "description"`, + message: `channel parameter "${name}" must state its routing semantics in a non-empty "description"`, path: [...base, 'parameters', name], }); } diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml index a080716..91ed501 100644 --- a/api-design-guide/linter/rulesets/s17.yaml +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -53,9 +53,9 @@ rules: function: s17-channelIds # 17.4 [M+R] — every {param} in a channel address MUST be declared under the - # channel parameters object and documented (non-empty description). AsyncAPI - # 3.0 Parameter Objects have no `schema` field, so the guide's "schema ... MUST - # be documented" is proxied by requiring a description (see the function). + # channel parameters object and documented (non-empty description). The rule's + # enum-for-closed-sets and examples-for-open-values clauses are [R]: whether a + # parameter's value set is closed cannot be determined from the document. govstack-17.4: description: "Every {param} in a channel address must be declared under parameters and documented (guide 17.4, [M+R])." message: "[17.4][M+R] {{error}}" diff --git a/api-design-guide/part-d/17-asyncapi-channel-rules.md b/api-design-guide/part-d/17-asyncapi-channel-rules.md index fcab375..a1dfb45 100644 --- a/api-design-guide/part-d/17-asyncapi-channel-rules.md +++ b/api-design-guide/part-d/17-asyncapi-channel-rules.md @@ -24,7 +24,7 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.4 Declared channel parameters <a href="#174-declared-channel-parameters" id="174-declared-channel-parameters"></a> -**[M+R]** Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object, and its schema and routing semantics **MUST** be documented. +**[M+R]** Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object with a non-empty `description` stating its routing semantics. The AsyncAPI 3 Parameter Object carries no `schema` field: a parameter whose permitted values form a closed set **MUST** declare them with `enum`, and one whose values are open **SHOULD** carry `examples`. ## 17.5 No environment names in addresses <a href="#175-no-environment-names-in-addresses" id="175-no-environment-names-in-addresses"></a> diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 88a230e..35ba671 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -1270,11 +1270,11 @@ rules: - id: "17.4" title: "Declared channel parameters" class: M+R - strengths: ["MUST", "MAY"] + strengths: ["MUST", "SHOULD", "MAY"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 174-declared-channel-parameters - text: "Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object, and its schema and routing semantics **MUST** be documented." + text: "Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object with a non-empty `description` stating its routing semantics. The AsyncAPI 3 Parameter Object carries no `schema` field: a parameter whose permitted values form a closed set **MUST** declare them with `enum`, and one whose values are open **SHOULD** carry `examples`." open_questions: [] - id: "17.5" title: "No environment names in addresses" From 19fd76d497592b16628ee5af371c75229ff78977 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:33:22 +0700 Subject: [PATCH 11/19] fix: carve out of 9.7 the enum values other rules mandate 9.7 required every enum value to be SCREAMING_SNAKE_CASE, which forbade six value sets the guide itself mandates: 17.11 delivery guarantees, 17.13 capability values, 11.5 error codes, 16.3 event types, 10.9 BCP 47 tags and 12.7 sort keys. A BB modelling any of them as an enum could not satisfy both rules. 9.7 now scopes the requirement to BB-defined states and lists the carve-outs with links. The linter follows for the five that are distinguishable by shape. Sort keys are lowerCamelCase field names, indistinguishable from the mis-cased state names the rule must keep catching, so they stay a false positive recorded in the rule comment and the coverage note rather than a hole in the pattern. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/linter/coverage.yaml | 2 +- .../linter/functions/s09-enumCasing.js | 33 +++++++++++++++---- api-design-guide/linter/rulesets/s09.yaml | 8 +++-- .../tests/fixtures/govstack-9.7/pass.yaml | 23 +++++++++++++ .../part-c/9-json-conventions-and-naming.md | 2 +- api-design-guide/rules.yaml | 4 +-- 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 015a31c..542c715 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -383,7 +383,7 @@ rules: class: "M" status: partial-proxy spectral_rules: [govstack-9.7] - note: "proxy: enum string values SCREAMING_SNAKE_CASE with known exceptions (ISO codes, health status)" + note: "proxy: enum string values SCREAMING_SNAKE_CASE minus the rule's carve-outs (BCP 47 tags, reverse-DNS error codes and event types, x-govstack-* vocabularies, health status). §12.7 sort keys are a known false positive." - id: "9.8" class: "M" status: implemented diff --git a/api-design-guide/linter/functions/s09-enumCasing.js b/api-design-guide/linter/functions/s09-enumCasing.js index 8dc3a0b..d535edc 100644 --- a/api-design-guide/linter/functions/s09-enumCasing.js +++ b/api-design-guide/linter/functions/s09-enumCasing.js @@ -3,18 +3,23 @@ import { isObject, toRegExp } from './lib/util.js'; /** * s09-enumCasing — PROXY for §9.7 (SCREAMING_SNAKE_CASE enum values). Flags - * string `enum` members that are not SCREAMING_SNAKE_CASE, with documented - * exceptions for values that idiomatically stay lowercase: + * string `enum` members that are not SCREAMING_SNAKE_CASE, minus the carve-outs + * §9.7 lists for values whose form is fixed by another rule or an external + * standard: * - * - ISO-style short codes / locales (language `en`, `fra`, locale `en-US`), - * matched by `allowPattern`; - * - health / status vocab (`pass`, `fail`, `warn`, `up`, `down`, ...), listed - * in `allowValues`. + * - BCP 47 language tags and reverse-DNS identifiers (error codes, event + * types), matched by `allowPattern`; + * - the health-status vocabulary and the GovStack x-govstack-* extension + * vocabularies, listed in `allowValues`. * * Because those exceptions are pattern-based, a lowercase enum that merely looks * ISO-like (any 2-3 letter token) is not flagged: the proxy trades a few misses * for far fewer false positives. Non-string enum members are ignored. * + * Known false positive: §12.7 sort keys are field names in lowerCamelCase, which + * is indistinguishable from a mis-cased state name, so a sort-key enum declared + * under components.schemas is flagged. Inline sort parameters are not visited. + * * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. * * options: @@ -28,8 +33,13 @@ import { isObject, toRegExp } from './lib/util.js'; * @returns {{message:string, path:(string|number)[]}[]|undefined} */ const SCREAMING = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/; -const DEFAULT_ALLOW_PATTERN = '^[a-z]{2,3}([-_][A-Za-z0-9]{2,4})?$'; +// Two shapes §9.7 carves out and that are distinguishable from a mis-cased +// state name: BCP 47 language tags (§10.9) and reverse-DNS identifiers built to +// a shape this guide defines (error codes §11.5, event types §16.3). +const DEFAULT_ALLOW_PATTERN = + '^([a-z]{2,3}([-_][A-Za-z0-9]{2,8})*|[a-z][a-zA-Z0-9]*(\\.[a-zA-Z0-9-]+)+)$'; const DEFAULT_ALLOW_VALUES = [ + // §5.9 health-status vocabulary and the operational synonyms around it. 'pass', 'fail', 'warn', @@ -39,6 +49,15 @@ const DEFAULT_ALLOW_VALUES = [ 'healthy', 'unhealthy', 'degraded', + // §17.11 delivery guarantees. + 'atMostOnce', + 'atLeastOnce', + 'effectivelyOnce', + // §17.13 delivery-management capabilities, and §17.12's explicit "no ordering". + 'supported', + 'unsupported', + 'notApplicable', + 'none', ]; export default function s09EnumCasing(targetVal, options, context) { diff --git a/api-design-guide/linter/rulesets/s09.yaml b/api-design-guide/linter/rulesets/s09.yaml index fc4ced2..6105565 100644 --- a/api-design-guide/linter/rulesets/s09.yaml +++ b/api-design-guide/linter/rulesets/s09.yaml @@ -103,9 +103,11 @@ rules: forbidPattern: '\s|[^\x00-\x7F]' # 9.7 [M] — PROXY (bucket B), MUST -> warn. Flags string enum members that are - # not SCREAMING_SNAKE_CASE. Does NOT flag ISO-style short lowercase codes - # (language/locale) or health-status vocab, which are exempted; a few genuine - # violations that happen to look ISO-like are therefore missed. + # not SCREAMING_SNAKE_CASE, minus the carve-outs the rule lists (BCP 47 tags, + # reverse-DNS error codes and event types, x-govstack-* vocabularies, health + # status). A few genuine violations that happen to look ISO-like are missed, + # and §12.7 sort keys are lowerCamelCase field names indistinguishable from a + # mis-cased state name, so a sort-key schema is a known false positive. govstack-9.7: description: "Enum values must be SCREAMING_SNAKE_CASE (guide 9.7, [M], proxy)." message: "[9.7][M] {{error}}" diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml index 384c0da..d6d7f19 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml @@ -10,3 +10,26 @@ components: enum: - ACTIVE - PENDING_REVIEW + # §9.7 carve-outs: values whose form is fixed by another rule or an + # external standard keep their own casing and must not be flagged. + HealthStatus: + type: string + enum: [pass, fail, warn] + DeliveryGuarantee: + type: string + enum: [atMostOnce, atLeastOnce, effectivelyOnce] + DeliveryCapability: + type: string + enum: [supported, unsupported, notApplicable] + ErrorCode: + type: string + enum: [global.govstack.identity.personNotFound] + EventType: + type: string + enum: [global.govstack.registration.record.created] + LanguageTag: + type: string + enum: [en, en-US, zh-Hans-CN] + CurrencyCode: + type: string + enum: [USD, EUR] diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md index 858cb3b..3ea2e60 100644 --- a/api-design-guide/part-c/9-json-conventions-and-naming.md +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -36,7 +36,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.7 Screaming snake case enum values <a href="#97-screaming-snake-case-enum-values" id="97-screaming-snake-case-enum-values"></a> -**[M]** Enum values **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). +**[M]** Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), the GovStack `x-govstack-*` extension vocabularies ([§17.11](../part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees), [§17.13](../part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and the health-status vocabulary of [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint). ## 9.8 Forward-compatible schemas <a href="#98-forward-compatible-schemas" id="98-forward-compatible-schemas"></a> diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 35ba671..2d2b289 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -667,11 +667,11 @@ rules: - id: "9.7" title: "Screaming snake case enum values" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 97-screaming-snake-case-enum-values - text: "Enum values **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`)." + text: "Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per §11.5, event types per §16.3, sort keys per §12.7), the GovStack `x-govstack-*` extension vocabularies (§17.11, §17.13), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and the health-status vocabulary of §5.9." open_questions: [] - id: "9.8" title: "Forward-compatible schemas" From 4ab43658eab75c80b27d004262fb22aea8c4c582 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:34:22 +0700 Subject: [PATCH 12/19] fix: exempt the 5.9 operational endpoints from 5.1 and 18.2 5.1 required a /v{N}/ prefix on every path and 18.2 required a major version in every path key, but 5.9 mandates an unversioned /health. A BB satisfying 5.9 broke both. The linter already carved out /health and /ready with a comment saying why, and 18.2's function only ever checked paths that already carry a prefix, so the guide text was the only thing asserting the contradiction. Both rules now state the exemption. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/part-b/5-url-structure-and-versioning.md | 2 +- api-design-guide/part-d/18-compatibility-and-lifecycle.md | 2 +- api-design-guide/rules.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api-design-guide/part-b/5-url-structure-and-versioning.md b/api-design-guide/part-b/5-url-structure-and-versioning.md index e6f9766..c5c843c 100644 --- a/api-design-guide/part-b/5-url-structure-and-versioning.md +++ b/api-design-guide/part-b/5-url-structure-and-versioning.md @@ -12,7 +12,7 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.1 Major version in the path <a href="#51-major-version-in-the-path" id="51-major-version-in-the-path"></a> -**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). [`[OPEN-4-A]`](../appendix/b-open-questions.md) +**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The unversioned operational endpoints of [§5.9](#59-unversioned-health-endpoint) (`/health`, and `/ready` where exposed) are the only exception. [`[OPEN-4-A]`](../appendix/b-open-questions.md) ## 5.2 Plural noun resources <a href="#52-plural-noun-resources" id="52-plural-noun-resources"></a> diff --git a/api-design-guide/part-d/18-compatibility-and-lifecycle.md b/api-design-guide/part-d/18-compatibility-and-lifecycle.md index 8043901..8bf6c14 100644 --- a/api-design-guide/part-d/18-compatibility-and-lifecycle.md +++ b/api-design-guide/part-d/18-compatibility-and-lifecycle.md @@ -16,7 +16,7 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.2 Major version in path or channel <a href="#182-major-version-in-path-or-channel" id="182-major-version-in-path-or-channel"></a> -**[M]** A major version increment **MUST** be reflected in every OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it. +**[M]** A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it. ## 18.3 Backward-compatible minor changes <a href="#183-backward-compatible-minor-changes" id="183-backward-compatible-minor-changes"></a> diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 2d2b289..97e2259 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -221,7 +221,7 @@ rules: surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 51-major-version-in-the-path - text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). `[OPEN-4-A]`" + text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The unversioned operational endpoints of §5.9 (`/health`, and `/ready` where exposed) are the only exception. `[OPEN-4-A]`" open_questions: ["OPEN-4-A"] - id: "5.2" title: "Plural noun resources" @@ -1436,7 +1436,7 @@ rules: surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 182-major-version-in-path-or-channel - text: "A major version increment **MUST** be reflected in every OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by §17.2 or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it." + text: "A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of §5.9 carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by §17.2 or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it." open_questions: [] - id: "18.3" title: "Backward-compatible minor changes" From 9d1ab6df7ac0066853a4838b3830c820e4ce752b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:43:05 +0700 Subject: [PATCH 13/19] fix: reconcile guide rules with the immutable cross-functional requirements The guide restated three CFR-derived constraints more loosely than the CFRs themselves state them. A specification that extends the CFRs may tighten or elaborate a requirement but must not weaken one, and all three of these are classified IMMUTABLE, so the looser wording was not a permissible variation. - 1.3 replaces the "precedence is unsettled" punt with the framework's actual rule, and points at the citation convention used below. - 10.2 requires UTC with the Z designator rather than any RFC 3339 offset, per govstack-cfr-data#req-2. - 13.7 requires negotiated TLS 1.3 or higher, per govstack-cfr-security#req-1. - 10.11 is new: UTF-8 text encoding, per govstack-cfr-data#req-1, with govstack-10.11-openapi and govstack-10.11-asyncapi enforcing that no declared media type or contentType names another charset. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/1-introduction.md | 4 +- api-design-guide/all-rules.md | 1 + api-design-guide/linter/coverage.yaml | 7 ++- api-design-guide/linter/rulesets/s10.yaml | 49 ++++++++++++++++++- .../govstack-10.11-asyncapi/fail.yaml | 21 ++++++++ .../govstack-10.11-asyncapi/pass.yaml | 22 +++++++++ .../fixtures/govstack-10.11-openapi/fail.yaml | 16 ++++++ .../fixtures/govstack-10.11-openapi/pass.yaml | 19 +++++++ .../part-c/10-data-types-and-formats.md | 6 ++- .../13-authentication-and-authorisation.md | 2 +- api-design-guide/rules.yaml | 17 +++++-- 11 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/pass.yaml diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md index 18cccd6..d2131e8 100644 --- a/api-design-guide/1-introduction.md +++ b/api-design-guide/1-introduction.md @@ -49,7 +49,9 @@ A BB MAY additionally expose surfaces under other industry standards (for exampl ## 1.3 Relationship to existing GovStack documents <a href="#13-relationship-to-existing-govstack-documents" id="13-relationship-to-existing-govstack-documents"></a> -Where this guide overlaps with existing GovStack requirements or BB-specific conventions, precedence must be settled through ratification and reconciliation with the existing GovStack Specification Framework and CFR compliance model. A full reconciliation matrix will accompany v1.0. +This guide is a GovStack specification that extends the Cross-Functional Requirements. Under the GovStack Specification Framework an extending specification may tighten or elaborate a cross-functional requirement but **MUST NOT** contradict or weaken one, and a requirement classified IMMUTABLE cannot be altered at all. Where a rule here inherits a cross-functional requirement it cites the requirement identifier, for example `govstack-cfr-data#req-2` in [§10.2](part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps), so that the inheritance and its immutability are visible at the point of use. + +Where this guide overlaps with existing GovStack requirements or BB-specific conventions in ways that rule does not settle, precedence must be settled through ratification and reconciliation with the existing GovStack Specification Framework and CFR compliance model. A full reconciliation matrix will accompany v1.0. ## 1.4 Audience <a href="#14-audience" id="14-audience"></a> diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md index b20716c..65667ac 100644 --- a/api-design-guide/all-rules.md +++ b/api-design-guide/all-rules.md @@ -138,6 +138,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [10.8](part-c/10-data-types-and-formats.md#108-iso-3166-1-country-codes) | M+R | MUST | Universal | ISO 3166-1 country codes | | [10.9](part-c/10-data-types-and-formats.md#109-bcp-47-language-codes) | M+R | MUST | Universal | BCP 47 language codes | | [10.10](part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes) | M+R | MUST | Universal | ISO 4217 currency codes | +| [10.11](part-c/10-data-types-and-formats.md#1011-utf-8-text-encoding) | M | MUST | Universal | UTF-8 text encoding | ## 11. Errors diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 542c715..0937e02 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -454,6 +454,11 @@ rules: status: partial-proxy spectral_rules: [govstack-10.10] note: "proxy: currency fields match ^[A-Z]{3}$" + - id: "10.11" + class: "M" + status: implemented + spectral_rules: [govstack-10.11-openapi, govstack-10.11-asyncapi] + note: "no declared media type or contentType sets charset to an encoding other than UTF-8. Payload bytes themselves are outside a document linter's reach." - id: "11.1" class: "M" status: implemented @@ -578,7 +583,7 @@ rules: class: "M+R" status: human spectral_rules: [] - note: "externally reachable surface classification, equivalent AsyncAPI transport protection, and deployment TLS configuration require protocol and runtime review; §2.6 separately checks declared OpenAPI server URLs" + note: "externally reachable surface classification, equivalent AsyncAPI transport protection, negotiated TLS version, and deployment TLS configuration require protocol and runtime review; §2.6 separately checks declared OpenAPI server URLs" - id: "14.1" class: "M+R" status: partial-proxy diff --git a/api-design-guide/linter/rulesets/s10.yaml b/api-design-guide/linter/rulesets/s10.yaml index 49741f7..e1a9690 100644 --- a/api-design-guide/linter/rulesets/s10.yaml +++ b/api-design-guide/linter/rulesets/s10.yaml @@ -1,9 +1,9 @@ # Rules for §10 data types and formats — generated from the guide; see coverage.yaml # -# Implements guide rules 10.1-10.10. Source text: ../../rules.yaml. +# Implements guide rules 10.1-10.11. Source text: ../../rules.yaml. # Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. # -# All ten rules here are bucket-B PROXIES: the guide's `surface` for every 10.x +# Rules 10.1-10.10 are bucket-B PROXIES: the guide's `surface` for every 10.x # rule is "Universal" (OpenAPI request/response bodies AND AsyncAPI message # payloads), but a linter can only see property NAMES and their declared JSON # Schema keywords, not what the field actually means or what data flows through @@ -13,6 +13,10 @@ # every proxy here runs one notch below its class's strength: all ten rules are # MUST-strength in rules.yaml, so all run at `warn` (not `error`). # +# 10.11 is NOT a proxy and is not covered by that paragraph: a charset parameter +# on a declared media type is specification text a linter can read directly, so +# it is checked exactly and runs at `error`. +# # Shared `given`: every rule scans the same set of schema-bearing locations, # via the `DataSchemas` alias below — components.schemas / inline OpenAPI # request+response body schemas for oas3_1 docs, components.messages/channel @@ -22,6 +26,7 @@ functionsDir: "../functions" functions: - schemaFieldFormat + - mediaTypeExpected aliases: DataSchemas: description: "Every JSON Schema node that may declare data-type fields: OpenAPI reusable/inline body schemas, or AsyncAPI message payloads." @@ -249,3 +254,43 @@ rules: functionOptions: namePattern: '(^currency|Currency)' require: { type: string, pattern: '^[A-Z]{3}$' } + + # 10.11 [M] — OpenAPI half. No declared media type may carry a charset + # parameter naming an encoding other than UTF-8. The character classes spell + # the match case-insensitively because functionOptions patterns take no flags. + # Payload bytes are a runtime property and are not checked here; this rule + # catches only the declared contract. + govstack-10.11-openapi: + description: "No declared media type may set charset to an encoding other than UTF-8 (guide 10.11, [M])." + message: "[10.11][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#1011-utf-8-text-encoding + severity: error + formats: [oas3_1] + given: + - $.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content + - $.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content + - $.webhooks[*][get,put,post,delete,options,head,patch,trace].requestBody.content + - $.webhooks[*][get,put,post,delete,options,head,patch,trace].responses[*].content + - $.components.requestBodies[*].content + - $.components.responses[*].content + then: + function: mediaTypeExpected + functionOptions: + forbid: '[Cc][Hh][Aa][Rr][Ss][Ee][Tt]\s*=\s*"?(?![Uu][Tt][Ff]-8)' + + # 10.11 [M] — AsyncAPI half. Same constraint on the contentType strings, which + # are plain values rather than map keys. + govstack-10.11-asyncapi: + description: "No declared contentType may set charset to an encoding other than UTF-8 (guide 10.11, [M])." + message: "[10.11][M] contentType must not set charset to an encoding other than UTF-8 (§10.11)." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#1011-utf-8-text-encoding + severity: error + formats: [aas3] + given: + - $.defaultContentType + - $.components.messages[*].contentType + - $.channels[*].messages[*].contentType + then: + function: pattern + functionOptions: + notMatch: '[Cc][Hh][Aa][Rr][Ss][Ee][Tt]\s*=\s*"?(?![Uu][Tt][Ff]-8)' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/fail.yaml new file mode 100644 index 0000000..66a1f94 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/fail.yaml @@ -0,0 +1,21 @@ +asyncapi: 3.0.0 +info: + title: Section 10.11 fail + version: 1.0.0 + description: contentType names an encoding other than UTF-8. + contact: + name: GovStack Identity BB + url: https://example.org/contact +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + contentType: application/json; charset=iso-8859-1 + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/pass.yaml new file mode 100644 index 0000000..799a35f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/pass.yaml @@ -0,0 +1,22 @@ +asyncapi: 3.0.0 +info: + title: Section 10.11 pass + version: 1.0.0 + description: contentType is plain JSON, and an explicit utf-8 charset is allowed. + contact: + name: GovStack Identity BB + url: https://example.org/contact +defaultContentType: application/json +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + contentType: application/json; charset=utf-8 + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/fail.yaml new file mode 100644 index 0000000..d330e64 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/fail.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: Section 10.11 fail + version: 1.0.0 +paths: + /v1/records: + get: + operationId: listRecords + responses: + '200': + description: A page of records. + content: + # charset names an encoding other than UTF-8. + application/json; charset=iso-8859-1: + schema: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/pass.yaml new file mode 100644 index 0000000..e32bba5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/pass.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Section 10.11 pass + version: 1.0.0 +paths: + /v1/records: + get: + operationId: listRecords + responses: + '200': + description: A page of records. + content: + # No charset parameter, and an explicit utf-8 one: both allowed. + application/json: + schema: + type: object + text/csv; charset=utf-8: + schema: + type: string diff --git a/api-design-guide/part-c/10-data-types-and-formats.md b/api-design-guide/part-c/10-data-types-and-formats.md index 916c1ad..eaa67d9 100644 --- a/api-design-guide/part-c/10-data-types-and-formats.md +++ b/api-design-guide/part-c/10-data-types-and-formats.md @@ -16,7 +16,7 @@ description: "Canonical representations for identifiers, dates, money, phone num ## 10.2 RFC 3339 timestamps <a href="#102-rfc-3339-timestamps" id="102-rfc-3339-timestamps"></a> -**[M]** Timestamps **MUST** be RFC 3339 with timezone, declared as `format: date-time`. +**[M]** Timestamps **MUST** be RFC 3339 in UTC, serialized with the `Z` designator and declared as `format: date-time`. A non-UTC offset **MUST NOT** be used in an API payload; where a local time zone is significant to the consumer, it is carried in a separate field alongside the UTC value. UTC-only is inherited from `govstack-cfr-data#req-2` and cannot be relaxed by a BB specification. ## 10.3 RFC 3339 calendar dates <a href="#103-rfc-3339-calendar-dates" id="103-rfc-3339-calendar-dates"></a> @@ -49,3 +49,7 @@ description: "Canonical representations for identifiers, dates, money, phone num ## 10.10 ISO 4217 currency codes <a href="#1010-iso-4217-currency-codes" id="1010-iso-4217-currency-codes"></a> **[M+R]** Currency codes **MUST** be ISO 4217. + +## 10.11 UTF-8 text encoding <a href="#1011-utf-8-text-encoding" id="1011-utf-8-text-encoding"></a> + +**[M]** Text in API payloads **MUST** be UTF-8. A media type declared anywhere in the specification **MUST NOT** carry a `charset` parameter naming any other encoding. `charset=utf-8` **MAY** be stated explicitly, though it is redundant on JSON media types, whose encoding RFC 8259 already fixes at UTF-8. This is inherited from `govstack-cfr-data#req-1` and cannot be relaxed by a BB specification. diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md index f643149..0c98726 100644 --- a/api-design-guide/part-d/13-authentication-and-authorisation.md +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -40,4 +40,4 @@ Cross-service propagation of end-user consent or authorisation context (for exam ## 13.7 Protected transport <a href="#137-protected-transport" id="137-protected-transport"></a> -**[M+R]** Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion. +**[M+R]** Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion. diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 97e2259..89b40a9 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -5,7 +5,7 @@ # The same `#anchor` fragment resolves on both GitHub and GitBook. guide: GovStack Cross-BB API Design Guide version: 0.2.0-draft -rule_count: 171 +rule_count: 172 rules: - id: "2.1" title: "OpenAPI 3.1 required" @@ -721,11 +721,11 @@ rules: - id: "10.2" title: "RFC 3339 timestamps" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-c/10-data-types-and-formats.md anchor: 102-rfc-3339-timestamps - text: "Timestamps **MUST** be RFC 3339 with timezone, declared as `format: date-time`." + text: "Timestamps **MUST** be RFC 3339 in UTC, serialized with the `Z` designator and declared as `format: date-time`. A non-UTC offset **MUST NOT** be used in an API payload; where a local time zone is significant to the consumer, it is carried in a separate field alongside the UTC value. UTC-only is inherited from `govstack-cfr-data#req-2` and cannot be relaxed by a BB specification." open_questions: [] - id: "10.3" title: "RFC 3339 calendar dates" @@ -799,6 +799,15 @@ rules: anchor: 1010-iso-4217-currency-codes text: "Currency codes **MUST** be ISO 4217." open_questions: [] +- id: "10.11" + title: "UTF-8 text encoding" + class: M + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 1011-utf-8-text-encoding + text: "Text in API payloads **MUST** be UTF-8. A media type declared anywhere in the specification **MUST NOT** carry a `charset` parameter naming any other encoding. `charset=utf-8` **MAY** be stated explicitly, though it is redundant on JSON media types, whose encoding RFC 8259 already fixes at UTF-8. This is inherited from `govstack-cfr-data#req-1` and cannot be relaxed by a BB specification." + open_questions: [] - id: "11.1" title: "RFC 9457 problem details" class: M @@ -1022,7 +1031,7 @@ rules: surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 137-protected-transport - text: "Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion." + text: "Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion." open_questions: [] - id: "14.1" title: "Idempotency-Key on non-idempotent POSTs" From c158081bc390ddca6108c5597689b8564de83f9a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:45:21 +0700 Subject: [PATCH 14/19] docs: point the shipped rulesets at conventions an adopter can reach Seventeen ruleset fragments cited <SCRATCHPAD>/linter-conventions.md, a document that exists nowhere in the repository. The conventions it described are the ones the linter README already documents, so the references now point there. ruleset.yaml claimed s03-s20 were empty skeletons and named the guide version without its -draft suffix; both were true during the initial build and neither is now. strict.yaml loses the same build-scaffolding prose. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/linter/ruleset.yaml | 9 ++++----- api-design-guide/linter/rulesets/s02.yaml | 2 +- api-design-guide/linter/rulesets/s03.yaml | 2 +- api-design-guide/linter/rulesets/s04.yaml | 2 +- api-design-guide/linter/rulesets/s05-strict.yaml | 2 +- api-design-guide/linter/rulesets/s05.yaml | 2 +- api-design-guide/linter/rulesets/s06.yaml | 2 +- api-design-guide/linter/rulesets/s07.yaml | 2 +- api-design-guide/linter/rulesets/s08-strict.yaml | 4 ++-- api-design-guide/linter/rulesets/s08.yaml | 2 +- api-design-guide/linter/rulesets/s09.yaml | 2 +- api-design-guide/linter/rulesets/s10.yaml | 2 +- api-design-guide/linter/rulesets/s13.yaml | 2 +- api-design-guide/linter/rulesets/s15.yaml | 2 +- api-design-guide/linter/rulesets/s16.yaml | 2 +- api-design-guide/linter/rulesets/s17-strict.yaml | 2 +- api-design-guide/linter/rulesets/s17.yaml | 2 +- api-design-guide/linter/rulesets/s18.yaml | 2 +- api-design-guide/linter/strict.yaml | 5 ++--- 19 files changed, 24 insertions(+), 26 deletions(-) diff --git a/api-design-guide/linter/ruleset.yaml b/api-design-guide/linter/ruleset.yaml index 0f00567..8e0077a 100644 --- a/api-design-guide/linter/ruleset.yaml +++ b/api-design-guide/linter/ruleset.yaml @@ -1,7 +1,7 @@ # GovStack API Design Guide — Spectral ruleset (entry point) # ============================================================ # This ruleset mechanically enforces the GovStack Cross-BB API Design Guide. -# Guide version implemented: 0.2.0 +# Guide version implemented: 0.2.0-draft # Rule catalogue (source of truth): ../rules.yaml # Coverage contract (rule -> status -> spectral rules): ./coverage.yaml # @@ -16,10 +16,9 @@ # npx spectral lint -r strict.yaml path/to/openapi.yaml # + opt-in strict heuristics # npm test # fixtures + unit + harness # -# Fragment status: s02 is implemented (proof-of-concept). s03–s20 are valid -# empty skeletons, populated by the per-section work. ALL fragments are listed -# below already so the bundle loads today and section agents never edit this -# file. +# Fragment sNN.yaml carries the machine-checkable rules for guide section NN. +# Not every guide rule has one: coverage.yaml records, per guide rule, whether +# it is enforced directly, enforced by proxy, or left to human review. extends: - ./rulesets/s02.yaml - ./rulesets/s03.yaml diff --git a/api-design-guide/linter/rulesets/s02.yaml b/api-design-guide/linter/rulesets/s02.yaml index 64e622c..f2dfc2d 100644 --- a/api-design-guide/linter/rulesets/s02.yaml +++ b/api-design-guide/linter/rulesets/s02.yaml @@ -1,7 +1,7 @@ # Rules for §2 OpenAPI document standards — proof-of-concept section. # # Implements guide rules 2.1, 2.5, 2.6, 2.7. Source text: ../../rules.yaml. -# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. # # Custom functions: this fragment declares functionsDir + functions so the # bundler (programmatic) and the CLI both resolve valuePattern from diff --git a/api-design-guide/linter/rulesets/s03.yaml b/api-design-guide/linter/rulesets/s03.yaml index 8206ac0..7806bb2 100644 --- a/api-design-guide/linter/rulesets/s03.yaml +++ b/api-design-guide/linter/rulesets/s03.yaml @@ -1,7 +1,7 @@ # Rules for §3 AsyncAPI document standards — generated from the guide; see coverage.yaml. # # Implements guide rules 3.1, 3.5, 3.6, 3.7 (implemented) and 3.9 (partial-proxy). -# Source text: ../../rules.yaml. Severity + formats policy: <SCRATCHPAD>/linter-conventions.md. +# Source text: ../../rules.yaml. Severity + formats policy: ../README.md#rule-naming-and-severities. # 3.2/3.3/3.4 are driver-status (file-tree/validator) and 3.8 is needs-context — not here. # # Custom functions: this fragment declares functionsDir + functions so both the diff --git a/api-design-guide/linter/rulesets/s04.yaml b/api-design-guide/linter/rulesets/s04.yaml index a0fdcae..9f9492b 100644 --- a/api-design-guide/linter/rulesets/s04.yaml +++ b/api-design-guide/linter/rulesets/s04.yaml @@ -1,7 +1,7 @@ # Rules for §4 documentation and descriptions — generated from the guide; see coverage.yaml # # Implements guide rules 4.1, 4.2, 4.3. Source text: ../../rules.yaml. -# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. # 4.4 is STRICT-ONLY and lives in s04-strict.yaml instead. # # §4 surface is "Universal": every rule here fires on both OpenAPI and diff --git a/api-design-guide/linter/rulesets/s05-strict.yaml b/api-design-guide/linter/rulesets/s05-strict.yaml index 0cce2d0..46384ee 100644 --- a/api-design-guide/linter/rulesets/s05-strict.yaml +++ b/api-design-guide/linter/rulesets/s05-strict.yaml @@ -1,6 +1,6 @@ # STRICT-only rules for §5 — noisy lexical heuristics, opt-in via # ../strict.yaml. All at severity `warn` per -# <SCRATCHPAD>/linter-conventions.md, regardless of the guide rule's own +# ../README.md#rule-naming-and-severities, regardless of the guide rule's own # strength. Source text: ../../rules.yaml. functionsDir: "../functions" functions: diff --git a/api-design-guide/linter/rulesets/s05.yaml b/api-design-guide/linter/rulesets/s05.yaml index cf01c19..a59f19c 100644 --- a/api-design-guide/linter/rulesets/s05.yaml +++ b/api-design-guide/linter/rulesets/s05.yaml @@ -3,7 +3,7 @@ # Implements guide rules 5.1, 5.3, 5.4, 5.6, 5.9 (implemented) and 5.5 # (partial-proxy). Strict-only siblings 5.2, 5.7, 5.8 live in # rulesets/s05-strict.yaml. Severity policy and formats follow -# <SCRATCHPAD>/linter-conventions.md. +# ../README.md#rule-naming-and-severities. functionsDir: "../functions" functions: - pathSegments diff --git a/api-design-guide/linter/rulesets/s06.yaml b/api-design-guide/linter/rulesets/s06.yaml index 7bb4a67..99cab2a 100644 --- a/api-design-guide/linter/rulesets/s06.yaml +++ b/api-design-guide/linter/rulesets/s06.yaml @@ -1,7 +1,7 @@ # Rules for §6 HTTP methods — generated from the guide; see coverage.yaml # # Implements guide rules 6.1, 6.4, 6.5, 6.6, 6.7. Source text: ../../rules.yaml. -# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. # surface: OpenAPI for all of §6 (these are HTTP-method semantics; AsyncAPI # has no GET/PUT/PATCH/DELETE/POST), so every rule uses formats: [oas3_1]. functionsDir: "../functions" diff --git a/api-design-guide/linter/rulesets/s07.yaml b/api-design-guide/linter/rulesets/s07.yaml index 01c07cf..308c610 100644 --- a/api-design-guide/linter/rulesets/s07.yaml +++ b/api-design-guide/linter/rulesets/s07.yaml @@ -5,7 +5,7 @@ # checks) and 7.14, 7.16, 7.17 as partial-proxy (bucket B: severity notched # one step below the rule's strongest normative keyword; each carries a # comment on what it does NOT verify). Source text: ../../rules.yaml. -# Severity/formats policy: <SCRATCHPAD>/linter-conventions.md. +# Severity/formats policy: ../README.md#rule-naming-and-severities. functionsDir: "../functions" functions: - responseHeaderRequired diff --git a/api-design-guide/linter/rulesets/s08-strict.yaml b/api-design-guide/linter/rulesets/s08-strict.yaml index 106576b..0661bdb 100644 --- a/api-design-guide/linter/rulesets/s08-strict.yaml +++ b/api-design-guide/linter/rulesets/s08-strict.yaml @@ -1,7 +1,7 @@ # STRICT-only rules for §8 — implements guide rule 8.6. # -# Ships only via ../strict.yaml (opt-in). See ../coverage.yaml and the -# fragment format in <SCRATCHPAD>/linter-conventions.md. +# Ships only via ../strict.yaml (opt-in). See ../coverage.yaml and +# ../README.md#rule-naming-and-severities. functionsDir: "../functions" functions: - s08-personalDataInUrl diff --git a/api-design-guide/linter/rulesets/s08.yaml b/api-design-guide/linter/rulesets/s08.yaml index 7ea5859..f9ede83 100644 --- a/api-design-guide/linter/rulesets/s08.yaml +++ b/api-design-guide/linter/rulesets/s08.yaml @@ -1,7 +1,7 @@ # Rules for §8 HTTP headers — implements guide rules 8.1-8.5, 8.7. # # 8.6 is STRICT-ONLY; see ./s08-strict.yaml. Source text: ../../rules.yaml. -# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. # # Custom functions: none of the shared library functions (functions/README.md) # combine "operation parameters" with "responses" or scan header names across diff --git a/api-design-guide/linter/rulesets/s09.yaml b/api-design-guide/linter/rulesets/s09.yaml index 6105565..b9554ee 100644 --- a/api-design-guide/linter/rulesets/s09.yaml +++ b/api-design-guide/linter/rulesets/s09.yaml @@ -2,7 +2,7 @@ # # Implements guide rules 9.1-9.5, 9.7-9.9, 9.11 (9.6 and 9.10 are STRICT-only, # in rulesets/s09-strict.yaml). Source text: ../../rules.yaml. Severity and -# formats follow <SCRATCHPAD>/linter-conventions.md. +# formats follow ../README.md#rule-naming-and-severities. # # Bucket-B proxies (9.1, 9.3, 9.4, 9.7, 9.9, 9.11) run one severity notch below # the rule's strength and each carry a comment saying what they do NOT verify. diff --git a/api-design-guide/linter/rulesets/s10.yaml b/api-design-guide/linter/rulesets/s10.yaml index e1a9690..fa7f631 100644 --- a/api-design-guide/linter/rulesets/s10.yaml +++ b/api-design-guide/linter/rulesets/s10.yaml @@ -1,7 +1,7 @@ # Rules for §10 data types and formats — generated from the guide; see coverage.yaml # # Implements guide rules 10.1-10.11. Source text: ../../rules.yaml. -# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. # # Rules 10.1-10.10 are bucket-B PROXIES: the guide's `surface` for every 10.x # rule is "Universal" (OpenAPI request/response bodies AND AsyncAPI message diff --git a/api-design-guide/linter/rulesets/s13.yaml b/api-design-guide/linter/rulesets/s13.yaml index 7b1d457..a503d31 100644 --- a/api-design-guide/linter/rulesets/s13.yaml +++ b/api-design-guide/linter/rulesets/s13.yaml @@ -1,7 +1,7 @@ # Rules for §13 authentication and authorisation — generated from the guide. # # Implements 13.1, 13.5 (implemented) and 13.2, 13.3, 13.4, 13.6 (partial-proxy). -# Source text: ../../rules.yaml. Severity/formats per <SCRATCHPAD>/linter-conventions.md. +# Source text: ../../rules.yaml. Severity/formats per ../README.md#rule-naming-and-severities. # # Surface note: §13 rules are "Universal", but the mechanical checks here target # the OpenAPI surface (formats [oas3_1]). AsyncAPI security coverage/schemes are diff --git a/api-design-guide/linter/rulesets/s15.yaml b/api-design-guide/linter/rulesets/s15.yaml index 6307e08..a8bfe5f 100644 --- a/api-design-guide/linter/rulesets/s15.yaml +++ b/api-design-guide/linter/rulesets/s15.yaml @@ -1,7 +1,7 @@ # Rules for §15 asynchronous operations — generated from the guide. # # Implements 15.1, 15.5 (implemented) and 15.2, 15.3, 15.4 (partial-proxy). -# Source text: ../../rules.yaml. Severity/formats per <SCRATCHPAD>/linter-conventions.md. +# Source text: ../../rules.yaml. Severity/formats per ../README.md#rule-naming-and-severities. functionsDir: "../functions" functions: - responseHeaderRequired diff --git a/api-design-guide/linter/rulesets/s16.yaml b/api-design-guide/linter/rulesets/s16.yaml index e852620..aebf5fa 100644 --- a/api-design-guide/linter/rulesets/s16.yaml +++ b/api-design-guide/linter/rulesets/s16.yaml @@ -2,7 +2,7 @@ # # Implements guide rules 16.1, 16.2, 16.3, 16.4, 16.6, 16.11. Source text: # ../../rules.yaml. Severity policy and formats follow -# <SCRATCHPAD>/linter-conventions.md. +# ../README.md#rule-naming-and-severities. # # Scope note: §16 spans both the OpenAPI/webhooks and the AsyncAPI surfaces. # These rules target the OpenAPI document (formats: [oas3_1]); the AsyncAPI diff --git a/api-design-guide/linter/rulesets/s17-strict.yaml b/api-design-guide/linter/rulesets/s17-strict.yaml index 19c809a..fdc610b 100644 --- a/api-design-guide/linter/rulesets/s17-strict.yaml +++ b/api-design-guide/linter/rulesets/s17-strict.yaml @@ -1,6 +1,6 @@ # STRICT-only rules for §17 — ships only via ../strict.yaml (opt-in), at warn. # -# Source text: ../../rules.yaml (§17.3). See <SCRATCHPAD>/linter-conventions.md. +# Source text: ../../rules.yaml (§17.3). See ../README.md#rule-naming-and-severities. functionsDir: "../functions" functions: - valuePattern diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml index 91ed501..1b42fc3 100644 --- a/api-design-guide/linter/rulesets/s17.yaml +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -1,7 +1,7 @@ # Rules for §17 event-driven APIs (AsyncAPI channel documentation). # # Source text: ../../rules.yaml (§17). Severity policy, formats (aas3) and the -# proxy/notch conventions follow <SCRATCHPAD>/linter-conventions.md and +# proxy/notch conventions follow ../README.md#rule-naming-and-severities and # ../coverage.yaml. AsyncAPI-surface rules gate on `formats: [aas3]`. # # Custom functions live in ../functions; every function referenced below is diff --git a/api-design-guide/linter/rulesets/s18.yaml b/api-design-guide/linter/rulesets/s18.yaml index 475dfec..47e0a43 100644 --- a/api-design-guide/linter/rulesets/s18.yaml +++ b/api-design-guide/linter/rulesets/s18.yaml @@ -1,7 +1,7 @@ # Rules for §18 compatibility and lifecycle — generated from the guide; see coverage.yaml. # # Implements guide rules 18.1, 18.2, 18.5, 18.7. Source text: ../../rules.yaml. -# Severity policy and formats follow <SCRATCHPAD>/linter-conventions.md. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. # 18.3/18.4 (backward-/breaking-change diffing against a previous version) and # 18.6 (informative) are out of scope for this fragment. # diff --git a/api-design-guide/linter/strict.yaml b/api-design-guide/linter/strict.yaml index ea377c1..04c03ed 100644 --- a/api-design-guide/linter/strict.yaml +++ b/api-design-guide/linter/strict.yaml @@ -9,9 +9,8 @@ # # Composition (verified to load via bundler and CLI): strict.yaml extends the # main ruleset (an extends-of-extends: strict -> ruleset.yaml -> section -# fragments) plus one strict fragment per section that owns STRICT-only rules. -# Section agents only ever edit their own rulesets/sNN-strict.yaml; they never -# touch this file. The strict fragments are pre-listed so the bundle loads today. +# fragments) plus one rulesets/sNN-strict.yaml fragment per section that owns +# STRICT-only rules. extends: - ./ruleset.yaml - ./rulesets/s04-strict.yaml From 3ddd28db88c742d106e0faa6529e620bd866b147 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 11:51:13 +0700 Subject: [PATCH 15/19] fix: make the event-signing and common-error rules satisfiable Two rules a reviewer hits early could not be satisfied as written. 11.7 requires a common error catalogue "defined in govstack-openapi-common.yaml and reused", but that file carried the codes only as literals inside response examples, seven of the nine, with nothing referenceable. It now defines CommonErrorCode, including the missing alreadyExists and unimplemented, and 11.7 names the schema so "reused" has something to point at. 16.8 signs the complete structured CloudEvent and has verifiers reconstruct it from the received body, but nothing required structured content mode on the webhooks surface. In CloudEvents binary mode the body is only `data`, so the signature was unverifiable. 16.2 now requires application/cloudevents+json there, matching what 17.6 already requires of AsyncAPI, and govstack-16.2-structured enforces it. The two remaining verification inputs, key discovery on transports with no subscription control plane and the replay-window bound, are genuine committee decisions rather than drafting errors, so they are declared as OPEN-15-H and marked inline at 16.8 and 16.9 instead of being invented here. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/appendix/b-open-questions.md | 1 + api-design-guide/linter/coverage.yaml | 4 +-- api-design-guide/linter/rulesets/s16.yaml | 19 +++++++++++ .../govstack-16.2-structured/fail.yaml | 34 +++++++++++++++++++ .../govstack-16.2-structured/pass.yaml | 34 +++++++++++++++++++ api-design-guide/part-c/11-errors.md | 2 +- .../part-d/16-cloudevents-and-webhooks.md | 6 ++-- api-design-guide/rules.yaml | 14 ++++---- api/common/govstack-openapi-common.yaml | 22 +++++++++++- 9 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.2-structured/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.2-structured/pass.yaml diff --git a/api-design-guide/appendix/b-open-questions.md b/api-design-guide/appendix/b-open-questions.md index ff3c211..50e4ec8 100644 --- a/api-design-guide/appendix/b-open-questions.md +++ b/api-design-guide/appendix/b-open-questions.md @@ -19,6 +19,7 @@ The **Blocks v1.0?** column marks the questions whose answers shape the shared ` | OPEN-12-A | OAuth scope syntax: `bb:{bb-code}:{resource}:{action}` vs reverse-DNS vs `resource.action` | `bb:` prefix for namespacing; reverse-DNS is the alternative | [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | Yes | | OPEN-14-A | Operation resource: GovStack-local shape vs strict Google AIP-151 mirror | AIP-151-aligned hybrid; strict AIP-151 is the alternative | [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | Yes | | OPEN-15-B | Event `type` naming convention | `global.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | +| OPEN-15-H | Event-signature verification inputs the guide does not yet supply: how a subscriber discovers the verification key that the JWS `kid` selects on transports with no subscription control plane, and what replay window a receiver enforces | Per-subscription key exchange via [§16.11](../part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces) where a control plane exists, plus a published key set for brokered and stream transports; a single default replay window stated in the guide rather than per BB | [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile), [§16.9](../part-d/16-cloudevents-and-webhooks.md#169-operational-signing-concerns-out-of-scope) | Yes | | OPEN-15-E | CloudEvents binding style for AsyncAPI | Structured CloudEvents JSON payload | [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | No | | OPEN-15-F | AsyncAPI protocol-binding depth | Require bindings where they affect interoperability; future profiles may add deeper broker-specific rules | [§17.19](../part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | No | | OPEN-15-G | GovStack AsyncAPI extension names and schemas | Define in `govstack-asyncapi-common.yaml` | [§17.15](../part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions) | No | diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 0937e02..577f529 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -657,8 +657,8 @@ rules: - id: "16.2" class: "M" status: implemented - spectral_rules: [govstack-16.2, govstack-16.2-recommended] - note: "event payload schema requires specversion(const \"1.0\")/id/source/type + data" + spectral_rules: [govstack-16.2, govstack-16.2-recommended, govstack-16.2-structured] + note: "event payload schema requires specversion(const \"1.0\")/id/source/type + data, and the webhooks surface must declare application/cloudevents+json rather than a binary-mode application/json body" - id: "16.3" class: "M" status: implemented diff --git a/api-design-guide/linter/rulesets/s16.yaml b/api-design-guide/linter/rulesets/s16.yaml index aebf5fa..af4016c 100644 --- a/api-design-guide/linter/rulesets/s16.yaml +++ b/api-design-guide/linter/rulesets/s16.yaml @@ -11,6 +11,7 @@ functionsDir: "../functions" functions: - envelopeShape + - mediaTypeExpected - s16-eventField - s16-signatureHeader - s16-subscriptionEndpoints @@ -63,6 +64,24 @@ rules: time: {} datacontenttype: {} + # 16.2 [M] structured-content-mode sentence — split per the mixed-strength + # convention. On the webhooks surface the event MUST be delivered as + # `application/cloudevents+json`; a plain `application/json` body is the + # CloudEvents binary content mode, whose body carries only `data` and so + # cannot be reconstructed for the 16.8 signature check. + govstack-16.2-structured: + description: "webhook events must use CloudEvents structured content mode, not binary (guide 16.2, [M])." + message: "[16.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required + severity: error + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content + then: + function: mediaTypeExpected + functionOptions: + require: '^application/cloudevents\+json\s*(;|$)' + forbid: '^application/json\s*(;|$)' + # 16.3 [M] — the pinned event `type` value MUST follow reverse-DNS # global.govstack.{bb-code}.{resource}.{action} and MUST NOT carry a version # segment. Only pinned const/enum values are checked (a free-form `type` is a diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/fail.yaml new file mode 100644 index 0000000..bbc91ea --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook is delivered in CloudEvents binary content mode. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/pass.yaml new file mode 100644 index 0000000..7a8226c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook is delivered in CloudEvents structured content mode. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json; charset=utf-8: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/part-c/11-errors.md b/api-design-guide/part-c/11-errors.md index d637b25..a3abfb8 100644 --- a/api-design-guide/part-c/11-errors.md +++ b/api-design-guide/part-c/11-errors.md @@ -63,7 +63,7 @@ description: "One RFC 9457 problem-details error envelope, GovStack extension fi ## 11.7 Common error catalogue <a href="#117-common-error-catalogue" id="117-common-error-catalogue"></a> -**[M+R]** A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is [`[OPEN-10-B]`](../appendix/b-open-questions.md). +**[M+R]** A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` as the `CommonErrorCode` schema and reused by reference rather than restated. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is [`[OPEN-10-B]`](../appendix/b-open-questions.md). ## 11.8 Transport-neutral asynchronous errors <a href="#118-transport-neutral-asynchronous-errors" id="118-transport-neutral-asynchronous-errors"></a> diff --git a/api-design-guide/part-d/16-cloudevents-and-webhooks.md b/api-design-guide/part-d/16-cloudevents-and-webhooks.md index f416f4d..0cf3078 100644 --- a/api-design-guide/part-d/16-cloudevents-and-webhooks.md +++ b/api-design-guide/part-d/16-cloudevents-and-webhooks.md @@ -18,7 +18,7 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.2 CloudEvents envelope required <a href="#162-cloudevents-envelope-required" id="162-cloudevents-envelope-required"></a> -**[M]** GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `"1.0"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. +**[M]** GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `"1.0"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used, because [§16.8](#168-pinned-signature-profile) signs the complete structured event object and a binary-mode request body carries only `data`. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) imposes the same requirement on the AsyncAPI surface. ## 16.3 Reverse-DNS event types <a href="#163-reverse-dns-event-types" id="163-reverse-dns-event-types"></a> @@ -60,11 +60,11 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.8 Pinned signature profile <a href="#168-pinned-signature-profile" id="168-pinned-signature-profile"></a> -**[R]** `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it. +**[R]** `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it. How a subscriber obtains the key that `kid` selects is not yet settled ecosystem-wide: [§16.11](#1611-subscription-management-interfaces) supplies it per subscription, which covers HTTP webhooks but not brokered or stream transports with no subscription control plane. [`[OPEN-15-H]`](../appendix/b-open-questions.md) ## 16.9 Operational signing concerns out of scope <a href="#169-operational-signing-concerns-out-of-scope" id="169-operational-signing-concerns-out-of-scope"></a> -Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). +Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). Until that companion fixes a bound, [§16.7](#167-replay-detectable-signed-material) gives a receiver the material to detect a replay but no ecosystem-wide window against which to reject one, so two conformant implementations can disagree about whether a given event is a replay. [`[OPEN-15-H]`](../appendix/b-open-questions.md) ## 16.10 Documented delivery-failure contract <a href="#1610-documented-delivery-failure-contract" id="1610-documented-delivery-failure-contract"></a> diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 89b40a9..6950434 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -869,7 +869,7 @@ rules: surface: Universal page: part-c/11-errors.md anchor: 117-common-error-catalogue - text: "A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` and reused. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is `[OPEN-10-B]`." + text: "A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` as the `CommonErrorCode` schema and reused by reference rather than restated. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is `[OPEN-10-B]`." open_questions: ["OPEN-10-B"] - id: "11.8" title: "Transport-neutral asynchronous errors" @@ -1162,11 +1162,11 @@ rules: - id: "16.2" title: "CloudEvents envelope required" class: M - strengths: ["MUST", "SHOULD"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 162-cloudevents-envelope-required - text: "GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `\"1.0\"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`." + text: "GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `\"1.0\"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used, because §16.8 signs the complete structured event object and a binary-mode request body carries only `data`. §17.6 imposes the same requirement on the AsyncAPI surface." open_questions: [] - id: "16.3" title: "Reverse-DNS event types" @@ -1220,8 +1220,8 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 168-pinned-signature-profile - text: "`govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it." - open_questions: [] + text: "`govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it. How a subscriber obtains the key that `kid` selects is not yet settled ecosystem-wide: §16.11 supplies it per subscription, which covers HTTP webhooks but not brokered or stream transports with no subscription control plane. `[OPEN-15-H]`" + open_questions: ["OPEN-15-H"] - id: "16.9" title: "Operational signing concerns out of scope" class: informative @@ -1229,8 +1229,8 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 169-operational-signing-concerns-out-of-scope - text: "Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion (§1.2)." - open_questions: [] + text: "Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion (§1.2). Until that companion fixes a bound, §16.7 gives a receiver the material to detect a replay but no ecosystem-wide window against which to reject one, so two conformant implementations can disagree about whether a given event is a replay. `[OPEN-15-H]`" + open_questions: ["OPEN-15-H"] - id: "16.10" title: "Documented delivery-failure contract" class: R diff --git a/api/common/govstack-openapi-common.yaml b/api/common/govstack-openapi-common.yaml index 1dcbf67..46dbaed 100644 --- a/api/common/govstack-openapi-common.yaml +++ b/api/common/govstack-openapi-common.yaml @@ -288,6 +288,23 @@ components: traceId: 4bf92f3577b34da6a3ce929d0e0e4736 timestamp: '2026-07-10T12:00:00Z' schemas: + CommonErrorCode: + type: string + description: >- + The cross-BB common error catalogue (guide 11.7), modelled on + google.rpc.Code and named with the reverse-DNS convention of guide 11.5. + A BB-specific error uses the same shape with the BB's own registered + code and is not listed here. + enum: + - global.govstack.common.unauthenticated + - global.govstack.common.permissionDenied + - global.govstack.common.notFound + - global.govstack.common.invalidArgument + - global.govstack.common.alreadyExists + - global.govstack.common.aborted + - global.govstack.common.resourceExhausted + - global.govstack.common.internal + - global.govstack.common.unimplemented Problem: type: object description: >- @@ -323,7 +340,10 @@ components: code: type: string pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*$' - description: Stable namespaced machine-readable error identifier. + description: >- + Stable namespaced machine-readable error identifier. A cross-BB + common error uses a value from CommonErrorCode; a BB-specific error + uses the BB's own registered code in the same shape. example: global.govstack.common.internal traceId: type: string From 1522904d6ca9e959b5ca725fb36646f435e41c64 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Tue, 4 Aug 2026 12:45:55 +0700 Subject: [PATCH 16/19] fix: close the gaps an external adopter hit applying the guide An outside team applied the guide to a non-BB project and reported eight issues. Six were real: rules that could not be satisfied as written, or carve-outs the linter did not honour. - 5.9: health is carried by the status code. Declare both 200 and 503; the 200 body is plain application/json, minimal, and must not expose system-internal detail. Drops draft-inadarei-api-health-check, an expired Internet-Draft that never became an RFC (resolves OPEN-4-B). - 5.10: the standard unversioned endpoints are read-only, so they cannot be used to smuggle a business resource outside /v{N}/. - 9.7: name the real enum carve-out, values drawn from an IANA registry (JOSE and COSE algorithms and curves, media types), not health vocabulary. - 12.1: a collection that bounds every array it returns with maxItems may be returned unpaginated. - 13.2: accept an http bearer JWT scheme. A resource server that validates tokens issued elsewhere would misdescribe itself declaring an oauth2 flow. The 5.10 path set now lives in one module, functions/lib/standardEndpoints.js, replacing three inline copies that had already drifted apart. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/1-introduction.md | 3 +- api-design-guide/all-rules.md | 1 + api-design-guide/appendix/b-open-questions.md | 2 +- .../appendix/c-normative-references.md | 3 +- .../guides/spec-editor-checklist.md | 3 +- api-design-guide/linter/coverage.yaml | 23 +++-- .../linter/functions/lib/standardEndpoints.js | 26 ++++++ .../linter/functions/pathSegments.js | 33 ++++++-- .../linter/functions/s09-enumCasing.js | 50 ++++++----- .../functions/s12-collectionPagination.js | 79 +++++++++++++++--- .../linter/functions/s13-schemeExists.js | 10 +++ api-design-guide/linter/rulesets/s05.yaml | 70 ++++++++++------ api-design-guide/linter/rulesets/s12.yaml | 48 +++-------- api-design-guide/linter/rulesets/s13.yaml | 15 +++- .../tests/fixtures/govstack-12.1/pass.yaml | 21 +++++ .../tests/fixtures/govstack-5.10/fail.yaml | 29 +++++++ .../tests/fixtures/govstack-5.10/pass.yaml | 60 ++++++++++++++ .../govstack-5.9-media-type/fail.yaml | 7 +- .../govstack-5.9-media-type/pass.yaml | 13 ++- .../fixtures/govstack-5.9-no-auth/fail.yaml | 7 +- .../fixtures/govstack-5.9-no-auth/pass.yaml | 7 +- .../fixtures/govstack-5.9-presence/pass.yaml | 7 +- .../fail.yaml | 11 ++- .../pass.yaml | 14 +--- .../tests/fixtures/govstack-9.7/pass.yaml | 10 ++- .../linter/tests/functions.test.mjs | 83 +++++++++++++++++++ api-design-guide/linter/tests/golden.test.mjs | 17 ++-- .../linter/tests/golden/openapi-golden.yaml | 75 +++++++++-------- .../part-b/5-url-structure-and-versioning.md | 13 +-- .../part-c/12-pagination-filtering-sorting.md | 2 +- .../part-c/9-json-conventions-and-naming.md | 4 +- .../13-authentication-and-authorisation.md | 6 +- .../part-d/18-compatibility-and-lifecycle.md | 2 +- api-design-guide/rules.yaml | 39 +++++---- 34 files changed, 562 insertions(+), 231 deletions(-) create mode 100644 api-design-guide/linter/functions/lib/standardEndpoints.js create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.10/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-5.10/pass.yaml rename api-design-guide/linter/tests/fixtures/{govstack-5.9-status-enum => govstack-5.9-status-codes}/fail.yaml (62%) rename api-design-guide/linter/tests/fixtures/{govstack-5.9-status-enum => govstack-5.9-status-codes}/pass.yaml (65%) diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md index d2131e8..a7c2c0d 100644 --- a/api-design-guide/1-introduction.md +++ b/api-design-guide/1-introduction.md @@ -75,7 +75,8 @@ Where this guide adopts an external standard or convention on the OpenAPI 3.1, C - RFC 9457 fields inside error envelopes ([§11.1](part-c/11-errors.md#111-rfc-9457-problem-details); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). - CloudEvents fields inside event envelopes ([§16.2](part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). -- GovStack-adopted health-check enum values derived from `draft-inadarei-api-health-check` inside `/health` responses ([§5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint); carve-out on [§9.7](part-c/9-json-conventions-and-naming.md#carve-out-from-97)). +- IANA-registered values, notably JOSE and COSE algorithm and curve names and media types, wherever a BB enumerates them (carve-out on [§9.7](part-c/9-json-conventions-and-naming.md#carve-out-from-97)). +- RFC 8615 well-known URIs, whose location is fixed at `/.well-known/` and therefore outside the versioned path scheme ([§5.10](part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints)). - OAuth 2.0 / OIDC field shapes inside tokens, claims, and discovery documents ([§13.2](part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations)). - Code-list standards (ISO 3166-1, ISO 4217, BCP 47, E.164) inside their respective fields ([§10.5](part-c/10-data-types-and-formats.md#105-e164-phone-numbers)–[§10.10](part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)). diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md index 65667ac..6b38d90 100644 --- a/api-design-guide/all-rules.md +++ b/api-design-guide/all-rules.md @@ -57,6 +57,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [5.7](part-b/5-url-structure-and-versioning.md#57-no-verbs-in-crud-paths) | M+R | MUST | OpenAPI | No verbs in CRUD paths | | [5.8](part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources) | R | MUST | OpenAPI | Actions as sub-resources | | [5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | M+R | MUST | OpenAPI | Unversioned health endpoint | +| [5.10](part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints) | M | MUST | OpenAPI | Standard unversioned endpoints | ## 6. HTTP methods diff --git a/api-design-guide/appendix/b-open-questions.md b/api-design-guide/appendix/b-open-questions.md index 50e4ec8..6f99049 100644 --- a/api-design-guide/appendix/b-open-questions.md +++ b/api-design-guide/appendix/b-open-questions.md @@ -11,7 +11,6 @@ The **Blocks v1.0?** column marks the questions whose answers shape the shared ` | ID | Topic | Default | Section | Blocks v1.0? | |---|---|---|---|---| | OPEN-4-A | BB code in URL path | No (rely on `servers` URL or mediator routing) | [§5](../part-b/5-url-structure-and-versioning.md) | Yes | -| OPEN-4-B | Health endpoint shape: align with `draft-inadarei-api-health-check`, or use a simpler local shape | Align with the draft | [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | No | | OPEN-4-C | Path nesting depth: soft cap of two levels under `/v{N}/` | Keep as SHOULD with the soft cap | [§5.4](../part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | No | | OPEN-6-A | 400 vs 422 boundary | Keep both (400 unparseable, 422 semantic) | [§7](../part-b/7-http-status-codes.md) | No | | OPEN-10-A | Error code shape: reverse-DNS named code vs reverse-DNS numeric code vs shorter BB-prefixed code | Reverse-DNS named code: `global.govstack.{bb-code}.{error-name}` | [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes) | Yes | @@ -33,6 +32,7 @@ The identifiers below remain frozen for discussion-history links, but they are n | ID | Resolution | Section | |---|---|---| +| OPEN-4-B | Use the simpler local shape, not `draft-inadarei-api-health-check` (an expired Internet-Draft that never became an RFC). Health is carried by the status code, `200` or `503`; the `200` body is `application/json`, minimal, and informational. | [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | | OPEN-7-A | Use W3C `traceparent` / `tracestate`; do not introduce `X-Request-Id` as the cross-BB standard. | [§8.4–8.5](../part-b/8-headers.md#84-w3c-trace-context-correlation) | | OPEN-7-B | Pin the Structured Field `RateLimit` / `RateLimit-Policy` form from draft revision 11; the legacy three-field form is not draft-conformant. | [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared) | | OPEN-15-C | Use HTTP `GovStack-Signature` and camelCase AsyncAPI metadata `govstackSignature`, unless a protocol binding supplies a standard field. | [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header) | diff --git a/api-design-guide/appendix/c-normative-references.md b/api-design-guide/appendix/c-normative-references.md index a6d3fb4..db5b2ab 100644 --- a/api-design-guide/appendix/c-normative-references.md +++ b/api-design-guide/appendix/c-normative-references.md @@ -19,6 +19,7 @@ description: "Normative references cited throughout the guide." - IETF RFC 7797, *JSON Web Signature (JWS) Unencoded Payload Option* (explicitly excluded by the `0.2.0-draft` event-signature profile; cited by [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) - IETF RFC 8785, *JSON Canonicalization Scheme (JCS)* (cited by [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) - IETF RFC 8594, *The Sunset HTTP Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) +- IETF RFC 8615, *Well-Known Uniform Resource Identifiers (URIs)* (cited by [§5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints)) - IETF RFC 8705, *OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens* - IETF RFC 9110, *HTTP Semantics* (obsoletes RFC 7231) - IETF RFC 9396, *OAuth 2.0 Rich Authorization Requests* @@ -29,7 +30,6 @@ description: "Normative references cited throughout the guide." - IETF RFC 9745, *The Deprecation HTTP Response Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) - IETF draft `draft-ietf-httpapi-ratelimit-headers-11`, *RateLimit Header Fields for HTTP* (pinned work-in-progress revision; cited by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared)) - IETF draft `draft-ietf-httpapi-idempotency-key-header-07`, *The Idempotency-Key HTTP Header Field* (pinned expired Internet-Draft revision used as a GovStack convention; cited by [§14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) -- IETF draft `draft-inadarei-api-health-check`, *Health Check Response Format for HTTP APIs* (expired Internet-Draft, never an RFC; cited by [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) - OpenAPI Specification 3.1 patch series; guide/ruleset `0.2.0-draft` qualify 3.1.0, 3.1.1, and 3.1.2 - AsyncAPI Specification 3.0 (cited by [§1.2](../1-introduction.md#12-scope), [§3](../part-a/3-asyncapi-document-standards.md), [§16.1](../part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented), [§17](../part-d/17-asyncapi-channel-rules.md), [§20](../part-e/20-conformance-and-validation.md)) - OpenID Connect Core 1.0 @@ -40,6 +40,7 @@ description: "Normative references cited throughout the guide." - Google AIP-158, *Pagination* (cited by [§12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) - GraphQL Cursor Connections Specification (cited by [§12.3](../part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope)) - gRPC, *google.rpc.Code* canonical error codes (cited by [§11.7](../part-c/11-errors.md#117-common-error-catalogue)) +- IANA registries whose registered values keep their own casing under [§9.7](../part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values): *JSON Web Signature and Encryption Algorithms*, *JSON Web Key Elliptic Curve*, *COSE Algorithms*, and *Media Types* - ISO 3166-1 alpha-2 (country codes) - ISO 4217 (currency codes) - BCP 47 (language tags) diff --git a/api-design-guide/guides/spec-editor-checklist.md b/api-design-guide/guides/spec-editor-checklist.md index 63dbe09..ffc8794 100644 --- a/api-design-guide/guides/spec-editor-checklist.md +++ b/api-design-guide/guides/spec-editor-checklist.md @@ -28,7 +28,8 @@ Run this before submitting a BB specification for review. Each item links to the - [ ] The canonical entrypoint is at the default `api/openapi.yaml` path or is listed in `api/index.yaml`; no legacy `api/swagger.yaml` or JSON copy is treated as canonical. ([2.2](../part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint)) - [ ] Every non-local server and webhook URL uses HTTPS, and OAuth declarations follow the RFC 9700 baseline: authorization code plus PKCE, no password or implicit grant, access tokens rather than ID Tokens. ([13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations), [13.7](../part-d/13-authentication-and-authorisation.md#137-protected-transport)) - [ ] The major version appears in the URL path (`/v{N}/...`). ([5.1](../part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path)) -- [ ] `/health` is unversioned, uses `application/health+json`, and carries no citizen authentication. ([5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) +- [ ] `/health` is unversioned, declares both `200` and `503`, returns `application/json` on `200`, and carries no citizen authentication. ([5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) +- [ ] Only the standard unversioned endpoints sit outside `/v{N}/`, and none of them declares a mutating method. ([5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints)) - [ ] No bulk-mutating or bulk-deleting operation can run against an entire collection with no selection parameter. ([6.7](../part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection)) - [ ] Every operation declares every status code it can actually return; no operation declares only `200`. ([7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) - [ ] Input operations declare `400`; secured operations declare applicable `401`/`403`; resource operations declare `404`; conflict-capable operations declare `409`; every operation declares `500`. Creation completed now is `201`, while accepted work is `202` plus an Operation. ([7.2](../part-b/7-http-status-codes.md#72-201-created-with-location)–[7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 577f529..ccbe6a4 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -172,8 +172,13 @@ rules: - id: "5.9" class: "M+R" status: implemented - spectral_rules: [govstack-5.9-presence, govstack-5.9-media-type, govstack-5.9-status-enum, govstack-5.9-no-auth] - note: "/health present, unversioned, media application/health+json, status enum pass/fail/warn, no auth; media-type and status-enum checks scoped to the 200 response so the 11.1 problem+json error response is satisfiable" + spectral_rules: [govstack-5.9-presence, govstack-5.9-media-type, govstack-5.9-status-codes, govstack-5.9-no-auth] + note: "/health present, unversioned, 200 media application/json, both 200 and 503 declared, no auth; the media-type check is scoped to the 200 response so the 11.1 problem+json error responses stay satisfiable. Not checked: 'cheap and bounded', 'MUST NOT expose system-internal detail', and the dependency-probing limit, none of which are decidable from the document." + - id: "5.10" + class: "M" + status: implemented + spectral_rules: [govstack-5.10] + note: "the exemptions this rule grants are implemented where they bite (5.1/5.3 in pathSegments, 12.x in s12-collectionPagination, 13.1 by accepting an explicit `security: []`), all reading the closed set from functions/lib/standardEndpoints.js; the rule of its own enforces the prohibition, that these paths declare no mutating method. Whether a GET on such a path is a business resource in disguise is left to review." - id: "6.1" class: "M+R" status: implemented @@ -383,7 +388,7 @@ rules: class: "M" status: partial-proxy spectral_rules: [govstack-9.7] - note: "proxy: enum string values SCREAMING_SNAKE_CASE minus the rule's carve-outs (BCP 47 tags, reverse-DNS error codes and event types, x-govstack-* vocabularies, health status). §12.7 sort keys are a known false positive." + note: "proxy: enum string values SCREAMING_SNAKE_CASE minus the rule's carve-outs (BCP 47 tags, reverse-DNS error codes and event types, media types, x-govstack-* vocabularies, and the JOSE/COSE names not already all-caps). IANA registry membership is not visible in a document, so a registered value outside the listed set is a miss. §12.7 sort keys are a known false positive." - id: "9.8" class: "M" status: implemented @@ -503,7 +508,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-12.1] - note: "proxy: list endpoints (array response) declare pagination params/envelope; exempts the 5.9 operational endpoints /health and /ready (a liveness probe is not a collection)" + note: "proxy: list endpoints (array response) declare pagination params/envelope; exempts the 5.10 standard unversioned endpoints (not collections) and collections whose response declares its own maxItems bound" - id: "12.2" class: "M+R" status: partial-proxy @@ -518,7 +523,7 @@ rules: class: "M+R" status: implemented spectral_rules: [govstack-12.4] - note: "pageSize param schema has default and maximum; exempts the 5.9 operational endpoints /health and /ready" + note: "pageSize param schema has default and maximum; exempts the 5.10 standard unversioned endpoints and collections bounded by their own maxItems, on the same terms as 12.1" - id: "12.5" class: "R" status: human @@ -553,12 +558,12 @@ rules: class: "M" status: implemented spectral_rules: [govstack-13.1] - note: "root security + securitySchemes present and every operation covered (or explicit override)" + note: "root security + securitySchemes present and every operation covered (or explicit override); the 5.10 standard unversioned endpoints satisfy it with an explicit `security: []`, which counts as that override" - id: "13.2" class: "M+R" status: partial-proxy spectral_rules: [govstack-13.2, govstack-13.2-forbidden-flows] - note: "proxy: an openIdConnect/oauth2 scheme exists; deterministic error forbids password and implicit OAuth2 flows" + note: "proxy: an openIdConnect/oauth2 scheme exists, or the resource-server alternative (type http, scheme bearer, bearerFormat JWT); deterministic error forbids password and implicit OAuth2 flows. Not checked: that the resource-server case really is one, or that its accepted issuers and required audience are documented" - id: "13.3" class: "M+R" status: partial-proxy @@ -568,7 +573,7 @@ rules: class: "M" status: partial-proxy spectral_rules: [govstack-13.4] - note: "proxy: scope strings match bb:{bb-code}:{resource}:{action}" + note: "proxy: scope strings match bb:{bb-code}:{resource}:{action}. Only fires where scopes are declared, so an API authorizing on token claims instead is silent here; that its required claims are documented per operation is left to review" - id: "13.5" class: "M+R" status: implemented @@ -808,7 +813,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-18.1] - note: "info.version matches SemVer regex" + note: "info.version matches SemVer regex. Not checked: that it is the contract version rather than the implementation version, which no document reveals" - id: "18.2" class: "M" status: implemented diff --git a/api-design-guide/linter/functions/lib/standardEndpoints.js b/api-design-guide/linter/functions/lib/standardEndpoints.js new file mode 100644 index 0000000..2ea2335 --- /dev/null +++ b/api-design-guide/linter/functions/lib/standardEndpoints.js @@ -0,0 +1,26 @@ +/** + * The guide §5.10 closed set of standard unversioned endpoints: paths whose + * location or spelling is fixed by something other than this guide, and which + * are therefore exempt from §5.1 (version prefix), §5.3 (kebab-case segments), + * and the §12 collection rules. + * + * Kept in one place because four rulesets need the same answer; changing the + * set means changing guide §5.10 first. + */ + +// Exact path keys: the §5.9 operational endpoints and the conventional runtime +// specification-discovery endpoints. +const EXACT = new Set(['/health', '/ready', '/openapi.json', '/asyncapi.json']); + +// RFC 8615 roots every well-known URI at this exact prefix, so a well-known +// path cannot be moved under /v{N}/ without ceasing to be one. +const WELL_KNOWN_PREFIX = '/.well-known/'; + +/** + * @param {unknown} pathKey - an OpenAPI `paths` key. + * @returns {boolean} true when the path is in the guide §5.10 set. + */ +export function isStandardUnversionedPath(pathKey) { + if (typeof pathKey !== 'string') return false; + return EXACT.has(pathKey) || pathKey.startsWith(WELL_KNOWN_PREFIX); +} diff --git a/api-design-guide/linter/functions/pathSegments.js b/api-design-guide/linter/functions/pathSegments.js index 12e4e7e..8b60b38 100644 --- a/api-design-guide/linter/functions/pathSegments.js +++ b/api-design-guide/linter/functions/pathSegments.js @@ -1,9 +1,11 @@ import { isObject } from './lib/util.js'; import { matchesCasing } from './lib/casing.js'; +import { isStandardUnversionedPath } from './lib/standardEndpoints.js'; const VERSION_SEG = /^v\d+$/; const isParam = (seg) => seg.startsWith('{') && seg.endsWith('}'); const split = (key) => key.split('/').filter((s) => s.length > 0); +const MUTATING_METHODS = ['post', 'put', 'patch', 'delete']; /** * pathSegments — structural checks over the OpenAPI `paths` object. One check @@ -20,11 +22,16 @@ const split = (key) => key.split('/').filter((s) => s.length > 0); * "maxDepthAfterVersion" at most `max` NON-PARAM levels follow the version * prefix; `{param}` segments do NOT count as levels * (default max 2) (guide 5.4) + * "standardEndpointsReadOnly" the guide 5.10 standard unversioned endpoints + * declare no mutating method (guide 5.10) * casing {string} for "segmentCasing" (default "kebab"). * max {number} for "maxDepthAfterVersion" (default 2). - * exemptPaths {string[]} for "versionPrefix": path keys matched EXACTLY that - * are exempt from the version-prefix requirement (the - * guide 5.9 unversioned operational endpoints). + * exemptPaths {string[]} for "versionPrefix": additional path keys matched + * EXACTLY that are exempt from the version-prefix + * requirement, on top of the guide 5.10 set. + * + * "versionPrefix" and "segmentCasing" both skip the guide 5.10 standard + * unversioned endpoints, whose location and spelling are fixed elsewhere. * * @param {unknown} targetVal - the paths object. * @param {object} options @@ -43,10 +50,12 @@ export default function pathSegments(targetVal, options, context) { const segs = split(key); const here = [...base, key]; + // Guide 5.10 fixes the location and spelling of a closed set of endpoints + // elsewhere (RFC 8615 well-known URIs, the 5.9 operational endpoints, and + // runtime specification discovery), so 5.1 and 5.3 must not fire on them. + if (isStandardUnversionedPath(key) && (check === 'versionPrefix' || check === 'segmentCasing')) continue; + if (check === 'versionPrefix') { - // Guide 5.9 mandates UNVERSIONED operational liveness endpoints (/health, - // and optionally /ready); exempt those exact path keys from the /v{N}/ - // requirement so 5.1 and 5.9 do not contradict each other. const exemptPaths = Array.isArray(opts.exemptPaths) ? opts.exemptPaths : []; if (exemptPaths.includes(key)) continue; if (segs.length === 0 || !VERSION_SEG.test(segs[0])) { @@ -60,6 +69,18 @@ export default function pathSegments(targetVal, options, context) { results.push({ message: `path "${key}" segment "${seg}" must be ${casing} case`, path: here }); } } + } else if (check === 'standardEndpointsReadOnly') { + if (!isStandardUnversionedPath(key)) continue; + const item = targetVal[key]; + if (!isObject(item)) continue; + for (const method of MUTATING_METHODS) { + if (method in item) { + results.push({ + message: `path "${key}" is a standard unversioned endpoint (guide 5.10) and must not declare "${method}"; a business resource belongs on the versioned surface`, + path: [...here, method], + }); + } + } } else if (check === 'maxDepthAfterVersion') { const max = Number.isInteger(opts.max) ? opts.max : 2; const afterVersion = VERSION_SEG.test(segs[0]) ? segs.slice(1) : segs; diff --git a/api-design-guide/linter/functions/s09-enumCasing.js b/api-design-guide/linter/functions/s09-enumCasing.js index d535edc..66382cd 100644 --- a/api-design-guide/linter/functions/s09-enumCasing.js +++ b/api-design-guide/linter/functions/s09-enumCasing.js @@ -7,15 +7,22 @@ import { isObject, toRegExp } from './lib/util.js'; * §9.7 lists for values whose form is fixed by another rule or an external * standard: * - * - BCP 47 language tags and reverse-DNS identifiers (error codes, event - * types), matched by `allowPattern`; - * - the health-status vocabulary and the GovStack x-govstack-* extension - * vocabularies, listed in `allowValues`. + * - BCP 47 language tags, reverse-DNS identifiers (error codes, event types), + * and media types, matched by `allowPattern`; + * - the GovStack x-govstack-* extension vocabularies and the JOSE/COSE + * algorithm and curve names that are not already SCREAMING_SNAKE_CASE, + * listed in `allowValues`. * * Because those exceptions are pattern-based, a lowercase enum that merely looks * ISO-like (any 2-3 letter token) is not flagged: the proxy trades a few misses * for far fewer false positives. Non-string enum members are ignored. * + * §9.7 exempts "values registered in an IANA registry", which no document linter + * can decide: registry membership is not visible in the spec. The literal list + * below covers the JOSE/COSE names the guide names and the neighbours an API is + * likely to declare beside them; a registered value outside it is a miss, and + * the fix is to add it here rather than to re-case the value. + * * Known false positive: §12.7 sort keys are field names in lowerCamelCase, which * is indistinguishable from a mis-cased state name, so a sort-key enum declared * under components.schemas is flagged. Inline sort parameters are not visited. @@ -25,7 +32,8 @@ import { isObject, toRegExp } from './lib/util.js'; * options: * pattern {string} regex an enum value MUST match (default SCREAMING_SNAKE). * allowPattern {string} regex of always-allowed values (default ISO-ish codes). - * allowValues {string[]} literal always-allowed values (default health vocab). + * allowValues {string[]} literal always-allowed values (default: the + * x-govstack-* and JOSE/COSE vocabularies below). * * @param {unknown} targetVal - a JSON Schema. * @param {object} [options] @@ -33,22 +41,26 @@ import { isObject, toRegExp } from './lib/util.js'; * @returns {{message:string, path:(string|number)[]}[]|undefined} */ const SCREAMING = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/; -// Two shapes §9.7 carves out and that are distinguishable from a mis-cased -// state name: BCP 47 language tags (§10.9) and reverse-DNS identifiers built to -// a shape this guide defines (error codes §11.5, event types §16.3). +// Three shapes §9.7 carves out and that are distinguishable from a mis-cased +// state name: BCP 47 language tags (§10.9), reverse-DNS identifiers built to a +// shape this guide defines (error codes §11.5, event types §16.3), and IANA +// media types. const DEFAULT_ALLOW_PATTERN = - '^([a-z]{2,3}([-_][A-Za-z0-9]{2,8})*|[a-z][a-zA-Z0-9]*(\\.[a-zA-Z0-9-]+)+)$'; + '^([a-z]{2,3}([-_][A-Za-z0-9]{2,8})*|[a-z][a-zA-Z0-9]*(\\.[a-zA-Z0-9-]+)+|[a-z]+/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*)$'; const DEFAULT_ALLOW_VALUES = [ - // §5.9 health-status vocabulary and the operational synonyms around it. - 'pass', - 'fail', - 'warn', - 'ok', - 'up', - 'down', - 'healthy', - 'unhealthy', - 'degraded', + // JOSE/COSE algorithm and curve names (§9.7) that are not already + // SCREAMING_SNAKE_CASE. The all-caps ones (ES256, RS256, HS256, A128GCM …) + // satisfy the default pattern and need no entry. + 'EdDSA', + 'Ed25519', + 'Ed448', + 'X25519', + 'X448', + 'P-256', + 'P-384', + 'P-521', + 'secp256k1', + 'ECDH-ES', // §17.11 delivery guarantees. 'atMostOnce', 'atLeastOnce', diff --git a/api-design-guide/linter/functions/s12-collectionPagination.js b/api-design-guide/linter/functions/s12-collectionPagination.js index 977130f..6fa3094 100644 --- a/api-design-guide/linter/functions/s12-collectionPagination.js +++ b/api-design-guide/linter/functions/s12-collectionPagination.js @@ -1,4 +1,5 @@ import { isObject, asArray } from './lib/util.js'; +import { isStandardUnversionedPath } from './lib/standardEndpoints.js'; import envelopeShape from './envelopeShape.js'; /** @@ -15,11 +16,16 @@ import envelopeShape from './envelopeShape.js'; * An operation is considered to have opted into offset pagination when its * `parameters` array declares a parameter named `offset`. * - * The operational liveness endpoints `/health` and `/ready` (guide §5.9) are - * NOT collections and are exempted by exact path-key match (see EXEMPT_PATHS). + * Two carve-outs apply to every mode, so that the whole of §12's collection + * surface answers them the same way: + * - the guide §5.10 standard unversioned endpoints are not collections; + * - a collection whose 200 response declares its own bound with `maxItems` + * may be returned unpaginated (guide §12.1). * * options: - * mode {'cursorParams'|'cursorEnvelope'|'offsetEnvelope'} (required) + * mode {'pageParam'|'cursorParams'|'cursorEnvelope'|'pageSizeBounds'|'offsetEnvelope'} (required) + * - pageParam: operation MUST declare some page-size-like query + * parameter (`pageSize` or `offset`). * - cursorParams: operation MUST declare both `pageSize` and `cursor` * query parameters. No-op when the operation has an * `offset` parameter (that operation is covered by the @@ -27,6 +33,8 @@ import envelopeShape from './envelopeShape.js'; * - cursorEnvelope: the 200 response body schema MUST declare the §12.3 * envelope `{ items, pageInfo: { nextCursor, hasMore } }`. * No-op when the operation has an `offset` parameter. + * - pageSizeBounds: the `pageSize` parameter MUST exist and declare both a + * `default` and a `maximum` (guide §12.4). * - offsetEnvelope: the 200 response body schema MUST declare the §12.6 * flat envelope `{ items, offset, limit, total }`. * No-op when the operation does NOT have an `offset` @@ -40,24 +48,75 @@ import envelopeShape from './envelopeShape.js'; * @param {{path?: (string|number)[]}} [context] * @returns {{message:string, path:(string|number)[]}[]|undefined} */ -const EXEMPT_PATHS = new Set(['/health', '/ready']); +/** + * Guide §12.1 lets a collection go unpaginated when the specification itself + * fixes its size and says so with `maxItems`. Every array the response returns + * must carry that bound: one unbounded array is enough to make the response + * unbounded. + * + * @param {unknown} schema - the 200 response body schema. + * @returns {boolean} true when the response declares its own bound. + */ +function hasDeclaredBound(schema) { + if (!isObject(schema)) return false; + const arrays = []; + if (schema.type === 'array') arrays.push(schema); + if (isObject(schema.properties)) { + for (const prop of Object.values(schema.properties)) { + if (isObject(prop) && prop.type === 'array') arrays.push(prop); + } + } + if (arrays.length === 0) return false; + return arrays.every((a) => Number.isInteger(a.maxItems)); +} export default function collectionPagination(targetVal, options, context) { if (!isObject(targetVal)) return; const opts = isObject(options) ? options : {}; const base = context && Array.isArray(context.path) ? context.path : []; const mediaType = typeof opts.mediaType === 'string' ? opts.mediaType : 'application/json'; + const responseSchema = targetVal.responses?.['200']?.content?.[mediaType]?.schema; - // Guide §12.1 covers "endpoints returning collections". The operational - // liveness probes /health and /ready (guide §5.9) are not collections, so - // exempt them by exact path-key match. `given` selects the GET operation, so - // context.path is ['paths', '<pathKey>', 'get'] — the key sits before 'get'. + // Guide §12 covers "endpoints returning collections". The guide §5.10 + // standard unversioned endpoints are not collections, so exempt them by path + // key. `given` selects the GET operation, so context.path is + // ['paths', '<pathKey>', 'get'] — the key sits before 'get'. const pathKey = base.length >= 2 ? base[base.length - 2] : undefined; - if (typeof pathKey === 'string' && EXEMPT_PATHS.has(pathKey)) return undefined; + if (isStandardUnversionedPath(pathKey)) return undefined; + + // Guide §12.1: a collection bounded by its own schema may skip pagination, + // and with it the pagination parameters and envelopes of §12.2–§12.4. + if (hasDeclaredBound(responseSchema)) return undefined; const params = asArray(targetVal.parameters).filter(isObject); const offsetMode = params.some((p) => p.name === 'offset'); + if (opts.mode === 'pageParam') { + const names = new Set(params.map((p) => p.name)); + if (names.has('pageSize') || names.has('offset')) return undefined; + return [{ + message: 'collection endpoint must paginate: declare a "pageSize" (or, for offset pagination, "offset") query parameter, or bound the response with maxItems (guide 12.1)', + path: [...base, 'parameters'], + }]; + } + + if (opts.mode === 'pageSizeBounds') { + const pageSize = params.find((p) => p.name === 'pageSize'); + if (!pageSize) { + return [{ + message: 'collection endpoint must declare a "pageSize" query parameter with a documented default and maximum (guide 12.4)', + path: [...base, 'parameters'], + }]; + } + const schema = isObject(pageSize.schema) ? pageSize.schema : {}; + const missing = ['default', 'maximum'].filter((k) => schema[k] === undefined); + if (missing.length === 0) return undefined; + return [{ + message: `"pageSize" parameter schema must declare ${missing.join(' and ')} (guide 12.4)`, + path: [...base, 'parameters', params.indexOf(pageSize)], + }]; + } + if (opts.mode === 'cursorParams') { if (offsetMode) return undefined; const names = new Set(params.map((p) => p.name)); @@ -79,7 +138,7 @@ export default function collectionPagination(targetVal, options, context) { if (opts.mode !== 'cursorEnvelope' && opts.mode !== 'offsetEnvelope') return undefined; - const schema = targetVal.responses?.['200']?.content?.[mediaType]?.schema; + const schema = responseSchema; if (!isObject(schema)) return undefined; const schemaPath = [...base, 'responses', '200', 'content', mediaType, 'schema']; diff --git a/api-design-guide/linter/functions/s13-schemeExists.js b/api-design-guide/linter/functions/s13-schemeExists.js index edbeeea..d420c74 100644 --- a/api-design-guide/linter/functions/s13-schemeExists.js +++ b/api-design-guide/linter/functions/s13-schemeExists.js @@ -20,6 +20,12 @@ import { isObject, asArray } from './lib/util.js'; * oauthFlows {string[]} if present, a `type: oauth2` scheme also matches when * it declares at least one of these flow names under * `flows` (e.g. ["clientCredentials"]). + * httpBearerFormats {string[]} if present, a `type: http` scheme with + * `scheme: bearer` also matches when its `bearerFormat` + * is one of these (e.g. ["JWT"]). This is the §13.2 + * resource-server case: an API that validates tokens + * from an authorization server it does not own would + * misdescribe itself by declaring an oauth2 flow. * label {string} human label used in the message. * * @param {unknown} targetVal - the document root. @@ -33,6 +39,7 @@ export default function schemeExists(targetVal, options, context) { const base = context && Array.isArray(context.path) ? context.path : []; const types = asArray(opts.types).filter((t) => typeof t === 'string'); const oauthFlows = asArray(opts.oauthFlows).filter((f) => typeof f === 'string'); + const httpBearerFormats = asArray(opts.httpBearerFormats).filter((f) => typeof f === 'string'); const label = typeof opts.label === 'string' ? opts.label : 'a matching'; const components = isObject(targetVal.components) ? targetVal.components : {}; @@ -45,6 +52,9 @@ export default function schemeExists(targetVal, options, context) { if (t === 'oauth2' && oauthFlows.length && isObject(scheme.flows)) { return oauthFlows.some((f) => isObject(scheme.flows[f])); } + if (t === 'http' && httpBearerFormats.length && scheme.scheme === 'bearer') { + return httpBearerFormats.includes(scheme.bearerFormat); + } return false; }; diff --git a/api-design-guide/linter/rulesets/s05.yaml b/api-design-guide/linter/rulesets/s05.yaml index a59f19c..ed291d0 100644 --- a/api-design-guide/linter/rulesets/s05.yaml +++ b/api-design-guide/linter/rulesets/s05.yaml @@ -1,6 +1,6 @@ # Rules for §5 URL structure and versioning. Source text: ../../rules.yaml. # -# Implements guide rules 5.1, 5.3, 5.4, 5.6, 5.9 (implemented) and 5.5 +# Implements guide rules 5.1, 5.3, 5.4, 5.6, 5.9, 5.10 (implemented) and 5.5 # (partial-proxy). Strict-only siblings 5.2, 5.7, 5.8 live in # rulesets/s05-strict.yaml. Severity policy and formats follow # ../README.md#rule-naming-and-severities. @@ -9,14 +9,13 @@ functions: - pathSegments - valuePattern - mediaTypeExpected - - envelopeShape + - operationResponses - securityCoverage rules: - # 5.1 [M] MUST — major version prefix /v{N}/... on every path. - # exemptPaths carves out the guide 5.9 UNVERSIONED operational endpoints: - # "Each BB MUST expose an UNVERSIONED operational liveness endpoint at /health - # ... A separate /ready endpoint MAY be exposed." Those exact path keys cannot - # carry a /v{N}/ prefix, so 5.1 must not fire on them. + # 5.1 [M] MUST — major version prefix /v{N}/... on every path. The guide 5.10 + # standard unversioned endpoints cannot carry a /v{N}/ prefix and are skipped + # inside `pathSegments` (functions/lib/standardEndpoints.js), which is the one + # place that closed set is written down. govstack-5.1: description: "Every path must start with a major version prefix /v{N}/ (guide 5.1, [M])." message: "[5.1][M] {{error}}" @@ -26,7 +25,7 @@ rules: given: $.paths then: function: pathSegments - functionOptions: { check: versionPrefix, exemptPaths: ['/health', '/ready'] } + functionOptions: { check: versionPrefix } # 5.3 [M] MUST — multi-word path segments must be kebab-case. govstack-5.3: @@ -117,12 +116,12 @@ rules: type: object required: ["/health"] - # Scoped to the SUCCESS (200) response only. Guide 5.9's health+json media - # type describes the health PAYLOAD; guide 11.1 separately REQUIRES 4xx/5xx to - # be application/problem+json, so a responses[*] scope would make /health's - # error responses unsatisfiable (health+json vs problem+json conflict). + # Scoped to the SUCCESS (200) response only. Guide 5.9 gives the 200 body + # plain application/json and says the 503 is an ordinary error response, so + # guide 11.1 (4xx/5xx MUST be application/problem+json) owns the 503 and a + # responses[*] scope here would make the two unsatisfiable together. govstack-5.9-media-type: - description: "/health 200 response must declare media type application/health+json (guide 5.9, [M+R])." + description: "/health 200 response must declare media type application/json (guide 5.9, [M+R])." message: "[5.9][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint severity: error @@ -131,25 +130,26 @@ rules: then: function: mediaTypeExpected functionOptions: - require: ['application/health\+json'] + require: ['^application/json\s*(;|$)'] - # Scoped to the SUCCESS (200) response only — same 5.9-vs-11.1 interplay as - # 5.9-media-type above: the pass|fail|warn health enum belongs to the - # health+json 200 payload, not to the problem+json error responses. - govstack-5.9-status-enum: - description: "/health 200 response body must declare status with enum pass|fail|warn (guide 5.9, [M+R])." + # Guide 5.9 carries health in the status code: 200 healthy, 503 temporarily + # unable to accept work. Both must be declared, or a consumer told to read the + # status code has no documented 503 to read. Applies to /ready on the same + # terms ("under the same rules"), and only when it is present (the MAY clause + # means an absent /ready is not a finding). + govstack-5.9-status-codes: + description: "/health (and /ready, if present) must declare both 200 and 503 responses (guide 5.9, [M+R])." message: "[5.9][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint severity: error formats: [oas3_1] - given: "$.paths['/health'].get.responses['200'].content[*].schema" + given: + - "$.paths['/health'].get" + - "$.paths['/ready'].get" then: - function: envelopeShape + function: operationResponses functionOptions: - requiredProperties: [status] - properties: - status: - enum: ["pass", "fail", "warn"] + require: ["200", "503"] govstack-5.9-no-auth: description: "/health (and /ready, if present) must not require citizen authentication (guide 5.9, [M+R])." @@ -163,3 +163,23 @@ rules: then: function: securityCoverage functionOptions: { mode: none } + + # 5.10 [M] — the closed set of standard unversioned endpoints. The exemptions + # the section grants are implemented where they bite (5.1 and 5.3 in + # `pathSegments`, §12 in `s12-collectionPagination`, 13.1 in + # `securityCoverage`), all reading the same set from + # functions/lib/standardEndpoints.js. What is left for a rule of its own is + # the prohibition: "a BB MUST NOT place a business resource under one of these + # paths ... they are read-only and MUST NOT declare POST, PUT, PATCH, or + # DELETE". Whether a GET under one of these paths is a business resource in + # disguise is not mechanically decidable and stays with review. + govstack-5.10: + description: "standard unversioned endpoints are read-only and must not declare a mutating method (guide 5.10, [M])." + message: "[5.10][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints + severity: error + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: standardEndpointsReadOnly } diff --git a/api-design-guide/linter/rulesets/s12.yaml b/api-design-guide/linter/rulesets/s12.yaml index 8d0cf75..5013d40 100644 --- a/api-design-guide/linter/rulesets/s12.yaml +++ b/api-design-guide/linter/rulesets/s12.yaml @@ -14,11 +14,10 @@ # 12.7), it is used only to scope an otherwise-exact shape check to the # endpoints the shape applies to. # -# Operational-endpoint carve-out: the guide 5.9 liveness probes `/health` and -# `/ready` are NOT collections, so they are exempt from the collection-GET -# rules. Function-driven rules (12.2/12.3/12.6) exempt them inside -# `s12-collectionPagination` (exact path-key match); the two inline-schema -# rules (12.1/12.4) exempt them directly in their `given`. +# Two carve-outs are shared by every collection-GET rule and therefore live in +# one place, inside `s12-collectionPagination`: the guide 5.10 standard +# unversioned endpoints are not collections, and a collection whose 200 +# response declares its own `maxItems` bound may go unpaginated (guide 12.1). functionsDir: "../functions" functions: - s12-collectionPagination @@ -37,23 +36,11 @@ rules: documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate severity: warn formats: [oas3_1] - # Inline-schema rule: exempt /health and /ready (guide 5.9 operational - # endpoints, not collections) directly in the given. - given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/) && @property != "/health" && @property != "/ready")][get]' + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' then: - function: schema + function: s12-collectionPagination functionOptions: - schema: - type: object - required: [parameters] - properties: - parameters: - type: array - contains: - type: object - required: [name] - properties: - name: { enum: [pageSize, offset] } + mode: pageParam # 12.2 [M+R] — PROXY (bucket B, MUST -> warn). Default pagination MUST be # cursor-based with `pageSize` + `cursor` query parameters. No-op (via @@ -94,32 +81,17 @@ rules: # 12.4 [M+R] — pageSize MUST have a documented default and maximum. Fires # both when pageSize is missing entirely and when it is present without # both keywords (both cases mean "not documented"). - # Inline-schema rule: exempt /health and /ready (guide 5.9 operational - # endpoints, not collections) directly in the given, same as 12.1. govstack-12.4: description: "pageSize parameter must declare a default and a maximum (guide 12.4, [M+R])." message: "[12.4][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#124-documented-pagesize-bounds severity: error formats: [oas3_1] - given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/) && @property != "/health" && @property != "/ready")][get]' + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' then: - function: schema + function: s12-collectionPagination functionOptions: - schema: - type: object - required: [parameters] - properties: - parameters: - type: array - contains: - type: object - required: [name, schema] - properties: - name: { const: pageSize } - schema: - type: object - required: [default, maximum] + mode: pageSizeBounds # 12.6 [M+R] — offset pagination, when opted into (an `offset` parameter is # declared), MUST use the flat envelope { items, offset, limit, total }, diff --git a/api-design-guide/linter/rulesets/s13.yaml b/api-design-guide/linter/rulesets/s13.yaml index a503d31..2c0cbf7 100644 --- a/api-design-guide/linter/rulesets/s13.yaml +++ b/api-design-guide/linter/rulesets/s13.yaml @@ -32,11 +32,17 @@ rules: requireSchemes: true # 13.2 [M+R] — PROXY (bucket B), MUST notched to warn. - # Verifies: an openIdConnect or oauth2 scheme is declared. + # Verifies: an openIdConnect or oauth2 scheme is declared, or the resource- + # server alternative the guide allows (type: http, scheme: bearer, + # bearerFormat: JWT) for an API that validates tokens from an authorization + # server it does not own. # Does NOT verify: which operations are citizen-facing, that citizen-facing - # operations actually apply this scheme, or discovery-URL validity. + # operations actually apply this scheme, discovery-URL validity, that the + # resource-server case really is one, or that its accepted issuers and + # required audience are documented (guide 13.2 requires it in prose or an + # adjacent /.well-known/ document; neither is mechanically decidable). govstack-13.2: - description: "an OAuth 2.0 / OpenID Connect security scheme must be declared (guide 13.2, [M+R], proxy)." + description: "an OAuth 2.0 / OpenID Connect (or resource-server bearer JWT) security scheme must be declared (guide 13.2, [M+R], proxy)." message: "[13.2][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations severity: warn @@ -46,7 +52,8 @@ rules: function: s13-schemeExists functionOptions: types: [openIdConnect, oauth2] - label: "OAuth 2.0 / OpenID Connect" + httpBearerFormats: [JWT] + label: "OAuth 2.0 / OpenID Connect (or resource-server bearer JWT)" # 13.2 [M+R] — deterministic security baseline: resource-owner password and # implicit grants MUST NOT be declared. diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml index 77e9308..e91a5a5 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml @@ -36,6 +36,27 @@ paths: properties: nextCursor: { type: string, nullable: true } hasMore: { type: boolean } + # Guide 12.1 lets a collection whose size the specification itself fixes go + # unpaginated, provided the bound is declared with maxItems. + /v1/supported-locales: + get: + operationId: listSupportedLocales + summary: List supported locales + description: The fixed set of locales this BB serves. + tags: [foos] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + maxItems: 40 + items: { type: string } # Guide 12.1 covers endpoints returning collections; the operational liveness # probe /health is not a collection, so a bare GET must NOT fire this rule. /health: diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.10/fail.yaml new file mode 100644 index 0000000..f872791 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.10/fail.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: >- + A sample API that hides a business resource under a standard unversioned + path to escape the versioning and collection rules. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + # Guide 5.10: the standard unversioned endpoints are read-only. A mutating + # method here is a business resource on an exempt path. + /.well-known/registrants: + post: + operationId: createRegistrant + summary: Create a registrant + description: Creates a registrant record. + tags: [Registrants] + security: [] + responses: + "201": + description: Created. + content: + application/json: + schema: + type: object + properties: + id: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.10/pass.yaml new file mode 100644 index 0000000..d2c8954 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.10/pass.yaml @@ -0,0 +1,60 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: >- + A sample API whose standard unversioned endpoints are read-only, and whose + business resources stay on the versioned surface. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /.well-known/oauth-protected-resource: + get: + operationId: getProtectedResourceMetadata + summary: Protected-resource metadata + description: RFC 9728 metadata describing this resource server. + tags: [Discovery] + security: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + resource: { type: string, format: uri } + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } + # A mutating method is fine where it belongs: on the versioned surface. + /v1/registrants: + post: + operationId: createRegistrant + summary: Create a registrant + description: Creates a registrant record. + tags: [Registrants] + security: [] + responses: + "201": + description: Created. + content: + application/json: + schema: + type: object + properties: + id: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml index e7c23bd..5962aa7 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml @@ -18,11 +18,8 @@ paths: "200": description: Service is healthy. content: - application/json: + application/health+json: schema: type: object - required: [status] properties: - status: - type: string - enum: [pass, fail, warn] + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml index 21fc343..3cd57d8 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml @@ -18,18 +18,15 @@ paths: "200": description: Service is healthy. content: - application/health+json: + application/json: schema: type: object - required: [status] properties: - status: - type: string - enum: [pass, fail, warn] - # Per guide 11.1, the error response is problem+json, NOT health+json. + description: { type: string } + # Per guide 11.1 an error response is problem+json, not application/json. # 5.9-media-type is scoped to the 200 response, so it must NOT fire here. - "500": - description: Service is unhealthy. + "503": + description: Service is temporarily unable to accept work. content: application/problem+json: schema: diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml index bf559eb..0fb08cc 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml @@ -25,11 +25,8 @@ paths: "200": description: Service is healthy. content: - application/health+json: + application/json: schema: type: object - required: [status] properties: - status: - type: string - enum: [pass, fail, warn] + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml index 06d5bd1..4133299 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml @@ -18,11 +18,8 @@ paths: "200": description: Service is healthy. content: - application/health+json: + application/json: schema: type: object - required: [status] properties: - status: - type: string - enum: [pass, fail, warn] + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml index 06d5bd1..4133299 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml @@ -18,11 +18,8 @@ paths: "200": description: Service is healthy. content: - application/health+json: + application/json: schema: type: object - required: [status] properties: - status: - type: string - enum: [pass, fail, warn] + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/fail.yaml similarity index 62% rename from api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/fail.yaml rename to api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/fail.yaml index b5c2321..99af979 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/fail.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Sample API version: 1.0.0 - description: A sample API whose /health status enum does not match the guide. + description: A sample API whose /health declares no unhealthy status code. contact: name: Sample BB Team url: https://example.org/contact @@ -15,14 +15,13 @@ paths: tags: [Health] security: [] responses: + # Guide 5.9 carries health in the status code, so a consumer told to + # read it needs a documented 503 as well as the 200. "200": description: Service is healthy. content: - application/health+json: + application/json: schema: type: object - required: [status] properties: - status: - type: string - enum: [up, down] + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/pass.yaml similarity index 65% rename from api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/pass.yaml rename to api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/pass.yaml index a2e85c2..dcf525d 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-enum/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/pass.yaml @@ -18,19 +18,13 @@ paths: "200": description: Service is healthy. content: - application/health+json: + application/json: schema: type: object - required: [status] properties: - status: - type: string - enum: [pass, fail, warn] - # Per guide 11.1, the error response is problem+json with an integer - # `status`, NOT the health pass|fail|warn enum. 5.9-status-enum is scoped - # to the 200 response, so this problem envelope must NOT fire it. - "500": - description: Service is unhealthy. + description: { type: string } + "503": + description: Service is temporarily unable to accept work. content: application/problem+json: schema: diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml index d6d7f19..f8c6ef1 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml @@ -12,9 +12,15 @@ components: - PENDING_REVIEW # §9.7 carve-outs: values whose form is fixed by another rule or an # external standard keep their own casing and must not be flagged. - HealthStatus: + SigningAlgorithm: type: string - enum: [pass, fail, warn] + enum: [ES256, EdDSA] + SigningCurve: + type: string + enum: [Ed25519, P-256] + MediaType: + type: string + enum: [application/json, application/problem+json] DeliveryGuarantee: type: string enum: [atMostOnce, atLeastOnce, effectivelyOnce] diff --git a/api-design-guide/linter/tests/functions.test.mjs b/api-design-guide/linter/tests/functions.test.mjs index 4f82d59..eba495c 100644 --- a/api-design-guide/linter/tests/functions.test.mjs +++ b/api-design-guide/linter/tests/functions.test.mjs @@ -19,7 +19,10 @@ import mediaTypeExpected from '../functions/mediaTypeExpected.js'; import successResponseSchema from '../functions/s07-successResponseSchema.js'; import creationResponses from '../functions/s07-creationResponses.js'; import baselineResponses from '../functions/s07-baselineResponses.js'; +import collectionPagination from '../functions/s12-collectionPagination.js'; +import schemeExists from '../functions/s13-schemeExists.js'; import { walkSchema } from '../functions/lib/schemaWalk.js'; +import { isStandardUnversionedPath } from '../functions/lib/standardEndpoints.js'; const count = (r) => (r === undefined ? 0 : r.length); @@ -39,6 +42,8 @@ const ALL = { successResponseSchema, creationResponses, baselineResponses, + collectionPagination, + schemeExists, }; test('all functions return undefined on bad input, never throw', () => { for (const [name, fn] of Object.entries(ALL)) { @@ -133,6 +138,84 @@ test('pathSegments: version prefix / casing / depth', () => { assert.equal(count(pathSegments(paths, { check: 'maxDepthAfterVersion', max: 2 })), 1); // only the 3-non-param-level path }); +test('isStandardUnversionedPath: the 5.10 closed set', () => { + for (const p of ['/health', '/ready', '/openapi.json', '/asyncapi.json', '/.well-known/oauth-protected-resource']) { + assert.equal(isStandardUnversionedPath(p), true, p); + } + for (const p of ['/v1/health', '/healthz', '/well-known/jwks.json', '/', '', 42, undefined]) { + assert.equal(isStandardUnversionedPath(p), false, String(p)); + } +}); + +test('pathSegments: standard unversioned endpoints are exempt (5.10) and read-only', () => { + const paths = { + '/health': { get: {}, delete: {} }, + '/.well-known/oauth-protected-resource': { get: {}, post: {} }, + '/v1/things': { get: {}, post: {} }, + }; + // The 5.10 set sits outside /v{N}/ by design, and `.well-known` is not kebab. + assert.equal(count(pathSegments(paths, { check: 'versionPrefix' })), 0); + assert.equal(count(pathSegments(paths, { check: 'segmentCasing', casing: 'kebab' })), 0); + // They are read-only: a mutating method there is a business resource in hiding. + const readOnly = pathSegments(paths, { check: 'standardEndpointsReadOnly' }); + assert.deepEqual(readOnly.map((r) => r.path.join(' ')).sort(), [ + '/.well-known/oauth-protected-resource post', + '/health delete', + ]); +}); + +test('s12-collectionPagination: 5.10 and maxItems carve-outs, pageParam, pageSizeBounds', () => { + const ctx = (pathKey) => ({ path: ['paths', pathKey, 'get'] }); + const withSchema = (schema, parameters) => ({ + ...(parameters ? { parameters } : {}), + responses: { 200: { content: { 'application/json': { schema } } } }, + }); + const unbounded = withSchema({ type: 'object', properties: { items: { type: 'array' } } }); + + // An unpaginated, unbounded collection fires both 12.1 and 12.4... + assert.equal(count(collectionPagination(unbounded, { mode: 'pageParam' }, ctx('/v1/things'))), 1); + assert.equal(count(collectionPagination(unbounded, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 1); + // ...unless the path is a standard unversioned endpoint, which is not a collection... + assert.equal(count(collectionPagination(unbounded, { mode: 'pageParam' }, ctx('/health'))), 0); + assert.equal(count(collectionPagination(unbounded, { mode: 'pageSizeBounds' }, ctx('/health'))), 0); + // ...or the response bounds every array it returns with maxItems (12.1). + const bounded = withSchema({ type: 'object', properties: { items: { type: 'array', maxItems: 40 } } }); + assert.equal(count(collectionPagination(bounded, { mode: 'pageParam' }, ctx('/v1/locales'))), 0); + // One unbounded array is enough to make the whole response unbounded. + const halfBounded = withSchema({ + type: 'object', + properties: { items: { type: 'array', maxItems: 40 }, extras: { type: 'array' } }, + }); + assert.equal(count(collectionPagination(halfBounded, { mode: 'pageParam' }, ctx('/v1/locales'))), 1); + + // 12.1 is satisfied by either cursor or offset pagination (12.6). + const offset = withSchema({ type: 'object' }, [{ name: 'offset' }, { name: 'limit' }]); + assert.equal(count(collectionPagination(offset, { mode: 'pageParam' }, ctx('/v1/things'))), 0); + // 12.4 wants both bounds on pageSize, not just its presence. + const noMaximum = withSchema({ type: 'object' }, [{ name: 'pageSize', schema: { default: 20 } }]); + assert.equal(count(collectionPagination(noMaximum, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 1); + const bothBounds = withSchema({ type: 'object' }, [{ name: 'pageSize', schema: { default: 20, maximum: 100 } }]); + assert.equal(count(collectionPagination(bothBounds, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 0); +}); + +test('s13-schemeExists: types / oauthFlows / httpBearerFormats', () => { + const doc = (schemes) => ({ components: { securitySchemes: schemes } }); + const citizen = { types: ['openIdConnect', 'oauth2'], httpBearerFormats: ['JWT'] }; + assert.equal(count(schemeExists(doc({ a: { type: 'openIdConnect' } }), citizen)), 0); + // A resource server that only validates tokens issued elsewhere declares + // http bearer + JWT rather than misdescribing itself with an oauth2 flow. + assert.equal(count(schemeExists(doc({ a: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }), citizen)), 0); + // An opaque bearer token or basic auth is not that profile. + assert.equal(count(schemeExists(doc({ a: { type: 'http', scheme: 'bearer' } }), citizen)), 1); + assert.equal(count(schemeExists(doc({ a: { type: 'http', scheme: 'basic' } }), citizen)), 1); + + const service = { types: ['mutualTLS'], oauthFlows: ['clientCredentials'] }; + assert.equal(count(schemeExists(doc({ a: { type: 'mutualTLS' } }), service)), 0); + assert.equal(count(schemeExists(doc({ a: { type: 'oauth2', flows: { clientCredentials: {} } } }), service)), 0); + assert.equal(count(schemeExists(doc({ a: { type: 'oauth2', flows: { authorizationCode: {} } } }), service)), 1); + assert.equal(count(schemeExists(doc({}), service)), 1); +}); + test('envelopeShape: required / nested / const / enum / allOf', () => { const page = { type: 'object', diff --git a/api-design-guide/linter/tests/golden.test.mjs b/api-design-guide/linter/tests/golden.test.mjs index 311d81d..5ffafce 100644 --- a/api-design-guide/linter/tests/golden.test.mjs +++ b/api-design-guide/linter/tests/golden.test.mjs @@ -6,14 +6,15 @@ // BB spec editors. // // BOTH goldens MUST lint to ZERO findings of any severity under the default -// ruleset. The ruleset exempts the guide §5.9 operational endpoints (/health, -// /ready) from the resource-oriented rules that do not apply to a liveness probe -// (§5.1 version prefix, §12.x pagination, §7.16 ETag), scopes the §5.9 health+json -// media type / status enum to the SUCCESS response so the §11.1 problem+json error -// response is satisfiable, and counts only non-param path segments toward §5.4 max -// nesting depth so the §15.5/§16.11 mandated action sub-resource paths stay in -// bounds. Any finding is therefore a genuine regression; the test prints the full -// finding list on failure for debugging. +// ruleset. The ruleset exempts the guide §5.10 standard unversioned endpoints +// (/health, /ready, /.well-known/*, spec discovery) from the resource-oriented +// rules that do not apply to them (§5.1 version prefix, §5.3 segment casing, +// §12.x pagination, §7.16 ETag), scopes the §5.9 application/json media type to +// the SUCCESS response so the §11.1 problem+json error response is satisfiable, +// and counts only non-param path segments toward §5.4 max nesting depth so the +// §15.5/§16.11 mandated action sub-resource paths stay in bounds. Any finding is +// therefore a genuine regression; the test prints the full finding list on +// failure for debugging. import { test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/api-design-guide/linter/tests/golden/openapi-golden.yaml b/api-design-guide/linter/tests/golden/openapi-golden.yaml index d73dd71..dcfc324 100644 --- a/api-design-guide/linter/tests/golden/openapi-golden.yaml +++ b/api-design-guide/linter/tests/golden/openapi-golden.yaml @@ -16,14 +16,14 @@ # scopes (§13), and the §20.3 conformance declaration. # # This spec lints to ZERO findings under the default ruleset. The ruleset -# exempts the operational /health (and /ready) endpoint from the resource- -# oriented rules that do not apply to a liveness probe (§5.1 version prefix, -# §12.x pagination, §7.16 ETag), scopes the §5.9 health+json media type and -# status enum to the SUCCESS (200) response so the §11.1 problem+json error -# response is satisfiable, and counts only non-param segments toward §5.4 max -# nesting depth so the §15.5/§16.11 mandated action sub-resource paths -# (/v1/operations/{operationId}/cancel, .../subscriptions/{id}/rotate-secret) -# stay within two levels. +# exempts the §5.10 standard unversioned endpoints (here, /health) from the +# resource-oriented rules that do not apply to them (§5.1 version prefix, §5.3 +# segment casing, §12.x pagination, §7.16 ETag), scopes the §5.9 +# application/json media type to the SUCCESS (200) response so the §11.1 +# problem+json error responses are satisfiable, and counts only non-param +# segments toward §5.4 max nesting depth so the §15.5/§16.11 mandated action +# sub-resource paths (/v1/operations/{operationId}/cancel, +# .../subscriptions/{id}/rotate-secret) stay within two levels. openapi: 3.1.0 info: title: GovStack Registry Building Block API @@ -67,28 +67,32 @@ paths: operationId: getHealth summary: Liveness check description: >- - Unversioned operational liveness endpoint per guide §5.9. Reports overall - service health using the application/health+json media type. Unauthenticated - and free of citizen data and system-internal detail. + Unversioned operational liveness endpoint per guide §5.9. Health is carried + by the status code: 200 when the service can accept work, 503 when it + temporarily cannot. Unauthenticated and free of citizen data and + system-internal detail. tags: [Health] security: [] parameters: - $ref: '#/components/parameters/Traceparent' responses: '200': - description: The service is alive; body reports the aggregate status. + description: The service is alive and able to accept work. headers: traceparent: $ref: '#/components/headers/Traceparent' content: - application/health+json: + application/json: schema: $ref: '#/components/schemas/HealthStatus' - # §7.13 requires a documented 500. Per §11.1 every 4xx/5xx MUST be - # application/problem+json, so the /health error response uses the - # standard problem envelope (Cache-Control: no-store included). The - # §5.9 health+json media type and pass|fail|warn status enum apply to - # the SUCCESS (200) payload above, not to error responses. + # §5.9 requires the unhealthy status code as well as the healthy one, and + # §11.1 makes every 4xx/5xx application/problem+json, so the 503 carries + # the standard problem envelope (Cache-Control: no-store included). The + # §5.9 application/json media type applies to the SUCCESS (200) payload + # above, not to error responses. + '503': + $ref: '#/components/responses/ServiceUnavailable' + # §7.13 requires a documented 500 on every operation. '500': $ref: '#/components/responses/ServerError' /v1/registrants: @@ -885,6 +889,15 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + ServiceUnavailable: + description: The service is temporarily unable to accept work (guide §5.9). + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' schemas: Problem: type: object @@ -975,26 +988,18 @@ components: description: Human-readable description of what is wrong with the field. HealthStatus: type: object - description: application/health+json body reporting aggregate service health. - required: [status] + description: >- + Minimal application/json body accompanying a 200 from the liveness + endpoint. Health itself is carried by the status code (§5.9), so this + body is informational: it names the service and nothing more. Versions, + hostnames, and dependency topology are system-internal detail §5.9 + forbids here. properties: - status: - type: string - description: >- - Aggregate health indicator: "pass" (healthy), "warn" (healthy but with - concerns) or "fail" (unhealthy). - enum: [pass, warn, fail] - x-extensible-enum: true - version: - type: string - description: Version of the service reporting health. - releaseId: + description: type: string - description: Deployed release identifier of the service. + description: Human-readable name of the service reporting health. examples: - - status: pass - version: '1.0.0' - releaseId: '2026.07.10' + - description: health of the registry BB Money: type: object description: >- diff --git a/api-design-guide/part-b/5-url-structure-and-versioning.md b/api-design-guide/part-b/5-url-structure-and-versioning.md index c5c843c..fc17c15 100644 --- a/api-design-guide/part-b/5-url-structure-and-versioning.md +++ b/api-design-guide/part-b/5-url-structure-and-versioning.md @@ -12,7 +12,7 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.1 Major version in the path <a href="#51-major-version-in-the-path" id="51-major-version-in-the-path"></a> -**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The unversioned operational endpoints of [§5.9](#59-unversioned-health-endpoint) (`/health`, and `/ready` where exposed) are the only exception. [`[OPEN-4-A]`](../appendix/b-open-questions.md) +**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) are the only exception. [`[OPEN-4-A]`](../appendix/b-open-questions.md) ## 5.2 Plural noun resources <a href="#52-plural-noun-resources" id="52-plural-noun-resources"></a> @@ -20,7 +20,7 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.3 Kebab-case path segments <a href="#53-kebab-case-path-segments" id="53-kebab-case-path-segments"></a> -**[M]** Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`). +**[M]** Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`). The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) keep the segment spelling their own definition gives them, including the `.well-known` prefix that RFC 8615 fixes. ## 5.4 Shallow path nesting <a href="#54-shallow-path-nesting" id="54-shallow-path-nesting"></a> @@ -44,13 +44,16 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.9 Unversioned health endpoint <a href="#59-unversioned-health-endpoint" id="59-unversioned-health-endpoint"></a> -**[M+R]** Each BB **MUST** expose an unversioned operational liveness endpoint at `/health` using media type `application/health+json` with `status` values `"pass" | "fail" | "warn"`. This shape is modelled on `draft-inadarei-api-health-check`, an expired individual Internet-Draft (never adopted as an RFC); GovStack adopts it as a local convention, not as a live IETF standard. A separate `/ready` endpoint **MAY** be exposed for readiness probes. These endpoints **MUST NOT** carry citizen authentication and **MUST NOT** expose system-internal detail. [`[OPEN-4-B]`](../appendix/b-open-questions.md) +**[M+R]** Each BB **MUST** expose an unversioned operational liveness endpoint at `/health`. Health is carried by the HTTP status: `200` when the service is healthy and able to accept work, `503` when it is temporarily unable to. A consumer **MUST** determine health from the status code; response body fields are informational and **MUST NOT** be required for that determination. The `200` response **MUST** use media type `application/json` and **SHOULD** be minimal; the `503` is an error response and carries the problem envelope of [§11.1](../part-c/11-errors.md#111-rfc-9457-problem-details) like any other. The endpoint **MUST** be cheap and bounded, **MUST NOT** carry citizen authentication, and **MUST NOT** expose system-internal detail such as hostnames, versions, stack traces, or dependency topology. It **MUST NOT** probe an external dependency unless that dependency genuinely determines whether the service can accept work. A separate `/ready` endpoint **MAY** be exposed where readiness and liveness semantics differ, under the same rules. -**Example (informative).** A `/health` response (media type `application/health+json`), aligned with `draft-inadarei-api-health-check`: +**Example (informative).** A `/health` response: ```json { - "status": "pass", "description": "health of the registration BB" } ``` + +## 5.10 Standard unversioned endpoints <a href="#510-standard-unversioned-endpoints" id="510-standard-unversioned-endpoints"></a> + +**[M]** A closed set of endpoints sits outside the versioned business surface, because their location or spelling is fixed by something other than this guide. That set is: well-known URIs under `/.well-known/`, whose location RFC 8615 roots at that exact prefix; the operational endpoints of [§5.9](#59-unversioned-health-endpoint); and a runtime specification-discovery endpoint where a BB serves one, conventionally `/openapi.json` or `/asyncapi.json`. These endpoints are exempt from [§5.1](#51-major-version-in-the-path), [§5.3](#53-kebab-case-path-segments), and the collection rules of [§12](../part-c/12-pagination-filtering-sorting.md), and they satisfy [§13.1](../part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation) with an explicit empty security requirement (`security: []`) where they are unauthenticated. No other endpoint **MAY** claim this exemption, and a BB **MUST NOT** place a business resource under one of these paths in order to escape the rules above: they are read-only and **MUST NOT** declare `POST`, `PUT`, `PATCH`, or `DELETE`. diff --git a/api-design-guide/part-c/12-pagination-filtering-sorting.md b/api-design-guide/part-c/12-pagination-filtering-sorting.md index 5e3e2d4..6e26f22 100644 --- a/api-design-guide/part-c/12-pagination-filtering-sorting.md +++ b/api-design-guide/part-c/12-pagination-filtering-sorting.md @@ -12,7 +12,7 @@ description: "Mandatory pagination for collections, cursor and offset envelopes, ## 12.1 Collections must paginate <a href="#121-collections-must-paginate" id="121-collections-must-paginate"></a> -**[M+R]** Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden. +**[M+R]** Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden. A collection whose size is fixed by the specification itself **MAY** be returned unpaginated, provided the bound is declared in the schema with `maxItems`; an undeclared expectation that a collection stays small does not qualify, because an integrator cannot see it and a linter cannot check it. The standard unversioned endpoints of [§5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints) are not collections and this section does not apply to them. ## 12.2 Cursor pagination by default <a href="#122-cursor-pagination-by-default" id="122-cursor-pagination-by-default"></a> diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md index 3ea2e60..45ec4ce 100644 --- a/api-design-guide/part-c/9-json-conventions-and-naming.md +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -36,7 +36,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.7 Screaming snake case enum values <a href="#97-screaming-snake-case-enum-values" id="97-screaming-snake-case-enum-values"></a> -**[M]** Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), the GovStack `x-govstack-*` extension vocabularies ([§17.11](../part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees), [§17.13](../part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and the health-status vocabulary of [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint). +**[M]** Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), the GovStack `x-govstack-*` extension vocabularies ([§17.11](../part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees), [§17.13](../part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it. ## 9.8 Forward-compatible schemas <a href="#98-forward-compatible-schemas" id="98-forward-compatible-schemas"></a> @@ -68,4 +68,4 @@ Fields imported wholesale from an external standard retain that standard's namin (Per [§1.7](../1-introduction.md#17-precedence-of-external-standards).) -Enum values defined by an external standard retain that standard's casing. The known case in v0.1 is the `/health` status values `"pass" | "fail" | "warn"` per [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) (IETF `draft-inadarei-api-health-check`). +Enum values defined by an external standard retain that standard's casing. The known cases are values drawn from an IANA registry: JOSE and COSE algorithm and curve names (`ES256`, `EdDSA`, `Ed25519`, `P-256`) where a BB declares the algorithms it accepts, and media types. diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md index 0c98726..1b7a7b6 100644 --- a/api-design-guide/part-d/13-authentication-and-authorisation.md +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -12,11 +12,11 @@ description: "Rules for how BB API specs declare security schemes, OAuth scopes, ## 13.1 Default security on every operation <a href="#131-default-security-on-every-operation" id="131-default-security-on-every-operation"></a> -**[M]** Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit. +**[M]** Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit. The standard unversioned endpoints of [§5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints) satisfy this rule with an explicit empty security requirement (`security: []`) where they are unauthenticated: they are exempt from carrying a scheme, not from declaring what they carry. ## 13.2 OAuth and OIDC for citizen operations <a href="#132-oauth-and-oidc-for-citizen-operations" id="132-oauth-and-oidc-for-citizen-operations"></a> -**[M+R]** Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles. +**[M+R]** Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. An API that is purely a resource server, validating access tokens issued by an authorization server it does not own and whose endpoints are not part of its own contract, **MAY** instead declare `type: http` with `scheme: bearer` and `bearerFormat: JWT`, because declaring an `oauth2` flow it does not operate would misdescribe the deployed API. That declaration **MUST** document, in the scheme description or an adjacent `/.well-known/` metadata document, the issuers it accepts and the audience value it requires, so an integrator can still discover how to obtain a usable token. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles. ## 13.3 Distinct scheme for BB-to-BB calls <a href="#133-distinct-scheme-for-bb-to-bb-calls" id="133-distinct-scheme-for-bb-to-bb-calls"></a> @@ -24,7 +24,7 @@ description: "Rules for how BB API specs declare security schemes, OAuth scopes, ## 13.4 Namespaced OAuth scopes <a href="#134-namespaced-oauth-scopes" id="134-namespaced-oauth-scopes"></a> -**[M]** OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-12-A]`](../appendix/b-open-questions.md) +**[M]** Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-12-A]`](../appendix/b-open-questions.md) ## 13.5 Authorization is the credential channel <a href="#135-authorization-is-the-credential-channel" id="135-authorization-is-the-credential-channel"></a> diff --git a/api-design-guide/part-d/18-compatibility-and-lifecycle.md b/api-design-guide/part-d/18-compatibility-and-lifecycle.md index 8bf6c14..975b39c 100644 --- a/api-design-guide/part-d/18-compatibility-and-lifecycle.md +++ b/api-design-guide/part-d/18-compatibility-and-lifecycle.md @@ -12,7 +12,7 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.1 SemVer versioning <a href="#181-semver-versioning" id="181-semver-versioning"></a> -**[M]** `info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under [§18.2](#182-major-version-in-path-or-channel). +**[M]** `info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under [§18.2](#182-major-version-in-path-or-channel). An API served under `/v1` therefore carries an `info.version` of `1.x.y`. The version of the software that implements the contract is a separate number that this guide does not constrain: an implementation may be at `0.16.3` while the contract it serves is at `1.4.0`, and `info.version` **MUST NOT** be set to the implementation version. ## 18.2 Major version in path or channel <a href="#182-major-version-in-path-or-channel" id="182-major-version-in-path-or-channel"></a> diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 6950434..b569c40 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -5,7 +5,7 @@ # The same `#anchor` fragment resolves on both GitHub and GitBook. guide: GovStack Cross-BB API Design Guide version: 0.2.0-draft -rule_count: 172 +rule_count: 173 rules: - id: "2.1" title: "OpenAPI 3.1 required" @@ -221,7 +221,7 @@ rules: surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 51-major-version-in-the-path - text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The unversioned operational endpoints of §5.9 (`/health`, and `/ready` where exposed) are the only exception. `[OPEN-4-A]`" + text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of §5.10 are the only exception. `[OPEN-4-A]`" open_questions: ["OPEN-4-A"] - id: "5.2" title: "Plural noun resources" @@ -239,7 +239,7 @@ rules: surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 53-kebab-case-path-segments - text: "Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`)." + text: "Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`). The standard unversioned endpoints of §5.10 keep the segment spelling their own definition gives them, including the `.well-known` prefix that RFC 8615 fixes." open_questions: [] - id: "5.4" title: "Shallow path nesting" @@ -289,12 +289,21 @@ rules: - id: "5.9" title: "Unversioned health endpoint" class: M+R - strengths: ["MUST NOT", "MUST", "MAY"] + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 59-unversioned-health-endpoint - text: "Each BB **MUST** expose an unversioned operational liveness endpoint at `/health` using media type `application/health+json` with `status` values `\"pass\" | \"fail\" | \"warn\"`. This shape is modelled on `draft-inadarei-api-health-check`, an expired individual Internet-Draft (never adopted as an RFC); GovStack adopts it as a local convention, not as a live IETF standard. A separate `/ready` endpoint **MAY** be exposed for readiness probes. These endpoints **MUST NOT** carry citizen authentication and **MUST NOT** expose system-internal detail. `[OPEN-4-B]`" - open_questions: ["OPEN-4-B"] + text: "Each BB **MUST** expose an unversioned operational liveness endpoint at `/health`. Health is carried by the HTTP status: `200` when the service is healthy and able to accept work, `503` when it is temporarily unable to. A consumer **MUST** determine health from the status code; response body fields are informational and **MUST NOT** be required for that determination. The `200` response **MUST** use media type `application/json` and **SHOULD** be minimal; the `503` is an error response and carries the problem envelope of §11.1 like any other. The endpoint **MUST** be cheap and bounded, **MUST NOT** carry citizen authentication, and **MUST NOT** expose system-internal detail such as hostnames, versions, stack traces, or dependency topology. It **MUST NOT** probe an external dependency unless that dependency genuinely determines whether the service can accept work. A separate `/ready` endpoint **MAY** be exposed where readiness and liveness semantics differ, under the same rules." + open_questions: [] +- id: "5.10" + title: "Standard unversioned endpoints" + class: M + strengths: ["MUST NOT", "MAY"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 510-standard-unversioned-endpoints + text: "A closed set of endpoints sits outside the versioned business surface, because their location or spelling is fixed by something other than this guide. That set is: well-known URIs under `/.well-known/`, whose location RFC 8615 roots at that exact prefix; the operational endpoints of §5.9; and a runtime specification-discovery endpoint where a BB serves one, conventionally `/openapi.json` or `/asyncapi.json`. These endpoints are exempt from §5.1, §5.3, and the collection rules of §12, and they satisfy §13.1 with an explicit empty security requirement (`security: []`) where they are unauthenticated. No other endpoint **MAY** claim this exemption, and a BB **MUST NOT** place a business resource under one of these paths in order to escape the rules above: they are read-only and **MUST NOT** declare `POST`, `PUT`, `PATCH`, or `DELETE`." + open_questions: [] - id: "6.1" title: "GET is safe and idempotent" class: M+R @@ -671,7 +680,7 @@ rules: surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 97-screaming-snake-case-enum-values - text: "Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per §11.5, event types per §16.3, sort keys per §12.7), the GovStack `x-govstack-*` extension vocabularies (§17.11, §17.13), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and the health-status vocabulary of §5.9." + text: "Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per §11.5, event types per §16.3, sort keys per §12.7), the GovStack `x-govstack-*` extension vocabularies (§17.11, §17.13), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it." open_questions: [] - id: "9.8" title: "Forward-compatible schemas" @@ -883,11 +892,11 @@ rules: - id: "12.1" title: "Collections must paginate" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "MAY"] surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 121-collections-must-paginate - text: "Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden." + text: "Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden. A collection whose size is fixed by the specification itself **MAY** be returned unpaginated, provided the bound is declared in the schema with `maxItems`; an undeclared expectation that a collection stays small does not qualify, because an integrator cannot see it and a linter cannot check it. The standard unversioned endpoints of §5.10 are not collections and this section does not apply to them." open_questions: [] - id: "12.2" title: "Cursor pagination by default" @@ -977,7 +986,7 @@ rules: surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 131-default-security-on-every-operation - text: "Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit." + text: "Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit. The standard unversioned endpoints of §5.10 satisfy this rule with an explicit empty security requirement (`security: []`) where they are unauthenticated: they are exempt from carrying a scheme, not from declaring what they carry." open_questions: [] - id: "13.2" title: "OAuth and OIDC for citizen operations" @@ -986,7 +995,7 @@ rules: surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 132-oauth-and-oidc-for-citizen-operations - text: "Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles." + text: "Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. An API that is purely a resource server, validating access tokens issued by an authorization server it does not own and whose endpoints are not part of its own contract, **MAY** instead declare `type: http` with `scheme: bearer` and `bearerFormat: JWT`, because declaring an `oauth2` flow it does not operate would misdescribe the deployed API. That declaration **MUST** document, in the scheme description or an adjacent `/.well-known/` metadata document, the issuers it accepts and the audience value it requires, so an integrator can still discover how to obtain a usable token. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles." open_questions: [] - id: "13.3" title: "Distinct scheme for BB-to-BB calls" @@ -1000,11 +1009,11 @@ rules: - id: "13.4" title: "Namespaced OAuth scopes" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 134-namespaced-oauth-scopes - text: "OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-12-A]`" + text: "Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-12-A]`" open_questions: ["OPEN-12-A"] - id: "13.5" title: "Authorization is the credential channel" @@ -1432,11 +1441,11 @@ rules: - id: "18.1" title: "SemVer versioning" class: M - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 181-semver-versioning - text: "`info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under §18.2." + text: "`info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under §18.2. An API served under `/v1` therefore carries an `info.version` of `1.x.y`. The version of the software that implements the contract is a separate number that this guide does not constrain: an implementation may be at `0.16.3` while the contract it serves is at `1.4.0`, and `info.version` **MUST NOT** be set to the implementation version." open_questions: [] - id: "18.2" title: "Major version in path or channel" From 85f7e7ee9e5c159a19b50598e294e5a821ba40fa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Thu, 6 Aug 2026 14:05:06 +0700 Subject: [PATCH 17/19] Simplify API design guide and template contracts Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- .github/workflows/api-spec-lint.yml | 9 + api-design-guide/1-introduction.md | 10 +- api-design-guide/README.md | 11 +- api-design-guide/SUMMARY.md | 1 - api-design-guide/all-rules.md | 36 +- .../appendix/a-companion-documents.md | 9 +- api-design-guide/appendix/b-open-questions.md | 41 +- .../appendix/c-normative-references.md | 7 +- api-design-guide/guides/README.md | 2 +- .../guides/maintaining-this-guide.md | 4 +- .../guides/spec-editor-checklist.md | 12 +- .../guides/using-with-ai-agents.md | 2 +- .../guides/validating-your-spec.md | 4 +- api-design-guide/how-to-use-this-guide.md | 35 +- api-design-guide/linter/README.md | 6 +- api-design-guide/linter/cli.mjs | 2 +- api-design-guide/linter/coverage.yaml | 118 +++-- api-design-guide/linter/functions/README.md | 13 +- .../linter/functions/envelopeShape.js | 7 + .../linter/functions/extensionShape.js | 5 +- .../linter/functions/s09-bbCode.js | 17 +- .../linter/functions/s09-enumCasing.js | 9 - .../linter/functions/s09-extensionPrefix.js | 4 +- .../linter/functions/s11-fieldErrors.js | 79 ++++ .../linter/functions/s11-problemType.js | 58 +++ .../functions/s12-collectionPagination.js | 68 ++- .../linter/functions/s15-operationsPolling.js | 4 +- .../linter/functions/s16-signatureHeader.js | 49 --- .../functions/s16-subscriptionEndpoints.js | 17 +- .../functions/s17-cloudEventsPayload.js | 107 ++++- .../linter/functions/s17-opExtensions.js | 67 --- .../linter/functions/s17-rejectionMessage.js | 38 +- .../linter/functions/schemaDescriptions.js | 17 +- api-design-guide/linter/package-lock.json | 66 ++- api-design-guide/linter/package.json | 6 +- api-design-guide/linter/ruleset.yaml | 2 +- api-design-guide/linter/rulesets/s03.yaml | 2 +- api-design-guide/linter/rulesets/s11.yaml | 97 ++--- api-design-guide/linter/rulesets/s12.yaml | 15 +- api-design-guide/linter/rulesets/s15.yaml | 42 +- api-design-guide/linter/rulesets/s16.yaml | 29 +- api-design-guide/linter/rulesets/s17.yaml | 102 +---- .../linter/tests/coverage.test.mjs | 20 +- api-design-guide/linter/tests/driver.test.mjs | 2 +- .../fail.yaml | 20 +- .../pass.yaml} | 20 +- .../tests/fixtures/govstack-11.2/fail.yaml | 4 +- .../tests/fixtures/govstack-11.2/pass.yaml | 4 +- .../tests/fixtures/govstack-11.3/pass.yaml | 4 +- .../tests/fixtures/govstack-11.4/fail.yaml | 15 +- .../tests/fixtures/govstack-11.4/pass.yaml | 20 +- .../fixtures/govstack-11.5-enum/pass.yaml | 40 -- .../fixtures/govstack-11.5-example/pass.yaml | 40 -- .../tests/fixtures/govstack-12.1/pass.yaml | 3 +- .../tests/fixtures/govstack-12.2/fail.yaml | 3 +- .../tests/fixtures/govstack-12.2/pass.yaml | 3 +- .../tests/fixtures/govstack-12.3/pass.yaml | 5 +- .../fixtures/govstack-12.9-body/fail.yaml | 3 +- .../fixtures/govstack-12.9-body/pass.yaml | 3 +- .../fixtures/govstack-12.9-response/pass.yaml | 5 +- .../tests/fixtures/govstack-15.2/fail.yaml | 17 - .../tests/fixtures/govstack-15.2/pass.yaml | 30 -- .../tests/fixtures/govstack-15.3/fail.yaml | 26 -- .../tests/fixtures/govstack-15.3/pass.yaml | 27 -- .../tests/fixtures/govstack-16.11/fail.yaml | 10 +- .../tests/fixtures/govstack-16.11/pass.yaml | 17 +- .../tests/fixtures/govstack-16.6/fail.yaml | 26 -- .../tests/fixtures/govstack-16.6/pass.yaml | 27 -- .../tests/fixtures/govstack-17.11/fail.yaml | 26 -- .../tests/fixtures/govstack-17.11/pass.yaml | 26 -- .../tests/fixtures/govstack-17.12/fail.yaml | 26 -- .../tests/fixtures/govstack-17.12/pass.yaml | 28 -- .../tests/fixtures/govstack-17.13/fail.yaml | 29 -- .../tests/fixtures/govstack-17.13/pass.yaml | 29 -- .../tests/fixtures/govstack-17.15/fail.yaml | 31 -- .../tests/fixtures/govstack-17.15/pass.yaml | 33 -- .../tests/fixtures/govstack-17.16/fail.yaml | 26 +- .../tests/fixtures/govstack-17.16/pass.yaml | 32 +- .../tests/fixtures/govstack-17.6/fail.yaml | 3 +- .../tests/fixtures/govstack-17.6/pass.yaml | 12 + .../tests/fixtures/govstack-17.7/fail.yaml | 38 ++ .../tests/fixtures/govstack-17.7/pass.yaml | 45 ++ .../govstack-20.3-exceptions/fail.yaml | 4 +- .../govstack-20.3-exceptions/pass.yaml | 4 +- .../tests/fixtures/govstack-20.3/pass.yaml | 4 +- .../tests/fixtures/govstack-9.10/fail.yaml | 2 +- .../tests/fixtures/govstack-9.10/pass.yaml | 2 +- .../tests/fixtures/govstack-9.7/pass.yaml | 6 - .../linter/tests/functions.test.mjs | 165 ++++++- .../linter/tests/golden/asyncapi-golden.yaml | 294 ++++--------- .../linter/tests/golden/openapi-golden.yaml | 271 ++++-------- .../linter/tests/message-examples.test.mjs | 56 +++ .../linter/tests/openapi-examples.test.mjs | 203 +++++++++ .../part-a/2-openapi-document-standards.md | 12 +- .../part-a/3-asyncapi-document-standards.md | 6 +- .../part-b/5-url-structure-and-versioning.md | 4 +- .../part-b/7-http-status-codes.md | 4 +- api-design-guide/part-b/8-headers.md | 2 +- api-design-guide/part-c/11-errors.md | 44 +- .../part-c/12-pagination-filtering-sorting.md | 7 +- .../part-c/9-json-conventions-and-naming.md | 8 +- .../13-authentication-and-authorisation.md | 2 +- .../part-d/15-asynchronous-operations.md | 14 +- .../part-d/16-cloudevents-and-webhooks.md | 30 +- .../part-d/17-asyncapi-channel-rules.md | 63 ++- .../part-d/18-compatibility-and-lifecycle.md | 4 +- api-design-guide/part-e/19-localisation.md | 6 +- .../part-e/20-conformance-and-validation.md | 8 +- api-design-guide/rules.yaml | 254 +++++------ api-design-guide/tools/build_rules_index.py | 2 +- api-design-guide/version-history.md | 51 --- api/common/README.md | 16 +- api/common/govstack-asyncapi-common.yaml | 148 +------ api/common/govstack-openapi-common.yaml | 402 ++---------------- api/openapi.yaml | 388 +++++++++++++---- 115 files changed, 2052 insertions(+), 2441 deletions(-) create mode 100644 api-design-guide/linter/functions/s11-fieldErrors.js create mode 100644 api-design-guide/linter/functions/s11-problemType.js delete mode 100644 api-design-guide/linter/functions/s16-signatureHeader.js delete mode 100644 api-design-guide/linter/functions/s17-opExtensions.js rename api-design-guide/linter/tests/fixtures/{govstack-11.5-enum => govstack-11.2-type}/fail.yaml (59%) rename api-design-guide/linter/tests/fixtures/{govstack-11.5-example/fail.yaml => govstack-11.2-type/pass.yaml} (59%) delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml delete mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.7/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-17.7/pass.yaml create mode 100644 api-design-guide/linter/tests/message-examples.test.mjs create mode 100644 api-design-guide/linter/tests/openapi-examples.test.mjs delete mode 100644 api-design-guide/version-history.md diff --git a/.github/workflows/api-spec-lint.yml b/.github/workflows/api-spec-lint.yml index 26b4fd2..5d3d4f7 100644 --- a/.github/workflows/api-spec-lint.yml +++ b/.github/workflows/api-spec-lint.yml @@ -45,6 +45,15 @@ jobs: run: | npm ci npm test + - name: Validate the AsyncAPI golden and its external references + run: npx --yes @asyncapi/cli@6.0.2 validate api-design-guide/linter/tests/golden/asyncapi-golden.yaml + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Validate the OpenAPI golden and its external references + run: | + python -m pip install openapi-spec-validator==0.9.0 + openapi-spec-validator api-design-guide/linter/tests/golden/openapi-golden.yaml - name: Check generated rule index and guide links run: | python3 api-design-guide/tools/build_rules_index.py --check diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md index a7c2c0d..8895a03 100644 --- a/api-design-guide/1-introduction.md +++ b/api-design-guide/1-introduction.md @@ -20,10 +20,10 @@ The test for inclusion: *would two BB editors writing two different specs need t - Event payloads and metadata, standardised as CloudEvents across HTTP and non-HTTP transports. - Event-driven APIs over brokered transports and event streams, documented in AsyncAPI 3.0 (MQTT, AMQP, Kafka, WebSockets, SSE). - Webhook subscriptions, documented via OpenAPI 3.1 `webhooks` for HTTP push. -- The companion files `govstack-openapi-common.yaml` (REST security scheme, error schema, pagination components, common headers, Operation resource) and `govstack-asyncapi-common.yaml` (event envelope, message headers, security schemes, signing metadata, delivery declarations, common error messages) that BBs reference. +- The schema-only companion files `govstack-openapi-common.yaml` (`Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`) and `govstack-asyncapi-common.yaml` (the reusable event envelope and transport-neutral asynchronous error schemas). OpenAPI reuse is optional; BBs keep security schemes, parameters, headers, responses, examples, and Operation resources local. - The repository-level API inventory (`api/index.yaml`, when the default canonical paths are insufficient) and functional-requirement traceability file (`api/coverage.yaml`). -CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally lighter in v0.1: BBs must declare the relevant bindings where they affect the contract, while detailed broker-operation guidance belongs in the Security & Operations companion or a later protocol profile. That lighter coverage reflects the current BB set being predominantly REST, not a judgement that the ecosystem should remain so. +CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally limited: BBs declare the relevant bindings where they affect the contract, while detailed broker-operation guidance belongs in the Security & Operations companion or a protocol profile. This reflects the current BB landscape being predominantly REST, not a judgement that the ecosystem should remain so. The decision tree below summarises which artifact documents which kind of surface (informative). The document-level rules are in [§2](part-a/2-openapi-document-standards.md) and [§3](part-a/3-asyncapi-document-standards.md); the event rules are in [§16](part-d/16-cloudevents-and-webhooks.md) and [§17](part-d/17-asyncapi-channel-rules.md). @@ -71,7 +71,7 @@ A BB editor **MAY** propose deviating from a **MUST** rule through the exception ## 1.7 Precedence of external standards <a href="#17-precedence-of-external-standards" id="17-precedence-of-external-standards"></a> -Where this guide adopts an external standard or convention on the OpenAPI 3.1, CloudEvents, or AsyncAPI 3.0 surface, that standard or convention takes precedence over the guide's generic rules for the fields or payloads it covers. The known precedences in v0.1 are: +Where this guide adopts an external standard or convention on the OpenAPI 3.1, CloudEvents, or AsyncAPI 3.0 surface, that standard or convention takes precedence over the guide's generic rules for the fields or payloads it covers. The known precedences are: - RFC 9457 fields inside error envelopes ([§11.1](part-c/11-errors.md#111-rfc-9457-problem-details); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). - CloudEvents fields inside event envelopes ([§16.2](part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). @@ -116,7 +116,7 @@ Each numbered rule carries an enforcement-class tag, shown as a bold badge at th - **`[R]` Review.** The rule requires human judgement; no reliable automated check exists. Conformance is assessed through specification review and the conformance process. - **`[M+R]` Partly machine-checkable.** A linter can verify the structural part (presence, shape, naming, declared values), but a human reviewer must confirm the semantic part: whether the right construct was used for the right meaning. -The tag is guidance for the ruleset author and the conformance process, not part of the normative requirement: an `[M]` MUST and an `[R]` MUST are equally binding. The tag only marks where mechanical enforcement ends and review begins. Purely informative or scoping statements (for example [§12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope), [§16.9](part-d/16-cloudevents-and-webhooks.md#169-operational-signing-concerns-out-of-scope), [§18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields)) carry no tag. The machine-checkable subset that [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) expects the Spectral ruleset to cover is the `[M]` rules plus the mechanical portion of the `[M+R]` rules. Enforceability of a given rule also depends on the companion artifacts it references ([§2.8](part-a/2-openapi-document-standards.md#28-pinned-vendored-common-components), [§11.7](part-c/11-errors.md#117-common-error-catalogue), [§15.2](part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape), [§16.8](part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) actually existing; sequencing that against the pilot and ratification plan is governance, not design. +The tag is guidance for the ruleset author and the conformance process, not part of the normative requirement: an `[M]` MUST and an `[R]` MUST are equally binding. The tag only marks where mechanical enforcement ends and review begins. Purely informative or scoping statements (for example [§12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope), [§16.9](part-d/16-cloudevents-and-webhooks.md#169-readiness-for-a-shared-signature-profile), [§18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields)) carry no tag. The machine-checkable subset that [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) expects the Spectral ruleset to cover is the `[M]` rules plus the mechanical portion of the `[M+R]` rules. Enforceability of a rule may also depend on a referenced companion artifact, such as the conditional OpenAPI schema reuse in [§2.8](part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas) or the AsyncAPI schemas in [§3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components). Sequencing publication of those artifacts against the pilot and ratification plan is governance, not design. ## 1.10 Applicability and transition <a href="#110-applicability-and-transition" id="110-applicability-and-transition"></a> @@ -124,4 +124,4 @@ This guide applies in full to new API surfaces and to new major versions of exis The transition schedule, conformance levels, and enforcement dates for existing BBs are governance questions for the GovStack API Lifecycle & Governance companion ([Appendix A](appendix/a-companion-documents.md)). Every canonical specification pins the exact guide and ruleset versions it uses under [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); validation tooling **MUST NOT** silently substitute a newer compatible-looking version. -The guide itself follows SemVer, including the current exact prerelease identifier `0.2.0-draft`. A guide patch release **MUST NOT** change which specifications conform; it may only correct prose or tooling defects without changing normative meaning. A guide minor release **MAY** add optional guidance, deprecate a rule, or relax a requirement, but **MUST NOT** add or strengthen a mandatory requirement. Adding or strengthening a **MUST** or **SHOULD**, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming **MUST** increment the guide major version. These rules apply even during the `0.x` drafting series so that exact conformance declarations remain meaningful. +The guide itself follows SemVer, including the current exact prerelease identifier `0.1.0-draft`. A guide patch release **MUST NOT** change which specifications conform; it may only correct prose or tooling defects without changing normative meaning. A guide minor release **MAY** add optional guidance, deprecate a rule, or relax a requirement, but **MUST NOT** add or strengthen a mandatory requirement. Adding or strengthening a **MUST** or **SHOULD**, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming **MUST** increment the guide major version. These rules apply even during the `0.x` drafting series so that exact conformance declarations remain meaningful. diff --git a/api-design-guide/README.md b/api-design-guide/README.md index 1a7ad3b..3617981 100644 --- a/api-design-guide/README.md +++ b/api-design-guide/README.md @@ -5,10 +5,11 @@ description: "The rules every GovStack Building Block API specification must fol # GovStack Cross-BB API Design Guide {% hint style="warning" %} -**Status: DRAFT v0.2, for GovStack committee feedback.** This guide is not yet ratified. It supersedes the v0.1 document circulated on 2026-05-31; the [version history](version-history.md) lists every change, and [How to use this guide](how-to-use-this-guide.md) maps the old section numbers to the new ones. +**Status: DRAFT, for GovStack committee feedback.** This guide has not been +published or ratified. {% endhint %} -**Author:** Jeremi Joslin · **Date:** 2026-07-10 +**Author:** Jeremi Joslin ## Start here @@ -18,7 +19,7 @@ description: "The rules every GovStack Building Block API specification must fol ## Executive summary -GovStack has standardised a great deal, but never a single API design guide that every Building Block follows. In its absence each BB team made reasonable local choices that, predictably, diverged. The rules below were drafted against the published Building Block API specifications as they stood in 2026, so this guide closes gaps observed in those specifications, not hypothetical ones. +GovStack has standardised a great deal, but never a single API design guide that every Building Block follows. In its absence each BB team made reasonable local choices that, predictably, diverged. The rules below address gaps observed in published Building Block API specifications, not hypothetical ones. The GovStack Cross-BB API Design Guide defines the rules every Building Block API specification must follow, so that an integrator combining several BBs into a national digital platform sees consistent shapes for authentication, errors, identifiers, pagination, events, and lifecycle. It governs OpenAPI 3.1 REST surfaces, CloudEvents event payloads, OpenAPI webhooks, and AsyncAPI 3.0 documentation for brokered event channels and event streams. Operational behaviour (token validation, key rotation, audit logging) and ecosystem governance (ratification, enforcement, exception lifecycle) are out of scope. @@ -33,11 +34,11 @@ The substantive rules establish: - Standard HTTP status codes used consistently, with `ETag` / `If-Match` for optimistic concurrency ([§7](part-b/7-http-status-codes.md)). - Standard headers for authentication, idempotency, localisation, correlation, and rate limiting; no personal data in URLs, channel addresses, routing keys, or message headers ([§8](part-b/8-headers.md), [§17](part-d/17-asyncapi-channel-rules.md)). - `camelCase` JSON, RFC 3339 timestamps, decimal-string monetary amounts, E.164 phone numbers, ISO code lists for country / currency / language ([§9](part-c/9-json-conventions-and-naming.md)–[§10](part-c/10-data-types-and-formats.md)). -- One ecosystem-wide error format (RFC 9457 Problem Details, which obsoletes RFC 7807) with GovStack extensions for stable error codes, trace IDs, and field-level validation ([§11](part-c/11-errors.md)). +- One ecosystem-wide HTTP error format based on RFC 9457 Problem Details, with a stable `https://govstack.global/problems/...` type URI, trace IDs, and field-level validation ([§11](part-c/11-errors.md)). - Cursor-based pagination by default, with a single envelope shape for collection responses ([§12](part-c/12-pagination-filtering-sorting.md)). - OAuth 2.0 + OIDC for citizen-facing operations, mutual TLS or OAuth client credentials for BB-to-BB calls ([§13](part-d/13-authentication-and-authorisation.md)). - An `Idempotency-Key` contract for retry-safe POSTs ([§14](part-d/14-idempotency.md)). -- A single async pattern: `202 Accepted` plus an Operation resource referenced from a shared YAML file ([§15](part-d/15-asynchronous-operations.md)). +- A single async pattern: `202 Accepted` plus a locally defined Operation resource with a common baseline shape ([§15](part-d/15-asynchronous-operations.md)). - CloudEvents as the normative event envelope and type/source model across transports; OpenAPI `webhooks` and AsyncAPI 3.0 document the event surfaces ([§16](part-d/16-cloudevents-and-webhooks.md), [§17](part-d/17-asyncapi-channel-rules.md)). - SemVer with major versions visible in the relevant surface contract, additive minor changes, deprecation and sunset headers ([§18](part-d/18-compatibility-and-lifecycle.md)). - A single language model based on `Accept-Language` and `Content-Language` ([§19](part-e/19-localisation.md)). diff --git a/api-design-guide/SUMMARY.md b/api-design-guide/SUMMARY.md index 36b7f7e..25997c6 100644 --- a/api-design-guide/SUMMARY.md +++ b/api-design-guide/SUMMARY.md @@ -3,7 +3,6 @@ * [GovStack Cross-BB API Design Guide](README.md) * [How to use this guide](how-to-use-this-guide.md) * [Rules at a glance](all-rules.md) -* [Version history](version-history.md) * [1. Introduction](1-introduction.md) ## Part A. API artifacts diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md index 6b38d90..06b470c 100644 --- a/api-design-guide/all-rules.md +++ b/api-design-guide/all-rules.md @@ -17,7 +17,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [2.5](part-a/2-openapi-document-standards.md#25-complete-info-block) | M | MUST | OpenAPI | Complete info block | | [2.6](part-a/2-openapi-document-standards.md#26-meaningful-servers-block) | M+R | MUST | OpenAPI | Meaningful servers block | | [2.7](part-a/2-openapi-document-standards.md#27-complete-operation-metadata) | M+R | MUST | OpenAPI | Complete operation metadata | -| [2.8](part-a/2-openapi-document-standards.md#28-pinned-vendored-common-components) | M | MUST | OpenAPI | Pinned vendored common components | +| [2.8](part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas) | M+R | MUST | OpenAPI | Conditional vendored OpenAPI schemas | ## 3. AsyncAPI document standards @@ -146,13 +146,11 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | | [11.1](part-c/11-errors.md#111-rfc-9457-problem-details) | M | MUST | Universal | RFC 9457 problem details | -| [11.2](part-c/11-errors.md#112-standard-problem-fields-present) | M+R | MUST | Universal | Standard problem fields present | -| [11.3](part-c/11-errors.md#113-govstack-error-extension-fields) | M | MUST | Universal | GovStack error extension fields | +| [11.2](part-c/11-errors.md#112-stable-http-problem-type-uri) | M+R | MUST | Universal | Stable HTTP problem type URI | +| [11.3](part-c/11-errors.md#113-trace-identifier) | M | MUST | Universal | Trace identifier | | [11.4](part-c/11-errors.md#114-field-level-errors-array) | M+R | MUST | Universal | Field-level errors array | -| [11.5](part-c/11-errors.md#115-namespaced-stable-error-codes) | M+R | MUST | Universal | Namespaced stable error codes | -| [11.6](part-c/11-errors.md#116-stable-codes-across-languages) | R | MUST | Universal | Stable codes across languages | -| [11.7](part-c/11-errors.md#117-common-error-catalogue) | M+R | MUST | Universal | Common error catalogue | -| [11.8](part-c/11-errors.md#118-transport-neutral-asynchronous-errors) | M+R | MUST | Universal | Transport-neutral asynchronous errors | +| [11.5](part-c/11-errors.md#115-stable-http-problem-fields-across-languages) | R | MUST | Universal | Stable HTTP problem fields across languages | +| [11.6](part-c/11-errors.md#116-transport-neutral-asynchronous-errors) | M+R | MUST | Universal | Transport-neutral asynchronous errors | ## 12. Pagination, filtering, sorting @@ -197,8 +195,8 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | | [15.1](part-d/15-asynchronous-operations.md#151-202-with-operation-location) | M+R | MUST | OpenAPI | 202 with Operation Location | -| [15.2](part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape) | M | MUST | OpenAPI | Shared Operation resource shape | -| [15.3](part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | M | MUST | OpenAPI | Fixed Operation status enum | +| [15.2](part-d/15-asynchronous-operations.md#152-local-operation-resource-shape) | M+R | MUST | OpenAPI | Local Operation resource shape | +| [15.3](part-d/15-asynchronous-operations.md#153-documented-operation-lifecycle) | M+R | MUST | OpenAPI | Documented Operation lifecycle | | [15.4](part-d/15-asynchronous-operations.md#154-polling-the-operation-resource) | M+R | MUST | OpenAPI | Polling the Operation resource | | [15.5](part-d/15-asynchronous-operations.md#155-cancellation-via-cancel-sub-resource) | M+R | MUST | OpenAPI | Cancellation via cancel sub-resource | | [15.6](part-d/15-asynchronous-operations.md#156-webhook-completion-notification) | R | SHOULD | OpenAPI | Webhook completion notification | @@ -212,11 +210,11 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [16.2](part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required) | M | MUST | Event-driven | CloudEvents envelope required | | [16.3](part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | M | MUST | Event-driven | Reverse-DNS event types | | [16.4](part-d/16-cloudevents-and-webhooks.md#164-stable-cloudevents-source) | M+R | MUST | Event-driven | Stable CloudEvents source | -| [16.5](part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery) | R | MUST | Event-driven | Signed event delivery | -| [16.6](part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header) | M+R | MUST | Event-driven | GovStack-Signature header | +| [16.5](part-d/16-cloudevents-and-webhooks.md#165-optional-signed-event-delivery) | R | MAY | Event-driven | Optional signed event delivery | +| [16.6](part-d/16-cloudevents-and-webhooks.md#166-signature-metadata-when-used) | R | MUST | Event-driven | Signature metadata when used | | [16.7](part-d/16-cloudevents-and-webhooks.md#167-replay-detectable-signed-material) | R | MUST | Event-driven | Replay-detectable signed material | -| [16.8](part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile) | R | MUST | Event-driven | Pinned signature profile | -| [16.9](part-d/16-cloudevents-and-webhooks.md#169-operational-signing-concerns-out-of-scope) | — | — | Event-driven | Operational signing concerns out of scope | +| [16.8](part-d/16-cloudevents-and-webhooks.md#168-separate-experimental-signing-profile) | R | MUST | Event-driven | Separate experimental signing profile | +| [16.9](part-d/16-cloudevents-and-webhooks.md#169-readiness-for-a-shared-signature-profile) | — | — | Event-driven | Readiness for a shared signature profile | | [16.10](part-d/16-cloudevents-and-webhooks.md#1610-documented-delivery-failure-contract) | R | MUST | Event-driven | Documented delivery-failure contract | | [16.11](part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces) | M+R | MUST | Event-driven | Subscription management interfaces | @@ -230,15 +228,15 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [17.4](part-d/17-asyncapi-channel-rules.md#174-declared-channel-parameters) | M+R | MUST | AsyncAPI | Declared channel parameters | | [17.5](part-d/17-asyncapi-channel-rules.md#175-no-environment-names-in-addresses) | M+R | SHOULD | AsyncAPI | No environment names in addresses | | [17.6](part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | M | MUST | AsyncAPI | Structured CloudEvents JSON payloads | -| [17.7](part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-message-schema) | M | MUST | AsyncAPI | Shared CloudEvents message schema | +| [17.7](part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema) | M | MUST | AsyncAPI | Shared CloudEvents envelope schema | | [17.8](part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata) | M+R | MUST | AsyncAPI | Message headers and idempotency metadata | | [17.9](part-d/17-asyncapi-channel-rules.md#179-message-localisation-headers) | M+R | MUST | AsyncAPI | Message localisation headers | | [17.10](part-d/17-asyncapi-channel-rules.md#1710-security-schemes-cover-every-operation) | M+R | MUST | AsyncAPI | Security schemes cover every operation | -| [17.11](part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees) | M+R | MUST | AsyncAPI | Documented delivery guarantees | -| [17.12](part-d/17-asyncapi-channel-rules.md#1712-documented-ordering-guarantees) | M+R | MUST | AsyncAPI | Documented ordering guarantees | -| [17.13](part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities) | M+R | MUST | AsyncAPI | Declared delivery-management capabilities | -| [17.14](part-d/17-asyncapi-channel-rules.md#1714-portable-capability-contract) | R | MUST | AsyncAPI | Portable capability contract | -| [17.15](part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions) | M+R | MUST | AsyncAPI | Machine-readable delivery extensions | +| [17.11](part-d/17-asyncapi-channel-rules.md#1711-duplicate-delivery-contract) | R | MUST | AsyncAPI | Duplicate delivery contract | +| [17.12](part-d/17-asyncapi-channel-rules.md#1712-ordering-only-when-promised) | R | MUST | AsyncAPI | Ordering only when promised | +| [17.13](part-d/17-asyncapi-channel-rules.md#1713-public-delivery-management-capabilities) | R | MUST | AsyncAPI | Public delivery-management capabilities | +| [17.14](part-d/17-asyncapi-channel-rules.md#1714-implementation-values-in-protocol-profiles) | R | MUST | AsyncAPI | Implementation values in protocol profiles | +| [17.15](part-d/17-asyncapi-channel-rules.md#1715-no-universal-delivery-extensions) | R | MUST | AsyncAPI | No universal delivery extensions | | [17.16](part-d/17-asyncapi-channel-rules.md#1716-async-rejection-error-messages) | M+R | MUST | AsyncAPI | Async rejection error messages | | [17.17](part-d/17-asyncapi-channel-rules.md#1717-declared-request-reply-correlation) | M+R | MUST | AsyncAPI | Declared request-reply correlation | | [17.18](part-d/17-asyncapi-channel-rules.md#1718-correlated-completion-signals) | M+R | MUST | AsyncAPI | Correlated completion signals | diff --git a/api-design-guide/appendix/a-companion-documents.md b/api-design-guide/appendix/a-companion-documents.md index dcf568e..29735ad 100644 --- a/api-design-guide/appendix/a-companion-documents.md +++ b/api-design-guide/appendix/a-companion-documents.md @@ -8,10 +8,11 @@ description: "Companion documents and artifacts that pick up the topics this gui | Companion | Status | What it covers | |---|---|---| -| **GovStack API Lifecycle & Governance** | Proposed local v0.1 outline, not a ratified GovStack artifact | Ratification, enforcement, exception lifecycle, transition timelines, conformance levels, companion-artifact ownership, BB editor support, self-amendment of this guide. Reuses the existing GovStack Specification Framework where applicable and defines only the missing API-specific lifecycle, exception, publication, and conformance processes. | +| **GovStack API Lifecycle & Governance** | Proposed outline, not a ratified GovStack artifact | Ratification, enforcement, exception lifecycle, transition timelines, conformance levels, companion-artifact ownership, BB editor support, self-amendment of this guide. Reuses the existing GovStack Specification Framework where applicable and defines only the missing API-specific lifecycle, exception, publication, and conformance processes. | | **GovStack API Security & Operations** | Not yet drafted | Deployment details below the interface baseline in [§13](../part-d/13-authentication-and-authorisation.md): token and claim validation, certificate trust, TLS configuration, key rotation, replay enforcement, audit logging, log hygiene, algorithm allowlists, and FAPI conformance. It must not weaken the RFC 9700 and protected-transport requirements in this guide. | -| [`api/common/govstack-openapi-common.yaml`](../../api/common/govstack-openapi-common.yaml) | Draft artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Shared security schemes, RFC 9457 error schema, pagination envelope, W3C Trace Context headers, Operation resource, common error catalogue ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)), and the event-signature profile. | -| [`api/common/govstack-asyncapi-common.yaml`](../../api/common/govstack-asyncapi-common.yaml) | Draft artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Shared CloudEvents envelope ([§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)), `GovStackAsyncError` ([§11.8](../part-c/11-errors.md#118-transport-neutral-asynchronous-errors)), common message headers ([§17](../part-d/17-asyncapi-channel-rules.md)), security schemes, the event-signature profile, and delivery-semantics extensions. | -| **Spectral ruleset** | Exact draft `0.2.0-draft` ships in-repo at [`linter/`](../linter/README.md); formal ratification pending | Machine-enforceable subset of the exact guide version declared under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version), including canonical discovery, `api/index.yaml`, and `api/coverage.yaml`. [`linter/coverage.yaml`](../linter/coverage.yaml) records per-rule coverage. | +| [`api/common/govstack-openapi-common.yaml`](../../api/common/govstack-openapi-common.yaml) | Draft schema-only artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Conditional reuse of `Problem`, `ValidationProblem`, `FieldError`, and `PageInfo` under [§2.8](../part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas). BBs own security schemes, parameters, headers, Response Objects, examples, and Operation resources. | +| [`api/common/govstack-asyncapi-common.yaml`](../../api/common/govstack-asyncapi-common.yaml) | Draft artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Schema-only shared CloudEvents envelope ([§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)), `GovStackAsyncError`, and `AsyncFieldError` ([§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors)). BBs own Message Objects, security, headers, examples, and protocol bindings. | +| `experimental/govstack-openapi-signing-profile.yaml` | Separate optional profile, incomplete and not ratified | Opt-in message-signing mechanism. Key discovery, key rotation, replay policy, protocol mappings, and conformance test vectors must be completed before promotion. It is not part of either baseline common schema artifact ([§16.8](../part-d/16-cloudevents-and-webhooks.md#168-separate-experimental-signing-profile)). | +| **Spectral ruleset** | Exact draft `0.1.0-draft` ships in-repo at [`linter/`](../linter/README.md); formal ratification pending | Machine-enforceable subset of the exact guide version declared under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version), including canonical discovery, `api/index.yaml`, and `api/coverage.yaml`. [`linter/coverage.yaml`](../linter/coverage.yaml) records per-rule coverage. | | **Conformance test pack** | Future companion artifact | Governance-defined contract tests beyond schema and Spectral validation. | | **Reference BB implementation** | Draft example ships in this template; formal ratification pending | Worked example applying the guide end-to-end through [`api/openapi.yaml`](../../api/openapi.yaml), [`api/index.yaml`](../../api/index.yaml), [`api/coverage.yaml`](../../api/coverage.yaml), and the [generic BB specification](../../spec/README.md). | diff --git a/api-design-guide/appendix/b-open-questions.md b/api-design-guide/appendix/b-open-questions.md index 6f99049..69372c0 100644 --- a/api-design-guide/appendix/b-open-questions.md +++ b/api-design-guide/appendix/b-open-questions.md @@ -6,40 +6,19 @@ description: "Consolidated list of open committee questions referenced inline th The questions below are genuine committee decisions. Each appears inline as `[OPEN-N-X]` next to the relevant rule. -The **Blocks v1.0?** column marks the questions whose answers shape the shared `govstack-openapi-common.yaml` / `govstack-asyncapi-common.yaml` artifacts or every BB's URL surface; these need a committee decision before v1.0 ratification. The rest can be settled during the v1.0 drafting cycle without blocking pilot work. +The **Blocks v1.0?** column marks the questions whose answers shape the baseline shared schemas or every BB's URL surface; these need a committee decision before v1.0 ratification. The rest can be settled during the v1.0 drafting cycle without blocking pilot work. | ID | Topic | Default | Section | Blocks v1.0? | |---|---|---|---|---| -| OPEN-4-A | BB code in URL path | No (rely on `servers` URL or mediator routing) | [§5](../part-b/5-url-structure-and-versioning.md) | Yes | -| OPEN-4-C | Path nesting depth: soft cap of two levels under `/v{N}/` | Keep as SHOULD with the soft cap | [§5.4](../part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | No | -| OPEN-6-A | 400 vs 422 boundary | Keep both (400 unparseable, 422 semantic) | [§7](../part-b/7-http-status-codes.md) | No | -| OPEN-10-A | Error code shape: reverse-DNS named code vs reverse-DNS numeric code vs shorter BB-prefixed code | Reverse-DNS named code: `global.govstack.{bb-code}.{error-name}` | [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes) | Yes | -| OPEN-10-B | Common error catalogue: which canonical errors to include | The `google.rpc.Code` set mapped to reverse-DNS GovStack codes | [§11.7](../part-c/11-errors.md#117-common-error-catalogue) | Yes | -| OPEN-12-A | OAuth scope syntax: `bb:{bb-code}:{resource}:{action}` vs reverse-DNS vs `resource.action` | `bb:` prefix for namespacing; reverse-DNS is the alternative | [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | Yes | -| OPEN-14-A | Operation resource: GovStack-local shape vs strict Google AIP-151 mirror | AIP-151-aligned hybrid; strict AIP-151 is the alternative | [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum) | Yes | -| OPEN-15-B | Event `type` naming convention | `global.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | -| OPEN-15-H | Event-signature verification inputs the guide does not yet supply: how a subscriber discovers the verification key that the JWS `kid` selects on transports with no subscription control plane, and what replay window a receiver enforces | Per-subscription key exchange via [§16.11](../part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces) where a control plane exists, plus a published key set for brokered and stream transports; a single default replay window stated in the guide rather than per BB | [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile), [§16.9](../part-d/16-cloudevents-and-webhooks.md#169-operational-signing-concerns-out-of-scope) | Yes | -| OPEN-15-E | CloudEvents binding style for AsyncAPI | Structured CloudEvents JSON payload | [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | No | -| OPEN-15-F | AsyncAPI protocol-binding depth | Require bindings where they affect interoperability; future profiles may add deeper broker-specific rules | [§17.19](../part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | No | -| OPEN-15-G | GovStack AsyncAPI extension names and schemas | Define in `govstack-asyncapi-common.yaml` | [§17.15](../part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions) | No | -| OPEN-16-A | AsyncAPI deprecation metadata | `x-govstack-deprecated` with `since`, `sunset`, `replacement`, `reason` | [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata) | No | -| OPEN-17-A | Mandated language coverage | Per BB | [§19](../part-e/19-localisation.md) | No | +| OPEN-5-A | BB code in URL path | No (rely on `servers` URL or mediator routing) | [§5](../part-b/5-url-structure-and-versioning.md) | Yes | +| OPEN-5-B | Path nesting depth: soft cap of two levels under `/v{N}/` | Keep as SHOULD with the soft cap | [§5.4](../part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | No | +| OPEN-7-A | 400 vs 422 boundary | Keep both (400 unparseable, 422 semantic) | [§7](../part-b/7-http-status-codes.md) | No | +| OPEN-13-A | OAuth scope syntax: `bb:{bb-code}:{resource}:{action}` vs reverse-DNS vs `resource.action` | `bb:` prefix for namespacing; reverse-DNS is the alternative | [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | Yes | +| OPEN-16-A | Event `type` naming convention | `global.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | +| OPEN-17-A | CloudEvents binding style for AsyncAPI | Structured CloudEvents JSON payload | [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | No | +| OPEN-17-B | AsyncAPI protocol-binding depth | Require bindings where they affect interoperability; future profiles may add deeper broker-specific rules | [§17.19](../part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | No | +| OPEN-18-A | AsyncAPI deprecation metadata | `x-govstack-deprecated` with `since`, `sunset`, `replacement`, `reason` | [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata) | No | +| OPEN-19-A | Mandated language coverage | Per BB | [§19](../part-e/19-localisation.md) | No | | OPEN-9-A | BB-code register: where the canonical register of BB codes lives and who assigns them | Propose in the Lifecycle & Governance companion; until then, agree codes through the API Working Group | [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code) | No | -## Resolved in `0.2.0-draft` - -The identifiers below remain frozen for discussion-history links, but they are no longer open design choices. - -| ID | Resolution | Section | -|---|---|---| -| OPEN-4-B | Use the simpler local shape, not `draft-inadarei-api-health-check` (an expired Internet-Draft that never became an RFC). Health is carried by the status code, `200` or `503`; the `200` body is `application/json`, minimal, and informational. | [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | -| OPEN-7-A | Use W3C `traceparent` / `tracestate`; do not introduce `X-Request-Id` as the cross-BB standard. | [§8.4–8.5](../part-b/8-headers.md#84-w3c-trace-context-correlation) | -| OPEN-7-B | Pin the Structured Field `RateLimit` / `RateLimit-Policy` form from draft revision 11; the legacy three-field form is not draft-conformant. | [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared) | -| OPEN-15-C | Use HTTP `GovStack-Signature` and camelCase AsyncAPI metadata `govstackSignature`, unless a protocol binding supplies a standard field. | [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header) | -| OPEN-15-D | Put the reverse-DNS GovStack name and major version in the logical AsyncAPI channel ID; keep `address` protocol-native. | [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) | -| OPEN-15-A | Use detached JWS with `ES256` over the RFC 8785-canonicalized structured CloudEvent, using RFC 7515 detached-content semantics rather than RFC 7797 unencoded payloads. This drops the v0.1 option of an HMAC fallback for constrained deployments. This item was marked **Blocks v1.0? Yes**, so this resolution in particular needs explicit committee ratification. | [§16.5](../part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery), [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile) | -| OPEN-20-A | Pin exact `version` and `rulesetVersion`; exceptions carry scoped rationale, HTTPS evidence, approving authority, review date, and expiry using the exact fields in §20.3. | [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) | - -**A note on identifiers.** The `OPEN-N-X` identifiers are frozen from the circulated v0.1 draft and predate the v0.2 section renumbering: the `N` in an identifier refers to the v0.1 section number and is treated as an opaque label, so existing feedback threads stay valid. The Section column shows current section numbers. Questions added in v0.2 or later use current numbering (`OPEN-9-A`, `OPEN-20-A`). The old-to-new section mapping is on [How to use this guide](../how-to-use-this-guide.md). - Governance-side open questions (ratification process, enforcement actor, exception lifecycle, deviation board) are proposed for the **GovStack API Lifecycle & Governance** companion document, not here. diff --git a/api-design-guide/appendix/c-normative-references.md b/api-design-guide/appendix/c-normative-references.md index db5b2ab..b1d5d36 100644 --- a/api-design-guide/appendix/c-normative-references.md +++ b/api-design-guide/appendix/c-normative-references.md @@ -15,9 +15,6 @@ description: "Normative references cited throughout the guide." - IETF RFC 6901, *JavaScript Object Notation (JSON) Pointer* - IETF RFC 6902, *JavaScript Object Notation (JSON) Patch* - IETF RFC 7396, *JSON Merge Patch* -- IETF RFC 7515, *JSON Web Signature (JWS)* -- IETF RFC 7797, *JSON Web Signature (JWS) Unencoded Payload Option* (explicitly excluded by the `0.2.0-draft` event-signature profile; cited by [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) -- IETF RFC 8785, *JSON Canonicalization Scheme (JCS)* (cited by [§16.8](../part-d/16-cloudevents-and-webhooks.md#168-pinned-signature-profile)) - IETF RFC 8594, *The Sunset HTTP Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) - IETF RFC 8615, *Well-Known Uniform Resource Identifiers (URIs)* (cited by [§5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints)) - IETF RFC 8705, *OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens* @@ -30,16 +27,14 @@ description: "Normative references cited throughout the guide." - IETF RFC 9745, *The Deprecation HTTP Response Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) - IETF draft `draft-ietf-httpapi-ratelimit-headers-11`, *RateLimit Header Fields for HTTP* (pinned work-in-progress revision; cited by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared)) - IETF draft `draft-ietf-httpapi-idempotency-key-header-07`, *The Idempotency-Key HTTP Header Field* (pinned expired Internet-Draft revision used as a GovStack convention; cited by [§14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) -- OpenAPI Specification 3.1 patch series; guide/ruleset `0.2.0-draft` qualify 3.1.0, 3.1.1, and 3.1.2 +- OpenAPI Specification 3.1 patch series; guide/ruleset `0.1.0-draft` qualify 3.1.0, 3.1.1, and 3.1.2 - AsyncAPI Specification 3.0 (cited by [§1.2](../1-introduction.md#12-scope), [§3](../part-a/3-asyncapi-document-standards.md), [§16.1](../part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented), [§17](../part-d/17-asyncapi-channel-rules.md), [§20](../part-e/20-conformance-and-validation.md)) - OpenID Connect Core 1.0 - CloudEvents v1.0.2 (CNCF), *CloudEvents Specification* and JSON Format (cited by [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)) - CloudEvents, *Distributed Tracing Extension* (`traceparent`, `tracestate`) - W3C Recommendation, *Trace Context* -- Google AIP-151, *Long-running operations* (cited by [§15.2](../part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape)–[15.3](../part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum)) - Google AIP-158, *Pagination* (cited by [§12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) - GraphQL Cursor Connections Specification (cited by [§12.3](../part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope)) -- gRPC, *google.rpc.Code* canonical error codes (cited by [§11.7](../part-c/11-errors.md#117-common-error-catalogue)) - IANA registries whose registered values keep their own casing under [§9.7](../part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values): *JSON Web Signature and Encryption Algorithms*, *JSON Web Key Elliptic Curve*, *COSE Algorithms*, and *Media Types* - ISO 3166-1 alpha-2 (country codes) - ISO 4217 (currency codes) diff --git a/api-design-guide/guides/README.md b/api-design-guide/guides/README.md index 5a56387..3577dd2 100644 --- a/api-design-guide/guides/README.md +++ b/api-design-guide/guides/README.md @@ -14,7 +14,7 @@ Four guides live here today: - [Maintaining this guide](../guides/maintaining-this-guide.md): where the canonical copy lives, how to edit a rule without breaking its anchor, and how to regenerate the machine-readable index. {% hint style="info" %} -This book is exact draft version `0.2.0-draft` and is not yet ratified. The matching draft Spectral ruleset ships in this repository; the common OpenAPI/AsyncAPI component files and conformance test pack remain publication prerequisites (see [Appendix A](../appendix/a-companion-documents.md)). +This book is exact draft version `0.1.0-draft` and is not yet ratified. The matching draft Spectral ruleset ships in this repository; the common OpenAPI/AsyncAPI component files and conformance test pack remain publication prerequisites (see [Appendix A](../appendix/a-companion-documents.md)). {% endhint %} Before ratification, this section is expected to gain worked positive and negative examples for every numbered rule and a conformance walkthrough that takes one reference BB specification from a blank file to a passing run. diff --git a/api-design-guide/guides/maintaining-this-guide.md b/api-design-guide/guides/maintaining-this-guide.md index 33e5502..0cd4d0e 100644 --- a/api-design-guide/guides/maintaining-this-guide.md +++ b/api-design-guide/guides/maintaining-this-guide.md @@ -47,8 +47,8 @@ The second command fails if `rules.yaml` and `coverage.yaml` disagree about the ## Adding an open question -Append a row to [Appendix B](../appendix/b-open-questions.md) using an ID of the form `OPEN-{section}-{letter}`, keyed to the current section numbering. Never re-key an existing `OPEN-*` identifier: they are frozen once assigned, as noted on the Appendix B page itself, precisely so that a reference to `OPEN-15-A` in a discussion thread or a companion document keeps meaning the same thing over time. +Append a row to [Appendix B](../appendix/b-open-questions.md) using an ID of the form `OPEN-{section}-{letter}`, keyed to the current section numbering. Once the guide is published, an assigned identifier remains stable. ## Versioning the guide itself -This guide is versioned with SemVer, per [§1.10](../1-introduction.md#110-applicability-and-transition), and its current exact identifier is `0.2.0-draft`. A patch release may correct prose or tooling without changing conformance. A minor release may add optional guidance, deprecate a rule, or relax a requirement. Adding or strengthening a mandatory rule, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming requires a major release. A ruleset release is separately versioned and every canonical spec pins both exact versions under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version). Record every substantive change in the [version history](../version-history.md). +This guide is versioned with SemVer, per [§1.10](../1-introduction.md#110-applicability-and-transition), and its current exact identifier is `0.1.0-draft`. A patch release may correct prose or tooling without changing conformance. A minor release may add optional guidance, deprecate a rule, or relax a requirement. Adding or strengthening a mandatory rule, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming requires a major release. A ruleset release is separately versioned and every canonical spec pins both exact versions under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version). Release notes begin with the first published version. diff --git a/api-design-guide/guides/spec-editor-checklist.md b/api-design-guide/guides/spec-editor-checklist.md index ffc8794..6a06f13 100644 --- a/api-design-guide/guides/spec-editor-checklist.md +++ b/api-design-guide/guides/spec-editor-checklist.md @@ -16,7 +16,7 @@ Run this before submitting a BB specification for review. Each item links to the - [ ] No placeholder text anywhere: no `TBD`, no `Lorem ipsum`, no `a, b, c`, no leftover content copied from another BB. ([4.3](../part-a/4-documentation-requirements.md#43-no-placeholder-text)) - [ ] Canonical surfaces are discoverable at the default paths or through a valid `api/index.yaml`; `api/coverage.yaml` exactly traces every marked functional requirement to an operation, message, external contract, rationale, or tracked plan. ([4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory), [4.6](../part-a/4-documentation-requirements.md#46-functional-requirement-traceability)) - [ ] A security scheme is declared and applied to every operation by default, with per-operation overrides explicit. ([13.1](../part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation)) -- [ ] `info.x-govstack-api-guide` pins exact guide and ruleset versions (`0.2.0-draft`) and every exception has all seven §20.3 fields: `rule`, `scope`, `rationale`, `record`, `reviewedBy`, `reviewedAt`, and `expiresAt`. ([20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)) +- [ ] `info.x-govstack-api-guide` pins exact guide and ruleset versions (`0.1.0-draft`) and every exception has all seven §20.3 fields: `rule`, `scope`, `rationale`, `record`, `reviewedBy`, `reviewedAt`, and `expiresAt`. ([20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)) - [ ] The file passes its validator (see [Validating your spec](../guides/validating-your-spec.md)). ([20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation)) - [ ] `info.version` follows SemVer. ([18.1](../part-d/18-compatibility-and-lifecycle.md#181-semver-versioning)) - [ ] JSON field names are `camelCase`, applied consistently; check the [carve-outs](../part-c/9-json-conventions-and-naming.md#carve-out-from-92) before flagging fields imported from an external standard (RFC 9457, CloudEvents) as violations. ([9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names)) @@ -24,8 +24,9 @@ Run this before submitting a BB specification for review. Each item links to the ## REST surfaces (OpenAPI 3.1) -- [ ] The file declares a qualified OpenAPI 3.1 patch (`3.1.0`, `3.1.1`, or `3.1.2` for guide/ruleset `0.2.0-draft`). ([2.1](../part-a/2-openapi-document-standards.md#21-openapi-31-required)) +- [ ] The file declares a qualified OpenAPI 3.1 patch (`3.1.0`, `3.1.1`, or `3.1.2` for guide/ruleset `0.1.0-draft`). ([2.1](../part-a/2-openapi-document-standards.md#21-openapi-31-required)) - [ ] The canonical entrypoint is at the default `api/openapi.yaml` path or is listed in `api/index.yaml`; no legacy `api/swagger.yaml` or JSON copy is treated as canonical. ([2.2](../part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint)) +- [ ] Security schemes, parameters, headers, responses, examples, and Operation resources are local. If the four shared OpenAPI schemas are reused, the common file is vendored and version-pinned. ([2.8](../part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas)) - [ ] Every non-local server and webhook URL uses HTTPS, and OAuth declarations follow the RFC 9700 baseline: authorization code plus PKCE, no password or implicit grant, access tokens rather than ID Tokens. ([13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations), [13.7](../part-d/13-authentication-and-authorisation.md#137-protected-transport)) - [ ] The major version appears in the URL path (`/v{N}/...`). ([5.1](../part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path)) - [ ] `/health` is unversioned, declares both `200` and `503`, returns `application/json` on `200`, and carries no citizen authentication. ([5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) @@ -35,8 +36,9 @@ Run this before submitting a BB specification for review. Each item links to the - [ ] Input operations declare `400`; secured operations declare applicable `401`/`403`; resource operations declare `404`; conflict-capable operations declare `409`; every operation declares `500`. Creation completed now is `201`, while accepted work is `202` plus an Operation. ([7.2](../part-b/7-http-status-codes.md#72-201-created-with-location)–[7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) - [ ] Every successful response body declares a concrete media type and schema; `204` declares no content. ([7.21](../part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies)) - [ ] Cross-service operations declare W3C `traceparent` and optional `tracestate`; error `traceId` maps to the W3C trace-id. ([8.4](../part-b/8-headers.md#84-w3c-trace-context-correlation)) -- [ ] Error responses use `application/problem+json` (RFC 9457) with `code`, `traceId`, and `timestamp` present alongside the standard fields. ([11.1](../part-c/11-errors.md#111-rfc-9457-problem-details), [11.3](../part-c/11-errors.md#113-govstack-error-extension-fields)) -- [ ] Collection endpoints paginate, and cursor pagination (`pageSize`, opaque `cursor`) is the default. ([12.1](../part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate), [12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) +- [ ] Error responses use `application/problem+json` (RFC 9457), use `https://govstack.global/problems/{bb-code}/{problem-slug}` as the sole machine identifier, and include `traceId`. They do not add `code` or `timestamp`. ([11.1](../part-c/11-errors.md#111-rfc-9457-problem-details)–[11.3](../part-c/11-errors.md#113-trace-identifier)) +- [ ] Collection endpoints paginate, cursor pagination (`pageSize`, opaque `cursor`) is the default, and `pageInfo.nextCursor` alone indicates whether another page exists. ([12.1](../part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate)–[12.3](../part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope)) +- [ ] Every `202` response returns an Operation using a local schema with an opaque string ID and documented terminal, non-terminal, result, error, polling, and cancellation semantics. ([15.1](../part-d/15-asynchronous-operations.md#151-202-with-operation-location)–[15.3](../part-d/15-asynchronous-operations.md#153-documented-operation-lifecycle)) - [ ] Non-idempotent POST endpoints (creation, payment, submission, subscription, job start) accept an `Idempotency-Key` header. ([14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) - [ ] The major version is visible in the surface contract, and deprecated endpoints return `Deprecation` and `Sunset` headers. ([18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel), [18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) @@ -47,7 +49,7 @@ Run this before submitting a BB specification for review. Each item links to the - [ ] Event `type` values follow the reverse-DNS convention. ([16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types)) - [ ] Logical channel IDs follow the versioned reverse-DNS convention, while Channel Object addresses use protocol-native syntax and bindings document the mapping. ([17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses)) - [ ] No logical channel ID, native address, topic, queue name, routing key, or channel parameter carries personal data. ([17.3](../part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels)) -- [ ] Every operation documents its delivery guarantee, ordering guarantee, and supported delivery-management capabilities. ([17.11](../part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees)–[17.13](../part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities)) +- [ ] Delivery, duplicate-handling, ordering, retention, and replay behaviour is documented only where consumers may rely on it, using standard protocol bindings where available. ([17.11](../part-d/17-asyncapi-channel-rules.md#1711-duplicate-delivery-contract)–[17.15](../part-d/17-asyncapi-channel-rules.md#1715-no-universal-delivery-extensions)) - [ ] Every message has an example. ([17.20](../part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message)) Ticking every box above is the human half of conformance. See [Validating your spec](../guides/validating-your-spec.md) for the mechanical half. diff --git a/api-design-guide/guides/using-with-ai-agents.md b/api-design-guide/guides/using-with-ai-agents.md index e41a948..fa16f85 100644 --- a/api-design-guide/guides/using-with-ai-agents.md +++ b/api-design-guide/guides/using-with-ai-agents.md @@ -23,7 +23,7 @@ This repository's API specifications must conform to the GovStack Cross-BB API D Before writing or reviewing OpenAPI/AsyncAPI content: - Read api-design-guide/rules.yaml and treat every MUST rule as blocking. -- Require each canonical spec to pin guide and ruleset version `0.2.0-draft`; do not substitute a newer version. +- Require each canonical spec to pin guide and ruleset version `0.1.0-draft`; do not substitute a newer version. - Validate api/index.yaml discovery and api/coverage.yaml requirement traceability before editing an API surface. - Cite rule IDs (for example 9.2, 11.1) when flagging or fixing violations. - Lint the spec: `cd api-design-guide/linter && npm ci && node cli.mjs --repo-root ../..` diff --git a/api-design-guide/guides/validating-your-spec.md b/api-design-guide/guides/validating-your-spec.md index 16a2146..e6ce118 100644 --- a/api-design-guide/guides/validating-your-spec.md +++ b/api-design-guide/guides/validating-your-spec.md @@ -25,7 +25,7 @@ This checks that the file is a structurally valid AsyncAPI 3.0 document. It is t ## The GovStack Spectral ruleset -The exact `0.2.0-draft` ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) ships at [`linter/`](../linter/README.md). The recommended entrypoint is the driver, which discovers default canonical files or consumes `api/index.yaml`, validates `api/coverage.yaml`, runs the base validators and file-layout checks, and applies [20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling: +The exact `0.1.0-draft` ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) ships at [`linter/`](../linter/README.md). The recommended entrypoint is the driver, which discovers default canonical files or consumes `api/index.yaml`, validates `api/coverage.yaml`, runs the base validators and file-layout checks, and applies [20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling: ```bash cd api-design-guide/linter && npm ci @@ -38,7 +38,7 @@ Or run Spectral directly against the ruleset: npx @stoplight/spectral-cli lint -r api-design-guide/linter/ruleset.yaml api/openapi.yaml ``` -Every finding is prefixed with the guide rule ID it enforces (for example `[7.13][M]`) and links to the rule. Each canonical file must declare `info.x-govstack-api-guide.version: 0.2.0-draft` and `rulesetVersion: 0.2.0-draft`; a missing, unavailable, or different exact version is an error, not a request to use the latest rules. An exception suppresses only the declared `rule` at or below its JSON Pointer `scope`, and only when all governance fields required by [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) are valid and unexpired. Offline validation checks the HTTPS record URI syntax but does not dereference it. An opt-in `strict.yaml` adds noisier heuristics; [`linter/coverage.yaml`](../linter/coverage.yaml) records how each guide rule is covered. In CI, the same checks run through `linter/action.yml`. +Every finding is prefixed with the guide rule ID it enforces (for example `[7.13][M]`) and links to the rule. Each canonical file must declare `info.x-govstack-api-guide.version: 0.1.0-draft` and `rulesetVersion: 0.1.0-draft`; a missing, unavailable, or different exact version is an error, not a request to use the latest rules. An exception suppresses only the declared `rule` at or below its JSON Pointer `scope`, and only when all governance fields required by [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) are valid and unexpired. Offline validation checks the HTTPS record URI syntax but does not dereference it. An opt-in `strict.yaml` adds noisier heuristics; [`linter/coverage.yaml`](../linter/coverage.yaml) records how each guide rule is covered. In CI, the same checks run through `linter/action.yml`. Running `npx @stoplight/spectral-cli lint api/openapi.yaml` *without* `-r` applies only Spectral's generic built-in rules: useful as a quick structural check, but it knows nothing about this guide. diff --git a/api-design-guide/how-to-use-this-guide.md b/api-design-guide/how-to-use-this-guide.md index 4a07e4e..cbded87 100644 --- a/api-design-guide/how-to-use-this-guide.md +++ b/api-design-guide/how-to-use-this-guide.md @@ -1,5 +1,5 @@ --- -description: "Entry points by audience, how to review the draft, and the v0.1 to v0.2 section mapping." +description: "Entry points by audience and guidance for reviewing the draft." --- # How to use this guide @@ -18,41 +18,12 @@ The guide is written for lookup, not end-to-end reading ([§1.4](1-introduction. This is a strawman of a normative cross-BB API design guide. It is not yet ratified. At this stage, committee feedback should focus on: 1. **Scope of harmonisation** (see [§1.2](1-introduction.md#12-scope)). -2. **Structure and depth.** 20 numbered sections ([§1](1-introduction.md)–[§20](part-e/20-conformance-and-validation.md)). The final v1.0 will expand rules with positive/negative examples and link machine-checkable rules to Spectral rule IDs. +2. **Structure and depth.** 20 numbered sections ([§1](1-introduction.md)–[§20](part-e/20-conformance-and-validation.md)), supported by examples and rule-specific linter coverage where mechanical enforcement is practical. 3. **Prescriptiveness.** RFC 2119 keywords and linter-backed conformance (see [§1.5](1-introduction.md#15-language) and [§20](part-e/20-conformance-and-validation.md)). 4. **The open questions.** [Appendix B](appendix/b-open-questions.md) consolidates every deliberate design call; the **Blocks v1.0?** column marks the ones that need a committee decision before ratification. Companion documents (governance, security/operations, common YAML, Spectral ruleset, conformance pack) are referenced where relevant; their scope is in [Appendix A](appendix/a-companion-documents.md). -## Section renumbering from v0.1 - -v0.2 eliminates the lettered sections (old §2A and §15A) in favour of a continuous 1–20 numbering. If you are cross-checking against the circulated v0.1 document: - -| v0.1 section | v0.2 section | -|---|---| -| 1 Introduction | [1](1-introduction.md) | -| 2 OpenAPI document standards | [2](part-a/2-openapi-document-standards.md) | -| 2A AsyncAPI document standards | [3](part-a/3-asyncapi-document-standards.md) | -| 3 Documentation requirements | [4](part-a/4-documentation-requirements.md) | -| 4 URL structure and versioning | [5](part-b/5-url-structure-and-versioning.md) | -| 5 HTTP methods | [6](part-b/6-http-methods.md) | -| 6 HTTP status codes | [7](part-b/7-http-status-codes.md) | -| 7 Headers | [8](part-b/8-headers.md) | -| 8 JSON conventions and naming | [9](part-c/9-json-conventions-and-naming.md) | -| 9 Data types and formats | [10](part-c/10-data-types-and-formats.md) | -| 10 Errors | [11](part-c/11-errors.md) | -| 11 Pagination, filtering, sorting | [12](part-c/12-pagination-filtering-sorting.md) | -| 12 Authentication and authorisation | [13](part-d/13-authentication-and-authorisation.md) | -| 13 Idempotency | [14](part-d/14-idempotency.md) | -| 14 Asynchronous operations | [15](part-d/15-asynchronous-operations.md) | -| 15 CloudEvents and webhooks | [16](part-d/16-cloudevents-and-webhooks.md) | -| 15A AsyncAPI channel documentation rules | [17](part-d/17-asyncapi-channel-rules.md) | -| 16 Compatibility and lifecycle | [18](part-d/18-compatibility-and-lifecycle.md) | -| 17 Localisation | [19](part-e/19-localisation.md) | -| 18 Conformance and validation | [20](part-e/20-conformance-and-validation.md) | - -Rule numbers moved with their sections (v0.1 rule 8.2 is now 9.2). The `OPEN-N-X` identifiers in [Appendix B](appendix/b-open-questions.md) are deliberately **not** re-keyed: they are frozen labels from v0.1, so feedback threads that cite them stay valid. - ## About the rule titles <a href="#about-the-rule-titles" id="about-the-rule-titles"></a> -The short titles on rule headings (for example "9.2 camelCase field names") were added in v0.2 as navigation aids. They are **non-normative**: only the rule body defines the requirement. If a title and its body ever seem to disagree, the body wins, and please report it. +The short titles on rule headings (for example "9.2 camelCase field names") are navigation aids. They are **non-normative**: only the rule body defines the requirement. If a title and its body ever seem to disagree, the body wins, and please report it. diff --git a/api-design-guide/linter/README.md b/api-design-guide/linter/README.md index 6db674c..8329050 100644 --- a/api-design-guide/linter/README.md +++ b/api-design-guide/linter/README.md @@ -5,7 +5,7 @@ The GovStack Spectral ruleset and lint tooling for the behind rule [20.2](../part-e/20-conformance-and-validation.md) (draft; the formal companion publication is tracked in [Appendix A](../appendix/a-companion-documents.md)). It implements guide -version **0.2.0-draft** (`guide_version` in [coverage.yaml](coverage.yaml)). +version **0.1.0-draft** (`guide_version` in [coverage.yaml](coverage.yaml)). ## Quick start @@ -66,8 +66,8 @@ the following shape. `record` must be HTTPS, dates use `YYYY-MM-DD`, and ```yaml info: x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft exceptions: - rule: "9.5" scope: /components/schemas/LegacyRecord diff --git a/api-design-guide/linter/cli.mjs b/api-design-guide/linter/cli.mjs index 728471b..fb1dda4 100755 --- a/api-design-guide/linter/cli.mjs +++ b/api-design-guide/linter/cli.mjs @@ -28,7 +28,7 @@ const { Spectral, Document } = spectralCore; const { bundleAndLoadRuleset } = bundler; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const SUPPORTED_GUIDE_VERSION = '0.2.0-draft'; +const SUPPORTED_GUIDE_VERSION = '0.1.0-draft'; const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; // Spectral severity numbers -> our names. 0=error 1=warn 2=info 3=hint. diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index ccbe6a4..33f27e5 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -12,7 +12,7 @@ # runtime - constrains wire behaviour; conformance test-harness territory, not a linter's # human - review/governance judgment; no useful automated check # informative - non-normative guide entry; nothing to enforce -guide_version: "0.2.0-draft" +guide_version: "0.1.0-draft" rules: - id: "2.1" class: "M" @@ -50,10 +50,10 @@ rules: spectral_rules: [govstack-2.7] note: "each operation has operationId(camelCase)/summary/description/>=1 tag" - id: "2.8" - class: "M" + class: "M+R" status: needs-context spectral_rules: [] - note: "shared components $ref pinned local govstack-openapi-common.yaml - needs vendored file" + note: "conditional Problem/ValidationProblem/FieldError/PageInfo references require the vendored schema-only common file and semantic recognition of where each schema applies" - id: "3.1" class: "M" status: implemented @@ -408,7 +408,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-9.11] - note: "in-doc: extract bb-code from error codes/scopes/event types/logical channel IDs, verify identical + regex ^[a-z][a-z0-9-]{1,30}$; protocol-native address values are excluded; ecosystem-uniqueness needs registry" + note: "in-doc: extract bb-code from canonical problem-type URIs, OAuth scopes, event types and logical channel IDs; verify identical + regex ^[a-z][a-z0-9-]{1,30}$; protocol-native address values are excluded; ecosystem uniqueness needs registry" - id: "10.1" class: "M+R" status: partial-proxy @@ -471,35 +471,25 @@ rules: note: "every 4xx/5xx response uses application/problem+json. OpenAPI surface only (problem+json/status-code mechanism has no AsyncAPI equivalent)." - id: "11.2" class: "M+R" - status: implemented - spectral_rules: [govstack-11.2] - note: "problem schema has type/title/status; detail/instance advisory; no-PII clause human. OpenAPI surface only." + status: partial-proxy + spectral_rules: [govstack-11.2, govstack-11.2-type] + note: "problem schema has type/title/status, omits code/timestamp after ref resolution, and literal examples use the canonical type URI; status/body equality, URI dereferenceability and no-sensitive-detail clauses require review or instance tests" - id: "11.3" class: "M" status: implemented spectral_rules: [govstack-11.3] - note: "problem schema includes code/traceId/timestamp (may need resolved $ref). OpenAPI surface only." + note: "problem schema requires traceId after reference resolution; runtime equality with the effective W3C traceparent is not checked" - id: "11.4" class: "M+R" - status: implemented + status: partial-proxy spectral_rules: [govstack-11.4] - note: "validation-error schema has errors[] with pointer/code/message. OpenAPI surface only; HTTP 400 stands in for 'attributable to specific fields'." + note: "when an HTTP problem schema declares errors, it requires errors[] items with pointer/message and omits field-level code; the linter does not infer field attribution from status codes or semantics" - id: "11.5" - class: "M+R" - status: partial-proxy - spectral_rules: [govstack-11.5-enum, govstack-11.5-example] - note: "proxy: where code enum/examples exist, match global.govstack.{bb-code}.{lowerCamel}. OpenAPI surface only." - - id: "11.6" class: "R" status: runtime spectral_rules: [] - note: "code/type stable across localised responses - runtime" - - id: "11.7" - class: "M+R" - status: needs-context - spectral_rules: [] - note: "common errors $ref'd from govstack-openapi-common.yaml - needs common file" - - id: "11.8" + note: "problem fields stable across localised responses - runtime" + - id: "11.6" class: "M+R" status: needs-context spectral_rules: [] @@ -516,9 +506,9 @@ rules: note: "proxy: paginated lists declare pageSize+cursor; exempts the 5.9 operational endpoints /health and /ready" - id: "12.3" class: "M" - status: implemented + status: partial-proxy spectral_rules: [govstack-12.3] - note: "paginated response shape {items,pageInfo{nextCursor,hasMore,total?}}; exempts the 5.9 operational endpoints /health and /ready" + note: "proxy: paginated response schema requires items and pageInfo with a nullable, non-empty nextCursor and does not declare hasMore; runtime cursor availability and total exact/estimated plus snapshot/current semantics require review" - id: "12.4" class: "M+R" status: implemented @@ -625,15 +615,15 @@ rules: spectral_rules: [govstack-15.1] note: "every 202 declares Location" - id: "15.2" - class: "M" - status: partial-proxy - spectral_rules: [govstack-15.2] - note: "Operation schema $ref from common; shape {id,status,result,error,createdAt,updatedAt,progress?}" + class: "M+R" + status: human + spectral_rules: [] + note: "local Operation identifier and state-dependent shape require semantic review; no ecosystem field names or formats are prescribed" - id: "15.3" - class: "M" - status: partial-proxy - spectral_rules: [govstack-15.3] - note: "Operation status enum matches fixed set from common" + class: "M+R" + status: human + spectral_rules: [] + note: "terminal/non-terminal lifecycle and result/error/cancellation semantics require review; no fixed enum is prescribed" - id: "15.4" class: "M+R" status: partial-proxy @@ -676,29 +666,29 @@ rules: note: "proxy: flag source const/example containing host/env/pod tokens" - id: "16.5" class: "R" - status: runtime + status: human spectral_rules: [] - note: "actual event signing runtime" + note: "conditional opt-in signing choice and profile documentation require threat-model context" - id: "16.6" - class: "M+R" - status: implemented - spectral_rules: [govstack-16.6] - note: "webhook ops / async messages declare GovStack-Signature header/field. OpenAPI webhooks half only; AsyncAPI clause not enforced (its binding-exception makes it non-mechanical)." + class: "R" + status: needs-context + spectral_rules: [] + note: "signature metadata naming applies only when a surface opts into signing" - id: "16.7" class: "R" status: runtime spectral_rules: [] - note: "signed-material contents runtime" + note: "conditional signed-material contents are verified at runtime when signing is adopted" - id: "16.8" class: "R" status: needs-context spectral_rules: [] - note: "common files pin signed bytes/algorithm - needs common file" + note: "experimental OpenAPI profile and adopter-supplied key/replay contract need common-file and implementation context" - id: "16.9" class: "informative" status: informative spectral_rules: [] - note: "out-of-scope statement (informative)" + note: "readiness criteria for a future shared profile (informative)" - id: "16.10" class: "R" status: human @@ -708,7 +698,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-16.11] - note: "proxy: subscription create/list/rotate-secret/delete endpoints present" + note: "proxy: subscription create/list/delete endpoints present; conditional signing-key rotation is not checked" - id: "17.1" class: "M+R" status: implemented @@ -736,14 +726,14 @@ rules: note: "proxy: flag dev/test/prod/broker tokens in channel addresses" - id: "17.6" class: "M" - status: implemented + status: partial-proxy spectral_rules: [govstack-17.6] - note: "message payloads CloudEvents-shaped with domain data under data" + note: "recognized CloudEvents Message Objects use application/cloudevents+json and carry the required envelope attributes; identifying every domain event and misplaced domain data requires semantic review" - id: "17.7" class: "M" - status: needs-context - spectral_rules: [] - note: "channel messages $ref shared CloudEvents schema from common - needs common file" + status: partial-proxy + spectral_rules: [govstack-17.7] + note: "recognized CloudEvents and async-error messages reference the vendored common schemas; identifying every domain event or rejection requires semantic review" - id: "17.8" class: "M+R" status: partial-proxy @@ -760,30 +750,30 @@ rules: spectral_rules: [govstack-17.10] note: "every operation covered by a security scheme via servers/operations" - id: "17.11" - class: "M+R" - status: implemented - spectral_rules: [govstack-17.11] - note: "each op declares x-govstack-delivery in {atMostOnce,atLeastOnce,effectivelyOnce}" + class: "R" + status: human + spectral_rules: [] + note: "conditional duplicate-delivery and application de-duplication contract requires protocol context" - id: "17.12" - class: "M+R" - status: implemented - spectral_rules: [govstack-17.12] - note: "each op declares x-govstack-ordering (key/scope or explicit none)" + class: "R" + status: human + spectral_rules: [] + note: "ordering is documented only when promised; scope/key require protocol context" - id: "17.13" - class: "M+R" - status: implemented - spectral_rules: [govstack-17.13] - note: "each op declares redelivery/dead-letter/retention/replay as supported/unsupported/n-a. Extension key names (x-govstack-redelivery/-dead-letter/-retention/-replay) inferred pending govstack-asyncapi-common.yaml." + class: "R" + status: human + spectral_rules: [] + note: "consumer-visible redelivery/dead-letter/retention/replay capabilities require protocol and deployment context" - id: "17.14" class: "R" status: human spectral_rules: [] - note: "authoring portable contract shape/defaults - governance" + note: "deciding which values are stable public promises rather than implementation settings requires review" - id: "17.15" - class: "M+R" - status: implemented - spectral_rules: [govstack-17.15] - note: "each op carries x-govstack-delivery/ordering/replay + description" + class: "R" + status: human + spectral_rules: [] + note: "review that standard bindings are used first and custom extensions are not treated as portable contracts" - id: "17.16" class: "M+R" status: partial-proxy diff --git a/api-design-guide/linter/functions/README.md b/api-design-guide/linter/functions/README.md index eac9454..93e86f7 100644 --- a/api-design-guide/linter/functions/README.md +++ b/api-design-guide/linter/functions/README.md @@ -112,7 +112,8 @@ Recursively assert every declared **property name** obeys a casing/pattern. Recursively assert schema nodes carry a non-empty `description`. - **Given:** a JSON Schema. - **Options:** `mode` — `"properties"` (default: every declared property needs a - description) or `"all"` (every subschema node except pure combinator wrappers); + description) or `"all"` (every subschema node except pure combinator wrappers + and required-only assertions below `not`); `includeRoot` (properties mode only: also require a description on the root). - **Example (4.1):** ```yaml @@ -171,7 +172,8 @@ object/array shapes, const/enum/type on leaves). Inspects the schema, does not validate a data instance. One level of top-level `allOf` is merged. - **Given:** the schema (a response schema, `$.components.schemas.Foo`). - **Options (a recursive spec node):** `requiredProperties` (names that must be - in `required`), `properties` (`name -> child spec`; each named property must + in `required`), `forbiddenProperties` (names that must not be declared), + `properties` (`name -> child spec`; each named property must be declared and is validated by its child), `type`, `const`, `enum` (schema's `enum` must equal this as a set), `items` (child spec for array `items`). - **Example (12.3 page envelope):** @@ -180,12 +182,12 @@ validate a data instance. One level of top-level `allOf` is merged. requiredProperties: [items, pageInfo] properties: items: { type: array } - pageInfo: { requiredProperties: [nextCursor, hasMore] } + pageInfo: { requiredProperties: [nextCursor] } ``` - **Example (16.2 CloudEvents payload):** ```yaml functionOptions: - requiredProperties: [specversion, id, source, type, data] + requiredProperties: [specversion, id, source, type] properties: { specversion: { const: "1.0" } } ``` @@ -227,9 +229,8 @@ Validate presence and shape of an `x-govstack-*` extension on a container. `requiredKeys` (object keys that must be present), `semverKeys` (object keys whose value must be SemVer), `keyEnums` (`{ key: [values] }`), `keyPatterns` (`{ key: regexString }`). -- **Example (17.11 delivery enum; 20.3 guide metadata):** +- **Example (20.3 guide metadata):** ```yaml - functionOptions: { extension: x-govstack-delivery, enum: [atMostOnce, atLeastOnce, effectivelyOnce] } functionOptions: { extension: x-govstack-api-guide, valueType: object, requiredKeys: [version], semverKeys: [version] } ``` diff --git a/api-design-guide/linter/functions/envelopeShape.js b/api-design-guide/linter/functions/envelopeShape.js index ef26ec3..9ac2e2a 100644 --- a/api-design-guide/linter/functions/envelopeShape.js +++ b/api-design-guide/linter/functions/envelopeShape.js @@ -12,6 +12,7 @@ import { isObject, asArray } from './lib/util.js'; * * The options object is a "spec" node. A spec node may contain: * requiredProperties {string[]} names that must appear in schema.required. + * forbiddenProperties {string[]} names that must not be declared. * properties {object} map of name -> child spec node; each named property * must be declared, and is validated by its child spec. * type {string} schema.type must equal this (array-typed `type` ok). @@ -82,6 +83,12 @@ function matchNode(schema, spec, path, results, depth) { } } + for (const name of asArray(spec.forbiddenProperties)) { + if (Object.prototype.hasOwnProperty.call(eff.properties, name)) { + results.push({ message: `schema must not declare property "${name}"`, path: [...path, 'properties', name] }); + } + } + if (isObject(spec.properties)) { for (const [name, childSpec] of Object.entries(spec.properties)) { const child = eff.properties[name]; diff --git a/api-design-guide/linter/functions/extensionShape.js b/api-design-guide/linter/functions/extensionShape.js index 0ca2c44..d71096d 100644 --- a/api-design-guide/linter/functions/extensionShape.js +++ b/api-design-guide/linter/functions/extensionShape.js @@ -3,14 +3,13 @@ import { isObject, toRegExp, SEMVER_PATTERN } from './lib/util.js'; /** * extensionShape — validate the presence and shape of an `x-govstack-*` * extension on a container object (operation, info, message, …). Drives the - * §17 delivery/ordering trio, §18.7 deprecation metadata and §20.3 guide - * metadata. + * §18.7 deprecation metadata and §20.3 guide metadata. * * `given` should select the container that carries the extension (e.g. an * operation, or `$.info`). * * options: - * extension {string} the extension key to validate, e.g. "x-govstack-delivery". + * extension {string} the extension key to validate, e.g. "x-govstack-api-guide". * required {boolean} default true — absent extension is a violation. * valueType {"string"|"object"|"array"} expected type of the value. * enum {any[]} for string values: allowed values. diff --git a/api-design-guide/linter/functions/s09-bbCode.js b/api-design-guide/linter/functions/s09-bbCode.js index 393e6f9..5094f5a 100644 --- a/api-design-guide/linter/functions/s09-bbCode.js +++ b/api-design-guide/linter/functions/s09-bbCode.js @@ -7,14 +7,14 @@ import { isObject } from './lib/util.js'; * * 1. every extracted BB code matches `^[a-z][a-z0-9-]{1,30}$`; * 2. all extracted BB codes are identical (the same BB uses one code across - * OAuth scopes, error codes, event types and logical channel IDs). + * OAuth scopes, problem-type URIs, event types and logical channel IDs). * * Extraction runs over every object key and string value (skipping free-text * prose keys) using the two documented shapes: - * - OAuth scope: `bb:{bb-code}:{resource}:{action}` - * - reverse-DNS: `global.govstack.{bb-code}....` (error codes, event types, - * logical channel IDs, problem-type URIs) - * The segment `common` is reserved (§11.7) and excluded from the identity check. + * - OAuth scope: `bb:{bb-code}:{resource}:{action}` + * - problem type: `https://govstack.global/problems/{bb-code}/{slug}` + * - reverse-DNS: `global.govstack.{bb-code}....` (event types and logical + * channel IDs) * * It does NOT verify ecosystem-wide uniqueness of the BB code — that needs the * cross-repo BB-code register (Appendix A), which is out of scope here. @@ -32,8 +32,8 @@ import { isObject } from './lib/util.js'; */ const SCOPE_RE = /^bb:([^:\s]+):/; const RDNS_RE = /global\.govstack\.([^.\s]+)\./gi; +const PROBLEM_TYPE_RE = /https:\/\/govstack\.global\/problems\/([^/\s?#]+)\//gi; const BB_CODE_RE = /^[a-z][a-z0-9-]{1,30}$/; -const RESERVED = 'common'; const DEFAULT_SKIP_KEYS = ['description', 'summary', 'title', 'externalDocs', 'address']; function truncate(s) { @@ -59,6 +59,8 @@ export default function s09BbCode(targetVal, options, context) { RDNS_RE.lastIndex = 0; let m; while ((m = RDNS_RE.exec(str)) !== null) raw.push(m[1]); + PROBLEM_TYPE_RE.lastIndex = 0; + while ((m = PROBLEM_TYPE_RE.exec(str)) !== null) raw.push(m[1]); for (const code of raw) { if (!BB_CODE_RE.test(code)) { @@ -68,7 +70,6 @@ export default function s09BbCode(targetVal, options, context) { }); continue; } - if (code === RESERVED) continue; if (!codes.has(code)) codes.set(code, path); } }; @@ -100,7 +101,7 @@ export default function s09BbCode(targetVal, options, context) { results.push({ message: `document uses ${codes.size} distinct BB codes (${list.join(', ')}); a BB must use its single ` + - `registered code identically across error codes, scopes, event types and logical channel IDs (§9.11)`, + `registered code identically across problem types, scopes, event types and logical channel IDs (§9.11)`, path: base, }); } diff --git a/api-design-guide/linter/functions/s09-enumCasing.js b/api-design-guide/linter/functions/s09-enumCasing.js index 66382cd..a6f5d26 100644 --- a/api-design-guide/linter/functions/s09-enumCasing.js +++ b/api-design-guide/linter/functions/s09-enumCasing.js @@ -61,15 +61,6 @@ const DEFAULT_ALLOW_VALUES = [ 'P-521', 'secp256k1', 'ECDH-ES', - // §17.11 delivery guarantees. - 'atMostOnce', - 'atLeastOnce', - 'effectivelyOnce', - // §17.13 delivery-management capabilities, and §17.12's explicit "no ordering". - 'supported', - 'unsupported', - 'notApplicable', - 'none', ]; export default function s09EnumCasing(targetVal, options, context) { diff --git a/api-design-guide/linter/functions/s09-extensionPrefix.js b/api-design-guide/linter/functions/s09-extensionPrefix.js index b8386b0..b072064 100644 --- a/api-design-guide/linter/functions/s09-extensionPrefix.js +++ b/api-design-guide/linter/functions/s09-extensionPrefix.js @@ -6,7 +6,7 @@ import { isObject } from './lib/util.js'; * look GovStack-defined but are not prefixed exactly `x-govstack-`: * * 1. an `x-<token>` whose token is a known GovStack extension concept - * (delivery, ordering, replay, deprecated, api-guide) -> should be + * (deprecated, api-guide) -> should be * `x-govstack-<token>`; * 2. any `x-*` key that mentions "govstack" but is not prefixed * `x-govstack-` (typos / wrong casing / wrong separator). @@ -26,7 +26,7 @@ import { isObject } from './lib/util.js'; * @param {{path?: (string|number)[]}} [context] * @returns {{message:string, path:(string|number)[]}[]|undefined} */ -const DEFAULT_KNOWN = ['delivery', 'ordering', 'replay', 'deprecated', 'api-guide']; +const DEFAULT_KNOWN = ['deprecated', 'api-guide']; export default function s09ExtensionPrefix(targetVal, options, context) { if (!isObject(targetVal) && !Array.isArray(targetVal)) return; diff --git a/api-design-guide/linter/functions/s11-fieldErrors.js b/api-design-guide/linter/functions/s11-fieldErrors.js new file mode 100644 index 0000000..d1b2adc --- /dev/null +++ b/api-design-guide/linter/functions/s11-fieldErrors.js @@ -0,0 +1,79 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s11-fieldErrors — validate the shape of a field-level `errors` extension + * only when a problem schema actually declares that extension. + * + * This deliberately does not infer that every 400 response is a validation + * problem. Whether a failure is attributable to request fields is semantic; + * the linter only verifies the declared shape once a schema opts in. + * + * One level of `allOf` composition is inspected, matching envelopeShape's + * resolved-schema behaviour. Spectral resolves external references before + * invoking the function. + */ +function effective(schema) { + const required = new Set(asArray(schema.required).filter((name) => typeof name === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + for (const branch of asArray(schema.allOf)) { + if (!isObject(branch)) continue; + for (const name of asArray(branch.required)) { + if (typeof name === 'string') required.add(name); + } + if (isObject(branch.properties)) Object.assign(properties, branch.properties); + } + return { required, properties }; +} + +export default function s11FieldErrors(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const problem = effective(targetVal); + const errors = problem.properties.errors; + if (!isObject(errors)) return; + + const results = []; + if (!problem.required.has('errors')) { + results.push({ + message: 'a declared field-level errors array must be required', + path: [...base, 'required'], + }); + } + if (errors.type !== 'array') { + results.push({ + message: 'field-level errors must be an array', + path: [...base, 'properties', 'errors', 'type'], + }); + return results; + } + if (!isObject(errors.items)) { + results.push({ + message: 'field-level errors must declare an item schema', + path: [...base, 'properties', 'errors', 'items'], + }); + return results; + } + + const item = effective(errors.items); + if (Object.prototype.hasOwnProperty.call(item.properties, 'code')) { + results.push({ + message: 'field-error items must not declare "code"', + path: [...base, 'properties', 'errors', 'items', 'properties', 'code'], + }); + } + for (const name of ['pointer', 'message']) { + if (!item.required.has(name)) { + results.push({ + message: `field-error items must require "${name}"`, + path: [...base, 'properties', 'errors', 'items', 'required'], + }); + } + if (!isObject(item.properties[name])) { + results.push({ + message: `field-error items must declare "${name}"`, + path: [...base, 'properties', 'errors', 'items', 'properties'], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s11-problemType.js b/api-design-guide/linter/functions/s11-problemType.js new file mode 100644 index 0000000..de841e7 --- /dev/null +++ b/api-design-guide/linter/functions/s11-problemType.js @@ -0,0 +1,58 @@ +import { isObject, asArray } from './lib/util.js'; + +const TYPE_RE = /^https:\/\/govstack\.global\/problems\/[a-z][a-z0-9-]{1,30}\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +/** + * Validate literal RFC 9457 type values supplied with a problem response. + * The shared schema carries the same pattern for runtime instance validation; + * this function catches author-provided examples during normal Spectral lint. + */ +export default function s11ProblemType(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const check = (value, path) => { + if (typeof value !== 'string' || !TYPE_RE.test(value)) { + results.push({ + message: + 'problem type must match https://govstack.global/problems/{bb-code}/{kebab-problem-slug}', + path, + }); + } + }; + + if (isObject(targetVal.example) && 'type' in targetVal.example) { + check(targetVal.example.type, [...base, 'example', 'type']); + } + if (isObject(targetVal.examples)) { + for (const [name, example] of Object.entries(targetVal.examples)) { + const value = isObject(example) && 'value' in example ? example.value : example; + if (isObject(value) && 'type' in value) { + check(value.type, [...base, 'examples', name, ...(isObject(example) && 'value' in example ? ['value'] : []), 'type']); + } + } + } + + const schemas = [targetVal.schema, ...asArray(targetVal.schema?.allOf)].filter(isObject); + for (const schema of schemas) { + if (isObject(schema.example) && 'type' in schema.example) { + check(schema.example.type, [...base, 'schema', 'example', 'type']); + } + for (const [index, example] of asArray(schema.examples).entries()) { + if (isObject(example) && 'type' in example) { + check(example.type, [...base, 'schema', 'examples', index, 'type']); + } + } + const typeSchema = schema.properties?.type; + if (isObject(typeSchema)) { + if ('const' in typeSchema) check(typeSchema.const, [...base, 'schema', 'properties', 'type', 'const']); + for (const [index, value] of asArray(typeSchema.enum).entries()) { + check(value, [...base, 'schema', 'properties', 'type', 'enum', index]); + } + if ('example' in typeSchema) check(typeSchema.example, [...base, 'schema', 'properties', 'type', 'example']); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s12-collectionPagination.js b/api-design-guide/linter/functions/s12-collectionPagination.js index 6fa3094..3c8acdb 100644 --- a/api-design-guide/linter/functions/s12-collectionPagination.js +++ b/api-design-guide/linter/functions/s12-collectionPagination.js @@ -31,7 +31,9 @@ import envelopeShape from './envelopeShape.js'; * `offset` parameter (that operation is covered by the * `offsetEnvelope` mode / §12.6 instead). * - cursorEnvelope: the 200 response body schema MUST declare the §12.3 - * envelope `{ items, pageInfo: { nextCursor, hasMore } }`. + * envelope `{ items, pageInfo: { nextCursor } }`, with a + * nullable non-empty cursor and no declared `hasMore` + * member. * No-op when the operation has an `offset` parameter. * - pageSizeBounds: the `pageSize` parameter MUST exist and declare both a * `default` and a `maximum` (guide §12.4). @@ -70,6 +72,49 @@ function hasDeclaredBound(schema) { return arrays.every((a) => Number.isInteger(a.maxItems)); } +/** Merge a schema's direct shape with one level of allOf composition. */ +function effective(schema) { + if (!isObject(schema)) return { required: new Set(), properties: {} }; + const required = new Set(asArray(schema.required).filter((name) => typeof name === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + for (const branch of asArray(schema.allOf)) { + if (!isObject(branch)) continue; + for (const name of asArray(branch.required)) { + if (typeof name === 'string') required.add(name); + } + if (isObject(branch.properties)) Object.assign(properties, branch.properties); + } + return { required, properties }; +} + +function nullableNonEmptyString(schema) { + if (!isObject(schema)) return false; + const directTypes = asArray(schema.type); + const directTypeSet = new Set(directTypes); + if ( + directTypes.length === 2 && + directTypeSet.size === 2 && + directTypeSet.has('string') && + directTypeSet.has('null') + ) { + return Number.isInteger(schema.minLength) && schema.minLength >= 1; + } + + const oneOf = asArray(schema.oneOf); + const anyOf = asArray(schema.anyOf); + if ((oneOf.length > 0) === (anyOf.length > 0)) return false; + const branches = oneOf.length > 0 ? oneOf : anyOf; + if (branches.length !== 2 || !branches.every(isObject)) return false; + const stringBranch = branches.find((branch) => branch.type === 'string'); + const nullBranch = branches.find((branch) => branch.type === 'null'); + return Boolean( + stringBranch && + nullBranch && + Number.isInteger(stringBranch.minLength) && + stringBranch.minLength >= 1, + ); +} + export default function collectionPagination(targetVal, options, context) { if (!isObject(targetVal)) return; const opts = isObject(options) ? options : {}; @@ -144,17 +189,32 @@ export default function collectionPagination(targetVal, options, context) { if (opts.mode === 'cursorEnvelope') { if (offsetMode) return undefined; - return envelopeShape( + const findings = envelopeShape( schema, { requiredProperties: ['items', 'pageInfo'], properties: { items: { type: 'array' }, - pageInfo: { requiredProperties: ['nextCursor', 'hasMore'] }, + pageInfo: { + requiredProperties: ['nextCursor'], + forbiddenProperties: ['hasMore'], + }, }, }, { path: schemaPath }, - ); + ) ?? []; + + const pageInfo = effective(schema).properties.pageInfo; + if (isObject(pageInfo)) { + const nextCursor = effective(pageInfo).properties.nextCursor; + if (!nullableNonEmptyString(nextCursor)) { + findings.push({ + message: 'pageInfo.nextCursor must be an explicitly nullable, non-empty string', + path: [...schemaPath, 'properties', 'pageInfo', 'properties', 'nextCursor'], + }); + } + } + return findings.length ? findings : undefined; } // mode === 'offsetEnvelope' diff --git a/api-design-guide/linter/functions/s15-operationsPolling.js b/api-design-guide/linter/functions/s15-operationsPolling.js index 0f62296..56ecd10 100644 --- a/api-design-guide/linter/functions/s15-operationsPolling.js +++ b/api-design-guide/linter/functions/s15-operationsPolling.js @@ -8,8 +8,10 @@ const POLL_PATH = /^\/v\d+\/operations\/\{[^}]+\}$/; * This flags a spec that uses Operations but does not declare that canonical * poll endpoint. * - * "Uses Operations" is detected structurally: the document declares a + * "Uses Operations" is detected structurally: the document declares a local * `components.schemas.Operation` schema, or has any path under `/operations/`. + * The function deliberately does not inspect the schema's fields or status + * values; those semantics belong to the BB. * * It is a proxy: it cannot decide whether asynchronous behaviour is actually * needed, and it accepts any version major and any path-parameter name. diff --git a/api-design-guide/linter/functions/s16-signatureHeader.js b/api-design-guide/linter/functions/s16-signatureHeader.js deleted file mode 100644 index eb778c1..0000000 --- a/api-design-guide/linter/functions/s16-signatureHeader.js +++ /dev/null @@ -1,49 +0,0 @@ -import { isObject } from './lib/util.js'; - -const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; - -/** - * s16-signatureHeader — guide 16.6 (OpenAPI/webhooks surface): the event - * signature MUST travel in a single ecosystem-wide HTTP header named - * `GovStack-Signature`. Every webhook delivery therefore has to declare that - * header as a request parameter. - * - * `given` should select each webhook path-item object, i.e. `$.webhooks[*]`. - * Header parameters may be declared at the path-item level (shared) or on the - * individual operations; this function accepts the header found at either - * level. Matching is case-insensitive because HTTP header names are. - * - * Options: - * field {string} header name to require (default "GovStack-Signature"). - * - * @param {unknown} targetVal - a webhook Path Item Object. - * @param {object} options - * @param {{path?: (string|number)[]}} [context] - * @returns {{message:string, path:(string|number)[]}[]|undefined} - */ -export default function signatureHeader(targetVal, options, context) { - if (!isObject(targetVal)) return; - const field = (isObject(options) && typeof options.field === 'string' ? options.field : 'GovStack-Signature'); - const wanted = field.toLowerCase(); - const base = context && Array.isArray(context.path) ? context.path : []; - const label = base.length ? String(base[base.length - 1]) : 'webhook'; - - const hasHeader = (params) => - Array.isArray(params) && - params.some( - (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === wanted, - ); - - if (hasHeader(targetVal.parameters)) return; - for (const method of METHODS) { - const op = targetVal[method]; - if (isObject(op) && hasHeader(op.parameters)) return; - } - - return [ - { - message: `webhook "${label}" must declare a "${field}" header parameter so receivers can verify the event signature (§16.6).`, - path: base, - }, - ]; -} diff --git a/api-design-guide/linter/functions/s16-subscriptionEndpoints.js b/api-design-guide/linter/functions/s16-subscriptionEndpoints.js index b5ae414..2fb0ea0 100644 --- a/api-design-guide/linter/functions/s16-subscriptionEndpoints.js +++ b/api-design-guide/linter/functions/s16-subscriptionEndpoints.js @@ -4,10 +4,10 @@ const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'tr /** * s16-subscriptionEndpoints — guide 16.11 (proxy): subscription management MUST - * expose interfaces to create, list, rotate the signing secret, and delete a - * subscription. This is a proxy: it can only reason about paths that are + * expose interfaces to create, list, and delete a subscription. This is a + * proxy: it can only reason about paths that are * *named* as subscription resources. When a document exposes any subscription - * path, the four capabilities are required; a document with no subscription + * path, the three baseline capabilities are required; a document with no subscription * path is left clean (absence of a subscription surface cannot be judged here, * nor can the AsyncAPI message-command control plane the guide also permits). * @@ -15,7 +15,6 @@ const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'tr * create POST on a collection path ending in `/subscriptions` * list GET on that collection path * delete DELETE on an item path `/subscriptions/{id}` - * rotate-secret POST/PUT on a path whose last segment mentions rotate/secret * * `given` should be `$.paths`. * @@ -30,7 +29,6 @@ export default function subscriptionEndpoints(targetVal, options, context) { const collection = new Set(); const item = new Set(); - const rotate = new Set(); let sawSubscription = false; for (const [route, pathItem] of Object.entries(targetVal)) { @@ -42,9 +40,7 @@ export default function subscriptionEndpoints(targetVal, options, context) { const methods = METHODS.filter((m) => isObject(pathItem[m])); const last = segments[segments.length - 1] || ''; - if (/rotate|secret/i.test(last)) { - methods.forEach((m) => rotate.add(m)); - } else if (/^subscriptions?$/i.test(last)) { + if (/^subscriptions?$/i.test(last)) { methods.forEach((m) => collection.add(m)); } else if (/^\{.+\}$/.test(last)) { methods.forEach((m) => item.add(m)); @@ -56,16 +52,13 @@ export default function subscriptionEndpoints(targetVal, options, context) { const missing = []; if (!collection.has('post')) missing.push('create (POST /…/subscriptions)'); if (!collection.has('get')) missing.push('list (GET /…/subscriptions)'); - if (!(rotate.has('post') || rotate.has('put'))) { - missing.push('rotate-secret (POST /…/subscriptions/{id}/rotate-secret)'); - } if (!item.has('delete')) missing.push('delete (DELETE /…/subscriptions/{id})'); if (missing.length === 0) return; return [ { - message: `subscription management is exposed but missing: ${missing.join(', ')}. §16.11 requires create, list, rotate-secret, and delete interfaces.`, + message: `subscription management is exposed but missing: ${missing.join(', ')}. §16.11 requires create, list, and delete interfaces.`, path: base, }, ]; diff --git a/api-design-guide/linter/functions/s17-cloudEventsPayload.js b/api-design-guide/linter/functions/s17-cloudEventsPayload.js index 2640601..dcc3501 100644 --- a/api-design-guide/linter/functions/s17-cloudEventsPayload.js +++ b/api-design-guide/linter/functions/s17-cloudEventsPayload.js @@ -1,47 +1,68 @@ import { isObject, asArray } from './lib/util.js'; -const CE_REQUIRED = ['specversion', 'id', 'source', 'type', 'data']; +const CE_ENVELOPE_REQUIRED = ['specversion', 'id', 'source', 'type']; /** * s17-cloudEventsPayload — §17.6 structured CloudEvents JSON payloads. * * Given: a single AsyncAPI 3.0 message object (`$.components.messages[*]`). * - * For domain-event messages, asserts the payload declares the structured - * CloudEvents shape: `specversion` (const "1.0"), `id`, `source`, `type`, and - * `data` (the GovStack-owned domain data). One level of top-level `allOf` is - * merged so a message that composes the shared CloudEvents schema with a - * specialised `data` still satisfies the check. + * For domain-event messages, asserts contentType is + * `application/cloudevents+json` and the payload declares the structured + * CloudEvents shape: `specversion` (const "1.0"), `id`, `source`, and `type`. + * Event `data` remains optional and locally specialised. One level of top-level + * `allOf` is merged. A reference to the reviewed shared CloudEventEnvelope + * contributes its known fields; base AsyncAPI validation separately proves + * that the external reference resolves. * - * Bare §11 error/rejection envelopes (payload declares problem fields - * `title`+`status`, or `code`+`traceId`, and no `specversion`) are OUT of scope - * here — they are governed by §17.16 — so they are skipped to avoid false - * positives. + * Messages that do not declare the CloudEvents media type, reference a + * CloudEvents envelope, or expose CloudEvents fields are OUT of scope. This is + * deliberate: the guide permits non-CloudEvents commands and transport-native + * messages, and a linter cannot infer that they are domain events. * - * options: none. + * Options: + * requireSharedReference {boolean} when true, only check that the message + * payload references the vendored CloudEventEnvelope or GovStackAsyncError. + * The corresponding rule runs with `resolved: false` so the authored + * external reference remains visible. * @param {unknown} targetVal - a message object. * @param {object} _options * @param {{path?: (string|number)[]}} [context] * @returns {{message:string, path:(string|number)[]}[]|undefined} */ -export default function s17CloudEventsPayload(targetVal, _options, context) { +export default function s17CloudEventsPayload(targetVal, options, context) { if (!isObject(targetVal)) return; const payload = targetVal.payload; if (!isObject(payload)) return; // no inline payload schema to inspect const base = context && Array.isArray(context.path) ? context.path : []; + if (isObject(options) && options.requireSharedReference === true) { + if ( + referencesVendoredSchema(payload, 'CloudEventEnvelope') || + referencesVendoredSchema(payload, 'GovStackAsyncError') + ) { + return; + } + if (!isCloudEventMessage(targetVal, payload) && !isAsyncErrorSchema(payload)) return; + return [ + { + message: + 'message payload must reference the schema from the vendored common/govstack-asyncapi-common.yaml file', + path: [...base, 'payload'], + }, + ]; + } + if (!isCloudEventMessage(targetVal, payload)) return; const { required, properties } = effective(payload); - const declared = (n) => required.has(n) || properties[n] !== undefined; - - // Skip bare §11 error envelopes: they are not CloudEvents-shaped. - const looksLikeError = - !declared('specversion') && - ((declared('title') && declared('status')) || - (declared('code') && (declared('traceId') || declared('traceid')))); - if (looksLikeError) return; const results = []; const at = [...base, 'payload']; - for (const name of CE_REQUIRED) { + if (targetVal.contentType !== 'application/cloudevents+json') { + results.push({ + message: 'CloudEvents Message Object must set contentType to "application/cloudevents+json"', + path: [...base, 'contentType'], + }); + } + for (const name of CE_ENVELOPE_REQUIRED) { if (!required.has(name)) { results.push({ message: `CloudEvents payload must list "${name}" in required`, path: [...at, 'required'] }); } @@ -56,12 +77,30 @@ export default function s17CloudEventsPayload(targetVal, _options, context) { return results.length ? results : undefined; } +function isCloudEventMessage(message, payload) { + if (message.contentType === 'application/cloudevents+json') return true; + if (referencesSchema(payload, 'CloudEventEnvelope')) return true; + const { required, properties } = effective(payload); + return required.has('specversion') || properties.specversion !== undefined; +} + +function isAsyncErrorSchema(payload) { + if (referencesSchema(payload, 'GovStackAsyncError')) return true; + const { required, properties } = effective(payload); + const declared = (name) => required.has(name) || properties[name] !== undefined; + return !declared('status') && declared('code') && (declared('traceId') || declared('traceid')); +} + /** Merge a schema's own required/properties with one level of allOf branches. */ function effective(schema) { const required = new Set(asArray(schema.required).filter((s) => typeof s === 'string')); const properties = isObject(schema.properties) ? { ...schema.properties } : {}; for (const branch of asArray(schema.allOf)) { if (!isObject(branch)) continue; + if (referencesSchema(branch, 'CloudEventEnvelope')) { + for (const name of CE_ENVELOPE_REQUIRED) required.add(name); + properties.specversion ??= { const: '1.0' }; + } for (const r of asArray(branch.required)) if (typeof r === 'string') required.add(r); if (isObject(branch.properties)) { for (const [k, v] of Object.entries(branch.properties)) if (!(k in properties)) properties[k] = v; @@ -69,3 +108,29 @@ function effective(schema) { } return { required, properties }; } + +function referencesSchema(schema, name) { + if (!isObject(schema)) return false; + const suffix = `#/components/schemas/${name}`; + if (typeof schema.$ref === 'string' && schema.$ref.endsWith(suffix)) return true; + return asArray(schema.allOf).some( + (branch) => isObject(branch) && typeof branch.$ref === 'string' && branch.$ref.endsWith(suffix), + ); +} + +function referencesVendoredSchema(schema, name) { + if (!isObject(schema)) return false; + const pattern = new RegExp( + `(?:^|/)(?:api/)?common/govstack-asyncapi-common\\.yaml#/components/schemas/${name}$`, + ); + const matchesLocalRef = (ref) => + typeof ref === 'string' && + !ref.startsWith('/') && + !ref.startsWith('//') && + !/^[a-z][a-z+.-]*:/i.test(ref) && + pattern.test(ref); + if (matchesLocalRef(schema.$ref)) return true; + return asArray(schema.allOf).some( + (branch) => isObject(branch) && matchesLocalRef(branch.$ref), + ); +} diff --git a/api-design-guide/linter/functions/s17-opExtensions.js b/api-design-guide/linter/functions/s17-opExtensions.js deleted file mode 100644 index 6e1e353..0000000 --- a/api-design-guide/linter/functions/s17-opExtensions.js +++ /dev/null @@ -1,67 +0,0 @@ -import { isObject, isNonEmptyString } from './lib/util.js'; - -/** - * s17-opExtensions — presence (and optional enum) of the machine-readable - * GovStack delivery-semantics extensions on an AsyncAPI operation, plus an - * optional human-readable `description` requirement. - * - * Drives: - * §17.13 — the four delivery-management capabilities, each stated as - * supported / unsupported / notApplicable: - * require: [x-govstack-redelivery, x-govstack-dead-letter, - * x-govstack-retention, x-govstack-replay] - * enumEach: [supported, unsupported, notApplicable] - * §17.15 — the headline machine-readable extensions plus a description: - * require: [x-govstack-delivery, x-govstack-ordering, x-govstack-replay] - * requireDescription: true - * - * Given: a single AsyncAPI 3.0 operation object (`$.operations[*]`). - * - * A required extension's value satisfies `enumEach` when it is a string in the - * enum, or an object whose `status` (or `support`) field is in the enum — so the - * exact common-file value shape (bare string vs `{status: ...}`) is tolerated. - * - * options: - * require {string[]} extension keys that must be present. (required) - * enumEach {any[]} allowed value/status for each required extension. - * requireDescription {boolean} operation must carry a non-empty `description`. - * - * @param {unknown} targetVal - an operation object. - * @param {object} options - * @param {{path?: (string|number)[]}} [context] - * @returns {{message:string, path:(string|number)[]}[]|undefined} - */ -export default function s17OpExtensions(targetVal, options, context) { - if (!isObject(targetVal) || !isObject(options)) return; - const base = context && Array.isArray(context.path) ? context.path : []; - const require = Array.isArray(options.require) ? options.require : []; - const enumEach = Array.isArray(options.enumEach) ? options.enumEach : undefined; - const results = []; - - for (const key of require) { - if (typeof key !== 'string' || !key) continue; - if (!Object.prototype.hasOwnProperty.call(targetVal, key)) { - results.push({ message: `operation must declare "${key}"`, path: [...base] }); - continue; - } - if (enumEach) { - const raw = targetVal[key]; - const effective = isObject(raw) ? (raw.status !== undefined ? raw.status : raw.support) : raw; - if (!enumEach.includes(effective)) { - results.push({ - message: `operation "${key}" must be one of ${JSON.stringify(enumEach)}`, - path: [...base, key], - }); - } - } - } - - if (options.requireDescription === true && !isNonEmptyString(targetVal.description)) { - results.push({ - message: 'operation must also document delivery/ordering/replay semantics in a non-empty "description"', - path: [...base, 'description'], - }); - } - - return results.length ? results : undefined; -} diff --git a/api-design-guide/linter/functions/s17-rejectionMessage.js b/api-design-guide/linter/functions/s17-rejectionMessage.js index 9a5751d..f494a60 100644 --- a/api-design-guide/linter/functions/s17-rejectionMessage.js +++ b/api-design-guide/linter/functions/s17-rejectionMessage.js @@ -10,9 +10,9 @@ import { isObject, asArray } from './lib/util.js'; * §11 error envelope, correlated to the original message. This function CANNOT * identify which messages are "command-like", so, when the document declares any * operations, it asserts: - * - at least one `components.messages` entry looks like a §11 error envelope - * (payload declares problem-style fields: `type`+`title`+`status`, or - * `code`+`traceId`/`traceid`), and + * - at least one `components.messages` entry references GovStackAsyncError or + * declares its transport-neutral `code`+`traceId`/`traceid` shape without + * an HTTP `status`, and * - at least one such error message declares a correlation mechanism (a * message-level `correlationId`, or a header/payload property whose name * contains "correlation"). @@ -74,21 +74,43 @@ function propNames(schema) { /** True when a set of property names looks like the §11 problem envelope. */ function problemShaped(names) { const has = (n) => names.has(n); - if (has('title') && has('status')) return true; - if (has('code') && (has('traceId') || has('traceid'))) return true; - return false; + return !has('status') && has('code') && (has('traceId') || has('traceid')); } function isErrorEnvelope(msg) { if (!isObject(msg)) return false; const payload = msg.payload; + if (referencesSchema(payload, 'GovStackAsyncError')) return true; // Bare §11 envelope, or CloudEvents-wrapped with the problem under `data`. if (problemShaped(propNames(payload))) return true; - const data = isObject(payload) && isObject(payload.properties) ? payload.properties.data : undefined; - if (problemShaped(propNames(data))) return true; + for (const data of propertySchemas(payload, 'data')) { + if (referencesSchema(data, 'GovStackAsyncError')) return true; + if (problemShaped(propNames(data))) return true; + } return false; } +/** Property schemas declared directly or in one level of allOf. */ +function propertySchemas(schema, name) { + if (!isObject(schema)) return []; + const values = []; + if (isObject(schema.properties?.[name])) values.push(schema.properties[name]); + for (const branch of asArray(schema.allOf)) { + if (isObject(branch) && isObject(branch.properties?.[name])) { + values.push(branch.properties[name]); + } + } + return values; +} + +function referencesSchema(schema, name) { + return ( + isObject(schema) && + typeof schema.$ref === 'string' && + schema.$ref.endsWith(`#/components/schemas/${name}`) + ); +} + function hasCorrelation(msg) { if (!isObject(msg)) return false; if (isObject(msg.correlationId)) return true; diff --git a/api-design-guide/linter/functions/schemaDescriptions.js b/api-design-guide/linter/functions/schemaDescriptions.js index 31d4f3c..629677c 100644 --- a/api-design-guide/linter/functions/schemaDescriptions.js +++ b/api-design-guide/linter/functions/schemaDescriptions.js @@ -1,17 +1,25 @@ import { walkSchema, forEachProperty } from './lib/schemaWalk.js'; import { isObject, isNonEmptyString } from './lib/util.js'; -// Nodes whose ONLY keywords are structural combinators carry no description of -// their own; requiring one there produces noise. They are skipped in mode "all". +// Nodes whose ONLY keywords are structural combinators carry no domain meaning +// of their own. A `required`-only assertion below `not` is likewise just a +// prohibition such as `not: { required: [legacyField] }`, not a schema editors +// need to describe. const COMBINATOR_ONLY = new Set([ 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', '$ref', 'description', 'title', ]); +const ASSERTION_ONLY = new Set(['required', 'description', 'title']); function isCombinatorWrapper(node) { const keys = Object.keys(node); return keys.length > 0 && keys.every((k) => COMBINATOR_ONLY.has(k)); } +function isNegatedRequiredAssertion(node, path) { + const keys = Object.keys(node); + return path.includes('not') && keys.length > 0 && keys.every((key) => ASSERTION_ONLY.has(key)); +} + /** * schemaDescriptions — assert schema nodes carry a non-empty `description`. * @@ -22,7 +30,8 @@ function isCombinatorWrapper(node) { * "properties" — every declared property schema (recursively) needs a * non-empty description. Good default: documents each field. * "all" — every subschema node needs one, except pure combinator - * wrappers (a node whose only keywords are allOf/anyOf/…). + * wrappers (a node whose only keywords are allOf/anyOf/…) + * and required-only assertions nested under `not`. * includeRoot {boolean} in "properties" mode, also require the root schema to * have a description. Default false. * @@ -40,7 +49,7 @@ export default function schemaDescriptions(targetVal, options, context) { if (mode === 'all') { walkSchema(targetVal, (node, path) => { - if (isCombinatorWrapper(node)) return; + if (isCombinatorWrapper(node) || isNegatedRequiredAssertion(node, path)) return; if (!isNonEmptyString(node.description)) { results.push({ message: diff --git a/api-design-guide/linter/package-lock.json b/api-design-guide/linter/package-lock.json index 38413ac..cbcb291 100644 --- a/api-design-guide/linter/package-lock.json +++ b/api-design-guide/linter/package-lock.json @@ -1,12 +1,12 @@ { "name": "govstack-api-lint", - "version": "0.2.0-draft", + "version": "0.1.0-draft", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "govstack-api-lint", - "version": "0.2.0-draft", + "version": "0.1.0-draft", "dependencies": { "@stoplight/spectral-cli": "^6.16.1", "@stoplight/spectral-core": "^1.23.0", @@ -15,6 +15,10 @@ "@stoplight/spectral-ruleset-bundler": "^1.6.2", "yaml": "^2.8.0" }, + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" + }, "engines": { "node": ">=20" } @@ -321,6 +325,23 @@ "node": "^12.20 || >=14.13" } }, + "node_modules/@stoplight/spectral-core/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/@stoplight/spectral-formats": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@stoplight/spectral-formats/-/spectral-formats-1.8.5.tgz", @@ -384,6 +405,23 @@ "node": "^16.20 || ^18.18 || >= 20.17" } }, + "node_modules/@stoplight/spectral-functions/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/@stoplight/spectral-parsers": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@stoplight/spectral-parsers/-/spectral-parsers-1.0.5.tgz", @@ -528,6 +566,23 @@ "node": "^16.20 || ^18.18 || >= 20.17" } }, + "node_modules/@stoplight/spectral-rulesets/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/@stoplight/spectral-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.6.tgz", @@ -680,9 +735,10 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" diff --git a/api-design-guide/linter/package.json b/api-design-guide/linter/package.json index 28b4566..2ec7921 100644 --- a/api-design-guide/linter/package.json +++ b/api-design-guide/linter/package.json @@ -1,6 +1,6 @@ { "name": "govstack-api-lint", - "version": "0.2.0-draft", + "version": "0.1.0-draft", "private": true, "description": "GovStack Spectral ruleset and lint driver for the Cross-BB API Design Guide", "type": "module", @@ -18,5 +18,9 @@ "@stoplight/spectral-parsers": "^1.0.5", "@stoplight/spectral-ruleset-bundler": "^1.6.2", "yaml": "^2.8.0" + }, + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" } } diff --git a/api-design-guide/linter/ruleset.yaml b/api-design-guide/linter/ruleset.yaml index 8e0077a..09b98cd 100644 --- a/api-design-guide/linter/ruleset.yaml +++ b/api-design-guide/linter/ruleset.yaml @@ -1,7 +1,7 @@ # GovStack API Design Guide — Spectral ruleset (entry point) # ============================================================ # This ruleset mechanically enforces the GovStack Cross-BB API Design Guide. -# Guide version implemented: 0.2.0-draft +# Guide version implemented: 0.1.0-draft # Rule catalogue (source of truth): ../rules.yaml # Coverage contract (rule -> status -> spectral rules): ./coverage.yaml # diff --git a/api-design-guide/linter/rulesets/s03.yaml b/api-design-guide/linter/rulesets/s03.yaml index 7806bb2..6a9908a 100644 --- a/api-design-guide/linter/rulesets/s03.yaml +++ b/api-design-guide/linter/rulesets/s03.yaml @@ -13,7 +13,7 @@ functions: - s03-asyncOperation rules: # 3.1 [M] — the spec MUST declare an AsyncAPI 3 version qualified by this - # ruleset. 0.2.0-draft qualifies 3.0.0 and 3.1.0; 2.x and earlier MUST NOT. + # ruleset. 0.1.0-draft qualifies 3.0.0 and 3.1.0; 2.x and earlier MUST NOT. # formats [aas2, aas3] so a 2.x document is still told to move to AsyncAPI 3. govstack-3.1: description: "AsyncAPI version must be a qualified AsyncAPI 3 version, 3.0.0 or 3.1.0 (guide 3.1, [M])." diff --git a/api-design-guide/linter/rulesets/s11.yaml b/api-design-guide/linter/rulesets/s11.yaml index f087920..595b4a1 100644 --- a/api-design-guide/linter/rulesets/s11.yaml +++ b/api-design-guide/linter/rulesets/s11.yaml @@ -1,8 +1,8 @@ # Rules for §11 error handling — generated from the guide; see coverage.yaml. # -# Implements guide rules 11.1-11.4 (implemented) and 11.5 (partial-proxy). -# 11.6 (runtime, localisation stability) and 11.7 (needs the not-yet-existing -# govstack-openapi-common.yaml) are out of scope per coverage.yaml. +# Implements the mechanically checkable portions of guide rules 11.1-11.4. +# Field-error applicability and identifier stability remain review/runtime +# concerns as documented in coverage.yaml. # # Surface for 11.1-11.5 is "Universal" in rules.yaml, but the mechanism below # (HTTP status codes, `content` media types) only maps onto the OpenAPI @@ -12,7 +12,8 @@ functionsDir: "../functions" functions: - mediaTypeExpected - envelopeShape - - valuePattern + - s11-fieldErrors + - s11-problemType rules: # 11.1 [M] — error responses (4xx/5xx) MUST use application/problem+json. # "Error responses" is read as any response declared under a 4xx or 5xx @@ -30,14 +31,14 @@ rules: functionOptions: require: ['application/problem\+json'] - # 11.2 [M+R] — the RFC 9457 fields type/title/status MUST be present on the - # problem+json schema. `detail`/`instance` are SHOULD ("when they add - # diagnostic value") and the no-PII declaration is a documentation/human - # concern (D-bucket); neither is mechanically checked here. + # 11.2 [M+R] — the RFC 9457 fields type/title/status MUST be present and the + # duplicate identifier `code` and redundant `timestamp` MUST NOT be added. + # `detail`/`instance` are SHOULD ("when they add diagnostic value") and the + # no-PII declaration is a documentation/human concern. govstack-11.2: description: "problem+json schema must declare type, title, status (guide 11.2, [M+R])." message: "[11.2][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#112-standard-problem-fields-present + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#112-stable-http-problem-type-uri severity: error formats: [oas3_1] given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' @@ -45,77 +46,47 @@ rules: function: envelopeShape functionOptions: requiredProperties: [type, title, status] + forbiddenProperties: [code, timestamp] - # 11.3 [M] — GovStack extension fields code/traceId/timestamp MUST be - # present on the problem+json schema. `resolved` defaults to true so a - # shared Problem schema referenced via $ref is inspected post-resolution. + # 11.3 [M] — traceId is the sole required GovStack extension. `resolved` + # defaults to true, so a shared Problem schema referenced from the vendored + # common file is inspected after reference resolution. govstack-11.3: - description: "problem+json schema must declare code, traceId, timestamp (guide 11.3, [M])." + description: "problem+json schema must declare traceId (guide 11.3, [M])." message: "[11.3][M] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#113-govstack-error-extension-fields + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#113-trace-identifier severity: error formats: [oas3_1] given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' then: function: envelopeShape functionOptions: - requiredProperties: [code, traceId, timestamp] + requiredProperties: [traceId] - # 11.4 [M+R] — field-level validation errors MUST appear in an `errors` - # array with pointer/code/message per entry. The guide conditions this on - # "where a failure is attributable to specific request fields"; the 400 - # (Bad Request) status is used as the mechanical stand-in for that - # condition, so this only fires on operations that declare a 400 response. + # 11.4 [M+R] — when a problem schema opts into field-level errors, the array + # MUST be required and each item MUST contain pointer/message. This rule does + # NOT infer field attribution from an HTTP status code: an ordinary 400 may + # use Problem without errors, while a 422 may legitimately use + # ValidationProblem. Deciding when field errors apply requires review. govstack-11.4: - description: "400 problem+json schema must declare an errors[] array with pointer/code/message (guide 11.4, [M+R])." + description: "declared field errors must be a required errors[] array with pointer/message (guide 11.4, [M+R], proxy)." message: "[11.4][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#114-field-level-errors-array - severity: error - formats: [oas3_1] - given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses["400"].content["application/problem+json"].schema' - then: - function: envelopeShape - functionOptions: - requiredProperties: [errors] - properties: - errors: - type: array - items: - requiredProperties: [pointer, code, message] - - # 11.5 [M+R] — PROXY (bucket B, MUST -> warn). Error codes MUST be - # namespaced reverse-DNS `global.govstack.{bb-code}.{error-name}`, bb-code - # matching the §9.11 pattern `^[a-z][a-z0-9-]{1,30}$`, error-name either - # lowerCamelCase or (per the guide's numeric-catalogue MAY) a plain integer. - # Only checks literal `code` values that appear as a schema `enum` member or - # an `example`; a `code` typed as a bare string with no enum/example is - # invisible to this check. Does NOT verify: that {bb-code} is actually the - # BB's single REGISTERED code (the register does not exist yet, OPEN-10-A), - # nor codes declared only via OpenAPI-level `examples` maps, nor codes - # nested inside an `allOf` branch (only the schema's own direct - # properties.code are inspected). - govstack-11.5-enum: - description: "problem+json code enum values must match global.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." - message: "[11.5][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#115-namespaced-stable-error-codes severity: warn formats: [oas3_1] - given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema.properties.code.enum[*]' + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' then: - function: valuePattern - functionOptions: - name: "code" - match: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' + function: s11-fieldErrors - govstack-11.5-example: - description: "problem+json code example must match global.govstack.{bb-code}.{error-name} (guide 11.5, [M+R], proxy)." - message: "[11.5][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#115-namespaced-stable-error-codes + # 11.2 [M+R] — literal problem type values supplied in examples, consts or + # enums use the canonical GovStack URI. Runtime instances are validated by + # the shared schema and conformance tests, not Spectral. + govstack-11.2-type: + description: "literal problem types must use the canonical GovStack problem URI (guide 11.2, [M+R], proxy)." + message: "[11.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#112-stable-http-problem-type-uri severity: warn formats: [oas3_1] - given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema.properties.code.example' + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"]' then: - function: valuePattern - functionOptions: - name: "code" - match: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.([a-z][a-zA-Z0-9]*|\d+)$' + function: s11-problemType diff --git a/api-design-guide/linter/rulesets/s12.yaml b/api-design-guide/linter/rulesets/s12.yaml index 5013d40..b010320 100644 --- a/api-design-guide/linter/rulesets/s12.yaml +++ b/api-design-guide/linter/rulesets/s12.yaml @@ -62,12 +62,14 @@ rules: mode: cursorParams # 12.3 [M] — cursor pagination envelope MUST be { items, pageInfo: - # { nextCursor, hasMore } } (total is optional per 12.5, not checked here). + # { nextCursor } } with an explicitly nullable, non-empty cursor and no + # declared hasMore field. total is optional per + # 12.5 and its BB-specific semantics remain a review concern. # No-op on operations that declared an `offset` parameter: those use the # distinct §12.6 flat envelope instead. The /health and /ready operational # endpoints (guide 5.9) are exempted inside `s12-collectionPagination`. govstack-12.3: - description: "collection GET 200 response must use the cursor envelope {items, pageInfo{nextCursor, hasMore}} (guide 12.3, [M])." + description: "collection GET 200 response must use {items, pageInfo{nextCursor}}, with nullable non-empty nextCursor and no hasMore (guide 12.3, [M])." message: "[12.3][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope severity: error @@ -202,11 +204,8 @@ rules: documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#129-complex-filtering-via-search severity: warn formats: [oas3_1] - given: '$.paths[?(@property.match(/\/search$/))].post.responses["200"].content["application/json"].schema' + given: '$.paths[?(@property.match(/\/search$/))].post' then: - function: envelopeShape + function: s12-collectionPagination functionOptions: - requiredProperties: [items, pageInfo] - properties: - items: { type: array } - pageInfo: { requiredProperties: [nextCursor, hasMore] } + mode: cursorEnvelope diff --git a/api-design-guide/linter/rulesets/s15.yaml b/api-design-guide/linter/rulesets/s15.yaml index a8bfe5f..4cd5bdc 100644 --- a/api-design-guide/linter/rulesets/s15.yaml +++ b/api-design-guide/linter/rulesets/s15.yaml @@ -1,11 +1,11 @@ # Rules for §15 asynchronous operations — generated from the guide. # -# Implements 15.1, 15.5 (implemented) and 15.2, 15.3, 15.4 (partial-proxy). +# Implements 15.1 and 15.5, plus the 15.4 polling-path proxy. Operation +# resource fields and lifecycle states are owned and documented by each BB. # Source text: ../../rules.yaml. Severity/formats per ../README.md#rule-naming-and-severities. functionsDir: "../functions" functions: - responseHeaderRequired - - envelopeShape - s15-operationsPolling - s15-cancelPath rules: @@ -26,47 +26,13 @@ rules: status: "202" headers: [Location] - # 15.2 [M] — PROXY (bucket B), MUST notched to warn. - # SCOPE LIMIT: checks the in-document `components.schemas.Operation` shape - # ({ id, status, result, error, createdAt, updatedAt } required; progress - # optional). Does NOT verify that the Operation resource is declared once in - # govstack-openapi-common.yaml and $ref'd (needs-context: the common file does - # not yet exist), nor the AIP-151 `done`/`metadata` alternative. - govstack-15.2: - description: "the Operation resource must declare the shared shape (guide 15.2, [M], proxy)." - message: "[15.2][M] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#152-shared-operation-resource-shape - severity: warn - formats: [oas3_1] - given: $.components.schemas.Operation - then: - function: envelopeShape - functionOptions: - requiredProperties: [id, status, result, error, createdAt, updatedAt] - - # 15.3 [M] — PROXY (bucket B), MUST notched to warn. - # SCOPE LIMIT: checks the in-document Operation `status` enum equals the fixed - # set. Does NOT verify the enum is declared in the common file (needs-context), - # nor the AIP-151 boolean-`done` alternative (which has no `status` property - # and so is not flagged). - govstack-15.3: - description: "Operation status must use the fixed enum (guide 15.3, [M], proxy)." - message: "[15.3][M] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#153-fixed-operation-status-enum - severity: warn - formats: [oas3_1] - given: $.components.schemas.Operation.properties.status - then: - function: envelopeShape - functionOptions: - enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] - # 15.4 [M+R] — PROXY (bucket B), notched to warn. # Verifies: when the Operations pattern is used (an Operation schema or an # /operations/ path exists), a canonical GET /v{N}/operations/{operationId} # poll endpoint is declared. # Does NOT verify: whether asynchronous behaviour is actually needed, or the - # response shape of the poll endpoint (that is §15.2/§15.3). + # response shape or lifecycle semantics of the poll endpoint, which are + # documented locally by the BB. govstack-15.4: description: "a canonical GET /v{N}/operations/{operationId} poll endpoint must exist when Operations are used (guide 15.4, [M+R], proxy)." message: "[15.4][M+R] {{error}}" diff --git a/api-design-guide/linter/rulesets/s16.yaml b/api-design-guide/linter/rulesets/s16.yaml index af4016c..f7d6704 100644 --- a/api-design-guide/linter/rulesets/s16.yaml +++ b/api-design-guide/linter/rulesets/s16.yaml @@ -1,19 +1,17 @@ # Rules for §16 CloudEvents and webhooks (OpenAPI/webhooks surface). # -# Implements guide rules 16.1, 16.2, 16.3, 16.4, 16.6, 16.11. Source text: +# Implements guide rules 16.1, 16.2, 16.3, 16.4, 16.11. Source text: # ../../rules.yaml. Severity policy and formats follow # ../README.md#rule-naming-and-severities. # # Scope note: §16 spans both the OpenAPI/webhooks and the AsyncAPI surfaces. # These rules target the OpenAPI document (formats: [oas3_1]); the AsyncAPI -# clauses of 16.1 (brokered transports use AsyncAPI 3.0) and 16.6 (signature in -# the message-metadata channel) are not enforced here — see the report Flags. +# clause of 16.1 (brokered transports use AsyncAPI 3.0) is not enforced here. functionsDir: "../functions" functions: - envelopeShape - mediaTypeExpected - s16-eventField - - s16-signatureHeader - s16-subscriptionEndpoints rules: # 16.1 [M+R] — PROXY (bucket B), notched MUST -> warn. @@ -68,7 +66,7 @@ rules: # convention. On the webhooks surface the event MUST be delivered as # `application/cloudevents+json`; a plain `application/json` body is the # CloudEvents binary content mode, whose body carries only `data` and so - # cannot be reconstructed for the 16.8 signature check. + # is not the structured event envelope required by the guide. govstack-16.2-structured: description: "webhook events must use CloudEvents structured content mode, not binary (guide 16.2, [M])." message: "[16.2][M] {{error}}" @@ -125,30 +123,15 @@ rules: expected: "it must not identify a specific deployment host, pod, broker, queue, or environment" forbidPattern: '(?:^|[^a-z0-9])(?:dev|test|staging|stage|qa|uat|prod|production|preprod|sandbox|pod|broker|queue)(?:[^a-z0-9]|$)|localhost|\.svc(?:[./]|$)|\.cluster\.local|\.internal(?:[:/]|$)|(?:\d{1,3}\.){3}\d{1,3}|:\d{2,5}(?:/|$)' - # 16.6 [M+R] — on the OpenAPI/webhooks surface the signature MUST travel in an - # HTTP header named `GovStack-Signature`. Every webhook delivery must declare - # that header parameter (path-item or operation level). The AsyncAPI clause of - # 16.6 (signature in the message-metadata channel) is out of scope here. - govstack-16.6: - description: "every webhook must declare the GovStack-Signature header parameter (guide 16.6, [M+R])." - message: "[16.6][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header - severity: error - formats: [oas3_1] - given: $.webhooks[*] - then: - function: s16-signatureHeader - functionOptions: - field: GovStack-Signature - # 16.11 [M+R] — PROXY (bucket B), notched MUST -> warn. # Verifies: when a subscription surface exists (paths named /subscriptions), - # it exposes create/list/rotate-secret/delete interfaces. + # it exposes create/list/delete interfaces. Conditional signing-key rotation + # is not checked because signing is optional and profile-specific. # Does NOT verify: that a BB which should offer subscriptions actually does # (an absent subscription surface is not flagged), nor the AsyncAPI # message-command control plane the guide also permits. govstack-16.11: - description: "subscription management must expose create/list/rotate-secret/delete (guide 16.11, [M+R], proxy)." + description: "subscription management must expose create/list/delete (guide 16.11, [M+R], proxy)." message: "[16.11][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces severity: warn diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml index 1b42fc3..1ebacb0 100644 --- a/api-design-guide/linter/rulesets/s17.yaml +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -11,11 +11,9 @@ functions: - valuePattern - s17-channelIds - schemaPropertyNames - - extensionShape - s17-channelParameters - s17-cloudEventsPayload - s17-securityCoverage - - s17-opExtensions - s17-rejectionMessage - s17-requestReply - s17-protocolBindings @@ -84,11 +82,13 @@ rules: flags: i forbidPattern: '(^|[.\-_])(dev|test|prod|staging|uat|qa|sandbox|preprod|nonprod|kafka|rabbitmq|mqtt|amqp|broker)([.\-_]|$)' - # 17.6 [M] — domain-event message payloads MUST be structured CloudEvents JSON - # with GovStack domain data under `data`. Bare §11 error envelopes are skipped - # (governed by 17.16). Does not resolve payloads $ref'd to external files. + # 17.6 [M] — domain-event Message Objects MUST use the structured CloudEvents + # JSON media type and carry any GovStack domain data under `data`. The rule checks + # messages that declare the media type, envelope reference, or CloudEvents + # fields. It deliberately skips other messages because their semantic role is + # not machine-identifiable. The base validator resolves external references. govstack-17.6: - description: "Domain-event message payloads must be structured CloudEvents JSON with data under `data` (guide 17.6, [M])." + description: "Domain-event Message Objects must use application/cloudevents+json and structured CloudEvents payloads with any domain data under `data` (guide 17.6, [M])." message: "[17.6][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads severity: error @@ -97,6 +97,23 @@ rules: then: function: s17-cloudEventsPayload + # 17.7 [M] — Message Objects remain local. Recognizable CloudEvents and async + # error payloads MUST reference the reviewed envelope or async-error schema + # from the vendored common file. Other message kinds are outside this rule. + # The base validator separately proves that the external ref resolves. + govstack-17.7: + description: "CloudEvents and async-error Message Objects must reference payload schemas from the vendored govstack-asyncapi-common.yaml file (guide 17.7, [M])." + message: "[17.7][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema + severity: error + formats: [aas3] + resolved: false + given: $.components.messages[*] + then: + function: s17-cloudEventsPayload + functionOptions: + requireSharedReference: true + # 17.8 [M+R] — PROXY (bucket B), MUST -> warn. GovStack-owned message headers # MUST be camelCase and MUST NOT use the X- prefix (camelCase subsumes the X- # ban). Does NOT verify the command idempotency-key requirement (needs command @@ -143,79 +160,6 @@ rules: then: function: s17-securityCoverage - # 17.11 [M+R] — each operation MUST document its delivery guarantee as - # x-govstack-delivery in {atMostOnce, atLeastOnce, effectivelyOnce}. The - # "effectivelyOnce MUST be backed by an idempotency contract" clause is not - # mechanically verifiable. - govstack-17.11: - description: "Each operation must declare x-govstack-delivery in {atMostOnce,atLeastOnce,effectivelyOnce} (guide 17.11, [M+R])." - message: "[17.11][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees - severity: error - formats: [aas3] - given: $.operations[*] - then: - function: extensionShape - functionOptions: - extension: x-govstack-delivery - enum: [atMostOnce, atLeastOnce, effectivelyOnce] - - # 17.12 [M+R] — each operation MUST document ordering via x-govstack-ordering - # (an explicit "none", or the partition key/scope). The key/scope contents are - # defined in govstack-asyncapi-common.yaml and not further validated here. - govstack-17.12: - description: "Each operation must declare x-govstack-ordering (key/scope or explicit none) (guide 17.12, [M+R])." - message: "[17.12][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1712-documented-ordering-guarantees - severity: error - formats: [aas3] - given: $.operations[*] - then: - function: extensionShape - functionOptions: - extension: x-govstack-ordering - - # 17.13 [M+R] — each operation MUST declare the four delivery-management - # capabilities, each stated supported/unsupported/notApplicable. Encoded as the - # per-capability extensions x-govstack-redelivery / -dead-letter / -retention / - # -replay (the exact names are defined in govstack-asyncapi-common.yaml; §17.15 - # confirms x-govstack-replay). Value may be a bare string or {status: ...}. - govstack-17.13: - description: "Each operation must declare redelivery/dead-letter/retention/replay as supported/unsupported/notApplicable (guide 17.13, [M+R])." - message: "[17.13][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities - severity: error - formats: [aas3] - given: $.operations[*] - then: - function: s17-opExtensions - functionOptions: - require: - - x-govstack-redelivery - - x-govstack-dead-letter - - x-govstack-retention - - x-govstack-replay - enumEach: [supported, unsupported, notApplicable] - - # 17.15 [M+R] — delivery/ordering/replay declarations MUST be machine-readable - # (x-govstack-delivery, x-govstack-ordering, x-govstack-replay) AND - # human-readable in the operation description. - govstack-17.15: - description: "Each operation must carry x-govstack-delivery/ordering/replay plus a description (guide 17.15, [M+R])." - message: "[17.15][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions - severity: error - formats: [aas3] - given: $.operations[*] - then: - function: s17-opExtensions - functionOptions: - require: - - x-govstack-delivery - - x-govstack-ordering - - x-govstack-replay - requireDescription: true - # 17.16 [M+R] — PROXY (bucket B), MUST -> warn. Asserts an async rejection/ # failure message using the §11 error envelope exists and is correlated. Does # NOT identify which operations are command-like, nor verify the correlation diff --git a/api-design-guide/linter/tests/coverage.test.mjs b/api-design-guide/linter/tests/coverage.test.mjs index fa8348c..680f5af 100644 --- a/api-design-guide/linter/tests/coverage.test.mjs +++ b/api-design-guide/linter/tests/coverage.test.mjs @@ -1,6 +1,6 @@ // Coverage drift checks. Opt-in: only runs under COVERAGE_ENFORCE=1, otherwise // every check is skipped (with a message). Reconciles three artefacts: -// ../rules.yaml — the 166-rule catalogue (source of truth) +// ../rules.yaml — the generated rule catalogue (source of truth) // ./coverage.yaml — the coverage contract (rule -> status -> spectral rules) // ruleset.yaml/strict.yaml bundles — the rules actually shipped // @@ -23,9 +23,9 @@ const opts = enforce ? {} : { skip: 'set COVERAGE_ENFORCE=1 to run coverage drif const IMPLEMENTED = new Set(['implemented', 'partial-proxy']); // ---- load artefacts (guarded so the skipped case never throws at import) ---- -function loadCatalogueIds() { +function loadCatalogue() { const doc = YAML.parse(readFileSync(resolve(LINTER_DIR, '..', 'rules.yaml'), 'utf8')); - return (doc.rules || []).map((r) => String(r.id)); + return doc.rules || []; } function loadCoverage() { const doc = YAML.parse(readFileSync(join(LINTER_DIR, 'coverage.yaml'), 'utf8')); @@ -52,14 +52,24 @@ function assertSameSet(actual, expected, label) { } test('(a) rules.yaml and coverage.yaml list exactly the same rule ids, once each', opts, () => { - const catalogue = loadCatalogueIds(); + const catalogue = loadCatalogue(); + const catalogueIds = catalogue.map((rule) => String(rule.id)); const coverage = loadCoverage(); const covIds = coverage.map((e) => String(e.id)); const dupes = covIds.filter((id, i) => covIds.indexOf(id) !== i); assert.equal(dupes.length, 0, `coverage.yaml has duplicate ids: ${JSON.stringify([...new Set(dupes)])}`); - assertSameSet(new Set(covIds), new Set(catalogue), 'rule id mismatch between rules.yaml and coverage.yaml:'); + assertSameSet(new Set(covIds), new Set(catalogueIds), 'rule id mismatch between rules.yaml and coverage.yaml:'); + + const coverageById = new Map(coverage.map((entry) => [String(entry.id), entry])); + for (const rule of catalogue) { + assert.equal( + coverageById.get(String(rule.id))?.class, + rule.class, + `coverage class differs from rules.yaml for ${rule.id}`, + ); + } }); test('(b) implemented/partial-proxy spectral_rules == rules shipped in ruleset.yaml', opts, async () => { diff --git a/api-design-guide/linter/tests/driver.test.mjs b/api-design-guide/linter/tests/driver.test.mjs index 0ec2b59..d83348f 100644 --- a/api-design-guide/linter/tests/driver.test.mjs +++ b/api-design-guide/linter/tests/driver.test.mjs @@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const CLI = path.join(HERE, '..', 'cli.mjs'); const MINI_RULESET = path.join(HERE, 'driver-fixtures', 'mini-ruleset.yaml'); -const GUIDE_VERSION = '0.2.0-draft'; +const GUIDE_VERSION = '0.1.0-draft'; const ADVISORY = ['--mode', 'advisory', '--skip-validators']; function makeRepo(files) { diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/fail.yaml similarity index 59% rename from api-design-guide/linter/tests/fixtures/govstack-11.5-enum/fail.yaml rename to api-design-guide/linter/tests/fixtures/govstack-11.2-type/fail.yaml index ae5161e..6ec45a4 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/fail.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Fixture API version: 1.0.0 - description: Fixture for govstack-11.5-enum. + description: Fixture for govstack-11.2-type. contact: {} paths: /v1/foos/{id}: @@ -17,24 +17,20 @@ paths: required: true schema: { type: string } responses: - '200': - description: OK - content: - application/json: - schema: { type: object } '404': description: Not found content: application/problem+json: schema: type: object - required: [type, title, status, code, traceId, timestamp] + required: [type, title, status, traceId] properties: - type: { type: string } + type: { type: string, format: uri } title: { type: string } status: { type: integer } - code: - type: string - enum: [FooNotFound] traceId: { type: string } - timestamp: { type: string, format: date-time } + example: + type: https://docs.example.gov/problems/personNotFound + title: Person not found + status: 404 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/pass.yaml similarity index 59% rename from api-design-guide/linter/tests/fixtures/govstack-11.5-example/fail.yaml rename to api-design-guide/linter/tests/fixtures/govstack-11.2-type/pass.yaml index 0c40eac..7d8c01d 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/pass.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Fixture API version: 1.0.0 - description: Fixture for govstack-11.5-example. + description: Fixture for govstack-11.2-type. contact: {} paths: /v1/foos/{id}: @@ -17,24 +17,20 @@ paths: required: true schema: { type: string } responses: - '200': - description: OK - content: - application/json: - schema: { type: object } '404': description: Not found content: application/problem+json: schema: type: object - required: [type, title, status, code, traceId, timestamp] + required: [type, title, status, traceId] properties: - type: { type: string } + type: { type: string, format: uri } title: { type: string } status: { type: integer } - code: - type: string - example: FooNotFound traceId: { type: string } - timestamp: { type: string, format: date-time } + example: + type: https://govstack.global/problems/identity/person-not-found + title: Person not found + status: 404 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml index 4648732..d2f1e7b 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml @@ -28,6 +28,8 @@ paths: application/problem+json: schema: type: object - required: [code] + required: [traceId, code, timestamp] properties: + traceId: { type: string } code: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml index ef58f4b..bc47a67 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml @@ -28,11 +28,9 @@ paths: application/problem+json: schema: type: object - required: [type, title, status, code, traceId, timestamp] + required: [type, title, status, traceId] properties: type: { type: string } title: { type: string } status: { type: integer } - code: { type: string } traceId: { type: string } - timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml index 81c0de6..19665f0 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml @@ -28,11 +28,9 @@ paths: application/problem+json: schema: type: object - required: [type, title, status, code, traceId, timestamp] + required: [type, title, status, traceId] properties: type: { type: string } title: { type: string } status: { type: integer } - code: { type: string } traceId: { type: string } - timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml index d273b20..7fde22f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml @@ -21,14 +21,23 @@ paths: content: application/json: schema: { type: object } - '400': - description: Bad request + '422': + description: Field validation failed content: application/problem+json: schema: type: object - required: [type, title, status] + required: [type, title, status, traceId, errors] properties: type: { type: string } title: { type: string } status: { type: integer } + traceId: { type: string } + errors: + type: array + items: + type: object + required: [pointer] + properties: + pointer: { type: string } + code: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml index 931ef27..fcf884e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml @@ -22,22 +22,34 @@ paths: application/json: schema: { type: object } '400': - description: Bad request + description: Malformed request without field attribution content: application/problem+json: schema: type: object - required: [type, title, status, errors] + required: [type, title, status, traceId] properties: type: { type: string } title: { type: string } status: { type: integer } + traceId: { type: string } + '422': + description: Field validation failed + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId, errors] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + traceId: { type: string } errors: type: array items: type: object - required: [pointer, code, message] + required: [pointer, message] properties: pointer: { type: string } - code: { type: string } message: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml deleted file mode 100644 index 7ebee6f..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-11.5-enum/pass.yaml +++ /dev/null @@ -1,40 +0,0 @@ -openapi: 3.1.0 -info: - title: Fixture API - version: 1.0.0 - description: Fixture for govstack-11.5-enum. - contact: {} -paths: - /v1/foos/{id}: - get: - operationId: getFoo - summary: Get a foo - description: Get a foo by id. - tags: [foos] - parameters: - - name: id - in: path - required: true - schema: { type: string } - responses: - '200': - description: OK - content: - application/json: - schema: { type: object } - '404': - description: Not found - content: - application/problem+json: - schema: - type: object - required: [type, title, status, code, traceId, timestamp] - properties: - type: { type: string } - title: { type: string } - status: { type: integer } - code: - type: string - enum: [global.govstack.identity.personNotFound] - traceId: { type: string } - timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml deleted file mode 100644 index 27f283f..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-11.5-example/pass.yaml +++ /dev/null @@ -1,40 +0,0 @@ -openapi: 3.1.0 -info: - title: Fixture API - version: 1.0.0 - description: Fixture for govstack-11.5-example. - contact: {} -paths: - /v1/foos/{id}: - get: - operationId: getFoo - summary: Get a foo - description: Get a foo by id. - tags: [foos] - parameters: - - name: id - in: path - required: true - schema: { type: string } - responses: - '200': - description: OK - content: - application/json: - schema: { type: object } - '404': - description: Not found - content: - application/problem+json: - schema: - type: object - required: [type, title, status, code, traceId, timestamp] - properties: - type: { type: string } - title: { type: string } - status: { type: integer } - code: - type: string - example: global.govstack.identity.personNotFound - traceId: { type: string } - timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml index e91a5a5..05a2e0e 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml @@ -32,10 +32,9 @@ paths: items: { type: object } pageInfo: type: object - required: [nextCursor, hasMore] + required: [nextCursor] properties: nextCursor: { type: string, nullable: true } - hasMore: { type: boolean } # Guide 12.1 lets a collection whose size the specification itself fixes go # unpaginated, provided the bound is declared with maxItems. /v1/supported-locales: diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml index 487813a..81a0d72 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml @@ -29,7 +29,6 @@ paths: items: { type: object } pageInfo: type: object - required: [nextCursor, hasMore] + required: [nextCursor] properties: nextCursor: { type: string, nullable: true } - hasMore: { type: boolean } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml index a9603f9..bb7ec72 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml @@ -32,10 +32,9 @@ paths: items: { type: object } pageInfo: type: object - required: [nextCursor, hasMore] + required: [nextCursor] properties: nextCursor: { type: string, nullable: true } - hasMore: { type: boolean } # Guide 12.2 covers collection endpoints; the operational liveness probe # /health is exempt (s12-collectionPagination), so a bare GET must NOT fire. /health: diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml index 2cd6eb4..818dd59 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml @@ -32,10 +32,9 @@ paths: items: { type: object } pageInfo: type: object - required: [nextCursor, hasMore] + required: [nextCursor] properties: - nextCursor: { type: string, nullable: true } - hasMore: { type: boolean } + nextCursor: { type: [string, 'null'], minLength: 1 } # Guide 12.3 covers collection endpoints; the operational liveness probe # /health is exempt (s12-collectionPagination), so a bare GET must NOT fire. /health: diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml index c48d454..b95c5ce 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml @@ -33,7 +33,6 @@ paths: items: { type: object } pageInfo: type: object - required: [nextCursor, hasMore] + required: [nextCursor] properties: nextCursor: { type: string, nullable: true } - hasMore: { type: boolean } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml index 73477a4..d42c665 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml @@ -37,7 +37,6 @@ paths: items: { type: object } pageInfo: type: object - required: [nextCursor, hasMore] + required: [nextCursor] properties: nextCursor: { type: string, nullable: true } - hasMore: { type: boolean } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml index 77f8f87..0f71913 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml @@ -37,7 +37,6 @@ paths: items: { type: object } pageInfo: type: object - required: [nextCursor, hasMore] + required: [nextCursor] properties: - nextCursor: { type: string, nullable: true } - hasMore: { type: boolean } + nextCursor: { type: [string, 'null'], minLength: 1 } diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml deleted file mode 100644 index e852ad6..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-15.2/fail.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# 15.2 fail: the in-document Operation resource omits required shape properties -# (result, error, createdAt, updatedAt are missing from `required`). -openapi: 3.1.0 -info: - title: Registry API - version: 1.0.0 -paths: {} -components: - schemas: - Operation: - type: object - required: [id, status] - properties: - id: - type: string - status: - type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml deleted file mode 100644 index 45c31ea..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-15.2/pass.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# 15.2 pass: the Operation resource declares the full shared shape -# ({ id, status, result, error, createdAt, updatedAt }; progress optional). -openapi: 3.1.0 -info: - title: Registry API - version: 1.0.0 -paths: {} -components: - schemas: - Operation: - type: object - required: [id, status, result, error, createdAt, updatedAt] - properties: - id: - type: string - status: - type: string - enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] - result: - type: object - error: - type: object - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - progress: - type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml deleted file mode 100644 index a97582c..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-15.3/fail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# 15.3 fail: the Operation status property declares no fixed enum. -openapi: 3.1.0 -info: - title: Registry API - version: 1.0.0 -paths: {} -components: - schemas: - Operation: - type: object - required: [id, status, result, error, createdAt, updatedAt] - properties: - id: - type: string - status: - type: string - result: - type: object - error: - type: object - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml deleted file mode 100644 index a29331a..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-15.3/pass.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# 15.3 pass: the Operation status property declares the fixed enum. -openapi: 3.1.0 -info: - title: Registry API - version: 1.0.0 -paths: {} -components: - schemas: - Operation: - type: object - required: [id, status, result, error, createdAt, updatedAt] - properties: - id: - type: string - status: - type: string - enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] - result: - type: object - error: - type: object - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml index de96b8b..fc55483 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Subscriptions API version: 1.0.0 - description: Subscription surface is missing a rotate-secret interface. + description: Subscription surface is missing a delete interface. contact: name: Sample BB Team url: https://example.org/contact @@ -25,10 +25,10 @@ paths: '200': description: OK /v1/subscriptions/{subscriptionId}: - delete: - operationId: deleteSubscription - summary: Delete a subscription - description: Removes a webhook subscription. + get: + operationId: getSubscription + summary: Get a subscription + description: Returns one webhook subscription but does not provide deletion. tags: [subscriptions] parameters: - name: subscriptionId diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml index fbe2406..2c09dda 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Subscriptions API version: 1.0.0 - description: Subscription surface exposes create/list/rotate-secret/delete. + description: Subscription surface exposes create/list/delete. contact: name: Sample BB Team url: https://example.org/contact @@ -39,18 +39,3 @@ paths: responses: '204': description: No Content - /v1/subscriptions/{subscriptionId}/rotate-secret: - post: - operationId: rotateSubscriptionSecret - summary: Rotate the signing secret - description: Rotates the subscription signing secret. - tags: [subscriptions] - parameters: - - name: subscriptionId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml deleted file mode 100644 index b7f844a..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-16.6/fail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -openapi: 3.1.0 -info: - title: Payments Events API - version: 1.0.0 - description: Webhook delivery does not declare a GovStack-Signature header. - contact: - name: Sample BB Team - url: https://example.org/contact -webhooks: - paymentCompleted: - post: - summary: Payment completed event - parameters: - - name: Content-Type - in: header - required: true - schema: - type: string - requestBody: - content: - application/cloudevents+json: - schema: - type: object - responses: - '200': - description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml deleted file mode 100644 index be3a3fa..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-16.6/pass.yaml +++ /dev/null @@ -1,27 +0,0 @@ -openapi: 3.1.0 -info: - title: Payments Events API - version: 1.0.0 - description: Webhook delivery declares the GovStack-Signature header. - contact: - name: Sample BB Team - url: https://example.org/contact -webhooks: - paymentCompleted: - post: - summary: Payment completed event - parameters: - - name: GovStack-Signature - in: header - required: true - description: Detached JWS signature over the canonicalised event. - schema: - type: string - requestBody: - content: - application/cloudevents+json: - schema: - type: object - responses: - '200': - description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml deleted file mode 100644 index 3c12e19..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.11/fail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - # 17.11: no x-govstack-delivery declared. - action: receive - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml deleted file mode 100644 index 0537cb2..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.11/pass.yaml +++ /dev/null @@ -1,26 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - action: receive - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' - x-govstack-delivery: atLeastOnce -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml deleted file mode 100644 index 5ea3d37..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.12/fail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - # 17.12: no x-govstack-ordering declared (neither a key/scope nor explicit none). - action: receive - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml deleted file mode 100644 index 3ce5b1b..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.12/pass.yaml +++ /dev/null @@ -1,28 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - action: receive - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' - x-govstack-ordering: - guarantee: keyed - key: personId -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml deleted file mode 100644 index 874fc96..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.13/fail.yaml +++ /dev/null @@ -1,29 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - # 17.13: does not declare redelivery/dead-letter/retention/replay capabilities. - action: receive - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' - x-govstack-redelivery: supported - x-govstack-retention: notApplicable - # x-govstack-dead-letter and x-govstack-replay are missing. -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml deleted file mode 100644 index 2e9ba34..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.13/pass.yaml +++ /dev/null @@ -1,29 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - action: receive - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' - x-govstack-redelivery: supported - x-govstack-dead-letter: supported - x-govstack-retention: notApplicable - x-govstack-replay: unsupported -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml deleted file mode 100644 index 4e3866e..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.15/fail.yaml +++ /dev/null @@ -1,31 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - action: receive - summary: Consume person-created events - # 17.15: machine-readable extensions present but no human-readable description. - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' - x-govstack-delivery: atLeastOnce - x-govstack-ordering: - guarantee: none - x-govstack-replay: supported -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml deleted file mode 100644 index 8b01b46..0000000 --- a/api-design-guide/linter/tests/fixtures/govstack-17.15/pass.yaml +++ /dev/null @@ -1,33 +0,0 @@ -asyncapi: 3.0.0 -info: - title: Registry events - version: 1.0.0 -channels: - personCreated: - address: global.govstack.reg.v1.person.created - messages: - evt: - $ref: '#/components/messages/PersonCreated' -operations: - onPersonCreated: - action: receive - summary: Consume person-created events - description: >- - Consumes person-created events at least once, with no ordering guarantee, - and supports replay from the retained log. - channel: - $ref: '#/channels/personCreated' - messages: - - $ref: '#/channels/personCreated/messages/evt' - x-govstack-delivery: atLeastOnce - x-govstack-ordering: - guarantee: none - x-govstack-replay: supported -components: - messages: - PersonCreated: - payload: - type: object - examples: - - name: sample - payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml index 8a27aea..dcb7771 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml @@ -8,6 +8,8 @@ channels: messages: cmd: $ref: '#/components/messages/SubmitPayment' + rejected: + $ref: '#/components/messages/PaymentRejected' operations: onSubmitPayment: action: receive @@ -17,8 +19,8 @@ operations: - $ref: '#/channels/submitPayment/messages/cmd' components: messages: - # 17.16: a command message exists but no rejection/failure message using the - # §11 error envelope is defined anywhere. + # 17.16: an HTTP-shaped problem with status is not a transport-neutral + # GovStackAsyncError and must not satisfy the rejection-message rule. SubmitPayment: payload: type: object @@ -37,3 +39,23 @@ components: examples: - name: sample payload: {} + PaymentRejected: + contentType: application/json + correlationId: + location: '$message.header#/correlationId' + payload: + type: object + required: [type, title, status] + properties: + type: + type: string + title: + type: string + status: + type: integer + examples: + - name: sample + payload: + type: about:blank + title: Payment rejected + status: 422 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml index 47fca9b..a6f5638 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml @@ -38,22 +38,28 @@ components: - name: sample payload: {} PaymentRejected: - # §11 error envelope, correlated to the original command. + # CloudEvents-wrapped §11 error, correlated to the original command. + contentType: application/cloudevents+json correlationId: location: '$message.header#/correlationId' payload: - type: object - required: [type, title, status] - properties: - type: - type: string - title: - type: string - status: - type: integer + allOf: + - $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + required: [data] + properties: + data: + $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/GovStackAsyncError' examples: - name: sample payload: - type: about:blank - title: Payment rejected - status: 422 + specversion: '1.0' + id: f68f9e59-f7aa-4efe-b3a1-4f893c0ce99d + source: urn:govstack:bb:pay + type: global.govstack.pay.payment.rejected + data: + type: about:blank + title: Payment rejected + code: global.govstack.pay.paymentRejected + traceId: 9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c + timestamp: '2026-07-10T12:41:05Z' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml index d21240c..30823d8 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml @@ -18,7 +18,8 @@ operations: components: messages: PersonCreated: - # 17.6: payload is a bare domain object, not a structured CloudEvent. + # 17.6: the message declares CloudEvents but carries a bare domain object. + contentType: application/cloudevents+json payload: type: object properties: diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml index 9e65d5d..35e48fb 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml @@ -18,6 +18,7 @@ operations: components: messages: PersonCreated: + contentType: application/cloudevents+json payload: type: object required: [specversion, id, source, type, data] @@ -44,3 +45,14 @@ components: type: global.govstack.reg.v1.person.created data: personId: p-1 + LocalJsonCommand: + contentType: application/json + payload: + type: object + properties: + command: + type: string + examples: + - name: sample + payload: + command: refresh diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.7/fail.yaml new file mode 100644 index 0000000..6db046e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.7/fail.yaml @@ -0,0 +1,38 @@ +asyncapi: 3.0.0 +info: + title: Shared envelope reuse fail + version: 1.0.0 +channels: + events: + address: registry.events + messages: + event: + $ref: '#/components/messages/RecordCreated' +operations: + receiveEvent: + action: receive + channel: + $ref: '#/channels/events' + messages: + - $ref: '#/channels/events/messages/event' +components: + messages: + RecordCreated: + contentType: application/cloudevents+json + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: '1.0' + id: + type: string + source: + type: string + type: + type: string + data: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.7/pass.yaml new file mode 100644 index 0000000..be96bcf --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.7/pass.yaml @@ -0,0 +1,45 @@ +asyncapi: 3.0.0 +info: + title: Shared envelope reuse pass + version: 1.0.0 +channels: + events: + address: registry.events + messages: + event: + $ref: '#/components/messages/RecordCreated' +operations: + receiveEvent: + action: receive + channel: + $ref: '#/channels/events' + messages: + - $ref: '#/channels/events/messages/event' +components: + messages: + RecordCreated: + contentType: application/cloudevents+json + payload: + allOf: + - $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + required: [data] + properties: + type: + const: global.govstack.registry.record.created + data: + type: object + examples: + - name: sample + payload: {} + LocalJsonCommand: + contentType: application/json + payload: + type: object + properties: + command: + type: string + examples: + - name: sample + payload: + command: refresh diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml index e1be931..b9cbb3d 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml @@ -7,8 +7,8 @@ info: name: Sample BB Team url: https://example.org/contact x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft exceptions: - RULE-9.3 - RULE-11.2 diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml index 42a0958..7b58a9f 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml @@ -7,8 +7,8 @@ info: name: Sample BB Team url: https://example.org/contact x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft exceptions: - rule: "9.3" scope: /components/schemas/Legacy/properties/enabled diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml index 63868a2..5118405 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml @@ -7,6 +7,6 @@ info: name: Sample BB Team url: https://example.org/contact x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml index 35be76b..3c67159 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml @@ -6,7 +6,7 @@ paths: /things: get: operationId: listThings - x-delivery: atLeastOnce + x-deprecated: true responses: '200': description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml index 5c5a725..270cc92 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml @@ -6,7 +6,7 @@ paths: /things: get: operationId: listThings - x-govstack-delivery: atLeastOnce + x-govstack-deprecated: true responses: '200': description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml index f8c6ef1..70d5fb8 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml @@ -21,12 +21,6 @@ components: MediaType: type: string enum: [application/json, application/problem+json] - DeliveryGuarantee: - type: string - enum: [atMostOnce, atLeastOnce, effectivelyOnce] - DeliveryCapability: - type: string - enum: [supported, unsupported, notApplicable] ErrorCode: type: string enum: [global.govstack.identity.personNotFound] diff --git a/api-design-guide/linter/tests/functions.test.mjs b/api-design-guide/linter/tests/functions.test.mjs index eba495c..284df8d 100644 --- a/api-design-guide/linter/tests/functions.test.mjs +++ b/api-design-guide/linter/tests/functions.test.mjs @@ -19,8 +19,12 @@ import mediaTypeExpected from '../functions/mediaTypeExpected.js'; import successResponseSchema from '../functions/s07-successResponseSchema.js'; import creationResponses from '../functions/s07-creationResponses.js'; import baselineResponses from '../functions/s07-baselineResponses.js'; +import bbCode from '../functions/s09-bbCode.js'; +import fieldErrors from '../functions/s11-fieldErrors.js'; +import problemType from '../functions/s11-problemType.js'; import collectionPagination from '../functions/s12-collectionPagination.js'; import schemeExists from '../functions/s13-schemeExists.js'; +import cloudEventsPayload from '../functions/s17-cloudEventsPayload.js'; import { walkSchema } from '../functions/lib/schemaWalk.js'; import { isStandardUnversionedPath } from '../functions/lib/standardEndpoints.js'; @@ -42,8 +46,12 @@ const ALL = { successResponseSchema, creationResponses, baselineResponses, + bbCode, + fieldErrors, + problemType, collectionPagination, schemeExists, + cloudEventsPayload, }; test('all functions return undefined on bad input, never throw', () => { for (const [name, fn] of Object.entries(ALL)) { @@ -95,6 +103,13 @@ test('schemaDescriptions: properties vs all mode', () => { assert.equal(count(schemaDescriptions(schema, { mode: 'all' })), 1); // b node const noRoot = { type: 'object', properties: { a: { type: 'string', description: 'A' } } }; assert.equal(count(schemaDescriptions(noRoot, { includeRoot: true })), 1); // root + + const assertion = { + type: 'object', + description: 'A schema that rejects a prohibited property.', + not: { required: ['prohibitedField'] }, + }; + assert.equal(count(schemaDescriptions(assertion, { mode: 'all' })), 0); }); test('responseHeaderRequired: header presence + companion status', () => { @@ -196,6 +211,116 @@ test('s12-collectionPagination: 5.10 and maxItems carve-outs, pageParam, pageSiz assert.equal(count(collectionPagination(noMaximum, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 1); const bothBounds = withSchema({ type: 'object' }, [{ name: 'pageSize', schema: { default: 20, maximum: 100 } }]); assert.equal(count(collectionPagination(bothBounds, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 0); + + const cursorParams = [{ name: 'pageSize' }, { name: 'cursor' }]; + const cursorEnvelope = withSchema({ + type: 'object', + required: ['items', 'pageInfo'], + properties: { + items: { type: 'array' }, + pageInfo: { + type: 'object', + required: ['nextCursor'], + properties: { + nextCursor: { type: ['string', 'null'], minLength: 1 }, + }, + }, + }, + }, cursorParams); + assert.equal(count(collectionPagination(cursorEnvelope, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 0); + + const nonNullable = structuredClone(cursorEnvelope); + nonNullable.responses[200].content['application/json'].schema.properties.pageInfo.properties.nextCursor = { + type: 'string', + minLength: 1, + }; + assert.equal(count(collectionPagination(nonNullable, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); + + const permitsIntegerCursor = structuredClone(cursorEnvelope); + permitsIntegerCursor.responses[200].content['application/json'].schema.properties.pageInfo.properties.nextCursor = { + type: ['string', 'null', 'integer'], + minLength: 1, + }; + assert.equal(count(collectionPagination(permitsIntegerCursor, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); + + const permitsIntegerBranch = structuredClone(cursorEnvelope); + permitsIntegerBranch.responses[200].content['application/json'].schema.properties.pageInfo.properties.nextCursor = { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'null' }, + { type: 'integer' }, + ], + }; + assert.equal(count(collectionPagination(permitsIntegerBranch, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); + + const declaresHasMore = structuredClone(cursorEnvelope); + declaresHasMore.responses[200].content['application/json'].schema.properties.pageInfo.properties.hasMore = { + type: 'boolean', + }; + assert.equal(count(collectionPagination(declaresHasMore, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); +}); + +test('s09-bbCode: canonical problem-type URIs participate in the single-code check', () => { + const consistent = { + security: [{ oauth: ['bb:registry:records:read'] }], + example: { type: 'https://govstack.global/problems/registry/record-not-found' }, + }; + assert.equal(count(bbCode(consistent)), 0); + + const inconsistent = structuredClone(consistent); + inconsistent.example.type = 'https://govstack.global/problems/payments/record-not-found'; + assert.equal(count(bbCode(inconsistent)), 1); + + const malformed = { + example: { type: 'https://govstack.global/problems/Registry/record-not-found' }, + }; + assert.equal(count(bbCode(malformed)), 1); +}); + +test('s11-fieldErrors: non-field problems are ignored and opted-in errors require pointer/message', () => { + const ordinaryProblem = { + type: 'object', + required: ['type', 'title', 'status', 'traceId'], + properties: { type: {}, title: {}, status: {}, traceId: {} }, + }; + assert.equal(fieldErrors(ordinaryProblem), undefined); + + const valid = { + allOf: [ + ordinaryProblem, + { + required: ['errors'], + properties: { + errors: { + type: 'array', + items: { + required: ['pointer', 'message'], + properties: { pointer: { type: 'string' }, message: { type: 'string' } }, + }, + }, + }, + }, + ], + }; + assert.equal(count(fieldErrors(valid)), 0); + + const invalid = structuredClone(valid); + invalid.allOf[1].properties.errors.items.required = ['pointer']; + delete invalid.allOf[1].properties.errors.items.properties.message; + assert.equal(count(fieldErrors(invalid)), 2); +}); + +test('s11-problemType: validates literal media-type examples only when present', () => { + const good = { + example: { + type: 'https://govstack.global/problems/registry/record-not-found', + title: 'Not found', + }, + }; + assert.equal(count(problemType(good)), 0); + good.example.type = 'https://docs.example.gov/problems/not-found'; + assert.equal(count(problemType(good)), 1); + assert.equal(problemType({ schema: { type: 'object' } }), undefined); }); test('s13-schemeExists: types / oauthFlows / httpBearerFormats', () => { @@ -222,15 +347,16 @@ test('envelopeShape: required / nested / const / enum / allOf', () => { required: ['items', 'pageInfo'], properties: { items: { type: 'array' }, - pageInfo: { type: 'object', required: ['nextCursor', 'hasMore'], properties: { nextCursor: {}, hasMore: {} } }, + pageInfo: { type: 'object', required: ['nextCursor'], properties: { nextCursor: {} } }, }, }; assert.equal( - count(envelopeShape(page, { requiredProperties: ['items', 'pageInfo'], properties: { pageInfo: { requiredProperties: ['nextCursor', 'hasMore'] } } })), + count(envelopeShape(page, { requiredProperties: ['items', 'pageInfo'], properties: { pageInfo: { requiredProperties: ['nextCursor'] } } })), 0, ); const bad = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } }; assert.ok(count(envelopeShape(bad, { requiredProperties: ['items', 'pageInfo'] })) >= 1); + assert.equal(count(envelopeShape(bad, { forbiddenProperties: ['items'] })), 1); const event = { type: 'object', properties: { specversion: { const: '1.0' }, type: { const: 'x' } } }; assert.equal(count(envelopeShape(event, { properties: { specversion: { const: '1.0' } } })), 0); @@ -279,9 +405,9 @@ test('schemaFieldFormat: format / forbidType / mustDeclare', () => { }); test('extensionShape: presence / enum / object shape / semver', () => { - assert.equal(count(extensionShape({}, { extension: 'x-govstack-delivery' })), 1); // required, absent - assert.equal(count(extensionShape({ 'x-govstack-delivery': 'atLeastOnce' }, { extension: 'x-govstack-delivery', enum: ['atMostOnce', 'atLeastOnce', 'effectivelyOnce'] })), 0); - assert.equal(count(extensionShape({ 'x-govstack-delivery': 'sometimes' }, { extension: 'x-govstack-delivery', enum: ['atMostOnce', 'atLeastOnce'] })), 1); + assert.equal(count(extensionShape({}, { extension: 'x-example-mode' })), 1); // required, absent + assert.equal(count(extensionShape({ 'x-example-mode': 'active' }, { extension: 'x-example-mode', enum: ['active', 'inactive'] })), 0); + assert.equal(count(extensionShape({ 'x-example-mode': 'unknown' }, { extension: 'x-example-mode', enum: ['active', 'inactive'] })), 1); const good = { 'x-govstack-api-guide': { version: '0.2.0' } }; assert.equal(count(extensionShape(good, { extension: 'x-govstack-api-guide', valueType: 'object', requiredKeys: ['version'], semverKeys: ['version'] })), 0); @@ -350,3 +476,32 @@ test('walkSchema: visits combinators and is cycle-safe', () => { walkSchema(cyc, () => (visits += 1)); assert.equal(visits, 1); // visited once, no infinite loop }); + +test('s17-cloudEventsPayload: scopes non-CloudEvents and requires a local vendored ref', () => { + const localJson = { + contentType: 'application/json', + payload: { type: 'object', properties: { command: { type: 'string' } } }, + }; + assert.equal(cloudEventsPayload(localJson, {}, { path: [] }), undefined); + + const localEnvelope = { + contentType: 'application/cloudevents+json', + payload: { + allOf: [ + { $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' }, + ], + }, + }; + assert.equal( + cloudEventsPayload(localEnvelope, { requireSharedReference: true }, { path: [] }), + undefined, + ); + + const remoteEnvelope = structuredClone(localEnvelope); + remoteEnvelope.payload.allOf[0].$ref = + 'https://example.org/api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope'; + assert.equal( + count(cloudEventsPayload(remoteEnvelope, { requireSharedReference: true }, { path: [] })), + 1, + ); +}); diff --git a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml index 98621be..4cdde6c 100644 --- a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml +++ b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml @@ -11,11 +11,11 @@ # with send/receive perspective and complete metadata (§3.7/§17.1), CloudEvents # JSON payloads (§17.6) with camelCase properties (§3.9) and camelCase headers # (§17.8), message examples (§17.20), security covering every operation via the -# server (§17.10), the machine-readable delivery/ordering/replay extensions -# (§17.11/§17.12/§17.13/§17.15), an async rejection message using the §11 error -# envelope with correlation (§17.16), a request-reply pair with a reply channel -# and correlationId (§17.17), protocol bindings matching the server protocol -# (§17.19), and the §20.3 conformance declaration. +# server (§17.10), shared external envelope and error schemas (§17.7), an async +# rejection message using the §11 error envelope with correlation (§17.16), a +# request-reply pair with a reply channel and correlationId (§17.17), protocol +# bindings matching the server protocol (§17.19), and the §20.3 conformance +# declaration. # # Unlike the OpenAPI golden, this file lints to ZERO findings: AsyncAPI has no # unversioned /health analogue, so none of the rule-scoping contradictions apply. @@ -36,8 +36,8 @@ info: name: CC-BY-4.0 url: https://creativecommons.org/licenses/by/4.0/ x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft servers: production: host: kafka.example.gov:9092 @@ -45,7 +45,7 @@ servers: description: Production Kafka broker for the Registry Building Block. security: - $ref: '#/components/securitySchemes/registryOAuth' -defaultContentType: application/json +defaultContentType: application/cloudevents+json channels: global.govstack.registry.v1.registrant.registered: address: registry.registrant.registered @@ -91,30 +91,22 @@ operations: summary: Publish a registrant-registered event description: >- The Registry BB publishes a CloudEvents envelope whenever a registrant is - registered. Delivery is at-least-once and ordered per registrant; consumers - must de-duplicate on the event id. + registered. The broker may redeliver an event; consumers de-duplicate on + the CloudEvents source and id pair. No ordering guarantee is promised. tags: - name: Registrants channel: $ref: '#/channels/global.govstack.registry.v1.registrant.registered' messages: - $ref: '#/channels/global.govstack.registry.v1.registrant.registered/messages/registered' - x-govstack-delivery: atLeastOnce - x-govstack-ordering: - scope: partitionKey - key: registrantId - x-govstack-redelivery: supported - x-govstack-dead-letter: supported - x-govstack-retention: supported - x-govstack-replay: supported receiveDeregisterCommand: action: receive summary: Consume a deregister-registrant command description: >- The Registry BB consumes a deregister command for a registrant and replies - on the command-replies channel. Delivery is at-least-once; the command is - idempotent on its idempotencyKey header. This is a request-reply operation - correlated by correlationId. + on the command-replies channel. The broker may redeliver the command; the + command is idempotent on its CloudEvents idempotencykey attribute. This is + a request-reply operation correlated by correlationId. tags: - name: Registrants channel: @@ -126,33 +118,19 @@ operations: $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies' messages: - $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies/messages/result' - x-govstack-delivery: atLeastOnce - x-govstack-ordering: - scope: partitionKey - key: registrantId - x-govstack-redelivery: supported - x-govstack-dead-letter: supported - x-govstack-retention: supported - x-govstack-replay: notApplicable sendDeregistrationRejected: action: send summary: Publish a deregistration-rejected error description: >- The Registry BB publishes a rejection using the §11 error envelope when a deregister command cannot be honoured. Correlated to the originating command - by correlationId. Delivery is at-least-once. + by correlationId. tags: - name: Registrants channel: $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies' messages: - $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies/messages/rejected' - x-govstack-delivery: atLeastOnce - x-govstack-ordering: none - x-govstack-redelivery: supported - x-govstack-dead-letter: supported - x-govstack-retention: supported - x-govstack-replay: notApplicable components: securitySchemes: registryOAuth: @@ -172,69 +150,38 @@ components: name: registrantRegisteredEvent title: Registrant registered summary: Emitted when a registrant is registered. - contentType: application/json - headers: - type: object - description: Message metadata headers for the registrant-registered event. - properties: - idempotencyKey: - type: string - format: uuid - description: Producer-assigned key so consumers can de-duplicate redeliveries. + contentType: application/cloudevents+json payload: - type: object - description: CloudEvents 1.0 envelope wrapping the registrant-registered domain event. - required: [specversion, id, source, type, time, datacontenttype, data] - properties: - specversion: - type: string - description: CloudEvents specification version; always "1.0". - const: '1.0' - id: - type: string - description: Unique identifier of this event occurrence. - source: - type: string - description: Stable logical identifier of the publishing context. - const: /govstack/registry - type: - type: string - description: Reverse-DNS event type carrying no version segment. - const: global.govstack.registry.registrant.registered - time: - type: string - format: date-time - description: RFC 3339 timestamp at which the event occurred. - datacontenttype: - type: string - description: Media type of the data member. - const: application/json - subject: - type: string - description: Identifier of the registrant the event concerns. - data: - type: object - description: GovStack domain payload for the registrant-registered event. - required: [registrantId, registrationStatus, occurredAt] + allOf: + - $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific event type and domain payload. + required: [data] properties: - registrantId: - type: string - format: uuid - description: Identifier of the registrant that was registered. - registrationStatus: - type: string - description: Registration status at the time of the event. - enum: [ACTIVE, SUSPENDED, DEREGISTERED] - x-extensible-enum: true - occurredAt: - type: string - format: date-time - description: RFC 3339 timestamp when the registration occurred. + type: + description: Stable semantic event type. + const: global.govstack.registry.registrant.registered + data: + type: object + description: GovStack domain payload for the registrant-registered event. + required: [registrantId, registrationStatus, occurredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was registered. + registrationStatus: + type: string + description: Registration status at the time of the event. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + occurredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registration occurred. examples: - name: registrantRegistered summary: A newly registered registrant. - headers: - idempotencyKey: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e payload: specversion: '1.0' id: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e @@ -251,15 +198,11 @@ components: name: deregisterRegistrantCommand title: Deregister registrant command summary: Requests deregistration of a registrant. - contentType: application/json + contentType: application/cloudevents+json headers: type: object description: Message metadata headers for the deregister command. properties: - idempotencyKey: - type: string - format: uuid - description: Command idempotency key so redeliveries are processed once. correlationId: type: string format: uuid @@ -268,50 +211,35 @@ components: location: $message.header#/correlationId description: Correlates the command with its reply on the command-replies channel. payload: - type: object - description: CloudEvents 1.0 envelope wrapping the deregister command. - required: [specversion, id, source, type, time, datacontenttype, data] - properties: - specversion: - type: string - description: CloudEvents specification version; always "1.0". - const: '1.0' - id: - type: string - description: Unique identifier of this command occurrence. - source: - type: string - description: Stable logical identifier of the command issuer. - const: /govstack/registry - type: - type: string - description: Reverse-DNS command type carrying no version segment. - const: global.govstack.registry.registrant.deregisterRequested - time: - type: string - format: date-time - description: RFC 3339 timestamp at which the command was issued. - datacontenttype: - type: string - description: Media type of the data member. - const: application/json - data: - type: object - description: GovStack domain payload for the deregister command. - required: [registrantId, reason] + allOf: + - $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific command type and domain payload. + required: [idempotencykey, data] properties: - registrantId: + type: + description: Stable semantic command type. + const: global.govstack.registry.registrant.deregisterRequested + idempotencykey: type: string format: uuid - description: Identifier of the registrant to deregister. - reason: - type: string - description: Human-readable reason for the deregistration. + description: CloudEvents extension used to de-duplicate command processing. + data: + type: object + description: GovStack domain payload for the deregister command. + required: [registrantId, reason] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant to deregister. + reason: + type: string + description: Human-readable reason for the deregistration. examples: - name: deregisterRegistrant summary: A deregister command for a registrant. headers: - idempotencyKey: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f correlationId: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d payload: specversion: '1.0' @@ -320,6 +248,7 @@ components: type: global.govstack.registry.registrant.deregisterRequested time: '2026-07-10T12:40:00Z' datacontenttype: application/json + idempotencykey: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f data: registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff reason: Requested by registrant. @@ -327,7 +256,7 @@ components: name: deregisterRegistrantResult title: Deregister registrant result summary: Reports the outcome of a deregister command. - contentType: application/json + contentType: application/cloudevents+json headers: type: object description: Message metadata headers for the deregister result. @@ -340,46 +269,28 @@ components: location: $message.header#/correlationId description: Correlates the reply back to the originating command. payload: - type: object - description: CloudEvents 1.0 envelope wrapping the deregister result. - required: [specversion, id, source, type, time, datacontenttype, data] - properties: - specversion: - type: string - description: CloudEvents specification version; always "1.0". - const: '1.0' - id: - type: string - description: Unique identifier of this result occurrence. - source: - type: string - description: Stable logical identifier of the publishing context. - const: /govstack/registry - type: - type: string - description: Reverse-DNS event type carrying no version segment. - const: global.govstack.registry.registrant.deregistered - time: - type: string - format: date-time - description: RFC 3339 timestamp at which the registrant was deregistered. - datacontenttype: - type: string - description: Media type of the data member. - const: application/json - data: - type: object - description: GovStack domain payload for the deregister result. - required: [registrantId, deregisteredAt] + allOf: + - $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific result type and domain payload. + required: [data] properties: - registrantId: - type: string - format: uuid - description: Identifier of the registrant that was deregistered. - deregisteredAt: - type: string - format: date-time - description: RFC 3339 timestamp when the registrant was deregistered. + type: + description: Stable semantic result type. + const: global.govstack.registry.registrant.deregistered + data: + type: object + description: GovStack domain payload for the deregister result. + required: [registrantId, deregisteredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was deregistered. + deregisteredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registrant was deregistered. examples: - name: deregisterResult summary: A successful deregistration result. @@ -412,35 +323,7 @@ components: location: $message.header#/correlationId description: Correlates the rejection back to the originating command. payload: - type: object - description: >- - RFC 9457 problem details envelope with GovStack extension fields, used - for asynchronous command rejections (§17.16). - required: [type, title, status, code, traceId, timestamp] - properties: - type: - type: string - format: uri - description: URI reference identifying the problem type. - title: - type: string - description: Short, human-readable summary of the problem type. - status: - type: integer - description: Nominal HTTP-equivalent status for the failure class. - detail: - type: string - description: Human-readable explanation specific to this rejection. - code: - type: string - description: Stable, namespaced GovStack error code. - traceId: - type: string - description: Distributed-trace identifier correlating this error to server logs. - timestamp: - type: string - format: date-time - description: RFC 3339 timestamp at which the rejection was produced. + $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/GovStackAsyncError' examples: - name: deregistrationRejected summary: A rejected deregistration. @@ -449,7 +332,6 @@ components: payload: type: https://docs.example.gov/registry/problems/deregistration-rejected title: Deregistration rejected - status: 409 detail: The registrant has an active obligation and cannot be deregistered. code: global.govstack.registry.deregistrationRejected traceId: 9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c diff --git a/api-design-guide/linter/tests/golden/openapi-golden.yaml b/api-design-guide/linter/tests/golden/openapi-golden.yaml index dcfc324..60c19b2 100644 --- a/api-design-guide/linter/tests/golden/openapi-golden.yaml +++ b/api-design-guide/linter/tests/golden/openapi-golden.yaml @@ -7,8 +7,8 @@ # # It deliberately exercises the guide's shapes: /v1 kebab-case paths, camelCase # params/properties, described schemas (§4.1) with examples (§4.2), the §5.9 -# health endpoint family, RFC 9457 problem+json error envelopes with the -# GovStack extension fields and field-level errors[] (§11), cursor pagination +# health endpoint family, local error Response Objects/examples built on the +# external RFC 9457 Problem and ValidationProblem schemas (§11), cursor pagination # (§12), a creating POST with Idempotency-Key + Location (§8.3/§14.1), traceparent # Trace Context correlation (§8.4), ETag/If-Match optimistic concurrency (§7.16/§7.17), merge- # patch PATCH (§6.4), async long-running Operations (§15), CloudEvents webhooks + @@ -21,9 +21,8 @@ # segment casing, §12.x pagination, §7.16 ETag), scopes the §5.9 # application/json media type to the SUCCESS (200) response so the §11.1 # problem+json error responses are satisfiable, and counts only non-param -# segments toward §5.4 max nesting depth so the §15.5/§16.11 mandated action -# sub-resource paths (/v1/operations/{operationId}/cancel, -# .../subscriptions/{id}/rotate-secret) stay within two levels. +# segments toward §5.4 max nesting depth so the §15.5 action sub-resource path +# (/v1/operations/{operationId}/cancel) stays within two levels. openapi: 3.1.0 info: title: GovStack Registry Building Block API @@ -42,8 +41,8 @@ info: name: CC-BY-4.0 url: https://creativecommons.org/licenses/by/4.0/ x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft servers: - url: https://api.example.gov/registry description: Production gateway for the Registry Building Block. @@ -174,6 +173,8 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationFailed' '500': $ref: '#/components/responses/ServerError' /v1/registrants/{registrantId}: @@ -520,9 +521,8 @@ paths: operationId: createSubscription summary: Create a subscription description: >- - Registers a webhook subscription for registrant lifecycle events. Returns - the created subscription including its initial signing secret. Idempotent - via Idempotency-Key. + Registers a webhook subscription for registrant lifecycle events and + returns the created subscription. Idempotent via Idempotency-Key. tags: [Subscriptions] security: - registryOAuth: @@ -586,41 +586,6 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/ServerError' - /v1/subscriptions/{subscriptionId}/rotate-secret: - parameters: - - $ref: '#/components/parameters/SubscriptionId' - post: - operationId: rotateSubscriptionSecret - summary: Rotate a subscription signing secret - description: >- - Rotates the HMAC signing secret used to compute the GovStack-Signature on - deliveries for this subscription, per guide §16.11. - tags: [Subscriptions] - security: - - registryOAuth: - - bb:registry:subscription:manage - parameters: - - $ref: '#/components/parameters/Traceparent' - responses: - '200': - description: The secret was rotated; the new secret is returned once. - headers: - traceparent: - $ref: '#/components/headers/Traceparent' - content: - application/json: - schema: - $ref: '#/components/schemas/SubscriptionSecret' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/ServerError' webhooks: registrantRegistered: post: @@ -628,12 +593,9 @@ webhooks: summary: Registrant registered event description: >- Delivered to a subscriber's callback URL when a registrant is registered. - The body is a structured CloudEvents 1.0 envelope; the delivery carries a - GovStack-Signature header the receiver verifies against the subscription - secret. + The body is a structured CloudEvents 1.0 envelope. This baseline example + uses authenticated transport and does not opt into message signing. tags: [Subscriptions] - parameters: - - $ref: '#/components/parameters/GovStackSignature' requestBody: required: true description: A CloudEvents 1.0 envelope wrapping the registrant event. @@ -723,7 +685,7 @@ components: description: Opaque identifier of a long-running Operation. schema: type: string - format: uuid + minLength: 1 SubscriptionId: name: subscriptionId in: path @@ -732,15 +694,6 @@ components: schema: type: string format: uuid - GovStackSignature: - name: GovStack-Signature - in: header - required: true - description: >- - Detached HMAC signature of the event body, for the receiver to verify - authenticity against the subscription secret (§16.6). - schema: - type: string PageSize: name: pageSize in: query @@ -816,7 +769,22 @@ components: ETag: $ref: '#/components/headers/ETag' BadRequest: - description: The request was malformed or failed field-level validation. + description: The request could not be parsed or its non-field parameters were invalid. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/bad-request + title: Bad request + status: 400 + detail: The cursor parameter is malformed. + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + ValidationFailed: + description: One or more request fields failed validation. headers: Cache-Control: $ref: '#/components/headers/CacheControl' @@ -824,6 +792,15 @@ components: application/problem+json: schema: $ref: '#/components/schemas/ValidationProblem' + example: + type: https://govstack.global/problems/registry/invalid-field + title: Validation failed + status: 422 + detail: One or more fields are invalid. + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + errors: + - pointer: /emailAddress + message: Must be a valid email address. Unauthorized: description: Authentication is required or the supplied credentials are invalid. headers: @@ -835,6 +812,11 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/authentication-required + title: Authentication required + status: 401 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d Forbidden: description: The authenticated principal lacks a required OAuth scope. headers: @@ -844,6 +826,11 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/permission-denied + title: Permission denied + status: 403 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d NotFound: description: The requested resource does not exist. headers: @@ -853,6 +840,11 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/resource-not-found + title: Resource not found + status: 404 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d Conflict: description: The request conflicts with the current state of the resource. headers: @@ -862,6 +854,11 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/state-conflict + title: State conflict + status: 409 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d PreconditionFailed: description: The If-Match precondition did not hold; the resource was modified. headers: @@ -871,6 +868,11 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/precondition-failed + title: Precondition failed + status: 412 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d UnsupportedMediaType: description: The request media type is not supported by this endpoint. headers: @@ -880,6 +882,11 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/unsupported-media-type + title: Unsupported media type + status: 415 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d ServerError: description: An unexpected error occurred while processing the request. headers: @@ -889,6 +896,11 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/internal-error + title: Internal error + status: 500 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d ServiceUnavailable: description: The service is temporarily unable to accept work (guide §5.9). headers: @@ -898,94 +910,18 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/service-unavailable + title: Service unavailable + status: 503 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d schemas: Problem: - type: object - description: >- - RFC 9457 problem details envelope with GovStack extension fields. Used for - every 4xx and 5xx response. - required: [type, title, status, code, traceId, timestamp] - properties: - type: - type: string - format: uri - description: URI reference identifying the problem type. - title: - type: string - description: Short, human-readable summary of the problem type. - status: - type: integer - description: HTTP status code duplicated in the body for convenience. - detail: - type: string - description: Human-readable explanation specific to this occurrence. - instance: - type: string - format: uri-reference - description: URI reference identifying this specific occurrence. - code: - type: string - description: >- - Stable, namespaced GovStack error code (§11.5), reverse-DNS - global.govstack.{bb-code}.{errorName}. - example: global.govstack.registry.requestFailed - traceId: - type: string - description: Distributed-trace identifier correlating this error to server logs. - timestamp: - type: string - format: date-time - description: RFC 3339 timestamp at which the error was produced. - examples: - - type: https://docs.example.gov/registry/problems/request-failed - title: Request failed - status: 500 - detail: The request could not be processed. - instance: /v1/registrants/6f9619ff-8b86-d011-b42d-00cf4fc964ff - code: global.govstack.registry.requestFailed - traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d - timestamp: '2026-07-10T12:34:56Z' + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/Problem' ValidationProblem: - description: >- - Problem details for field-level validation failures (§11.4): the standard - problem envelope plus an errors array pinpointing each offending field. - allOf: - - $ref: '#/components/schemas/Problem' - - type: object - description: The field-level errors extension of the problem envelope. - required: [errors] - properties: - errors: - type: array - description: One entry per field that failed validation. - items: - $ref: '#/components/schemas/FieldError' - examples: - - type: https://docs.example.gov/registry/problems/validation - title: Validation failed - status: 400 - detail: One or more fields are invalid. - code: global.govstack.registry.validationFailed - traceId: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d - timestamp: '2026-07-10T12:34:56Z' - errors: - - pointer: /emailAddress - code: global.govstack.registry.invalidEmail - message: emailAddress must be a valid email address. + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/ValidationProblem' FieldError: - type: object - description: A single field-level validation error. - required: [pointer, code, message] - properties: - pointer: - type: string - description: JSON Pointer to the offending field in the request body. - code: - type: string - description: Stable, namespaced code identifying the validation failure. - message: - type: string - description: Human-readable description of what is wrong with the field. + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/FieldError' HealthStatus: type: object description: >- @@ -1193,21 +1129,10 @@ components: registrationStatus: ACTIVE pageSize: 20 PageInfo: - type: object - description: Cursor pagination metadata (§12.3). - required: [nextCursor, hasMore] - properties: - nextCursor: - type: [string, 'null'] - description: >- - Opaque cursor to pass as the cursor parameter to fetch the next page; - null on the last page. - hasMore: - type: boolean - description: Whether more items exist beyond this page. - examples: - - nextCursor: b3BhcXVlLWN1cnNvci0y - hasMore: true + description: >- + Page metadata. total, when present, is exact and describes the + collection at the time this page is generated. + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/PageInfo' RegistrantPage: type: object description: A cursor-paginated page of registrant records. @@ -1230,7 +1155,6 @@ components: updatedAt: '2026-07-01T14:30:00Z' pageInfo: nextCursor: b3BhcXVlLWN1cnNvci0y - hasMore: true SubscriptionPage: type: object description: A cursor-paginated page of webhook subscriptions. @@ -1254,7 +1178,6 @@ components: updatedAt: '2026-06-01T08:00:00Z' pageInfo: nextCursor: null - hasMore: false BulkImportRequest: type: object description: Instructions for a long-running bulk import. @@ -1279,7 +1202,7 @@ components: properties: id: type: string - format: uuid + minLength: 1 description: Opaque identifier of the Operation. status: type: string @@ -1311,7 +1234,7 @@ components: format: date-time description: RFC 3339 timestamp when the Operation last changed state. examples: - - id: 3a7c1e94-2b6d-4f0a-9c1e-8d5f6a7b0c11 + - id: op_7JpQ9m2W4xK8fR3cT6vN1 status: RUNNING progress: 42 result: null @@ -1342,11 +1265,6 @@ components: description: Whether the subscription is ACTIVE or PAUSED. enum: [ACTIVE, PAUSED] x-extensible-enum: true - secret: - type: string - description: >- - Current signing secret; returned only at creation and rotation, then - masked. createdAt: type: string format: date-time @@ -1382,21 +1300,6 @@ components: - callbackUrl: https://partner.example.org/hooks/registry eventTypes: - global.govstack.registry.registrant.registered - SubscriptionSecret: - type: object - description: A freshly rotated subscription signing secret. - required: [secret, rotatedAt] - properties: - secret: - type: string - description: The new signing secret; shown once and not retrievable later. - rotatedAt: - type: string - format: date-time - description: RFC 3339 timestamp when the secret was rotated. - examples: - - secret: whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c - rotatedAt: '2026-07-10T12:34:56Z' RegistrantRegisteredEvent: type: object description: >- diff --git a/api-design-guide/linter/tests/message-examples.test.mjs b/api-design-guide/linter/tests/message-examples.test.mjs new file mode 100644 index 0000000..1ecba5d --- /dev/null +++ b/api-design-guide/linter/tests/message-examples.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import YAML from 'yaml'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const goldenPath = path.join(here, 'golden/asyncapi-golden.yaml'); +const commonPath = path.resolve(here, '../../../api/common/govstack-asyncapi-common.yaml'); +const golden = YAML.parse(await readFile(goldenPath, 'utf8')); +const common = YAML.parse(await readFile(commonPath, 'utf8')); + +function atPointer(root, fragment) { + return fragment + .replace(/^#\//, '') + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((value, part) => value?.[part], root); +} + +function dereference(value, sourceRoot = golden) { + if (Array.isArray(value)) return value.map((item) => dereference(item, sourceRoot)); + if (value === null || typeof value !== 'object') return value; + + if (typeof value.$ref === 'string') { + const [file, fragment = ''] = value.$ref.split('#'); + const refRoot = file.includes('govstack-asyncapi-common.yaml') ? common : sourceRoot; + const target = atPointer(refRoot, `#${fragment}`); + assert.ok(target, `Unresolved schema reference in golden example test: ${value.$ref}`); + return dereference(target, refRoot); + } + + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, dereference(child, sourceRoot)]), + ); +} + +test('every AsyncAPI golden payload example satisfies its composed schema', () => { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + + for (const [messageName, message] of Object.entries(golden.components.messages)) { + const validate = ajv.compile(dereference(message.payload)); + for (const example of message.examples ?? []) { + const valid = validate(example.payload); + assert.equal( + valid, + true, + `${messageName}/${example.name} does not satisfy its payload schema:\n${JSON.stringify(validate.errors, null, 2)}`, + ); + } + } +}); diff --git a/api-design-guide/linter/tests/openapi-examples.test.mjs b/api-design-guide/linter/tests/openapi-examples.test.mjs new file mode 100644 index 0000000..be6f448 --- /dev/null +++ b/api-design-guide/linter/tests/openapi-examples.test.mjs @@ -0,0 +1,203 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import YAML from 'yaml'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const goldenPath = path.join(here, 'golden/openapi-golden.yaml'); +const commonPath = path.resolve(here, '../../../api/common/govstack-openapi-common.yaml'); +const golden = YAML.parse(await readFile(goldenPath, 'utf8')); +const common = YAML.parse(await readFile(commonPath, 'utf8')); +const documents = new Map([ + [goldenPath, golden], + [commonPath, common], +]); + +const EXPECTED_COMMON_REFS = new Set( + ['Problem', 'ValidationProblem', 'FieldError', 'PageInfo'].map( + (name) => `../../../../api/common/govstack-openapi-common.yaml#/components/schemas/${name}`, + ), +); + +function atPointer(root, fragment) { + if (fragment === '' || fragment === '#') return root; + assert.match(fragment, /^#\//, `Only local JSON Pointer fragments are supported: ${fragment}`); + return fragment + .slice(2) + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((value, part) => value?.[part], root); +} + +function dereference(value, sourcePath, stack = new Set()) { + if (Array.isArray(value)) return value.map((item) => dereference(item, sourcePath, stack)); + if (value === null || typeof value !== 'object') return value; + + if (typeof value.$ref === 'string') { + const hash = value.$ref.indexOf('#'); + const file = hash === -1 ? value.$ref : value.$ref.slice(0, hash); + const fragment = hash === -1 ? '' : value.$ref.slice(hash); + const targetPath = file ? path.resolve(path.dirname(sourcePath), file) : sourcePath; + const targetRoot = documents.get(targetPath); + assert.ok(targetRoot, `Unresolved document in OpenAPI example test: ${value.$ref}`); + const target = atPointer(targetRoot, fragment); + assert.notEqual(target, undefined, `Unresolved JSON Pointer in OpenAPI example test: ${value.$ref}`); + + const key = `${targetPath}${fragment}`; + assert.ok(!stack.has(key), `Cyclic schema reference is not supported by this example test: ${key}`); + const nextStack = new Set(stack).add(key); + const resolved = dereference(target, targetPath, nextStack); + const siblings = Object.fromEntries(Object.entries(value).filter(([name]) => name !== '$ref')); + if (Object.keys(siblings).length === 0) return resolved; + return { allOf: [resolved, dereference(siblings, sourcePath, stack)] }; + } + + return Object.fromEntries( + Object.entries(value).map(([name, child]) => [name, dereference(child, sourcePath, stack)]), + ); +} + +function collectRefs(value, refs = []) { + if (Array.isArray(value)) { + for (const item of value) collectRefs(item, refs); + return refs; + } + if (value === null || typeof value !== 'object') return refs; + if (typeof value.$ref === 'string') refs.push(value.$ref); + for (const child of Object.values(value)) collectRefs(child, refs); + return refs; +} + +function makeAjv() { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + return ajv; +} + +function assertValid(validate, value, label) { + assert.equal( + validate(value), + true, + `${label} does not satisfy its schema:\n${JSON.stringify(validate.errors, null, 2)}`, + ); +} + +function assertInvalid(validate, value, label) { + assert.equal(validate(value), false, `${label} unexpectedly satisfied its schema`); +} + +test('OpenAPI golden consumes only the four approved common schemas', () => { + const commonRefs = collectRefs(golden).filter((ref) => ref.includes('govstack-openapi-common.yaml')); + assert.deepEqual(new Set(commonRefs), EXPECTED_COMMON_REFS); + assert.equal(commonRefs.length, EXPECTED_COMMON_REFS.size, 'each common schema should be aliased once'); + + for (const name of ['Problem', 'ValidationProblem', 'FieldError', 'PageInfo']) { + assert.equal( + golden.components.schemas[name].$ref, + `../../../../api/common/govstack-openapi-common.yaml#/components/schemas/${name}`, + ); + } + assert.equal(golden.components.schemas.Operation.$ref, undefined, 'Operation must remain BB-owned'); +}); + +test('common OpenAPI schemas validate their contract while remaining open to undeclared members', () => { + const ajv = makeAjv(); + const compile = (name) => ajv.compile(dereference(common.components.schemas[name], commonPath)); + const traceId = '4bf92f3577b34da6a3ce929d0e0e4736'; + + const problem = compile('Problem'); + const validProblem = { + type: 'https://govstack.global/problems/registry/record-not-found', + title: 'Record not found', + status: 404, + traceId, + }; + assertValid(problem, validProblem, 'Problem positive control'); + assertInvalid(problem, { ...validProblem, type: 'https://docs.example.gov/problems/not-found' }, 'Problem old type URI'); + assertInvalid(problem, { ...validProblem, type: 'https://govstack.global/problems/registry/recordNotFound' }, 'Problem non-kebab slug'); + assertValid(problem, { ...validProblem, code: 'consumer-extension' }, 'Problem undeclared member control'); + assertValid(problem, { ...validProblem, timestamp: '2026-07-10T12:34:56Z' }, 'Problem timestamp extension control'); + const missingTrace = { ...validProblem }; + delete missingTrace.traceId; + assertInvalid(problem, missingTrace, 'Problem missing traceId'); + + const fieldError = compile('FieldError'); + assertValid(fieldError, { pointer: '/emailAddress', message: 'Must be a valid email.' }, 'FieldError positive control'); + assertInvalid(fieldError, { pointer: 'emailAddress', message: 'Must be a valid email.' }, 'FieldError invalid JSON Pointer'); + assertInvalid(fieldError, { pointer: '/emailAddress' }, 'FieldError missing message'); + assertValid( + fieldError, + { pointer: '/emailAddress', code: 'consumer-extension', message: 'Must be a valid email.' }, + 'FieldError undeclared member control', + ); + + const validation = compile('ValidationProblem'); + const validValidation = { + type: 'https://govstack.global/problems/registry/invalid-field', + title: 'Validation failed', + status: 422, + traceId, + errors: [{ pointer: '/emailAddress', message: 'Must be a valid email.' }], + }; + assertValid(validation, validValidation, 'ValidationProblem positive control'); + assertInvalid(validation, { ...validValidation, errors: [] }, 'ValidationProblem empty errors'); + assertInvalid( + validation, + { ...validValidation, errors: [{ pointer: '/emailAddress' }] }, + 'ValidationProblem field error missing message', + ); + + const pageInfo = compile('PageInfo'); + assertValid(pageInfo, { nextCursor: null }, 'PageInfo final-page control'); + assertValid(pageInfo, { nextCursor: 'opaque-cursor', total: 12 }, 'PageInfo continuation control'); + assertValid(pageInfo, { nextCursor: null, hasMore: false }, 'PageInfo undeclared member control'); + assertInvalid(pageInfo, {}, 'PageInfo missing nextCursor'); + assertInvalid(pageInfo, { nextCursor: '' }, 'PageInfo empty cursor'); +}); + +test('every OpenAPI golden example satisfies its resolved schema', () => { + const ajv = makeAjv(); + let checked = 0; + + const validateExamples = (schema, examples, label) => { + const validate = ajv.compile(dereference(schema, goldenPath)); + for (const [index, example] of examples.entries()) { + assertValid(validate, example, `${label} example ${index + 1}`); + checked += 1; + } + }; + + for (const [name, schema] of Object.entries(golden.components.schemas)) { + const examples = Array.isArray(schema.examples) + ? schema.examples + : Object.prototype.hasOwnProperty.call(schema, 'example') + ? [schema.example] + : []; + if (examples.length > 0) validateExamples(schema, examples, `components.schemas.${name}`); + } + + const walk = (value, label) => { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${label}[${index}]`)); + return; + } + if (value === null || typeof value !== 'object') return; + if (value.schema && Object.prototype.hasOwnProperty.call(value, 'example')) { + validateExamples(value.schema, [value.example], label); + } + if (value.schema && value.examples && !Array.isArray(value.examples)) { + const examples = Object.values(value.examples) + .filter((example) => !example?.$ref) + .map((example) => (example && typeof example === 'object' && 'value' in example ? example.value : example)); + validateExamples(value.schema, examples, label); + } + for (const [name, child] of Object.entries(value)) walk(child, `${label}.${name}`); + }; + walk({ paths: golden.paths, webhooks: golden.webhooks, responses: golden.components.responses }, 'openapi'); + + assert.ok(checked > 0, 'golden must contain schema-bound examples'); +}); diff --git a/api-design-guide/part-a/2-openapi-document-standards.md b/api-design-guide/part-a/2-openapi-document-standards.md index c7b4c83..40614ee 100644 --- a/api-design-guide/part-a/2-openapi-document-standards.md +++ b/api-design-guide/part-a/2-openapi-document-standards.md @@ -1,5 +1,5 @@ --- -description: "Rules governing the canonical OpenAPI document: version, location, validation, metadata, and vendored shared components." +description: "Rules governing the canonical OpenAPI document: version, location, validation, metadata, and conditional schema reuse." --- # 2. OpenAPI document standards @@ -12,7 +12,7 @@ description: "Rules governing the canonical OpenAPI document: version, location, ## 2.1 OpenAPI 3.1 required <a href="#21-openapi-31-required" id="21-openapi-31-required"></a> -**[M]** The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. +**[M]** The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. ## 2.2 One canonical OpenAPI entrypoint <a href="#22-one-canonical-openapi-entrypoint" id="22-one-canonical-openapi-entrypoint"></a> @@ -40,8 +40,10 @@ An operation-free shared component library under `api/common/` is referenced sup **[M+R]** Every operation **MUST** include `operationId` (camelCase, verb-noun), `summary`, `description`, and at least one `tag`. -## 2.8 Pinned vendored common components <a href="#28-pinned-vendored-common-components" id="28-pinned-vendored-common-components"></a> +## 2.8 Conditional vendored OpenAPI schemas <a href="#28-conditional-vendored-openapi-schemas" id="28-conditional-vendored-openapi-schemas"></a> -**[M]** Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit. +**[M+R]** A BB **MAY** reuse the schema-only `govstack-openapi-common.yaml` artifact for `Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`. If it does, the file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml`, its version **MUST** be pinned explicitly, and the BB **MUST** reference the named schemas rather than copy them. A BB that does not reuse the artifact **MUST** define equivalent schemas locally that satisfy [§11](../part-c/11-errors.md) and [§12](../part-c/12-pagination-filtering-sorting.md). -Vendoring is required because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable. +Security schemes, parameters, headers, Response Objects, examples, and Operation resources **MUST** be defined locally because their values and semantics belong to the BB contract. They are not part of the shared OpenAPI artifact. + +Vendoring is required when reuse is chosen because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable. diff --git a/api-design-guide/part-a/3-asyncapi-document-standards.md b/api-design-guide/part-a/3-asyncapi-document-standards.md index 97656fc..ec7cf53 100644 --- a/api-design-guide/part-a/3-asyncapi-document-standards.md +++ b/api-design-guide/part-a/3-asyncapi-document-standards.md @@ -12,7 +12,7 @@ description: "Rules governing the canonical AsyncAPI document: version, location ## 3.1 AsyncAPI 3.0.0 required <a href="#31-asyncapi-300-required" id="31-asyncapi-300-required"></a> -**[M]** An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. +**[M]** An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. ## 3.2 One canonical AsyncAPI entrypoint <a href="#32-one-canonical-asyncapi-entrypoint" id="32-one-canonical-asyncapi-entrypoint"></a> @@ -38,11 +38,11 @@ An operation-free shared component library under `api/common/` is referenced sup ## 3.7 Complete AsyncAPI operation metadata <a href="#37-complete-asyncapi-operation-metadata" id="37-complete-asyncapi-operation-metadata"></a> -**[M+R]** Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced CloudEvents message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. +**[M+R]** Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads)–[§17.7](../part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema) define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema. ## 3.8 Pinned vendored AsyncAPI components <a href="#38-pinned-vendored-asyncapi-components" id="38-pinned-vendored-asyncapi-components"></a> -**[M]** Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit. +**[M]** A BB that documents GovStack domain events **MUST** reference `CloudEventEnvelope` from a pinned version of `govstack-asyncapi-common.yaml`. A BB that documents asynchronous rejections **MUST** reference `GovStackAsyncError`, and **MUST** reuse `AsyncFieldError` when it exposes field-level errors. The common file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml`, and the pinned version **MUST** be explicit. BB specifications own their Message Objects, security schemes, headers, examples, and protocol bindings because those objects require BB- and transport-specific values. ## 3.9 JSON Schema payload conventions <a href="#39-json-schema-payload-conventions" id="39-json-schema-payload-conventions"></a> diff --git a/api-design-guide/part-b/5-url-structure-and-versioning.md b/api-design-guide/part-b/5-url-structure-and-versioning.md index fc17c15..53c4e21 100644 --- a/api-design-guide/part-b/5-url-structure-and-versioning.md +++ b/api-design-guide/part-b/5-url-structure-and-versioning.md @@ -12,7 +12,7 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.1 Major version in the path <a href="#51-major-version-in-the-path" id="51-major-version-in-the-path"></a> -**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) are the only exception. [`[OPEN-4-A]`](../appendix/b-open-questions.md) +**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) are the only exception. [`[OPEN-5-A]`](../appendix/b-open-questions.md) ## 5.2 Plural noun resources <a href="#52-plural-noun-resources" id="52-plural-noun-resources"></a> @@ -24,7 +24,7 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.4 Shallow path nesting <a href="#54-shallow-path-nesting" id="54-shallow-path-nesting"></a> -**[M]** Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. [`[OPEN-4-C]`](../appendix/b-open-questions.md) +**[M]** Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. [`[OPEN-5-B]`](../appendix/b-open-questions.md) ## 5.5 Identifiers as path parameters <a href="#55-identifiers-as-path-parameters" id="55-identifiers-as-path-parameters"></a> diff --git a/api-design-guide/part-b/7-http-status-codes.md b/api-design-guide/part-b/7-http-status-codes.md index 8d5318c..f25c2cb 100644 --- a/api-design-guide/part-b/7-http-status-codes.md +++ b/api-design-guide/part-b/7-http-status-codes.md @@ -20,7 +20,7 @@ description: "Rules mapping API outcomes to standard HTTP status codes, caching, ## 7.3 202 Accepted for async operations <a href="#73-202-accepted-for-async-operations" id="73-202-accepted-for-async-operations"></a> -**[M+R]** Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the shared schema in [§15](../part-d/15-asynchronous-operations.md). `202` **MUST NOT** claim that the requested work succeeded. +**[M+R]** Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the local schema defined under [§15](../part-d/15-asynchronous-operations.md). `202` **MUST NOT** claim that the requested work succeeded. ## 7.4 204 for void responses <a href="#74-204-for-void-responses" id="74-204-for-void-responses"></a> @@ -52,7 +52,7 @@ description: "Rules mapping API outcomes to standard HTTP status codes, caching, ## 7.11 422 for semantic errors <a href="#711-422-for-semantic-errors" id="711-422-for-semantic-errors"></a> -**[R]** A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"). The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). [`[OPEN-6-A]`](../appendix/b-open-questions.md) +**[R]** A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"). The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). [`[OPEN-7-A]`](../appendix/b-open-questions.md) ## 7.12 429 for rate limits <a href="#712-429-for-rate-limits" id="712-429-for-rate-limits"></a> diff --git a/api-design-guide/part-b/8-headers.md b/api-design-guide/part-b/8-headers.md index a9e7371..489d2d5 100644 --- a/api-design-guide/part-b/8-headers.md +++ b/api-design-guide/part-b/8-headers.md @@ -24,7 +24,7 @@ description: "Rules governing standard, custom, and rate-limit HTTP headers used ## 8.4 W3C Trace Context correlation <a href="#84-w3c-trace-context-correlation" id="84-w3c-trace-context-correlation"></a> -**[M+R]** Every cross-service HTTP operation **MUST** declare the W3C Trace Context `traceparent` request header and **MAY** declare `tracestate`. A conforming implementation **MUST** propagate a valid received trace context on downstream calls and **MUST** create a valid new context when none is present or the received value is invalid. `tracestate` **MUST NOT** contain personal data. The RFC 9457 `traceId` extension in [§11.3](../part-c/11-errors.md#113-govstack-error-extension-fields) **MUST** equal the 32-hex-digit trace-id component of the request's effective `traceparent`. A separate business or support correlation identifier **MAY** be defined, but **MUST NOT** replace Trace Context. +**[M+R]** Every cross-service HTTP operation **MUST** declare the W3C Trace Context `traceparent` request header and **MAY** declare `tracestate`. A conforming implementation **MUST** propagate a valid received trace context on downstream calls and **MUST** create a valid new context when none is present or the received value is invalid. `tracestate` **MUST NOT** contain personal data. The RFC 9457 `traceId` extension in [§11.3](../part-c/11-errors.md#113-trace-identifier) **MUST** equal the 32-hex-digit trace-id component of the request's effective `traceparent`. A separate business or support correlation identifier **MAY** be defined, but **MUST NOT** replace Trace Context. ## 8.5 No new X- prefixed headers <a href="#85-no-new-x--prefixed-headers" id="85-no-new-x--prefixed-headers"></a> diff --git a/api-design-guide/part-c/11-errors.md b/api-design-guide/part-c/11-errors.md index a3abfb8..f4463ac 100644 --- a/api-design-guide/part-c/11-errors.md +++ b/api-design-guide/part-c/11-errors.md @@ -1,70 +1,60 @@ --- -description: "One RFC 9457 problem-details error envelope, GovStack extension fields, namespaced stable error codes, and a shared common-error catalogue." +description: "One RFC 9457 HTTP problem model with stable type URIs, trace correlation, field-level validation, and a separate transport-neutral asynchronous error model." --- # 11. Errors {% hint style="info" %} -**Intent.** One error format ecosystem-wide. A shared error schema lets integrators handle failures uniformly across BBs. +**Intent.** One HTTP error format ecosystem-wide. The RFC 9457 `type` URI is the machine identifier, so clients do not have to reconcile a second error-code field. -**Applies to:** Universal at the stable type, code, trace, and field-error level. RFC 9457 and its `status` member apply only to HTTP responses. AsyncAPI rejection and failure messages use the transport-neutral shape in [§11.8](#118-transport-neutral-asynchronous-errors). +**Applies to:** Universal. RFC 9457 and its `status` member apply only to HTTP responses. AsyncAPI rejection and failure messages keep the separate transport-neutral shape in [§11.6](#116-transport-neutral-asynchronous-errors). {% endhint %} ## 11.1 RFC 9457 problem details <a href="#111-rfc-9457-problem-details" id="111-rfc-9457-problem-details"></a> -**[M]** HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; [§11.8](#118-transport-neutral-asynchronous-errors) defines that mapping. +**[M]** HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; [§11.6](#116-transport-neutral-asynchronous-errors) defines the separate asynchronous model. -## 11.2 Standard problem fields present <a href="#112-standard-problem-fields-present" id="112-standard-problem-fields-present"></a> +## 11.2 Stable HTTP problem type URI <a href="#112-stable-http-problem-type-uri" id="112-stable-http-problem-type-uri"></a> -**[M+R]** Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.global/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. +**[M+R]** Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be the sole machine identifier for the problem and **MUST** use `https://govstack.global/problems/{bb-code}/{problem-slug}`. `{bb-code}` is the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code); `{problem-slug}` **MUST** be stable kebab-case, for example `bad-request`, `invalid-field`, or `internal-error`. The URI **SHOULD** dereference to human-readable documentation. A GovStack HTTP Problem **MUST NOT** add a duplicate machine identifier such as `code`. -## 11.3 GovStack error extension fields <a href="#113-govstack-error-extension-fields" id="113-govstack-error-extension-fields"></a> +`status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. HTTP problems **MUST NOT** add a `timestamp`; use response metadata and trace correlation for occurrence diagnostics. -**[M]** GovStack HTTP problems and asynchronous errors **MUST** include `code` (machine-stable error code), `traceId` (the W3C trace-id defined by [§8.4](../part-b/8-headers.md#84-w3c-trace-context-correlation)), and `timestamp` (an RFC 3339 `date-time`). +## 11.3 Trace identifier <a href="#113-trace-identifier" id="113-trace-identifier"></a> + +**[M]** Every GovStack HTTP problem **MUST** include `traceId`, containing the W3C trace-id defined by [§8.4](../part-b/8-headers.md#84-w3c-trace-context-correlation). ## 11.4 Field-level errors array <a href="#114-field-level-errors-array" id="114-field-level-errors-array"></a> -**[M+R]** Where a failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry contains `pointer` (JSON Pointer), `code`, and `message`. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch)). +**[M+R]** Where an HTTP failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry **MUST** contain `pointer` (JSON Pointer) and `message`. It **MUST NOT** add a field-level `code`; the enclosing Problem `type` identifies the problem class. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch)). -**Example (informative).** A `422` validation failure carrying the RFC 9457 fields, the GovStack extensions, and the field-level `errors` array: +**Example (informative).** A `422` validation failure carrying the RFC 9457 fields, `traceId`, and the field-level `errors` array: ```json { - "type": "https://docs.govstack.global/errors/registration/validationFailed", + "type": "https://govstack.global/problems/registration/invalid-field", "title": "Request validation failed", "status": 422, "detail": "Two request fields failed validation.", "instance": "/v1/applications/3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", - "code": "global.govstack.registration.validationFailed", "traceId": "6f1c3f0e2a9b4c8d7e6f5a4b3c2d1e0f", - "timestamp": "2026-07-10T08:30:00Z", "errors": [ { "pointer": "/applicant/phoneNumber", - "code": "global.govstack.registration.invalidPhoneNumber", "message": "Phone number must be an E.164 string." }, { "pointer": "/applicant/birthDate", - "code": "global.govstack.registration.invalidDate", "message": "Date must be an RFC 3339 calendar date." } ] } ``` -## 11.5 Namespaced stable error codes <a href="#115-namespaced-stable-error-codes" id="115-namespaced-stable-error-codes"></a> - -**[M+R]** Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` ([§11.2](#112-standard-problem-fields-present)). The default shape is reverse-DNS: `global.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `{error-name}` segment **MUST** use lowerCamelCase, for example `global.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `global.govstack.{bb-code}.{number}`. [`[OPEN-10-A]`](../appendix/b-open-questions.md) - -## 11.6 Stable codes across languages <a href="#116-stable-codes-across-languages" id="116-stable-codes-across-languages"></a> - -**[R]** `title` and `detail` **MAY** be localised; `code` and `type` **MUST** remain stable across languages. - -## 11.7 Common error catalogue <a href="#117-common-error-catalogue" id="117-common-error-catalogue"></a> +## 11.5 Stable HTTP problem fields across languages <a href="#115-stable-http-problem-fields-across-languages" id="115-stable-http-problem-fields-across-languages"></a> -**[M+R]** A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` as the `CommonErrorCode` schema and reused by reference rather than restated. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is [`[OPEN-10-B]`](../appendix/b-open-questions.md). +**[R]** HTTP Problem `title`, `detail`, and field-error `message` **MAY** be localised. The `type` URI, `status`, `traceId`, `instance`, and field-error `pointer` **MUST NOT** be translated. -## 11.8 Transport-neutral asynchronous errors <a href="#118-transport-neutral-asynchronous-errors" id="118-transport-neutral-asynchronous-errors"></a> +## 11.6 Transport-neutral asynchronous errors <a href="#116-transport-neutral-asynchronous-errors" id="116-transport-neutral-asynchronous-errors"></a> -**[M+R]** An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. The schema **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`, and **MAY** contain `detail` and `errors` with the semantics in [§11.4](#114-field-level-errors-array). When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines. +**[M+R]** An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. This transport-neutral schema remains separate from the HTTP Problem model and **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`; it **MAY** contain `detail` and `errors`. Each asynchronous field error contains `pointer`, `code`, and `message` as defined by the shared `AsyncFieldError` schema. When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines. diff --git a/api-design-guide/part-c/12-pagination-filtering-sorting.md b/api-design-guide/part-c/12-pagination-filtering-sorting.md index 6e26f22..beab327 100644 --- a/api-design-guide/part-c/12-pagination-filtering-sorting.md +++ b/api-design-guide/part-c/12-pagination-filtering-sorting.md @@ -16,11 +16,11 @@ description: "Mandatory pagination for collections, cursor and offset envelopes, ## 12.2 Cursor pagination by default <a href="#122-cursor-pagination-by-default" id="122-cursor-pagination-by-default"></a> -**[M+R]** Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable problem code. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries. +**[M+R]** Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable Problem `type`. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries. ## 12.3 Cursor pagination envelope <a href="#123-cursor-pagination-envelope" id="123-cursor-pagination-envelope"></a> -**[M]** The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. `nextCursor` **MUST** be a non-empty string when `hasMore` is `true` and **MUST** be `null` when `hasMore` is `false`; its schema therefore **MUST** declare explicit nullability. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and simplified continuation fields. +**[M]** The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, total? } }`. `nextCursor` **MUST** be a non-empty string when another page is available and **MUST** be `null` on the final page; its schema therefore **MUST** declare explicit nullability. Clients determine whether another page is available from `nextCursor` and no separate `hasMore` field is used. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and one continuation field. **Example (informative).** A cursor-paginated collection response (`total` omitted per [§12.5](#125-optional-total-count)): @@ -31,8 +31,7 @@ description: "Mandatory pagination for collections, cursor and offset envelopes, { "id": "8a1f9c2b-7e64-4f0d-8a3b-2c5d9e0f1b47", "status": "PENDING_REVIEW" } ], "pageInfo": { - "nextCursor": "pgn_7JpQ9m2W4xK8fR3cT6vN1", - "hasMore": true + "nextCursor": "pgn_7JpQ9m2W4xK8fR3cT6vN1" } } ``` diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md index 45ec4ce..6799dd6 100644 --- a/api-design-guide/part-c/9-json-conventions-and-naming.md +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -36,7 +36,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.7 Screaming snake case enum values <a href="#97-screaming-snake-case-enum-values" id="97-screaming-snake-case-enum-values"></a> -**[M]** Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), the GovStack `x-govstack-*` extension vocabularies ([§17.11](../part-d/17-asyncapi-channel-rules.md#1711-documented-delivery-guarantees), [§17.13](../part-d/17-asyncapi-channel-rules.md#1713-declared-delivery-management-capabilities)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it. +**[M]** Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it. ## 9.8 Forward-compatible schemas <a href="#98-forward-compatible-schemas" id="98-forward-compatible-schemas"></a> @@ -48,11 +48,11 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.10 GovStack extension prefix <a href="#910-govstack-extension-prefix" id="910-govstack-extension-prefix"></a> -**[M+R]** GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay` in [§17.15](../part-d/17-asyncapi-channel-rules.md#1715-machine-readable-delivery-extensions), `x-govstack-deprecated` in [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata), and `x-govstack-api-guide` in [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)). +**[M+R]** GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-deprecated` in [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata), and `x-govstack-api-guide` in [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)). A prefix rule does not create or standardise an extension; each GovStack extension still requires an explicit schema and governing rule. ## 9.11 Single registered BB code <a href="#911-single-registered-bb-code" id="911-single-registered-bb-code"></a> -**[M+R]** Every namespace that embeds a BB code (error codes [§11.5](../part-c/11-errors.md#115-namespaced-stable-error-codes), problem-type URLs [§11.2](../part-c/11-errors.md#112-standard-problem-fields-present), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), logical channel IDs [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts ([§11.7](../part-c/11-errors.md#117-common-error-catalogue)). The BB-code register is proposed for the Lifecycle & Governance companion ([Appendix A](../appendix/a-companion-documents.md)); until it exists, codes **SHOULD** be agreed through the API Working Group. [`[OPEN-9-A]`](../appendix/b-open-questions.md) +**[M+R]** Every namespace that embeds a BB code (HTTP problem-type URLs [§11.2](../part-c/11-errors.md#112-stable-http-problem-type-uri), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), logical channel IDs [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses), and any transport-neutral asynchronous error code that embeds one [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The BB-code register is proposed for the Lifecycle & Governance companion ([Appendix A](../appendix/a-companion-documents.md)); until it exists, codes **SHOULD** be agreed through the API Working Group. [`[OPEN-9-A]`](../appendix/b-open-questions.md) ## Note on 9.2 <a href="#note-on-92" id="note-on-92"></a> @@ -62,7 +62,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec (Per [§1.7](../1-introduction.md#17-precedence-of-external-standards).) -Fields imported wholesale from an external standard retain that standard's naming. The known cases in v0.1 are RFC 9457 error fields (`type`, `title`, `status`, `detail`, `instance`; see [§11.1](../part-c/11-errors.md#111-rfc-9457-problem-details)) and CloudEvents fields (`specversion`, `id`, `source`, `type`, `time`, `datacontenttype`, `data`; see [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)). CloudEvents extension attributes also follow CloudEvents naming rules, not [§9.2](#92-camelcase-field-names), because CloudEvents requires lowercase ASCII attribute names. The carve-out applies only to fields defined by the imported standard or by a CloudEvents extension; GovStack extensions to the RFC 9457 error envelope (the `code`, `traceId`, `timestamp` fields in [§11.3](../part-c/11-errors.md#113-govstack-error-extension-fields)), GovStack-owned transport/application headers, and the contents of the event `data` payload follow [§9.2](#92-camelcase-field-names). +Fields imported wholesale from an external standard retain that standard's naming. The known cases are RFC 9457 error fields (`type`, `title`, `status`, `detail`, `instance`; see [§11.1](../part-c/11-errors.md#111-rfc-9457-problem-details)) and CloudEvents fields (`specversion`, `id`, `source`, `type`, `time`, `datacontenttype`, `data`; see [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)). CloudEvents extension attributes also follow CloudEvents naming rules, not [§9.2](#92-camelcase-field-names), because CloudEvents requires lowercase ASCII attribute names. The carve-out applies only to fields defined by the imported standard or by a CloudEvents extension. The HTTP `traceId` extension in [§11.3](../part-c/11-errors.md#113-trace-identifier), transport-neutral asynchronous error fields in [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors), GovStack-owned transport/application headers, and the contents of event `data` follow [§9.2](#92-camelcase-field-names). ## Carve-out from 9.7 <a href="#carve-out-from-97" id="carve-out-from-97"></a> diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md index 1b7a7b6..6f23bf8 100644 --- a/api-design-guide/part-d/13-authentication-and-authorisation.md +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -24,7 +24,7 @@ description: "Rules for how BB API specs declare security schemes, OAuth scopes, ## 13.4 Namespaced OAuth scopes <a href="#134-namespaced-oauth-scopes" id="134-namespaced-oauth-scopes"></a> -**[M]** Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-12-A]`](../appendix/b-open-questions.md) +**[M]** Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-13-A]`](../appendix/b-open-questions.md) ## 13.5 Authorization is the credential channel <a href="#135-authorization-is-the-credential-channel" id="135-authorization-is-the-credential-channel"></a> diff --git a/api-design-guide/part-d/15-asynchronous-operations.md b/api-design-guide/part-d/15-asynchronous-operations.md index 70db156..fef801c 100644 --- a/api-design-guide/part-d/15-asynchronous-operations.md +++ b/api-design-guide/part-d/15-asynchronous-operations.md @@ -1,5 +1,5 @@ --- -description: "The shared Operation resource shape and polling pattern BBs use for operations that cannot complete synchronously." +description: "The local Operation resource shape and polling pattern BBs use for operations that cannot complete synchronously." --- # 15. Asynchronous operations @@ -7,26 +7,26 @@ description: "The shared Operation resource shape and polling pattern BBs use fo {% hint style="info" %} **Intent.** A single async pattern across BBs. Without one, every BB picks a different status code (200, 201, or 202) and a different polling shape, and integrators write per-BB glue. In this guide, *Operation resource* (capitalised) is the polling resource defined in this section; lowercase *operation* means an OpenAPI or AsyncAPI operation. -**Applies to:** OpenAPI surface (HTTP/REST). The Operation resource shape is reusable across surfaces; the `202` and polling mechanics are HTTP-specific. +**Applies to:** OpenAPI surface (HTTP/REST). The Operation resource is defined locally by each BB; the `202` and polling mechanics are HTTP-specific. {% endhint %} ## 15.1 202 with Operation Location <a href="#151-202-with-operation-location" id="151-202-with-operation-location"></a> **[M+R]** Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource and **MUST** return the current Operation representation in the response body. -## 15.2 Shared Operation resource shape <a href="#152-shared-operation-resource-shape" id="152-shared-operation-resource-shape"></a> +## 15.2 Local Operation resource shape <a href="#152-local-operation-resource-shape" id="152-local-operation-resource-shape"></a> -**[M]** The Operation resource **MUST** be declared once in `govstack-openapi-common.yaml` and `$ref`'d by all BBs. The default shape is `{ id, status, result, error, createdAt, updatedAt, progress? }`, modelled on Google AIP-151 (Long-Running Operations). A stricter AIP-151 mirror (with `done` and `metadata`) is a defensible alternative. [`[OPEN-14-A]`](../appendix/b-open-questions.md) +**[M+R]** A BB that exposes long-running work **MUST** define its Operation schema locally. Its identifier **MUST** be an opaque string and clients **MUST NOT** infer a UUID or any other internal format. The local schema **MUST** document how the identifier, lifecycle state, result, error, and any progress metadata are represented, including when each state-dependent field is present. This guide does not fix those field names or shapes. A shared Operation schema is deferred until multiple BBs demonstrate a stable reusable contract. -## 15.3 Fixed Operation status enum <a href="#153-fixed-operation-status-enum" id="153-fixed-operation-status-enum"></a> +## 15.3 Documented Operation lifecycle <a href="#153-documented-operation-lifecycle" id="153-documented-operation-lifecycle"></a> -**[M]** Operation `status` **MUST** be drawn from a fixed enumeration declared in the common file. The default set is `PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELLED`. AIP-151's boolean `done` plus a result-or-error union is a defensible alternative. [`[OPEN-14-A]`](../appendix/b-open-questions.md) +**[M+R]** The local Operation contract **MUST** distinguish terminal from non-terminal states and **MUST** document the result, error, polling, and cancellation semantics for each applicable state. This guide does not prescribe a status enum or lifecycle model. **Example (informative).** An in-progress Operation resource: ```json { - "id": "9c3d2f6a-5b1e-4d7c-a8f0-1e2d3c4b5a69", + "id": "op_7JpQ9m2W4xK8fR3cT6vN1", "status": "RUNNING", "createdAt": "2026-07-10T08:30:00Z", "updatedAt": "2026-07-10T08:30:05Z", diff --git a/api-design-guide/part-d/16-cloudevents-and-webhooks.md b/api-design-guide/part-d/16-cloudevents-and-webhooks.md index 0cf3078..3c31f42 100644 --- a/api-design-guide/part-d/16-cloudevents-and-webhooks.md +++ b/api-design-guide/part-d/16-cloudevents-and-webhooks.md @@ -1,15 +1,15 @@ --- -description: "Rules governing the CloudEvents envelope, event-type and source naming, signed and replay-detectable delivery, and subscription management for webhooks, brokered channels, and event streams." +description: "Rules governing the CloudEvents envelope, event-type and source naming, optional signing, and subscription management for webhooks, brokered channels, and event streams." --- # 16. CloudEvents and webhooks {% hint style="info" %} -**Intent.** A single event contract across BBs. Many BBs need event notifications; a shared CloudEvents envelope, type/source convention, signing contract, and delivery-failure contract is what makes them composable, regardless of transport. +**Intent.** A small event contract across BBs. The shared baseline is the CloudEvents envelope and the type/source convention. Transport security, optional message signing, and delivery-failure behaviour are documented only where the selected surface needs them. **Applies to:** Event-driven. CloudEvents rules apply to HTTP webhooks, brokered event channels, and event streams. OpenAPI and AsyncAPI are documentation formats for those surfaces, not alternative event-envelope standards. -**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§16.1](#161-event-surfaces-documented), [§16.3](#163-reverse-dns-event-types), [§16.4](#164-stable-cloudevents-source), [§16.6](#166-govstack-signature-header), [§16.10](#1610-documented-delivery-failure-contract), and [§16.11](#1611-subscription-management-interfaces) constrain the specification (documentation and declaration). [§16.5](#165-signed-event-delivery) and [§16.7](#167-replay-detectable-signed-material) are behavioural-contract rules verified by the conformance test pack against the event-signature profile in [§16.8](#168-pinned-signature-profile). [§16.9](#169-operational-signing-concerns-out-of-scope) keeps replay enforcement and signing-key rotation in the Security & Operations companion. +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§16.1](#161-event-surfaces-documented)–[§16.4](#164-stable-cloudevents-source), [§16.10](#1610-documented-delivery-failure-contract), and [§16.11](#1611-subscription-management-interfaces) constrain the specification. [§16.5](#165-optional-signed-event-delivery)–[§16.9](#169-readiness-for-a-shared-signature-profile) apply only when a BB opts into event signing. {% endhint %} ## 16.1 Event surfaces documented <a href="#161-event-surfaces-documented" id="161-event-surfaces-documented"></a> @@ -18,11 +18,11 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.2 CloudEvents envelope required <a href="#162-cloudevents-envelope-required" id="162-cloudevents-envelope-required"></a> -**[M]** GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `"1.0"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used, because [§16.8](#168-pinned-signature-profile) signs the complete structured event object and a binary-mode request body carries only `data`. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) imposes the same requirement on the AsyncAPI surface. +**[M]** GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `"1.0"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) imposes the same requirement on the AsyncAPI surface. ## 16.3 Reverse-DNS event types <a href="#163-reverse-dns-event-types" id="163-reverse-dns-event-types"></a> -**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-15-B]`](../appendix/b-open-questions.md) +**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-16-A]`](../appendix/b-open-questions.md) ## 16.4 Stable CloudEvents source <a href="#164-stable-cloudevents-source" id="164-stable-cloudevents-source"></a> @@ -46,25 +46,25 @@ description: "Rules governing the CloudEvents envelope, event-type and source na } ``` -## 16.5 Signed event delivery <a href="#165-signed-event-delivery" id="165-signed-event-delivery"></a> +## 16.5 Optional signed event delivery <a href="#165-optional-signed-event-delivery" id="165-optional-signed-event-delivery"></a> -**[R]** Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`. +**[R]** Event delivery **MAY** use message-level signing when the BB's threat model requires authenticity or integrity beyond authenticated transport. Signing is not part of the baseline GovStack event contract. Requirements for an explicitly adopted profile are in [§16.6](#166-signature-metadata-when-used)–[§16.8](#168-separate-experimental-signing-profile). -## 16.6 GovStack-Signature header <a href="#166-govstack-signature-header" id="166-govstack-signature-header"></a> +## 16.6 Signature metadata when used <a href="#166-signature-metadata-when-used" id="166-signature-metadata-when-used"></a> -**[M+R]** On the OpenAPI/webhooks surface the signature **MUST** travel in the ecosystem-wide HTTP header `GovStack-Signature`. On the AsyncAPI surface, a GovStack-owned transport/application metadata field **MUST** be named `govstackSignature` so it satisfies [§17.8](../part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata); when a protocol binding defines a standard signature field, that field **SHOULD** be used and the mapping **MUST** be documented. +**[R]** When the optional profile uses GovStack-owned metadata, an OpenAPI webhook signature **MUST** travel in `GovStack-Signature` and an AsyncAPI transport/application metadata field **MUST** be named `govstackSignature`. When a protocol binding defines a standard signature field, that field **SHOULD** be used and its mapping **MUST** be documented. A surface that does not adopt signing **MUST NOT** require signature metadata. ## 16.7 Replay-detectable signed material <a href="#167-replay-detectable-signed-material" id="167-replay-detectable-signed-material"></a> -**[R]** The signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers can detect replays. +**[R]** When signing is adopted, the signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers have the inputs needed to detect replays. -## 16.8 Pinned signature profile <a href="#168-pinned-signature-profile" id="168-pinned-signature-profile"></a> +## 16.8 Separate experimental signing profile <a href="#168-separate-experimental-signing-profile" id="168-separate-experimental-signing-profile"></a> -**[R]** `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it. How a subscriber obtains the key that `kid` selects is not yet settled ecosystem-wide: [§16.11](#1611-subscription-management-interfaces) supplies it per subscription, which covers HTTP webhooks but not brokered or stream transports with no subscription control plane. [`[OPEN-15-H]`](../appendix/b-open-questions.md) +**[R]** Event signing belongs in the separate optional `experimental/govstack-openapi-signing-profile.yaml` artifact, not in either baseline common schema artifact. A BB **MAY** adopt this profile explicitly, but it **MUST** pin the profile version and **MUST** document its key discovery, key rotation, replay policy, protocol mapping, and conformance tests. The profile remains incomplete pending a shared key-discovery and replay-policy contract and **MUST NOT** be presented as an ecosystem-wide baseline. -## 16.9 Operational signing concerns out of scope <a href="#169-operational-signing-concerns-out-of-scope" id="169-operational-signing-concerns-out-of-scope"></a> +## 16.9 Readiness for a shared signature profile <a href="#169-readiness-for-a-shared-signature-profile" id="169-readiness-for-a-shared-signature-profile"></a> -Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). Until that companion fixes a bound, [§16.7](#167-replay-detectable-signed-material) gives a receiver the material to detect a replay but no ecosystem-wide window against which to reject one, so two conformant implementations can disagree about whether a given event is a replay. [`[OPEN-15-H]`](../appendix/b-open-questions.md) +A future guide version should promote a shared signature profile only after it defines key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages. Those operational concerns belong in the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). ## 16.10 Documented delivery-failure contract <a href="#1610-documented-delivery-failure-contract" id="1610-documented-delivery-failure-contract"></a> @@ -72,4 +72,4 @@ Operational concerns such as replay-window enforcement and signing-key rotation ## 16.11 Subscription management interfaces <a href="#1611-subscription-management-interfaces" id="1611-subscription-management-interfaces"></a> -**[M+R]** Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret or verification key material used by the selected profile, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane. +**[M+R]** Subscription management **MUST** expose documented interfaces to create, list, and delete a subscription. If the subscription adopts message signing, it **MUST** also expose or document how to rotate or redistribute the verification material used by the selected profile. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane. diff --git a/api-design-guide/part-d/17-asyncapi-channel-rules.md b/api-design-guide/part-d/17-asyncapi-channel-rules.md index a1dfb45..e116a80 100644 --- a/api-design-guide/part-d/17-asyncapi-channel-rules.md +++ b/api-design-guide/part-d/17-asyncapi-channel-rules.md @@ -1,11 +1,11 @@ --- -description: "Rules governing AsyncAPI channel addressing, payload structure, message headers, delivery and ordering guarantees, and examples for brokered and event-stream surfaces." +description: "Rules governing AsyncAPI channel addressing, payload structure, message headers, protocol bindings, and examples for brokered and event-stream surfaces." --- # 17. AsyncAPI channel documentation rules {% hint style="info" %} -**Intent.** AsyncAPI documents brokered CloudEvents channels and event streams other than HTTP push webhooks. It does not replace CloudEvents and does not solve every broker's operational playbook in this guide. It makes the portable contract complete enough that an integrator can see what a BB publishes or consumes, on which channels, under which security model, and with which delivery guarantees. +**Intent.** AsyncAPI documents brokered CloudEvents channels and event streams other than HTTP push webhooks. It does not replace CloudEvents or turn broker operations into a universal abstraction. It makes the portable contract complete enough that an integrator can see what a BB publishes or consumes, on which channels, under which security model, and which transport behaviours are safe to rely on. **Applies to:** AsyncAPI surface. If a BB exposes no brokered or event-stream surface (only synchronous REST and/or HTTP push webhooks), [§3](../part-a/3-asyncapi-document-standards.md) and [§17](../part-d/17-asyncapi-channel-rules.md) do not apply. {% endhint %} @@ -32,15 +32,36 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.6 Structured CloudEvents JSON payloads <a href="#176-structured-cloudevents-json-payloads" id="176-structured-cloudevents-json-payloads"></a> -**[M]** AsyncAPI message payloads for GovStack domain events **MUST** use structured CloudEvents JSON: the message payload is the complete CloudEvent, and GovStack-owned domain data lives under the CloudEvents `data` field. This provides one portable, schema-validatable event shape across brokered transports. [`[OPEN-15-E]`](../appendix/b-open-questions.md) - -## 17.7 Shared CloudEvents message schema <a href="#177-shared-cloudevents-message-schema" id="177-shared-cloudevents-message-schema"></a> - -**[M]** AsyncAPI channel message entries **MUST** reference the shared CloudEvents message schema from `govstack-asyncapi-common.yaml` and specialise only the `data` schema for the BB-specific event payload. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0. +**[M]** AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports. [`[OPEN-17-A]`](../appendix/b-open-questions.md) + +## 17.7 Shared CloudEvents envelope schema <a href="#177-shared-cloudevents-envelope-schema" id="177-shared-cloudevents-envelope-schema"></a> + +**[M]** Each BB **MUST** define its Message Objects locally. A domain-event message payload **MUST** compose `#/components/schemas/CloudEventEnvelope` from the pinned `govstack-asyncapi-common.yaml` with a local schema that specialises the event `type` and, when present, `data`. A rejection message **MUST** either reference the shared `GovStackAsyncError` schema directly or use it as CloudEvent `data`. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0. Security schemes, headers, examples, correlation, and protocol bindings remain local because they require BB- or transport-specific values. + +**Example (informative).** A local Message Object using the shared envelope: + +```yaml +components: + messages: + recordCreated: + contentType: application/cloudevents+json + payload: + allOf: + - $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific event type and domain payload. + required: [data] + properties: + type: + description: Stable semantic event type. + const: global.govstack.registry.record.created + data: + $ref: '#/components/schemas/RecordCreatedData' +``` ## 17.8 Message headers and idempotency metadata <a href="#178-message-headers-and-idempotency-metadata" id="178-message-headers-and-idempotency-metadata"></a> -**[M+R]** GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. The event-signature metadata name **MUST** follow [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-govstack-signature-header). Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`. +**[M+R]** GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-signature-metadata-when-used). Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`. ## 17.9 Message localisation headers <a href="#179-message-localisation-headers" id="179-message-localisation-headers"></a> @@ -48,31 +69,31 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.10 Security schemes cover every operation <a href="#1710-security-schemes-cover-every-operation" id="1710-security-schemes-cover-every-operation"></a> -**[M+R]** AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Message signing ([§16.5](../part-d/16-cloudevents-and-webhooks.md#165-signed-event-delivery)) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication. +**[M+R]** AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Optional message signing ([§16.5](../part-d/16-cloudevents-and-webhooks.md#165-optional-signed-event-delivery)) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication. -## 17.11 Documented delivery guarantees <a href="#1711-documented-delivery-guarantees" id="1711-documented-delivery-guarantees"></a> +## 17.11 Duplicate delivery contract <a href="#1711-duplicate-delivery-contract" id="1711-duplicate-delivery-contract"></a> -**[M+R]** Each operation **MUST** document its delivery guarantee: `atMostOnce`, `atLeastOnce`, or `effectivelyOnce`. `effectivelyOnce` **MUST** be backed by an idempotency contract, duplicate detection, or a documented resource-state invariant; it **MUST NOT** imply the transport literally delivers a message exactly once. +**[R]** When the selected protocol or deployment can redeliver a message and consumers need to handle duplicates, the operation **MUST** document the duplicate-handling contract. For CloudEvents, the default duplicate identity is the pair `source` plus `id`. Protocol QoS, acknowledgement, and redelivery fields **MUST** use the applicable AsyncAPI binding when available. Specifications **MUST NOT** claim `effectivelyOnce` as a portable transport guarantee; they **MAY** document application-level idempotency or de-duplication instead. -## 17.12 Documented ordering guarantees <a href="#1712-documented-ordering-guarantees" id="1712-documented-ordering-guarantees"></a> +## 17.12 Ordering only when promised <a href="#1712-ordering-only-when-promised" id="1712-ordering-only-when-promised"></a> -**[M+R]** Each operation **MUST** document ordering guarantees, if any. If ordering is partitioned, keyed, or scoped, the key or scope **MUST** be declared. If no ordering is guaranteed, the spec **MUST** state that explicitly. +**[R]** Ordering **MUST** be documented only when consumers are allowed to rely on it. When ordering is promised, the applicable protocol binding or operation description **MUST** identify its scope and key, such as a Kafka partition key or an ordered queue. A specification with no ordering promise does not need a placeholder declaration. -## 17.13 Declared delivery-management capabilities <a href="#1713-declared-delivery-management-capabilities" id="1713-declared-delivery-management-capabilities"></a> +## 17.13 Public delivery-management capabilities <a href="#1713-public-delivery-management-capabilities" id="1713-public-delivery-management-capabilities"></a> -**[M+R]** Each operation **MUST** declare which delivery-management capabilities the chosen transport contract exposes: redelivery, dead-letter handling, retention, and replay. The declaration **MUST** state whether each capability is supported, unsupported, or not applicable. +**[R]** Redelivery, dead-letter handling, retention, and replay **MUST** be documented when they are part of the public contract available to a consumer. The specification **MUST** use the applicable protocol binding, channel configuration, or a linked protocol profile where one exists. Capabilities that are deployment-internal or unavailable to consumers **MAY** be omitted. -## 17.14 Portable capability contract <a href="#1714-portable-capability-contract" id="1714-portable-capability-contract"></a> +## 17.14 Implementation values in protocol profiles <a href="#1714-implementation-values-in-protocol-profiles" id="1714-implementation-values-in-protocol-profiles"></a> -**[R]** A reference specification **MUST** define the portable contract shape, defaults, and allowed bounds for supported delivery-management capabilities. Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings belong in implementation profiles. +**[R]** Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings **SHOULD** live in protocol or implementation profiles unless a value is a stable promise to every conforming consumer. The core cross-BB specification **MUST NOT** imply that a broker-specific setting is portable across protocols. -## 17.15 Machine-readable delivery extensions <a href="#1715-machine-readable-delivery-extensions" id="1715-machine-readable-delivery-extensions"></a> +## 17.15 No universal delivery extensions <a href="#1715-no-universal-delivery-extensions" id="1715-no-universal-delivery-extensions"></a> -**[M+R]** Delivery, ordering, and delivery-management capability declarations **MUST** be machine-readable using GovStack specification extensions declared in `govstack-asyncapi-common.yaml` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay`) as well as human-readable in `description`. [`[OPEN-15-G]`](../appendix/b-open-questions.md) +**[R]** `govstack-asyncapi-common.yaml` does not define universal delivery, ordering, redelivery, dead-letter, retention, or replay extensions. A specification **MUST** use standard AsyncAPI bindings first and **MUST** state any remaining consumer-visible promise in `description` or a linked protocol profile. The presence of a custom extension alone **MUST NOT** be treated as an interoperable delivery contract. ## 17.16 Async rejection error messages <a href="#1716-async-rejection-error-messages" id="1716-async-rejection-error-messages"></a> -**[M+R]** Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from [§11.8](../part-c/11-errors.md#118-transport-neutral-asynchronous-errors), not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using [§17.8](#178-message-headers-and-idempotency-metadata) or an equivalent protocol binding. +**[M+R]** Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors), not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using [§17.8](#178-message-headers-and-idempotency-metadata) or an equivalent protocol binding. ## 17.17 Declared request-reply correlation <a href="#1717-declared-request-reply-correlation" id="1717-declared-request-reply-correlation"></a> @@ -84,7 +105,7 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.19 Protocol bindings where relevant <a href="#1719-protocol-bindings-where-relevant" id="1719-protocol-bindings-where-relevant"></a> -**[M+R]** Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. [`[OPEN-15-F]`](../appendix/b-open-questions.md) +**[M+R]** Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. [`[OPEN-17-B]`](../appendix/b-open-questions.md) ## 17.20 Examples for every message <a href="#1720-examples-for-every-message" id="1720-examples-for-every-message"></a> diff --git a/api-design-guide/part-d/18-compatibility-and-lifecycle.md b/api-design-guide/part-d/18-compatibility-and-lifecycle.md index 975b39c..a36ebcf 100644 --- a/api-design-guide/part-d/18-compatibility-and-lifecycle.md +++ b/api-design-guide/part-d/18-compatibility-and-lifecycle.md @@ -16,7 +16,7 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.2 Major version in path or channel <a href="#182-major-version-in-path-or-channel" id="182-major-version-in-path-or-channel"></a> -**[M]** A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it. +**[M]** A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses); protocol-native channel addresses **MUST NOT** be rewritten solely to carry it. ## 18.3 Backward-compatible minor changes <a href="#183-backward-compatible-minor-changes" id="183-backward-compatible-minor-changes"></a> @@ -36,7 +36,7 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.7 AsyncAPI deprecation metadata <a href="#187-asyncapi-deprecation-metadata" id="187-asyncapi-deprecation-metadata"></a> -**[M+R]** AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. [`[OPEN-16-A]`](../appendix/b-open-questions.md) +**[M+R]** AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. [`[OPEN-18-A]`](../appendix/b-open-questions.md) ## Note on retrofitting <a href="#note-on-retrofitting" id="note-on-retrofitting"></a> diff --git a/api-design-guide/part-e/19-localisation.md b/api-design-guide/part-e/19-localisation.md index f28e20b..00a46ac 100644 --- a/api-design-guide/part-e/19-localisation.md +++ b/api-design-guide/part-e/19-localisation.md @@ -9,7 +9,7 @@ description: "Rules governing localisation of API content: request-language hand **Applies to:** Universal. On the OpenAPI surface localisation uses `Accept-Language` and `Content-Language` HTTP headers. On the AsyncAPI surface it uses the `acceptLanguage` and `contentLanguage` message headers defined in [§17](../part-d/17-asyncapi-channel-rules.md). -**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§19.4](#194-declare-the-response-language) constrains the specification: declare the response language header on localised responses. [§19.1](#191-honour-the-request-language)–[§19.3](#193-english-as-default-language) are behavioural-contract rules: they bind a conforming implementation at run time (honour the request language, never translate stable fields, default to English) and are verified by the conformance test pack. The set of languages a given BB must support is per-BB and per-deployment policy ([`[OPEN-17-A]`](../appendix/b-open-questions.md)), not fixed here. +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§19.4](#194-declare-the-response-language) constrains the specification: declare the response language header on localised responses. [§19.1](#191-honour-the-request-language)–[§19.3](#193-english-as-default-language) are behavioural-contract rules: they bind a conforming implementation at run time (honour the request language, never translate stable fields, default to English) and are verified by the conformance test pack. The set of languages a given BB must support is per-BB and per-deployment policy ([`[OPEN-19-A]`](../appendix/b-open-questions.md)), not fixed here. {% endhint %} ## 19.1 Honour the request language <a href="#191-honour-the-request-language" id="191-honour-the-request-language"></a> @@ -18,11 +18,11 @@ description: "Rules governing localisation of API content: request-language hand ## 19.2 Never translate stable content <a href="#192-never-translate-stable-content" id="192-never-translate-stable-content"></a> -**[R]** Stable content (error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated. +**[R]** Stable content (HTTP Problem `type`, transport-neutral asynchronous error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated. ## 19.3 English as default language <a href="#193-english-as-default-language" id="193-english-as-default-language"></a> -**[R]** Default language **MUST** be English. [`[OPEN-17-A]`](../appendix/b-open-questions.md) +**[R]** Default language **MUST** be English. [`[OPEN-19-A]`](../appendix/b-open-questions.md) ## 19.4 Declare the response language <a href="#194-declare-the-response-language" id="194-declare-the-response-language"></a> diff --git a/api-design-guide/part-e/20-conformance-and-validation.md b/api-design-guide/part-e/20-conformance-and-validation.md index d9499dc..14c40a9 100644 --- a/api-design-guide/part-e/20-conformance-and-validation.md +++ b/api-design-guide/part-e/20-conformance-and-validation.md @@ -16,19 +16,19 @@ description: "Rules governing mechanical conformance verification of BB API spec ## 20.2 Passes the GovStack Spectral ruleset <a href="#202-passes-the-govstack-spectral-ruleset" id="202-passes-the-govstack-spectral-ruleset"></a> -**[M]** Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under [§20.3](#203-declared-guide-conformance-version) for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules ([§1.9](../1-introduction.md#19-rule-enforcement-classes)). Ruleset `0.2.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility. +**[M]** Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under [§20.3](#203-declared-guide-conformance-version) for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules ([§1.9](../1-introduction.md#19-rule-enforcement-classes)). Ruleset `0.1.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility. ## 20.3 Declared guide conformance version <a href="#203-declared-guide-conformance-version" id="203-declared-guide-conformance-version"></a> -**[M]** Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.2.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under [§1.6](../1-introduction.md#16-exception-process) **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI. +**[M]** Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.1.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under [§1.6](../1-introduction.md#16-exception-process) **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI. **Example (informative).** ```yaml info: x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft exceptions: - rule: "5.2" scope: /paths/~1v1~1status/get diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index b569c40..8ee119b 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -4,8 +4,8 @@ # Invariant: for each rule, `page` + `anchor` locate it in the book. # The same `#anchor` fragment resolves on both GitHub and GitBook. guide: GovStack Cross-BB API Design Guide -version: 0.2.0-draft -rule_count: 173 +version: 0.1.0-draft +rule_count: 171 rules: - id: "2.1" title: "OpenAPI 3.1 required" @@ -14,7 +14,7 @@ rules: surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 21-openapi-31-required - text: "The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." + text: "The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." open_questions: [] - id: "2.2" title: "One canonical OpenAPI entrypoint" @@ -71,13 +71,13 @@ rules: text: "Every operation **MUST** include `operationId` (camelCase, verb-noun), `summary`, `description`, and at least one `tag`." open_questions: [] - id: "2.8" - title: "Pinned vendored common components" - class: M - strengths: ["MUST"] + title: "Conditional vendored OpenAPI schemas" + class: M+R + strengths: ["MUST", "MAY"] surface: OpenAPI page: part-a/2-openapi-document-standards.md - anchor: 28-pinned-vendored-common-components - text: "Shared components (security scheme, error schema, pagination, common headers, Operation resource) **MUST** be referenced from a pinned version of `govstack-openapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit.\nVendoring is required because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable." + anchor: 28-conditional-vendored-openapi-schemas + text: "A BB **MAY** reuse the schema-only `govstack-openapi-common.yaml` artifact for `Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`. If it does, the file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml`, its version **MUST** be pinned explicitly, and the BB **MUST** reference the named schemas rather than copy them. A BB that does not reuse the artifact **MUST** define equivalent schemas locally that satisfy §11 and §12.\nSecurity schemes, parameters, headers, Response Objects, examples, and Operation resources **MUST** be defined locally because their values and semantics belong to the BB contract. They are not part of the shared OpenAPI artifact.\nVendoring is required when reuse is chosen because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable." open_questions: [] - id: "3.1" title: "AsyncAPI 3.0.0 required" @@ -86,7 +86,7 @@ rules: surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 31-asyncapi-300-required - text: "An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.2.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." + text: "An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." open_questions: [] - id: "3.2" title: "One canonical AsyncAPI entrypoint" @@ -140,7 +140,7 @@ rules: surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 37-complete-asyncapi-operation-metadata - text: "Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced CloudEvents message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`." + text: "Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. §17.6–§17.7 define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema." open_questions: [] - id: "3.8" title: "Pinned vendored AsyncAPI components" @@ -149,7 +149,7 @@ rules: surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 38-pinned-vendored-asyncapi-components - text: "Shared event documentation components (CloudEvents message schema, common message headers, common error message, signing metadata, security schemes, delivery-semantics extensions) **MUST** be referenced from a pinned version of `govstack-asyncapi-common.yaml`. The file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml` in each BB repository, and the pinned version **MUST** be explicit." + text: "A BB that documents GovStack domain events **MUST** reference `CloudEventEnvelope` from a pinned version of `govstack-asyncapi-common.yaml`. A BB that documents asynchronous rejections **MUST** reference `GovStackAsyncError`, and **MUST** reuse `AsyncFieldError` when it exposes field-level errors. The common file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml`, and the pinned version **MUST** be explicit. BB specifications own their Message Objects, security schemes, headers, examples, and protocol bindings because those objects require BB- and transport-specific values." open_questions: [] - id: "3.9" title: "JSON Schema payload conventions" @@ -221,8 +221,8 @@ rules: surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 51-major-version-in-the-path - text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of §5.10 are the only exception. `[OPEN-4-A]`" - open_questions: ["OPEN-4-A"] + text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of §5.10 are the only exception. `[OPEN-5-A]`" + open_questions: ["OPEN-5-A"] - id: "5.2" title: "Plural noun resources" class: M+R @@ -248,8 +248,8 @@ rules: surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 54-shallow-path-nesting - text: "Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. `[OPEN-4-C]`" - open_questions: ["OPEN-4-C"] + text: "Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. `[OPEN-5-B]`" + open_questions: ["OPEN-5-B"] - id: "5.5" title: "Identifiers as path parameters" class: M+R @@ -392,7 +392,7 @@ rules: surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 73-202-accepted-for-async-operations - text: "Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the shared schema in §15. `202` **MUST NOT** claim that the requested work succeeded." + text: "Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the local schema defined under §15. `202` **MUST NOT** claim that the requested work succeeded." open_questions: [] - id: "7.4" title: "204 for void responses" @@ -464,8 +464,8 @@ rules: surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 711-422-for-semantic-errors - text: "A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"). The idempotency-fingerprint use of `422` is in §14.5. `[OPEN-6-A]`" - open_questions: ["OPEN-6-A"] + text: "A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"). The idempotency-fingerprint use of `422` is in §14.5. `[OPEN-7-A]`" + open_questions: ["OPEN-7-A"] - id: "7.12" title: "429 for rate limits" class: R @@ -680,7 +680,7 @@ rules: surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 97-screaming-snake-case-enum-values - text: "Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (error codes per §11.5, event types per §16.3, sort keys per §12.7), the GovStack `x-govstack-*` extension vocabularies (§17.11, §17.13), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it." + text: "Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per §16.3, sort keys per §12.7), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it." open_questions: [] - id: "9.8" title: "Forward-compatible schemas" @@ -707,7 +707,7 @@ rules: surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 910-govstack-extension-prefix - text: "GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay` in §17.15, `x-govstack-deprecated` in §18.7, and `x-govstack-api-guide` in §20.3)." + text: "GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-deprecated` in §18.7, and `x-govstack-api-guide` in §20.3). A prefix rule does not create or standardise an extension; each GovStack extension still requires an explicit schema and governing rule." open_questions: [] - id: "9.11" title: "Single registered BB code" @@ -716,7 +716,7 @@ rules: surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 911-single-registered-bb-code - text: "Every namespace that embeds a BB code (error codes §11.5, problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, logical channel IDs §17.2) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The segment `common` is reserved for ecosystem-wide artifacts (§11.7). The BB-code register is proposed for the Lifecycle & Governance companion (Appendix A); until it exists, codes **SHOULD** be agreed through the API Working Group. `[OPEN-9-A]`" + text: "Every namespace that embeds a BB code (HTTP problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, logical channel IDs §17.2, and any transport-neutral asynchronous error code that embeds one §11.6) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The BB-code register is proposed for the Lifecycle & Governance companion (Appendix A); until it exists, codes **SHOULD** be agreed through the API Working Group. `[OPEN-9-A]`" open_questions: ["OPEN-9-A"] - id: "10.1" title: "Opaque server-generated identifiers" @@ -824,70 +824,52 @@ rules: surface: Universal page: part-c/11-errors.md anchor: 111-rfc-9457-problem-details - text: "HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; §11.8 defines that mapping." + text: "HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; §11.6 defines the separate asynchronous model." open_questions: [] - id: "11.2" - title: "Standard problem fields present" + title: "Stable HTTP problem type URI" class: M+R - strengths: ["MUST", "SHOULD"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: Universal page: part-c/11-errors.md - anchor: 112-standard-problem-fields-present - text: "Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be a stable absolute URI that identifies the problem type and **SHOULD** dereference to human-readable documentation, for example `https://docs.govstack.global/errors/{bb-code}/{error-name}`. `status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments." + anchor: 112-stable-http-problem-type-uri + text: "Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be the sole machine identifier for the problem and **MUST** use `https://govstack.global/problems/{bb-code}/{problem-slug}`. `{bb-code}` is the registered code from §9.11; `{problem-slug}` **MUST** be stable kebab-case, for example `bad-request`, `invalid-field`, or `internal-error`. The URI **SHOULD** dereference to human-readable documentation. A GovStack HTTP Problem **MUST NOT** add a duplicate machine identifier such as `code`.\n`status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. HTTP problems **MUST NOT** add a `timestamp`; use response metadata and trace correlation for occurrence diagnostics." open_questions: [] - id: "11.3" - title: "GovStack error extension fields" + title: "Trace identifier" class: M strengths: ["MUST"] surface: Universal page: part-c/11-errors.md - anchor: 113-govstack-error-extension-fields - text: "GovStack HTTP problems and asynchronous errors **MUST** include `code` (machine-stable error code), `traceId` (the W3C trace-id defined by §8.4), and `timestamp` (an RFC 3339 `date-time`)." + anchor: 113-trace-identifier + text: "Every GovStack HTTP problem **MUST** include `traceId`, containing the W3C trace-id defined by §8.4." open_questions: [] - id: "11.4" title: "Field-level errors array" class: M+R - strengths: ["MUST"] + strengths: ["MUST NOT", "MUST"] surface: Universal page: part-c/11-errors.md anchor: 114-field-level-errors-array - text: "Where a failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry contains `pointer` (JSON Pointer), `code`, and `message`. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, §14.5)." + text: "Where an HTTP failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry **MUST** contain `pointer` (JSON Pointer) and `message`. It **MUST NOT** add a field-level `code`; the enclosing Problem `type` identifies the problem class. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, §14.5)." open_questions: [] - id: "11.5" - title: "Namespaced stable error codes" - class: M+R - strengths: ["MUST", "MAY"] - surface: Universal - page: part-c/11-errors.md - anchor: 115-namespaced-stable-error-codes - text: "Error codes **MUST** be stable, machine-readable identifiers, namespaced by BB so that integrators can disambiguate identical codes from different BBs. The `code` field is an identifier, not a URL; the problem-type URI belongs in `type` (§11.2). The default shape is reverse-DNS: `global.govstack.{bb-code}.{error-name}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `{error-name}` segment **MUST** use lowerCamelCase, for example `global.govstack.identity.personNotFound`. Numeric suffixes **MAY** be used where a BB already maintains a numbered error catalogue, for example `global.govstack.{bb-code}.{number}`. `[OPEN-10-A]`" - open_questions: ["OPEN-10-A"] -- id: "11.6" - title: "Stable codes across languages" + title: "Stable HTTP problem fields across languages" class: R - strengths: ["MUST", "MAY"] + strengths: ["MUST NOT", "MAY"] surface: Universal page: part-c/11-errors.md - anchor: 116-stable-codes-across-languages - text: "`title` and `detail` **MAY** be localised; `code` and `type` **MUST** remain stable across languages." + anchor: 115-stable-http-problem-fields-across-languages + text: "HTTP Problem `title`, `detail`, and field-error `message` **MAY** be localised. The `type` URI, `status`, `traceId`, `instance`, and field-error `pointer` **MUST NOT** be translated." open_questions: [] -- id: "11.7" - title: "Common error catalogue" - class: M+R - strengths: ["MUST"] - surface: Universal - page: part-c/11-errors.md - anchor: 117-common-error-catalogue - text: "A small set of cross-BB common errors **MUST** be defined in `govstack-openapi-common.yaml` as the `CommonErrorCode` schema and reused by reference rather than restated. The starting set is modelled on `google.rpc.Code` (gRPC canonical error codes) but uses the GovStack reverse-DNS error-code convention: `global.govstack.common.unauthenticated`, `global.govstack.common.permissionDenied`, `global.govstack.common.notFound`, `global.govstack.common.invalidArgument`, `global.govstack.common.alreadyExists`, `global.govstack.common.aborted`, `global.govstack.common.resourceExhausted`, `global.govstack.common.internal`, `global.govstack.common.unimplemented`. The final list is `[OPEN-10-B]`." - open_questions: ["OPEN-10-B"] -- id: "11.8" +- id: "11.6" title: "Transport-neutral asynchronous errors" class: M+R strengths: ["MUST NOT", "MUST", "MAY"] surface: Universal page: part-c/11-errors.md - anchor: 118-transport-neutral-asynchronous-errors - text: "An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. The schema **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`, and **MAY** contain `detail` and `errors` with the semantics in §11.4. When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines." + anchor: 116-transport-neutral-asynchronous-errors + text: "An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. This transport-neutral schema remains separate from the HTTP Problem model and **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`; it **MAY** contain `detail` and `errors`. Each asynchronous field error contains `pointer`, `code`, and `message` as defined by the shared `AsyncFieldError` schema. When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines." open_questions: [] - id: "12.1" title: "Collections must paginate" @@ -905,7 +887,7 @@ rules: surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 122-cursor-pagination-by-default - text: "Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable problem code. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries." + text: "Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable Problem `type`. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries." open_questions: [] - id: "12.3" title: "Cursor pagination envelope" @@ -914,7 +896,7 @@ rules: surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 123-cursor-pagination-envelope - text: "The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, hasMore, total? } }`. `nextCursor` **MUST** be a non-empty string when `hasMore` is `true` and **MUST** be `null` when `hasMore` is `false`; its schema therefore **MUST** declare explicit nullability. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and simplified continuation fields." + text: "The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, total? } }`. `nextCursor` **MUST** be a non-empty string when another page is available and **MUST** be `null` on the final page; its schema therefore **MUST** declare explicit nullability. Clients determine whether another page is available from `nextCursor` and no separate `hasMore` field is used. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and one continuation field." open_questions: [] - id: "12.4" title: "Documented pageSize bounds" @@ -1013,8 +995,8 @@ rules: surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 134-namespaced-oauth-scopes - text: "Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-12-A]`" - open_questions: ["OPEN-12-A"] + text: "Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-13-A]`" + open_questions: ["OPEN-13-A"] - id: "13.5" title: "Authorization is the credential channel" class: M+R @@ -1106,23 +1088,23 @@ rules: text: "Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource and **MUST** return the current Operation representation in the response body." open_questions: [] - id: "15.2" - title: "Shared Operation resource shape" - class: M - strengths: ["MUST"] + title: "Local Operation resource shape" + class: M+R + strengths: ["MUST NOT", "MUST"] surface: OpenAPI page: part-d/15-asynchronous-operations.md - anchor: 152-shared-operation-resource-shape - text: "The Operation resource **MUST** be declared once in `govstack-openapi-common.yaml` and `$ref`'d by all BBs. The default shape is `{ id, status, result, error, createdAt, updatedAt, progress? }`, modelled on Google AIP-151 (Long-Running Operations). A stricter AIP-151 mirror (with `done` and `metadata`) is a defensible alternative. `[OPEN-14-A]`" - open_questions: ["OPEN-14-A"] + anchor: 152-local-operation-resource-shape + text: "A BB that exposes long-running work **MUST** define its Operation schema locally. Its identifier **MUST** be an opaque string and clients **MUST NOT** infer a UUID or any other internal format. The local schema **MUST** document how the identifier, lifecycle state, result, error, and any progress metadata are represented, including when each state-dependent field is present. This guide does not fix those field names or shapes. A shared Operation schema is deferred until multiple BBs demonstrate a stable reusable contract." + open_questions: [] - id: "15.3" - title: "Fixed Operation status enum" - class: M + title: "Documented Operation lifecycle" + class: M+R strengths: ["MUST"] surface: OpenAPI page: part-d/15-asynchronous-operations.md - anchor: 153-fixed-operation-status-enum - text: "Operation `status` **MUST** be drawn from a fixed enumeration declared in the common file. The default set is `PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELLED`. AIP-151's boolean `done` plus a result-or-error union is a defensible alternative. `[OPEN-14-A]`" - open_questions: ["OPEN-14-A"] + anchor: 153-documented-operation-lifecycle + text: "The local Operation contract **MUST** distinguish terminal from non-terminal states and **MUST** document the result, error, polling, and cancellation semantics for each applicable state. This guide does not prescribe a status enum or lifecycle model." + open_questions: [] - id: "15.4" title: "Polling the Operation resource" class: M+R @@ -1175,7 +1157,7 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 162-cloudevents-envelope-required - text: "GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `\"1.0\"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used, because §16.8 signs the complete structured event object and a binary-mode request body carries only `data`. §17.6 imposes the same requirement on the AsyncAPI surface." + text: "GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `\"1.0\"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used. §17.6 imposes the same requirement on the AsyncAPI surface." open_questions: [] - id: "16.3" title: "Reverse-DNS event types" @@ -1184,8 +1166,8 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 163-reverse-dns-event-types - text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata (§18.2). `[OPEN-15-B]`" - open_questions: ["OPEN-15-B"] + text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata (§18.2). `[OPEN-16-A]`" + open_questions: ["OPEN-16-A"] - id: "16.4" title: "Stable CloudEvents source" class: M+R @@ -1196,22 +1178,22 @@ rules: text: "The CloudEvents `source` field **MUST** be a stable, non-empty URI-reference identifying the publishing BB or BB surface; an absolute URI or URN **SHOULD** be used. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment." open_questions: [] - id: "16.5" - title: "Signed event delivery" + title: "Optional signed event delivery" class: R - strengths: ["MUST"] + strengths: ["MAY"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md - anchor: 165-signed-event-delivery - text: "Event delivery **MUST** be signed using the GovStack event-signature profile defined in `govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml`." + anchor: 165-optional-signed-event-delivery + text: "Event delivery **MAY** use message-level signing when the BB's threat model requires authenticity or integrity beyond authenticated transport. Signing is not part of the baseline GovStack event contract. Requirements for an explicitly adopted profile are in §16.6–§16.8." open_questions: [] - id: "16.6" - title: "GovStack-Signature header" - class: M+R - strengths: ["MUST", "SHOULD"] + title: "Signature metadata when used" + class: R + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md - anchor: 166-govstack-signature-header - text: "On the OpenAPI/webhooks surface the signature **MUST** travel in the ecosystem-wide HTTP header `GovStack-Signature`. On the AsyncAPI surface, a GovStack-owned transport/application metadata field **MUST** be named `govstackSignature` so it satisfies §17.8; when a protocol binding defines a standard signature field, that field **SHOULD** be used and the mapping **MUST** be documented." + anchor: 166-signature-metadata-when-used + text: "When the optional profile uses GovStack-owned metadata, an OpenAPI webhook signature **MUST** travel in `GovStack-Signature` and an AsyncAPI transport/application metadata field **MUST** be named `govstackSignature`. When a protocol binding defines a standard signature field, that field **SHOULD** be used and its mapping **MUST** be documented. A surface that does not adopt signing **MUST NOT** require signature metadata." open_questions: [] - id: "16.7" title: "Replay-detectable signed material" @@ -1220,26 +1202,26 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 167-replay-detectable-signed-material - text: "The signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers can detect replays." + text: "When signing is adopted, the signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers have the inputs needed to detect replays." open_questions: [] - id: "16.8" - title: "Pinned signature profile" + title: "Separate experimental signing profile" class: R - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "MAY"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md - anchor: 168-pinned-signature-profile - text: "`govstack-openapi-common.yaml` and `govstack-asyncapi-common.yaml` **MUST** declare the GovStack event-signature profile as detached JWS using `ES256`. The JWS payload **MUST** be the UTF-8 bytes of the complete structured CloudEvent JSON object after JSON Canonicalization Scheme processing defined by RFC 8785. The serialized JWS **MUST** omit its payload using the detached-content procedure in RFC 7515 Appendix F; verifiers **MUST** reconstruct that payload from the received, RFC 8785-canonicalized event body. The protected JWS `kid` **MUST** select the publisher's verification key. Implementations **MUST NOT** use RFC 7797 unencoded-payload JWS unless a future guide version explicitly adopts it. How a subscriber obtains the key that `kid` selects is not yet settled ecosystem-wide: §16.11 supplies it per subscription, which covers HTTP webhooks but not brokered or stream transports with no subscription control plane. `[OPEN-15-H]`" - open_questions: ["OPEN-15-H"] + anchor: 168-separate-experimental-signing-profile + text: "Event signing belongs in the separate optional `experimental/govstack-openapi-signing-profile.yaml` artifact, not in either baseline common schema artifact. A BB **MAY** adopt this profile explicitly, but it **MUST** pin the profile version and **MUST** document its key discovery, key rotation, replay policy, protocol mapping, and conformance tests. The profile remains incomplete pending a shared key-discovery and replay-policy contract and **MUST NOT** be presented as an ecosystem-wide baseline." + open_questions: [] - id: "16.9" - title: "Operational signing concerns out of scope" + title: "Readiness for a shared signature profile" class: informative strengths: [] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md - anchor: 169-operational-signing-concerns-out-of-scope - text: "Operational concerns such as replay-window enforcement and signing-key rotation are out of scope here and live in the Security & Operations companion (§1.2). Until that companion fixes a bound, §16.7 gives a receiver the material to detect a replay but no ecosystem-wide window against which to reject one, so two conformant implementations can disagree about whether a given event is a replay. `[OPEN-15-H]`" - open_questions: ["OPEN-15-H"] + anchor: 169-readiness-for-a-shared-signature-profile + text: "A future guide version should promote a shared signature profile only after it defines key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages. Those operational concerns belong in the Security & Operations companion (§1.2)." + open_questions: [] - id: "16.10" title: "Documented delivery-failure contract" class: R @@ -1256,7 +1238,7 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 1611-subscription-management-interfaces - text: "Subscription management **MUST** expose documented interfaces to create, list, rotate the signing secret or verification key material used by the selected profile, and delete a subscription. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane." + text: "Subscription management **MUST** expose documented interfaces to create, list, and delete a subscription. If the subscription adopts message signing, it **MUST** also expose or document how to rotate or redistribute the verification material used by the selected profile. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane." open_questions: [] - id: "17.1" title: "Send and receive perspective" @@ -1310,16 +1292,16 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 176-structured-cloudevents-json-payloads - text: "AsyncAPI message payloads for GovStack domain events **MUST** use structured CloudEvents JSON: the message payload is the complete CloudEvent, and GovStack-owned domain data lives under the CloudEvents `data` field. This provides one portable, schema-validatable event shape across brokered transports. `[OPEN-15-E]`" - open_questions: ["OPEN-15-E"] + text: "AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports. `[OPEN-17-A]`" + open_questions: ["OPEN-17-A"] - id: "17.7" - title: "Shared CloudEvents message schema" + title: "Shared CloudEvents envelope schema" class: M strengths: ["MUST"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 177-shared-cloudevents-message-schema - text: "AsyncAPI channel message entries **MUST** reference the shared CloudEvents message schema from `govstack-asyncapi-common.yaml` and specialise only the `data` schema for the BB-specific event payload. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0." + anchor: 177-shared-cloudevents-envelope-schema + text: "Each BB **MUST** define its Message Objects locally. A domain-event message payload **MUST** compose `#/components/schemas/CloudEventEnvelope` from the pinned `govstack-asyncapi-common.yaml` with a local schema that specialises the event `type` and, when present, `data`. A rejection message **MUST** either reference the shared `GovStackAsyncError` schema directly or use it as CloudEvent `data`. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0. Security schemes, headers, examples, correlation, and protocol bindings remain local because they require BB- or transport-specific values." open_questions: [] - id: "17.8" title: "Message headers and idempotency metadata" @@ -1328,7 +1310,7 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 178-message-headers-and-idempotency-metadata - text: "GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. The event-signature metadata name **MUST** follow §16.6. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`." + text: "GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow §16.6. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`." open_questions: [] - id: "17.9" title: "Message localisation headers" @@ -1346,53 +1328,53 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 1710-security-schemes-cover-every-operation - text: "AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Message signing (§16.5) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication." + text: "AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Optional message signing (§16.5) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication." open_questions: [] - id: "17.11" - title: "Documented delivery guarantees" - class: M+R - strengths: ["MUST NOT", "MUST"] + title: "Duplicate delivery contract" + class: R + strengths: ["MUST NOT", "MUST", "MAY"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 1711-documented-delivery-guarantees - text: "Each operation **MUST** document its delivery guarantee: `atMostOnce`, `atLeastOnce`, or `effectivelyOnce`. `effectivelyOnce` **MUST** be backed by an idempotency contract, duplicate detection, or a documented resource-state invariant; it **MUST NOT** imply the transport literally delivers a message exactly once." + anchor: 1711-duplicate-delivery-contract + text: "When the selected protocol or deployment can redeliver a message and consumers need to handle duplicates, the operation **MUST** document the duplicate-handling contract. For CloudEvents, the default duplicate identity is the pair `source` plus `id`. Protocol QoS, acknowledgement, and redelivery fields **MUST** use the applicable AsyncAPI binding when available. Specifications **MUST NOT** claim `effectivelyOnce` as a portable transport guarantee; they **MAY** document application-level idempotency or de-duplication instead." open_questions: [] - id: "17.12" - title: "Documented ordering guarantees" - class: M+R + title: "Ordering only when promised" + class: R strengths: ["MUST"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 1712-documented-ordering-guarantees - text: "Each operation **MUST** document ordering guarantees, if any. If ordering is partitioned, keyed, or scoped, the key or scope **MUST** be declared. If no ordering is guaranteed, the spec **MUST** state that explicitly." + anchor: 1712-ordering-only-when-promised + text: "Ordering **MUST** be documented only when consumers are allowed to rely on it. When ordering is promised, the applicable protocol binding or operation description **MUST** identify its scope and key, such as a Kafka partition key or an ordered queue. A specification with no ordering promise does not need a placeholder declaration." open_questions: [] - id: "17.13" - title: "Declared delivery-management capabilities" - class: M+R - strengths: ["MUST"] + title: "Public delivery-management capabilities" + class: R + strengths: ["MUST", "MAY"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 1713-declared-delivery-management-capabilities - text: "Each operation **MUST** declare which delivery-management capabilities the chosen transport contract exposes: redelivery, dead-letter handling, retention, and replay. The declaration **MUST** state whether each capability is supported, unsupported, or not applicable." + anchor: 1713-public-delivery-management-capabilities + text: "Redelivery, dead-letter handling, retention, and replay **MUST** be documented when they are part of the public contract available to a consumer. The specification **MUST** use the applicable protocol binding, channel configuration, or a linked protocol profile where one exists. Capabilities that are deployment-internal or unavailable to consumers **MAY** be omitted." open_questions: [] - id: "17.14" - title: "Portable capability contract" + title: "Implementation values in protocol profiles" class: R - strengths: ["MUST"] + strengths: ["MUST NOT", "SHOULD"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 1714-portable-capability-contract - text: "A reference specification **MUST** define the portable contract shape, defaults, and allowed bounds for supported delivery-management capabilities. Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings belong in implementation profiles." + anchor: 1714-implementation-values-in-protocol-profiles + text: "Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings **SHOULD** live in protocol or implementation profiles unless a value is a stable promise to every conforming consumer. The core cross-BB specification **MUST NOT** imply that a broker-specific setting is portable across protocols." open_questions: [] - id: "17.15" - title: "Machine-readable delivery extensions" - class: M+R - strengths: ["MUST"] + title: "No universal delivery extensions" + class: R + strengths: ["MUST NOT", "MUST"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 1715-machine-readable-delivery-extensions - text: "Delivery, ordering, and delivery-management capability declarations **MUST** be machine-readable using GovStack specification extensions declared in `govstack-asyncapi-common.yaml` (for example, `x-govstack-delivery`, `x-govstack-ordering`, and `x-govstack-replay`) as well as human-readable in `description`. `[OPEN-15-G]`" - open_questions: ["OPEN-15-G"] + anchor: 1715-no-universal-delivery-extensions + text: "`govstack-asyncapi-common.yaml` does not define universal delivery, ordering, redelivery, dead-letter, retention, or replay extensions. A specification **MUST** use standard AsyncAPI bindings first and **MUST** state any remaining consumer-visible promise in `description` or a linked protocol profile. The presence of a custom extension alone **MUST NOT** be treated as an interoperable delivery contract." + open_questions: [] - id: "17.16" title: "Async rejection error messages" class: M+R @@ -1400,7 +1382,7 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 1716-async-rejection-error-messages - text: "Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from §11.8, not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using §17.8 or an equivalent protocol binding." + text: "Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from §11.6, not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using §17.8 or an equivalent protocol binding." open_questions: [] - id: "17.17" title: "Declared request-reply correlation" @@ -1427,8 +1409,8 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 1719-protocol-bindings-where-relevant - text: "Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. `[OPEN-15-F]`" - open_questions: ["OPEN-15-F"] + text: "Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. `[OPEN-17-B]`" + open_questions: ["OPEN-17-B"] - id: "17.20" title: "Examples for every message" class: M+R @@ -1454,7 +1436,7 @@ rules: surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 182-major-version-in-path-or-channel - text: "A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of §5.9 carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by §17.2 or in an equivalent machine-readable version field defined by `govstack-asyncapi-common.yaml`; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it." + text: "A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of §5.9 carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by §17.2; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it." open_questions: [] - id: "18.3" title: "Backward-compatible minor changes" @@ -1499,8 +1481,8 @@ rules: surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 187-asyncapi-deprecation-metadata - text: "AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. `[OPEN-16-A]`" - open_questions: ["OPEN-16-A"] + text: "AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. `[OPEN-18-A]`" + open_questions: ["OPEN-18-A"] - id: "19.1" title: "Honour the request language" class: R @@ -1517,7 +1499,7 @@ rules: surface: Universal page: part-e/19-localisation.md anchor: 192-never-translate-stable-content - text: "Stable content (error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated." + text: "Stable content (HTTP Problem `type`, transport-neutral asynchronous error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated." open_questions: [] - id: "19.3" title: "English as default language" @@ -1526,8 +1508,8 @@ rules: surface: Universal page: part-e/19-localisation.md anchor: 193-english-as-default-language - text: "Default language **MUST** be English. `[OPEN-17-A]`" - open_questions: ["OPEN-17-A"] + text: "Default language **MUST** be English. `[OPEN-19-A]`" + open_questions: ["OPEN-19-A"] - id: "19.4" title: "Declare the response language" class: M+R @@ -1553,7 +1535,7 @@ rules: surface: Universal page: part-e/20-conformance-and-validation.md anchor: 202-passes-the-govstack-spectral-ruleset - text: "Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under §20.3 for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules (§1.9). Ruleset `0.2.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility." + text: "Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under §20.3 for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules (§1.9). Ruleset `0.1.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility." open_questions: [] - id: "20.3" title: "Declared guide conformance version" @@ -1562,5 +1544,5 @@ rules: surface: Universal page: part-e/20-conformance-and-validation.md anchor: 203-declared-guide-conformance-version - text: "Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.2.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under §1.6 **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI." + text: "Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.1.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under §1.6 **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI." open_questions: [] diff --git a/api-design-guide/tools/build_rules_index.py b/api-design-guide/tools/build_rules_index.py index 72aecf8..1a50af5 100644 --- a/api-design-guide/tools/build_rules_index.py +++ b/api-design-guide/tools/build_rules_index.py @@ -32,7 +32,7 @@ from pathlib import Path GUIDE_NAME = "GovStack Cross-BB API Design Guide" -GUIDE_VERSION = "0.2.0-draft" +GUIDE_VERSION = "0.1.0-draft" # Regex for a heading line carrying an explicit anchor tag, e.g. # ## 2.1 OpenAPI 3.1.0 required <a href="#21-openapi-310-required" id="21-openapi-310-required"></a> diff --git a/api-design-guide/version-history.md b/api-design-guide/version-history.md deleted file mode 100644 index 1939a69..0000000 --- a/api-design-guide/version-history.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -description: "What changed in each version of the GovStack Cross-BB API Design Guide." ---- - -# Version history - -## v0.2 (DRAFT, 2026-07-10) - -This edition supersedes the circulated v0.1 document. It restructures the rulebook for publication as a GitBook and extends it with the changes below — new rules and substantive strengthenings, not only presentation. No normative wording changed other than what is listed here; a mechanical fidelity check of the restructured pages against the v0.1 text backed the restructuring itself. - -**Structure and presentation** - -- Published as a GitBook: one page per section, one anchored heading per rule, so every rule is deep-linkable with a stable URL fragment that works on GitBook and GitHub alike. -- The enforcement-class tag (`[M]`, `[R]`, `[M+R]`, see [§1.9](1-introduction.md#19-rule-enforcement-classes)) now appears as a bold badge at the start of each rule body instead of after the rule number; [§1.9](1-introduction.md#19-rule-enforcement-classes)'s first sentence was updated to match. -- Sections renumbered to a continuous 1–20, removing the lettered sections (v0.1 §2A is now §3, §15A is now §17); every cross-reference was updated. The mapping table is on [How to use this guide](how-to-use-this-guide.md). The `OPEN-N-X` identifiers were deliberately **not** re-keyed (see the note in [Appendix B](appendix/b-open-questions.md)). -- Non-normative short titles were added to every rule heading (see [About the rule titles](how-to-use-this-guide.md#about-the-rule-titles)). -- Cross-references are now hyperlinks; each section's Intent / Applies to / Layer preamble is presented as an info callout; the "Rules:" list label was dropped; the note and carve-out paragraphs (casing note and carve-outs in [§9](part-c/9-json-conventions-and-naming.md), consent propagation in [§13](part-d/13-authentication-and-authorisation.md), retrofitting in [§18](part-d/18-compatibility-and-lifecycle.md), governance in [§20](part-e/20-conformance-and-validation.md)) received their own anchored headings, with the carve-outs' "(per §1.7)" qualifier moved to a line under the heading. -- [§13](part-d/13-authentication-and-authorisation.md)'s page title drops the "(spec declarations)" qualifier from the v0.1 heading; the section's scope statement is unchanged. -- The broken table markup in Appendices A and B was repaired. -- The executive summary now says "This draft" instead of "This v0.1 draft", and the Part D group label is spelled "Behaviour", matching the body text. - -**New content (normative)** - -- New [§1.10 Applicability and transition](1-introduction.md#110-applicability-and-transition): the guide binds new surfaces and new major versions, existing specs are not retroactively non-conformant, and the guide itself is versioned with SemVer. -- New rule [9.11](part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code): every namespace that embeds a `{bb-code}` uses the BB's single registered code, with a required syntax; new open question OPEN-9-A. One-sentence pointers to 9.11 were added to rules [11.5](part-c/11-errors.md#115-namespaced-stable-error-codes), [13.4](part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), [16.3](part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), and [17.2](part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses). -- New rule [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version): each canonical spec declares the exact guide and ruleset versions it targets via `x-govstack-api-guide`. The extension was added to rule [9.10](part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix)'s example list. -- OpenAPI [§2.1](part-a/2-openapi-document-standards.md#21-openapi-31-required) now qualifies the published 3.1 patch series instead of freezing `3.1.0`; [§2.6](part-a/2-openapi-document-standards.md#26-meaningful-servers-block) removes the duplicated `/v1` server example and requires HTTPS; [§2.8](part-a/2-openapi-document-standards.md#28-pinned-vendored-common-components) and [§3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components) pin the vendored common-component files to `api/common/`. -- New rules [4.5](part-a/4-documentation-requirements.md#45-api-surface-inventory) and [4.6](part-a/4-documentation-requirements.md#46-functional-requirement-traceability) define `api/index.yaml`, the explicit `noApi` declaration, normative requirement markers, and exact `api/coverage.yaml` dispositions. -- [§6–§7](part-b/6-http-methods.md) now distinguish completed creation (`201`) from accepted work (`202` plus Operation), define applicability for common `4xx`/`500` responses (every operation now declares `500`), and require schemas for every successful response body in new [§7.21](part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies). [§7.16](part-b/7-http-status-codes.md#716-etag-and-if-none-match) now requires a strong validator, and new [§7.17](part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match) guidance adds `428 Precondition Required`. -- [§8.2](part-b/8-headers.md#82-accept-language-and-content-language) now requires `Content-Language` on localised responses and `Vary: Accept-Language` on cacheable ones; [§8.4](part-b/8-headers.md#84-w3c-trace-context-correlation) adopts W3C Trace Context instead of `X-Request-Id`; [§8.7](part-b/8-headers.md#87-rate-limit-headers-declared) pins the revision-11 Structured Field RateLimit contract. OPEN-7-A and OPEN-7-B are resolved. -- [§11](part-c/11-errors.md) limits RFC 9457 `status` semantics to HTTP and adds transport-neutral `GovStackAsyncError`; [§11.2](part-c/11-errors.md#112-standard-problem-fields-present) strengthens `type` to a mandatory stable absolute URI and requires `status` to equal the HTTP status code; [§12](part-c/12-pagination-filtering-sorting.md) requires integrity-protected cursors, stable page arguments and order, and body-based searches for personal criteria. -- [§13](part-d/13-authentication-and-authorisation.md) adds the RFC 9700 OAuth baseline, forbids the password grant outright and the implicit grant on new surfaces, distinguishes access from ID Tokens, and requires protected transport. -- [§14](part-d/14-idempotency.md) pins draft revision 07, defines Structured Field syntax, lookup scope and fingerprints, and prevents replay of per-attempt response headers. [§14.1](part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts) now *requires* (not merely accepts) the `Idempotency-Key` on the listed non-idempotent POSTs, backed by [§14.2](part-d/14-idempotency.md#142-opaque-client-generated-keys)'s `400` on a missing required key. -- [§15](part-d/15-asynchronous-operations.md): [15.1](part-d/15-asynchronous-operations.md#151-202-with-operation-location) now requires the `202` body to carry the current Operation representation, [15.4](part-d/15-asynchronous-operations.md#154-polling-the-operation-resource) adds `Retry-After` advice on non-terminal polls, and the Operation paths are spelled `/v{major}/…`. -- [§16–§17](part-d/16-cloudevents-and-webhooks.md) adopt standard tracing attributes, pin detached JWS `ES256` with RFC 8785 canonicalization (dropping the v0.1 option of an HMAC fallback), separate reverse-DNS logical channel IDs from protocol-native addresses, and resolve the HTTP/AsyncAPI signature-metadata casing conflict. OPEN-15-A (a v1.0-blocking decision), OPEN-15-C, and OPEN-15-D are resolved. -- [§18](part-d/18-compatibility-and-lifecycle.md) now evaluates compatibility separately for inputs, outputs, publishers, and consumers; [§1.10](1-introduction.md#110-applicability-and-transition) separates immediate non-wire adoption from next-major wire changes and defines compatible guide-version evolution. -- [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) now pins exact `version` and `rulesetVersion` values and defines scoped, expiring exception fields, resolving OPEN-20-A. -- Previously descriptive rule text now uses explicit RFC 2119 normative keywords (the §6 method and §7 status-code rules, and [§9.8](part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas)); untagged notes and examples remain informative. - -**New content (informative)** - -- Six examples: a `/health` response ([§5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)), an error envelope ([§11](part-c/11-errors.md)), cursor and offset pagination envelopes ([§12](part-c/12-pagination-filtering-sorting.md)), an Operation resource ([§15](part-d/15-asynchronous-operations.md)), and a structured CloudEvent ([§16](part-d/16-cloudevents-and-webhooks.md)). -- Two diagrams: which artifact documents which surface ([§1.2](1-introduction.md#12-scope)) and the layering model ([§1.8](1-introduction.md#18-layering-what-this-guide-constrains)). -- [Appendix B](appendix/b-open-questions.md) gained a **Blocks v1.0?** column marking the decisions that must precede ratification. -- A non-normative [Guides](guides/README.md) group: spec editor checklist, validation commands, AI-agent instructions, and maintenance notes. -- A machine layer: [Rules at a glance](all-rules.md) and `rules.yaml` (both generated from the pages by `tools/build_rules_index.py`), plus `tools/check_links.py` as a consistency guard. -- A draft of the GovStack Spectral ruleset with lint tooling ([`linter/`](linter/README.md)): 130 Spectral rules across both surfaces plus 8 opt-in strict heuristics, a driver that adds the [§20.1](part-e/20-conformance-and-validation.md#201-every-file-passes-validation) base validators, file-layout checks, and [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling, per-rule coverage recorded in `linter/coverage.yaml`, and a composite GitHub Action with a template workflow. The formal v1.0 companion publication remains pending ([Appendix A](appendix/a-companion-documents.md)). - -## v0.1 (DRAFT, 2026-05-31) - -Initial draft circulated to the GovStack committee for feedback: 164 numbered rules in sections 1–18 plus the lettered sections 2A and 15A, with three appendices (companion documents, open questions, normative references). Authored by Jeremi Joslin, drawing on a review of the published Building Block API specifications. diff --git a/api/common/README.md b/api/common/README.md index 8035d45..0a99cb2 100644 --- a/api/common/README.md +++ b/api/common/README.md @@ -3,14 +3,12 @@ These files are vendored from the draft incubation repository: - Source: <https://github.com/jeremi/govstack-api-common> -- Component version: `0.1.0-draft` -- Source revision: `0ca50895c3934e30096989c85e249e6431f2a21e` -| Local file | Upstream file | -|---|---| -| `govstack-openapi-common.yaml` | `openapi/govstack-openapi-common.yaml` | -| `govstack-asyncapi-common.yaml` | `asyncapi/govstack-asyncapi-common.yaml` | +| Local file | Version | Upstream file | Source state | SHA-256 | +|---|---|---|---|---| +| `govstack-openapi-common.yaml` | `0.1.0-draft` | `openapi/govstack-openapi-common.yaml` | Source commit `12b9b1acde9bfaa5a77e26380fa8f9e1f29bb28e` | `05d1bfc89c86a8d64005e343268326b3fb43cd044fe30f662fec0f9f573b0c73` | +| `govstack-asyncapi-common.yaml` | `0.1.0-draft` | `asyncapi/govstack-asyncapi-common.yaml` | Source commit `12b9b1acde9bfaa5a77e26380fa8f9e1f29bb28e` | `875c99f881d78c56b1b98e27198f904815c6e482c90c3c0fea574b1a792dd598` | -The source revision is pinned because no ratified release exists yet. Update the -two files and this provenance record together. Do not make component-source -changes only in this vendored directory. +The source commit and checksums pin the exact local artifacts. Update a vendored +file and this provenance record together; do not make component-source changes +only in this directory. diff --git a/api/common/govstack-asyncapi-common.yaml b/api/common/govstack-asyncapi-common.yaml index 9254496..792c4a1 100644 --- a/api/common/govstack-asyncapi-common.yaml +++ b/api/common/govstack-asyncapi-common.yaml @@ -1,10 +1,10 @@ asyncapi: 3.0.0 info: - title: GovStack AsyncAPI Common Components + title: GovStack AsyncAPI Common Schemas version: 0.1.0-draft description: >- - Minimal reusable CloudEvents, asynchronous error, trace, signature, and - service-authentication components for GovStack AsyncAPI specifications. + Minimal reusable schemas for structured CloudEvents and transport-neutral + asynchronous errors in GovStack AsyncAPI specifications. contact: name: GovStack API Working Group url: https://www.govstack.global/ @@ -12,137 +12,18 @@ servers: {} channels: {} operations: {} x-govstack-components-version: 0.1.0-draft -x-govstack-delivery-extensions: - version: 0.1.0-draft - description: >- - Declares the machine-readable delivery-semantics specification extensions - (guide §17.11–§17.15) that every GovStack AsyncAPI operation carries - alongside a human-readable description. Capability values are a bare - string or an object whose `status` field carries the value. Concrete - retry counts, backoff intervals, retention periods, replay windows, and - dead-letter store settings belong in implementation profiles (§17.14). - extensions: - x-govstack-delivery: - appliesTo: operation - values: [atMostOnce, atLeastOnce, effectivelyOnce] - semantics: >- - Delivery guarantee of the transport contract. effectivelyOnce must be - backed by an idempotency contract, duplicate detection, or a - documented resource-state invariant; it never implies the transport - literally delivers exactly once. - x-govstack-ordering: - appliesTo: operation - values: >- - The declared partition key or scope when ordering is partitioned, - keyed, or scoped; the explicit string "none" when no ordering is - guaranteed. - semantics: Ordering guarantee of the operation, stated explicitly either way. - x-govstack-redelivery: - appliesTo: operation - values: [supported, unsupported, notApplicable] - semantics: Whether the chosen transport contract exposes automatic redelivery. - x-govstack-dead-letter: - appliesTo: operation - values: [supported, unsupported, notApplicable] - semantics: Whether undeliverable or failed messages are routed to a dead-letter store. - x-govstack-retention: - appliesTo: operation - values: [supported, unsupported, notApplicable] - semantics: Whether delivered messages remain retained for later consumption. - x-govstack-replay: - appliesTo: operation - values: [supported, unsupported, notApplicable] - semantics: Whether consumers can replay previously delivered messages. -x-govstack-event-signature-profile: - version: 0.1.0-draft - serialization: JWS Compact Serialization with a detached payload - payloadEncoding: - b64: true - behavior: Default JWS payload encoding; RFC 7797 unencoded payload mode is not used. - protectedHeader: - alg: ES256 - kid: REQUIRED - payloadBytes: UTF-8 bytes of the RFC 8785 canonical JSON structured CloudEvent payload. - signingInput: "BASE64URL(protected) + '.' + BASE64URL(payload)" - wireValue: protected..signature - verificationInputs: The protected kid selects the publisher public key; the full event body is canonicalized and verified. components: - securitySchemes: - OAuthClientCredentials: - type: oauth2 - description: OAuth 2.0 client-credentials template for authenticated broker access. - flows: - clientCredentials: - tokenUrl: https://identity.example.org/oauth2/token - availableScopes: {} - messages: - CloudEvent: - name: cloudEvent - title: Structured GovStack CloudEvent - summary: Reusable structured CloudEvents JSON envelope for a BB domain event. - contentType: application/json - headers: - $ref: '#/components/schemas/EventHeaders' - payload: - $ref: '#/components/schemas/CloudEventEnvelope' - examples: - - name: referenceEvent - summary: Generic event showing trace and signature metadata. - headers: - govstackSignature: eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0xIn0..MEUCIQDexample - payload: - specversion: '1.0' - id: 5e0c63c2-2b8a-4d3f-9a51-7c6b0d9e8f21 - source: urn:govstack:bb:template - type: global.govstack.template.record.created - time: '2026-07-10T12:00:00Z' - datacontenttype: application/json - traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 - data: - resourceId: 7d9ad9df-bfc3-451a-94e0-24afae30750f - AsyncError: - name: govStackAsyncError - title: GovStack asynchronous error - summary: Transport-neutral rejection or processing failure for an asynchronous command. - contentType: application/json - payload: - $ref: '#/components/schemas/GovStackAsyncError' - examples: - - name: rejectedCommand - summary: A command rejected because one field is invalid. - payload: - type: https://docs.govstack.global/errors/common/invalidArgument - title: Command validation failed - code: global.govstack.common.invalidArgument - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - errors: - - pointer: /data/name - code: global.govstack.common.invalidArgument - message: Name must not be blank. schemas: - EventHeaders: - type: object - description: GovStack-owned transport metadata accompanying a structured CloudEvent. - required: - - govstackSignature - properties: - govstackSignature: - type: string - description: Detached JWS over the canonicalized structured CloudEvent payload. - idempotencyKey: - type: string - description: Opaque de-duplication key when the message triggers non-idempotent processing. - maxLength: 255 CloudEventEnvelope: type: object - description: CloudEvents 1.0 structured JSON envelope with GovStack trace extensions. + description: >- + CloudEvents 1.0 structured JSON envelope. A BB composes this schema in + a local Message Object and specialises type and data for its event. required: - specversion - id - source - type - - data properties: specversion: type: string @@ -150,10 +31,12 @@ components: description: CloudEvents wire version for the adopted 1.0.x specification. id: type: string + minLength: 1 description: Unique identifier of this event occurrence. source: type: string format: uri-reference + minLength: 1 description: Stable logical URI reference for the publishing BB surface. type: type: string @@ -172,7 +55,7 @@ components: description: Optional opaque identifier of the resource concerned by the event. traceparent: type: string - pattern: '^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$' + pattern: '^00-(?!0{32})[\da-f]{32}-(?!0{16})[\da-f]{16}-0[01]$' description: W3C Trace Context extension attribute for the distributed trace. tracestate: type: string @@ -184,9 +67,14 @@ components: causationid: type: string description: Optional identifier of the message that caused this event. + idempotencykey: + type: string + maxLength: 255 + description: >- + Optional CloudEvents extension attribute for de-duplicating a + command that triggers non-idempotent processing. data: - type: object - description: BB-specific domain payload specialised by the referencing message. + description: Optional event data whose requiredness and shape are specialised locally. GovStackAsyncError: type: object description: Transport-neutral asynchronous command rejection or processing failure. @@ -209,10 +97,11 @@ components: description: Human-readable explanation specific to this failure. code: type: string + pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.(?:[a-z][a-zA-Z0-9]*|[0-9]+)$' description: Stable namespaced machine-readable error identifier. traceId: type: string - pattern: '^[\da-f]{32}$' + pattern: '^(?!0{32}$)[\da-f]{32}$' description: W3C trace-id correlating the failure with the originating work. timestamp: type: string @@ -236,6 +125,7 @@ components: description: JSON Pointer identifying the invalid message field. code: type: string + pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.(?:[a-z][a-zA-Z0-9]*|[0-9]+)$' description: Stable namespaced code for the field failure. message: type: string diff --git a/api/common/govstack-openapi-common.yaml b/api/common/govstack-openapi-common.yaml index 46dbaed..f89b516 100644 --- a/api/common/govstack-openapi-common.yaml +++ b/api/common/govstack-openapi-common.yaml @@ -1,329 +1,41 @@ openapi: 3.1.0 info: - title: GovStack OpenAPI Common Components + title: GovStack OpenAPI Common Schemas version: 0.1.0-draft description: >- - Minimal reusable components for GovStack REST API specifications. Domain - resources and BB-specific OAuth scopes remain in each BB specification. + Minimal cross-BB schemas for HTTP problem details, field validation, and + cursor pagination. Each BB owns its operations, security, parameters, + headers, responses, and examples. contact: name: GovStack API Working Group url: https://www.govstack.global/ paths: {} x-govstack-components-version: 0.1.0-draft -x-govstack-event-signature-profile: - version: 0.1.0-draft - serialization: JWS Compact Serialization with a detached payload - payloadEncoding: - b64: true - behavior: Default JWS payload encoding; RFC 7797 unencoded payload mode is not used. - protectedHeader: - alg: ES256 - kid: REQUIRED - payloadBytes: UTF-8 bytes of the RFC 8785 canonical JSON structured CloudEvent request body. - signingInput: "BASE64URL(protected) + '.' + BASE64URL(payload)" - wireValue: protected..signature - verificationInputs: The protected kid selects the publisher public key; the full event body is canonicalized and verified. components: - securitySchemes: - OAuthAuthorizationCode: - type: oauth2 - description: >- - OAuth 2.0 authorization-code flow template for an authenticated - end-user. Clients use PKCE with S256, and APIs accept access tokens, - never OpenID Connect ID tokens. A BB declares its own resource scopes. - flows: - authorizationCode: - authorizationUrl: https://identity.example.org/oauth2/authorize - tokenUrl: https://identity.example.org/oauth2/token - scopes: {} - OAuthClientCredentials: - type: oauth2 - description: >- - OAuth 2.0 client-credentials flow template for service-to-service - access. A BB declares its own resource scopes in its canonical API. - flows: - clientCredentials: - tokenUrl: https://identity.example.org/oauth2/token - scopes: {} - MutualTLS: - type: mutualTLS - description: Mutual TLS for service-to-service deployments requiring certificate authentication. - parameters: - Traceparent: - name: traceparent - in: header - required: false - description: W3C Trace Context parent identifier propagated across service boundaries. - schema: - type: string - description: W3C Trace Context traceparent value. - pattern: '^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$' - Tracestate: - name: tracestate - in: header - required: false - description: Optional W3C Trace Context vendor state containing no personal data. - schema: - type: string - description: Ordered W3C Trace Context list-member values. - maxLength: 512 - GovStackSignature: - name: GovStack-Signature - in: header - required: true - description: Detached JWS over the RFC 8785 canonicalized structured CloudEvent body. - schema: - type: string - description: JWS Compact Serialization value in protected..signature wire form. - IdempotencyKey: - name: Idempotency-Key - in: header - required: true - description: Client-generated opaque key that makes a non-idempotent request safe to retry. - schema: - type: string - description: Opaque idempotency key retained for the BB's documented replay window. - minLength: 1 - maxLength: 255 - PageSize: - name: pageSize - in: query - required: false - description: Maximum number of resources returned in one page. - schema: - type: integer - description: Requested page size within the documented bounds. - minimum: 1 - default: 20 - maximum: 100 - Cursor: - name: cursor - in: query - required: false - description: Opaque cursor returned as pageInfo.nextCursor by the previous page. - schema: - type: string - description: Opaque server-generated continuation cursor. - OperationId: - name: operationId - in: path - required: true - description: Opaque identifier of a long-running Operation resource. - schema: - type: string - format: uuid - description: UUID assigned to the Operation by the server. - headers: - Location: - description: URI reference of the created resource or accepted Operation. - schema: - type: string - format: uri-reference - description: URI reference that the caller can subsequently retrieve. - ETag: - description: Entity tag representing the version of the returned resource. - schema: - type: string - description: Opaque entity tag suitable for a conditional request. - CacheControl: - description: Cache directive; error and Operation status responses use no-store. - schema: - type: string - description: HTTP Cache-Control field value. - example: no-store - WwwAuthenticate: - description: OAuth 2.0 Bearer authentication challenge. - schema: - type: string - description: RFC 6750 Bearer challenge. - example: 'Bearer realm="govstack", error="invalid_token"' - RetryAfter: - description: Number of seconds the caller waits before retrying. - schema: - type: integer - description: Non-negative retry delay in seconds. - minimum: 0 - responses: - NotModified: - description: The resource has not changed since the supplied entity tag. - headers: - ETag: - $ref: '#/components/headers/ETag' - BadRequest: - description: The request is malformed or contains invalid fields. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/ValidationProblem' - example: - type: https://docs.govstack.global/errors/common/invalidArgument - title: Request validation failed - status: 400 - detail: One request field is invalid. - instance: /v1/records - code: global.govstack.common.invalidArgument - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - errors: - - pointer: /name - code: global.govstack.common.invalidArgument - message: Name must not be blank. - Unauthorized: - description: Authentication is missing or invalid. - headers: - WWW-Authenticate: - $ref: '#/components/headers/WwwAuthenticate' - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://docs.govstack.global/errors/common/unauthenticated - title: Authentication required - status: 401 - code: global.govstack.common.unauthenticated - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - Forbidden: - description: The authenticated caller is not authorised for the operation. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://docs.govstack.global/errors/common/permissionDenied - title: Permission denied - status: 403 - code: global.govstack.common.permissionDenied - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - NotFound: - description: The addressed resource does not exist. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://docs.govstack.global/errors/common/notFound - title: Resource not found - status: 404 - code: global.govstack.common.notFound - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - Conflict: - description: The request conflicts with the current resource state. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://docs.govstack.global/errors/common/aborted - title: State conflict - status: 409 - code: global.govstack.common.aborted - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - UnprocessableContent: - description: The request is well formed but cannot be processed semantically. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://docs.govstack.global/errors/common/invalidArgument - title: Request cannot be processed - status: 422 - code: global.govstack.common.invalidArgument - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - TooManyRequests: - description: The caller has exceeded an applicable request limit. - headers: - Retry-After: - $ref: '#/components/headers/RetryAfter' - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://docs.govstack.global/errors/common/resourceExhausted - title: Request limit exceeded - status: 429 - code: global.govstack.common.resourceExhausted - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' - InternalError: - description: An unexpected server error occurred. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://docs.govstack.global/errors/common/internal - title: Internal error - status: 500 - code: global.govstack.common.internal - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - timestamp: '2026-07-10T12:00:00Z' schemas: - CommonErrorCode: - type: string - description: >- - The cross-BB common error catalogue (guide 11.7), modelled on - google.rpc.Code and named with the reverse-DNS convention of guide 11.5. - A BB-specific error uses the same shape with the BB's own registered - code and is not listed here. - enum: - - global.govstack.common.unauthenticated - - global.govstack.common.permissionDenied - - global.govstack.common.notFound - - global.govstack.common.invalidArgument - - global.govstack.common.alreadyExists - - global.govstack.common.aborted - - global.govstack.common.resourceExhausted - - global.govstack.common.internal - - global.govstack.common.unimplemented Problem: type: object description: >- - RFC 9457 problem details with stable GovStack error code and correlation - fields. Error text contains no personal data or system-internal detail. + RFC 9457 problem details with one stable machine identifier in type and + a trace identifier for correlation. Error text contains no personal + data or system-internal detail. required: - type - title - status - - code - traceId - - timestamp properties: type: type: string format: uri - description: Stable absolute URI identifying the problem type. + pattern: '^https://govstack\.global/problems/[a-z][a-z0-9-]{1,30}/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$' + description: >- + Stable GovStack problem-type URI and machine identifier in the form + https://govstack.global/problems/{bb-code}/{problem-slug}, where the + problem slug uses kebab-case. title: type: string + minLength: 1 description: Short human-readable summary of the problem type. status: type: integer @@ -337,33 +49,16 @@ components: type: string format: uri-reference description: URI reference identifying this problem occurrence. - code: - type: string - pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*$' - description: >- - Stable namespaced machine-readable error identifier. A cross-BB - common error uses a value from CommonErrorCode; a BB-specific error - uses the BB's own registered code in the same shape. - example: global.govstack.common.internal traceId: type: string - pattern: '^[\da-f]{32}$' + pattern: '^(?!0{32}$)[\da-f]{32}$' description: The 32-hex-digit trace-id component of the effective request traceparent. - timestamp: - type: string - format: date-time - description: RFC 3339 time at which the error occurred. - errors: - type: array - description: Field-level validation failures, omitted for non-field errors. - items: - $ref: '#/components/schemas/FieldError' ValidationProblem: description: Problem details for a request containing one or more invalid fields. allOf: - $ref: '#/components/schemas/Problem' - type: object - description: Extension requiring field-level error details. + description: Field-level validation details. required: - errors properties: @@ -375,83 +70,36 @@ components: $ref: '#/components/schemas/FieldError' FieldError: type: object - description: Machine-readable failure associated with one request field. + description: Human-readable failure associated with one request field. required: - pointer - - code - message properties: pointer: type: string + format: json-pointer description: JSON Pointer identifying the invalid request field. - code: - type: string - description: Stable namespaced code for this field failure. message: type: string + minLength: 1 description: Human-readable explanation of the field failure. PageInfo: type: object description: Cursor metadata for a bounded collection response. required: - nextCursor - - hasMore properties: nextCursor: type: - string - 'null' - description: Opaque cursor for the next page, or null on the final page. - hasMore: - type: boolean - description: Whether another page is available. + minLength: 1 + description: Opaque non-empty cursor for the next page, or null on the final page. total: type: integer minimum: 0 - description: Optional total number of matching resources when inexpensive to compute. - Operation: - type: object - description: Pollable representation of long-running work. - required: - - id - - status - - createdAt - - updatedAt - properties: - id: - type: string - format: uuid - description: Opaque server-generated identifier of the Operation. - status: - type: string - description: 'Current lifecycle state: PENDING, RUNNING, SUCCEEDED, FAILED, or CANCELLED.' - enum: - - PENDING - - RUNNING - - SUCCEEDED - - FAILED - - CANCELLED - result: - type: - - object - - 'null' - description: Result metadata when the Operation succeeds. - error: - description: Problem details when the Operation fails. - oneOf: - - $ref: '#/components/schemas/Problem' - - type: 'null' - description: No error has occurred. - createdAt: - type: string - format: date-time - description: RFC 3339 time at which the Operation was created. - updatedAt: - type: string - format: date-time - description: RFC 3339 time at which the Operation last changed. - progress: - type: integer - minimum: 0 - maximum: 100 - description: Optional completion percentage from zero through one hundred. + description: >- + Optional total number of matching resources when inexpensive to + compute. Each BB use must document whether the value is exact or + estimated and whether it describes the current collection or the + snapshot used to produce the first page. diff --git a/api/openapi.yaml b/api/openapi.yaml index f151088..2844a9b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -10,10 +10,10 @@ info: name: GovStack API Working Group url: https://www.govstack.global/ x-govstack-api-guide: - version: 0.2.0-draft - rulesetVersion: 0.2.0-draft + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft x-govstack-common-components: - openapi: 0.1.0 + openapi: 0.1.0-draft x-govstack-bb-code: template servers: - url: https://{gatewayHost}/{bbCode} @@ -51,15 +51,15 @@ paths: - BuildingBlockOAuth: - bb:template:records:read parameters: - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/PageSize' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Cursor' + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Cursor' responses: '200': description: A page of reference records. headers: ETag: - $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + $ref: '#/components/headers/ETag' content: application/json: schema: @@ -73,17 +73,16 @@ paths: updatedAt: '2026-07-10T10:00:00Z' pageInfo: nextCursor: pgn_7JpQ9m2W4xK8fR3cT6vN1 - hasMore: true '304': - $ref: './common/govstack-openapi-common.yaml#/components/responses/NotModified' + $ref: '#/components/responses/NotModified' '400': - $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + $ref: '#/components/responses/BadRequest' '401': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + $ref: '#/components/responses/Unauthorized' '403': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + $ref: '#/components/responses/Forbidden' '500': - $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + $ref: '#/components/responses/InternalError' post: operationId: createRecord summary: Create a reference record @@ -96,8 +95,8 @@ paths: - BuildingBlockOAuth: - bb:template:records:write parameters: - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true description: Values supplied by the caller for the new reference record. @@ -113,9 +112,9 @@ paths: description: Reference record created. headers: Location: - $ref: './common/govstack-openapi-common.yaml#/components/headers/Location' + $ref: '#/components/headers/Location' ETag: - $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + $ref: '#/components/headers/ETag' content: application/json: schema: @@ -127,17 +126,17 @@ paths: createdAt: '2026-07-10T10:00:00Z' updatedAt: '2026-07-10T10:00:00Z' '400': - $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + $ref: '#/components/responses/BadRequest' '401': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + $ref: '#/components/responses/Unauthorized' '403': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + $ref: '#/components/responses/Forbidden' '409': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Conflict' + $ref: '#/components/responses/Conflict' '422': - $ref: './common/govstack-openapi-common.yaml#/components/responses/UnprocessableContent' + $ref: '#/components/responses/UnprocessableContent' '500': - $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + $ref: '#/components/responses/InternalError' /v1/records/{recordId}: get: operationId: getRecord @@ -152,13 +151,13 @@ paths: - bb:template:records:read parameters: - $ref: '#/components/parameters/RecordId' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: The requested reference record. headers: ETag: - $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + $ref: '#/components/headers/ETag' content: application/json: schema: @@ -170,17 +169,17 @@ paths: createdAt: '2026-07-10T10:00:00Z' updatedAt: '2026-07-10T10:00:00Z' '304': - $ref: './common/govstack-openapi-common.yaml#/components/responses/NotModified' + $ref: '#/components/responses/NotModified' '400': - $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + $ref: '#/components/responses/BadRequest' '401': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + $ref: '#/components/responses/Unauthorized' '403': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + $ref: '#/components/responses/Forbidden' '404': - $ref: './common/govstack-openapi-common.yaml#/components/responses/NotFound' + $ref: '#/components/responses/NotFound' '500': - $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + $ref: '#/components/responses/InternalError' /v1/exports: post: operationId: requestRecordExport @@ -192,8 +191,8 @@ paths: - BuildingBlockOAuth: - bb:template:exports:write parameters: - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true description: Criteria and output format for the record export. @@ -209,31 +208,31 @@ paths: description: Export accepted for asynchronous processing. headers: Location: - $ref: './common/govstack-openapi-common.yaml#/components/headers/Location' + $ref: '#/components/headers/Location' Cache-Control: - $ref: './common/govstack-openapi-common.yaml#/components/headers/CacheControl' + $ref: '#/components/headers/CacheControl' content: application/json: schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Operation' + $ref: '#/components/schemas/Operation' example: - id: b41558c8-c248-4c39-88d5-fbc9a32326e4 + id: op_01J2P8WTM4YH7K6Q3N5R9C0XBF status: PENDING createdAt: '2026-07-10T10:05:00Z' updatedAt: '2026-07-10T10:05:00Z' progress: 0 '400': - $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + $ref: '#/components/responses/BadRequest' '401': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + $ref: '#/components/responses/Unauthorized' '403': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + $ref: '#/components/responses/Forbidden' '409': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Conflict' + $ref: '#/components/responses/Conflict' '422': - $ref: './common/govstack-openapi-common.yaml#/components/responses/UnprocessableContent' + $ref: '#/components/responses/UnprocessableContent' '500': - $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + $ref: '#/components/responses/InternalError' /v1/operations/{operationId}: get: operationId: getOperation @@ -245,38 +244,38 @@ paths: - BuildingBlockOAuth: - bb:template:operations:read parameters: - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/OperationId' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + - $ref: '#/components/parameters/OperationId' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: Current Operation state. headers: ETag: - $ref: './common/govstack-openapi-common.yaml#/components/headers/ETag' + $ref: '#/components/headers/ETag' Cache-Control: - $ref: './common/govstack-openapi-common.yaml#/components/headers/CacheControl' + $ref: '#/components/headers/CacheControl' content: application/json: schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Operation' + $ref: '#/components/schemas/Operation' example: - id: b41558c8-c248-4c39-88d5-fbc9a32326e4 + id: op_01J2P8WTM4YH7K6Q3N5R9C0XBF status: RUNNING createdAt: '2026-07-10T10:05:00Z' updatedAt: '2026-07-10T10:05:10Z' progress: 40 '304': - $ref: './common/govstack-openapi-common.yaml#/components/responses/NotModified' + $ref: '#/components/responses/NotModified' '400': - $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + $ref: '#/components/responses/BadRequest' '401': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + $ref: '#/components/responses/Unauthorized' '403': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + $ref: '#/components/responses/Forbidden' '404': - $ref: './common/govstack-openapi-common.yaml#/components/responses/NotFound' + $ref: '#/components/responses/NotFound' '500': - $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + $ref: '#/components/responses/InternalError' /v1/operations/{operationId}/cancel: post: operationId: cancelOperation @@ -288,37 +287,37 @@ paths: - BuildingBlockOAuth: - bb:template:operations:write parameters: - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/OperationId' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/OperationId' + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IdempotencyKey' responses: '400': - $ref: './common/govstack-openapi-common.yaml#/components/responses/BadRequest' + $ref: '#/components/responses/BadRequest' '200': description: Operation after the cancellation request was applied. headers: Cache-Control: - $ref: './common/govstack-openapi-common.yaml#/components/headers/CacheControl' + $ref: '#/components/headers/CacheControl' content: application/json: schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Operation' + $ref: '#/components/schemas/Operation' example: - id: b41558c8-c248-4c39-88d5-fbc9a32326e4 + id: op_01J2P8WTM4YH7K6Q3N5R9C0XBF status: CANCELLED createdAt: '2026-07-10T10:05:00Z' updatedAt: '2026-07-10T10:06:00Z' progress: 40 '401': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Unauthorized' + $ref: '#/components/responses/Unauthorized' '403': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Forbidden' + $ref: '#/components/responses/Forbidden' '404': - $ref: './common/govstack-openapi-common.yaml#/components/responses/NotFound' + $ref: '#/components/responses/NotFound' '409': - $ref: './common/govstack-openapi-common.yaml#/components/responses/Conflict' + $ref: '#/components/responses/Conflict' '500': - $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + $ref: '#/components/responses/InternalError' /health: get: operationId: getHealth @@ -328,19 +327,21 @@ paths: - Health security: [] parameters: - - $ref: './common/govstack-openapi-common.yaml#/components/parameters/Traceparent' + - $ref: '#/components/parameters/Traceparent' responses: '200': description: The Building Block is live. content: - application/health+json: + application/json: schema: $ref: '#/components/schemas/Health' example: - status: pass + status: PASS description: Reference API is live. + '503': + $ref: '#/components/responses/ServiceUnavailable' '500': - $ref: './common/govstack-openapi-common.yaml#/components/responses/InternalError' + $ref: '#/components/responses/InternalError' components: securitySchemes: CitizenOAuth: @@ -369,6 +370,51 @@ components: bb:template:operations:read: Read long-running Operation state. bb:template:operations:write: Request cancellation of long-running Operations. parameters: + Traceparent: + name: traceparent + in: header + required: false + description: W3C Trace Context parent identifier propagated across service boundaries. + schema: + type: string + description: W3C Trace Context traceparent value. + pattern: '^00-(?!0{32})[\da-f]{32}-(?!0{16})[\da-f]{16}-0[01]$' + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: Client-generated opaque key that makes this non-idempotent request safe to retry. + schema: + type: string + minLength: 1 + maxLength: 255 + description: Opaque key retained for this API's documented replay window. + PageSize: + name: pageSize + in: query + required: false + description: Maximum number of records returned in one page by this API. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + Cursor: + name: cursor + in: query + required: false + description: Opaque cursor returned as pageInfo.nextCursor by the previous page. + schema: + type: string + minLength: 1 + OperationId: + name: operationId + in: path + required: true + description: Opaque server-generated identifier of a long-running Operation resource. + schema: + type: string + minLength: 1 RecordId: name: recordId in: path @@ -378,6 +424,154 @@ components: type: string format: uuid description: UUID assigned to a reference record by the server. + headers: + Location: + description: URI reference of the created resource or accepted Operation. + schema: + type: string + format: uri-reference + ETag: + description: Entity tag representing the version of the returned resource. + schema: + type: string + minLength: 1 + CacheControl: + description: Cache directive preventing storage of transient errors or Operation state. + schema: + type: string + example: no-store + WwwAuthenticate: + description: OAuth 2.0 Bearer authentication challenge. + schema: + type: string + example: 'Bearer realm="govstack", error="invalid_token"' + responses: + NotModified: + description: The resource has not changed since the supplied entity tag. + headers: + ETag: + $ref: '#/components/headers/ETag' + BadRequest: + description: The request is malformed or cannot be interpreted. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + example: + type: https://govstack.global/problems/template/bad-request + title: Bad request + status: 400 + detail: The request could not be interpreted. + instance: /v1/records + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + Unauthorized: + description: Authentication is missing or invalid. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WwwAuthenticate' + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + example: + type: https://govstack.global/problems/template/unauthorized + title: Authentication required + status: 401 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + Forbidden: + description: The authenticated caller is not authorised for the operation. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + example: + type: https://govstack.global/problems/template/forbidden + title: Permission denied + status: 403 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + NotFound: + description: The addressed resource does not exist. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + example: + type: https://govstack.global/problems/template/not-found + title: Resource not found + status: 404 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + Conflict: + description: The request conflicts with the current resource state. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + example: + type: https://govstack.global/problems/template/conflict + title: State conflict + status: 409 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + UnprocessableContent: + description: The request is well formed but contains invalid field values. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/ValidationProblem' + example: + type: https://govstack.global/problems/template/invalid-field + title: Request validation failed + status: 422 + detail: The requested record status is invalid. + instance: /v1/records + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + errors: + - pointer: /status + message: Status must be ACTIVE or ARCHIVED. + InternalError: + description: An unexpected server error occurred. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + example: + type: https://govstack.global/problems/template/internal-error + title: Internal error + status: 500 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 + ServiceUnavailable: + description: The service is temporarily unavailable. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + example: + type: https://govstack.global/problems/template/service-unavailable + title: Service unavailable + status: 503 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 schemas: CreateRecordRequest: type: object @@ -443,6 +637,9 @@ components: items: $ref: '#/components/schemas/Record' pageInfo: + description: >- + Page metadata. total, when present, is exact and describes the + collection at the time this page is generated. $ref: './common/govstack-openapi-common.yaml#/components/schemas/PageInfo' ExportRequest: type: object @@ -464,6 +661,47 @@ components: - ACTIVE - ARCHIVED x-extensible-enum: true + Operation: + type: object + description: Pollable representation of long-running work owned by this API. + required: + - id + - status + - createdAt + - updatedAt + properties: + id: + type: string + minLength: 1 + description: Opaque server-generated Operation identifier. + status: + type: string + description: 'Current lifecycle state: PENDING, RUNNING, SUCCEEDED, FAILED, or CANCELLED.' + enum: + - PENDING + - RUNNING + - SUCCEEDED + - FAILED + - CANCELLED + x-extensible-enum: true + result: + type: object + description: Result metadata present when the Operation succeeds. + error: + $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' + createdAt: + type: string + format: date-time + description: RFC 3339 time at which the Operation was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 time at which the Operation last changed. + progress: + type: integer + minimum: 0 + maximum: 100 + description: Optional completion percentage from zero through one hundred. Health: type: object description: Minimal operational liveness response without internal details. @@ -472,11 +710,11 @@ components: properties: status: type: string - description: Liveness state defined by the adopted health-check convention. + description: Template-defined informational liveness state. enum: - - pass - - fail - - warn + - PASS + - FAIL + - WARN x-extensible-enum: false description: type: string From 30b64768c6352297175e3ab83ff769977ef1a1e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Thu, 6 Aug 2026 15:50:56 +0700 Subject: [PATCH 18/19] Simplify API guide conformance model Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- api-design-guide/1-introduction.md | 51 ++- api-design-guide/README.md | 15 +- api-design-guide/SUMMARY.md | 4 +- api-design-guide/all-rules.md | 22 +- .../appendix/a-companion-documents.md | 18 - api-design-guide/appendix/b-open-questions.md | 24 -- .../appendix/c-normative-references.md | 43 --- api-design-guide/appendix/references.md | 54 +++ api-design-guide/guides/README.md | 4 +- .../guides/maintaining-this-guide.md | 4 - .../guides/spec-editor-checklist.md | 12 +- api-design-guide/how-to-use-this-guide.md | 4 +- api-design-guide/linter/README.md | 20 +- api-design-guide/linter/cli.mjs | 200 ++++++++--- api-design-guide/linter/coverage.yaml | 62 ++-- .../linter/functions/s03-asyncOperation.js | 8 - .../linter/functions/s12-sortParam.js | 6 +- .../linter/functions/s15-cancelPath.js | 4 +- api-design-guide/linter/rulesets/s02.yaml | 17 +- api-design-guide/linter/rulesets/s03.yaml | 14 +- api-design-guide/linter/rulesets/s04.yaml | 10 +- .../linter/rulesets/s05-strict.yaml | 2 +- api-design-guide/linter/rulesets/s05.yaml | 18 +- api-design-guide/linter/rulesets/s06.yaml | 6 +- api-design-guide/linter/rulesets/s09.yaml | 14 +- api-design-guide/linter/rulesets/s12.yaml | 19 +- api-design-guide/linter/rulesets/s15.yaml | 4 +- api-design-guide/linter/rulesets/s16.yaml | 31 +- api-design-guide/linter/rulesets/s17.yaml | 10 +- api-design-guide/linter/rulesets/s18.yaml | 8 +- api-design-guide/linter/tests/driver.test.mjs | 229 ++++++++++-- .../govstack-16.3-no-version/fail.yaml | 33 ++ .../govstack-16.3-no-version/pass.yaml | 33 ++ .../tests/fixtures/govstack-16.3/fail.yaml | 4 +- .../tests/fixtures/govstack-3.5/fail.yaml | 3 +- .../tests/fixtures/govstack-3.7/fail.yaml | 2 +- .../part-a/2-openapi-document-standards.md | 4 +- .../part-a/3-asyncapi-document-standards.md | 4 +- .../part-a/4-documentation-requirements.md | 37 +- .../part-b/5-url-structure-and-versioning.md | 14 +- api-design-guide/part-b/6-http-methods.md | 4 +- .../part-b/7-http-status-codes.md | 4 +- .../part-c/12-pagination-filtering-sorting.md | 6 +- .../part-c/9-json-conventions-and-naming.md | 10 +- .../13-authentication-and-authorisation.md | 4 +- api-design-guide/part-d/14-idempotency.md | 2 +- .../part-d/15-asynchronous-operations.md | 4 +- .../part-d/16-cloudevents-and-webhooks.md | 4 +- .../part-d/17-asyncapi-channel-rules.md | 12 +- .../part-d/18-compatibility-and-lifecycle.md | 10 +- api-design-guide/part-e/19-localisation.md | 6 +- .../part-e/20-conformance-and-validation.md | 2 +- api-design-guide/rules.yaml | 329 +++++------------- api-design-guide/tools/build_rules_index.py | 25 +- api-design-guide/tools/check_links.py | 63 +--- api/coverage.yaml | 29 +- spec/1-version-history.md | 5 +- spec/5-cross-cutting-requirements.md | 21 +- spec/6-functional-requirements.md | 35 +- spec/8-service-apis.md | 19 +- spec/9-workflows.md | 4 +- spec/README.md | 11 +- 62 files changed, 920 insertions(+), 760 deletions(-) delete mode 100644 api-design-guide/appendix/a-companion-documents.md delete mode 100644 api-design-guide/appendix/b-open-questions.md delete mode 100644 api-design-guide/appendix/c-normative-references.md create mode 100644 api-design-guide/appendix/references.md create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/fail.yaml create mode 100644 api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/pass.yaml diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md index 8895a03..0d899d4 100644 --- a/api-design-guide/1-introduction.md +++ b/api-design-guide/1-introduction.md @@ -23,7 +23,7 @@ The test for inclusion: *would two BB editors writing two different specs need t - The schema-only companion files `govstack-openapi-common.yaml` (`Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`) and `govstack-asyncapi-common.yaml` (the reusable event envelope and transport-neutral asynchronous error schemas). OpenAPI reuse is optional; BBs keep security schemes, parameters, headers, responses, examples, and Operation resources local. - The repository-level API inventory (`api/index.yaml`, when the default canonical paths are insufficient) and functional-requirement traceability file (`api/coverage.yaml`). -CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally limited: BBs declare the relevant bindings where they affect the contract, while detailed broker-operation guidance belongs in the Security & Operations companion or a protocol profile. This reflects the current BB landscape being predominantly REST, not a judgement that the ecosystem should remain so. +CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally limited: BBs declare the relevant bindings where they affect the contract, while detailed broker-operation guidance is outside this guide or belongs in a protocol profile. This reflects the current BB landscape being predominantly REST, not a judgement that the ecosystem should remain so. The decision tree below summarises which artifact documents which kind of surface (informative). The document-level rules are in [§2](part-a/2-openapi-document-standards.md) and [§3](part-a/3-asyncapi-document-standards.md); the event rules are in [§16](part-d/16-cloudevents-and-webhooks.md) and [§17](part-d/17-asyncapi-channel-rules.md). @@ -32,6 +32,7 @@ flowchart TD Q{"What kind of API surface?"} -->|"Synchronous HTTP request-response"| R["REST: OpenAPI 3.1<br/>default api/openapi.yaml or api/index.yaml entry"] Q -->|"HTTP push to subscriber URLs"| W["Webhooks: OpenAPI 3.1 webhooks section"] Q -->|"Brokered channels or event streams<br/>(MQTT, AMQP, Kafka, WebSockets, SSE)"| A["AsyncAPI 3.0<br/>default api/asyncapi.yaml or api/index.yaml entry"] + Q -->|"Recognised protocol-native surface<br/>(for example OIDC, OID4VCI, SDMX, OGC)"| S["Normative standard and discovery metadata<br/>type: standard in api/index.yaml"] W --> CE["Domain events use the CloudEvents v1.0.2 envelope"] A --> CE ``` @@ -40,18 +41,40 @@ A BB MAY additionally expose surfaces under other industry standards (for exampl **Out of scope:** -- **Operational behaviour of a deployed BB** (token validation, certificate trust, key rotation, replay enforcement, audit logging, log redaction, alg allowlists, FAPI conformance, infrastructure). Belongs in a separate **GovStack API Security & Operations** companion (not yet drafted). -- **Ecosystem governance** (ratification, enforcement, exception lifecycle, transition timelines, conformance levels, companion-artifact ownership). Expected to be defined in the proposed **GovStack API Lifecycle & Governance** companion document, reconciled with the existing GovStack Specification Framework and CFR compliance model. +- **Operational behaviour of a deployed BB** (token validation, certificate trust, key rotation, replay enforcement, audit logging, log redaction, algorithm allowlists, FAPI conformance, infrastructure). +- **Ecosystem governance** (ratification, enforcement, exception lifecycle, transition timelines, conformance levels, artifact ownership). These decisions belong to the GovStack governance process and the existing Specification Framework. - Performance, SLOs, capacity planning. - gRPC, GraphQL, file protocols, bulk media streaming. - Implementation guidance for any specific BB. -- Design and maintenance of conformance test packs (separate companion artifact). +- Design and maintenance of implementation conformance test packs. ## 1.3 Relationship to existing GovStack documents <a href="#13-relationship-to-existing-govstack-documents" id="13-relationship-to-existing-govstack-documents"></a> -This guide is a GovStack specification that extends the Cross-Functional Requirements. Under the GovStack Specification Framework an extending specification may tighten or elaborate a cross-functional requirement but **MUST NOT** contradict or weaken one, and a requirement classified IMMUTABLE cannot be altered at all. Where a rule here inherits a cross-functional requirement it cites the requirement identifier, for example `govstack-cfr-data#req-2` in [§10.2](part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps), so that the inheritance and its immutability are visible at the point of use. - -Where this guide overlaps with existing GovStack requirements or BB-specific conventions in ways that rule does not settle, precedence must be settled through ratification and reconciliation with the existing GovStack Specification Framework and CFR compliance model. A full reconciliation matrix will accompany v1.0. +This guide is a candidate GovStack specification intended to extend the Cross-Functional Requirements. It is not yet a conformant CFR extension: its protocol-native and non-HTTP interface rules propose changes to current CFR wording, tracked in [cfr-architecture issue #7](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/7) and [issue #8](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/8). Under the GovStack Specification Framework an extending specification may tighten or elaborate a cross-functional requirement but **MUST NOT** contradict or weaken one, and a requirement classified IMMUTABLE cannot be altered at all. Where a rule here inherits a cross-functional requirement it cites the requirement identifier, for example `govstack-cfr-data#req-2` in [§10.2](part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps), so that the inheritance and its immutability are visible at the point of use. + +The guide's candidate specification identifier is `govstack-cfr-api`, its current version is `0.1.0-draft`, and its proposed parent is the developing `govstack-cfr` specification. The proposed relationships are: + +| Guide rules | CFR requirement | Relationship | +|---|---|---| +| §2.1–§2.4, §20.1 | `govstack-cfr-quality#req-4` | OpenAPI versions, discovery, and validation for applicable HTTP APIs. | +| §3, §16.1–§16.4, §17 | `govstack-cfr-architecture#req-19` | AsyncAPI and CloudEvents documentation for asynchronous interfaces. | +| §5.1, §18.1–§18.4 | `govstack-cfr-architecture#req-3` | Explicit versioning and compatibility. | +| §6.1, §6.3, §6.5, §14 | `govstack-cfr-architecture#req-5` | HTTP and idempotency behaviour. | +| §8.6, §17.3 | `govstack-cfr-architecture#req-6` | Data-protection constraints for addresses and metadata. | +| §8.4, §11.3, §17.8 | `govstack-cfr-architecture#req-13` | Trace Context and correlation. | +| §5.9 | `govstack-cfr-architecture#req-15` | Liveness contract. | +| §15.6, §16.1 | `govstack-cfr-architecture#req-14` | Callbacks and completion notifications. | +| §18.5, §18.7 | `govstack-cfr-architecture#req-17` | Consumer-visible deprecation. | +| §9.8, §9.9, §18.3, §18.6 | `govstack-cfr-architecture#req-18` | Tolerant-reader and additive-change behaviour. | +| §8.2, §19 | `govstack-cfr-quality#req-8` | Localisation. | +| §8.1, §13.1–§13.6, §17.10 | `govstack-cfr-security#req-3` | OIDC, OAuth, and service authentication declarations. | +| §11.2, §11.4 | `govstack-cfr-security#req-16` | Structured validation errors without exposed internals. | +| §10.11 | `govstack-cfr-data#req-1` | Immutable UTF-8 requirement. | +| §10.2 | `govstack-cfr-data#req-2` | Immutable UTC timestamp requirement. | +| §9.1, §10.3–§10.10 | `govstack-cfr-data#req-3` | Interoperable data representations and code lists. | +| §13.7 | `govstack-cfr-security#req-1` | Immutable protected-transport outcome. | + +These mappings are alignment references, not formal inheritance declarations. They become formal only after the conflicting parent wording is resolved and each normative guide rule has a CFR identifier and classifiers. This guide does not replace `govstack-cfr-architecture#req-7`; while that requirement remains in CFR, it continues to apply independently. Passing this guide's linter therefore does not by itself prove complete CFR conformance. ## 1.4 Audience <a href="#14-audience" id="14-audience"></a> @@ -67,7 +90,7 @@ The guide uses RFC 2119 language: **MUST**, **MUST NOT**, **SHOULD**, **SHOULD N ## 1.6 Exception process <a href="#16-exception-process" id="16-exception-process"></a> -A BB editor **MAY** propose deviating from a **MUST** rule through the exception process to be defined in the proposed **GovStack API Lifecycle & Governance** companion document. An exception is effective only after approval and only for its recorded scope and lifetime. The canonical specification **MUST** declare each approved exception using the exact fields in [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); an expired entry or one with a missing or syntactically invalid exception record URI does not suppress a rule. The submission, review, renewal, public-log, and revocation workflow remains governance. +A BB editor **MAY** propose deviating from a **MUST** rule through the GovStack governance process. An exception **MUST NOT** weaken an inherited IMMUTABLE requirement, broaden an inherited EXTENSIBLE requirement, or replace a parent requirement that the GovStack Requirements Model does not permit replacing. An exception is effective only after approval and only for its recorded scope and lifetime. The canonical specification **MUST** declare each approved exception using the exact fields in [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); an expired entry or one with a missing or syntactically invalid exception record URI does not suppress a rule. Until GovStack designates an approval authority and record system, no proposed exception can suppress conformance findings. ## 1.7 Precedence of external standards <a href="#17-precedence-of-external-standards" id="17-precedence-of-external-standards"></a> @@ -89,22 +112,22 @@ This guide sits at the top of a stack, and keeping the layers distinct is what s - **This guide** fixes the *shape* of every BB API (the rules below). It is generic and binds the spec author. - **A BB specification** (the OpenAPI/AsyncAPI document for one BB) fills that shape with concrete content: which resources, fields, status codes, scopes, and error names that BB has. The guide constrains the shape; the BB spec owns the content. - **An implementation profile** records the concrete deployment values a spec deliberately leaves open: server URLs, identity-provider endpoints, replay windows, retry counts, retention periods. The guide never fixes these. -- **A running deployment** has operational behaviour (token validation, key rotation, replay enforcement, logging) that no specification expresses. That is the **GovStack API Security & Operations** companion's domain ([§1.2](#12-scope)). +- **A running deployment** has operational behaviour (token validation, key rotation, replay enforcement, logging) that no specification expresses. Those controls are outside this guide's scope ([§1.2](#12-scope)). ```mermaid flowchart TB G["This guide<br/>fixes the shape of every BB API"] --> S["BB specification<br/>fills the shape with one BB's content"] S --> P["Implementation profile<br/>records deployment values the spec leaves open"] - P --> D["Running deployment<br/>operational behaviour, owned by the Security and Operations companion"] + P --> D["Running deployment<br/>operational behaviour outside this guide"] ``` A rule earns a place in this guide only if it constrains the specification document. Every rule is therefore one of three kinds: 1. **Spec-shape** rules constrain what the spec declares and are verifiable by reading the file (for example, [§9.2](part-c/9-json-conventions-and-naming.md#92-camelcase-field-names) camelCase, [§11.1](part-c/11-errors.md#111-rfc-9457-problem-details) `application/problem+json`). 2. **Documentation-obligation** rules require the spec to write down a contract whose value the guide does not itself fix (for example, [§14.3](part-d/14-idempotency.md#143-documented-replay-window), which makes the spec document its replay-window contract, and [§16.10](part-d/16-cloudevents-and-webhooks.md#1610-documented-delivery-failure-contract)). -3. **Behavioural-contract** rules state run-time behaviour an integrator relies on across BBs (for example, [§14.4](part-d/14-idempotency.md#144-replay-returns-original-response) idempotent replay, [§19.1](part-e/19-localisation.md#191-honour-the-request-language) honouring the request language). They are part of the interface contract but cannot be linted from the spec; they are verified by the conformance test pack ([Appendix A](appendix/a-companion-documents.md)). +3. **Behavioural-contract** rules state run-time behaviour an integrator relies on across BBs (for example, [§14.4](part-d/14-idempotency.md#144-replay-returns-original-response) idempotent replay, [§19.1](part-e/19-localisation.md#191-honour-the-request-language) honouring the request language). They are part of the interface contract but cannot be linted from the spec; they require implementation-level conformance testing. -What the guide does **not** do is mandate a concrete deployment value (an implementation profile's job) or operational behaviour (the Security & Operations companion's job). Where a section mixes the three kinds, a **Layer** note at the top of that section says which rules fall where. +What the guide does **not** do is mandate a concrete deployment value or operational behaviour. Those concerns are outside its scope. Where a section mixes the three kinds, a **Layer** note at the top of that section says which rules fall where. This axis is orthogonal to the enforcement class of [§1.9](#19-rule-enforcement-classes). Spec-shape rules are usually `[M]` or `[M+R]`; behavioural-contract rules are `[R]`, because no linter can reach run-time behaviour, though the rule is no less binding and is still verified through conformance testing. @@ -116,12 +139,12 @@ Each numbered rule carries an enforcement-class tag, shown as a bold badge at th - **`[R]` Review.** The rule requires human judgement; no reliable automated check exists. Conformance is assessed through specification review and the conformance process. - **`[M+R]` Partly machine-checkable.** A linter can verify the structural part (presence, shape, naming, declared values), but a human reviewer must confirm the semantic part: whether the right construct was used for the right meaning. -The tag is guidance for the ruleset author and the conformance process, not part of the normative requirement: an `[M]` MUST and an `[R]` MUST are equally binding. The tag only marks where mechanical enforcement ends and review begins. Purely informative or scoping statements (for example [§12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope), [§16.9](part-d/16-cloudevents-and-webhooks.md#169-readiness-for-a-shared-signature-profile), [§18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields)) carry no tag. The machine-checkable subset that [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) expects the Spectral ruleset to cover is the `[M]` rules plus the mechanical portion of the `[M+R]` rules. Enforceability of a rule may also depend on a referenced companion artifact, such as the conditional OpenAPI schema reuse in [§2.8](part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas) or the AsyncAPI schemas in [§3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components). Sequencing publication of those artifacts against the pilot and ratification plan is governance, not design. +The tag is guidance for the ruleset author and the conformance process, not part of the normative requirement: an `[M]` MUST and an `[R]` MUST are equally binding. The tag only marks where mechanical enforcement ends and review begins. Purely informative or scoping statements (for example [§12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope), [§16.9](part-d/16-cloudevents-and-webhooks.md#169-readiness-for-a-shared-signature-profile), [§18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields)) carry no tag. The machine-checkable subset that [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) expects the Spectral ruleset to cover is the `[M]` rules plus the mechanical portion of the `[M+R]` rules. Where a rule depends on a referenced schema artifact, such as the conditional OpenAPI reuse in [§2.8](part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas) or the AsyncAPI schemas in [§3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components), that dependency is stated directly in the rule. ## 1.10 Applicability and transition <a href="#110-applicability-and-transition" id="110-applicability-and-transition"></a> This guide applies in full to new API surfaces and to new major versions of existing surfaces. An already-published BB specification is not retroactively wire-incompatible merely because a newer guide exists. Existing surfaces **MUST** adopt requirements that do not change their public wire contract as soon as practical: canonical-file designation, `api/index.yaml` where needed, `api/coverage.yaml`, validation, complete metadata and descriptions, accurate examples, and security declarations that describe the behaviour already deployed. A change to paths, field names, representations, identifiers, status semantics, security behaviour, event addresses, or another consumer-visible contract **MUST NOT** be made in place solely to satisfy this guide; it **MUST** be released in the next major API version under [§18.4](part-d/18-compatibility-and-lifecycle.md#184-breaking-changes-bump-major-version). Until that major version, the existing surface documents the gap and follows the approved exception process rather than silently changing the wire contract. -The transition schedule, conformance levels, and enforcement dates for existing BBs are governance questions for the GovStack API Lifecycle & Governance companion ([Appendix A](appendix/a-companion-documents.md)). Every canonical specification pins the exact guide and ruleset versions it uses under [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); validation tooling **MUST NOT** silently substitute a newer compatible-looking version. +The GovStack governance process owns the transition schedule, conformance levels, and enforcement dates for existing BBs. Every canonical specification pins the exact guide and ruleset versions it uses under [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); validation tooling **MUST NOT** silently substitute a newer compatible-looking version. The guide itself follows SemVer, including the current exact prerelease identifier `0.1.0-draft`. A guide patch release **MUST NOT** change which specifications conform; it may only correct prose or tooling defects without changing normative meaning. A guide minor release **MAY** add optional guidance, deprecate a rule, or relax a requirement, but **MUST NOT** add or strengthen a mandatory requirement. Adding or strengthening a **MUST** or **SHOULD**, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming **MUST** increment the guide major version. These rules apply even during the `0.x` drafting series so that exact conformance declarations remain meaningful. diff --git a/api-design-guide/README.md b/api-design-guide/README.md index 3617981..bcab1d6 100644 --- a/api-design-guide/README.md +++ b/api-design-guide/README.md @@ -11,10 +11,17 @@ published or ratified. **Author:** Jeremi Joslin +**Specification:** `govstack-cfr-api`<br> +**Version:** `0.1.0-draft`<br> +**Proposed parent:** `govstack-cfr` (candidate relationship pending CFR issues +[#7](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/7) and +[#8](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/8)) + ## Start here - **Editing a BB specification?** Run the [spec editor checklist](guides/spec-editor-checklist.md) against your spec, use [Rules at a glance](all-rules.md) to jump to any rule, and [validate mechanically](guides/validating-your-spec.md) before review. -- **Reviewing this draft for the committee?** [How to use this guide](how-to-use-this-guide.md) says what feedback is most useful at this stage; [Appendix B](appendix/b-open-questions.md) is the decision agenda, with the questions that block v1.0 marked. +- **Reviewing this draft for the committee?** [How to use this guide](how-to-use-this-guide.md) says what feedback is most useful at this stage. +- **Reviewing CFR alignment?** [§1.3](1-introduction.md#13-relationship-to-existing-govstack-documents) maps the guide to its proposed parent requirements and identifies the CFR changes needed for protocol-native and non-HTTP interfaces. - **Pointing an AI coding agent at the rules?** The book ships a machine-readable index of every rule (`rules.yaml`, at the root of this folder in the repository); [Using this guide with AI agents](guides/using-with-ai-agents.md) has a ready-made instruction block for a BB repository. ## Executive summary @@ -30,10 +37,10 @@ This draft is intended to be stress-tested immediately against live specificatio The substantive rules establish: - Canonical, machine-validatable OpenAPI and AsyncAPI entrypoints at known locations ([§2](part-a/2-openapi-document-standards.md), [§3](part-a/3-asyncapi-document-standards.md)). -- REST-style URLs with major versions in the path, plural-noun resource names, standard HTTP verb semantics, and an unversioned `/health` endpoint ([§5](part-b/5-url-structure-and-versioning.md)–[§6](part-b/6-http-methods.md)). +- Standard HTTP semantics and an unversioned `/health` endpoint, with versioned resource paths and consistent URL naming as recommended defaults rather than universal wire requirements ([§5](part-b/5-url-structure-and-versioning.md)–[§6](part-b/6-http-methods.md)). - Standard HTTP status codes used consistently, with `ETag` / `If-Match` for optimistic concurrency ([§7](part-b/7-http-status-codes.md)). - Standard headers for authentication, idempotency, localisation, correlation, and rate limiting; no personal data in URLs, channel addresses, routing keys, or message headers ([§8](part-b/8-headers.md), [§17](part-d/17-asyncapi-channel-rules.md)). -- `camelCase` JSON, RFC 3339 timestamps, decimal-string monetary amounts, E.164 phone numbers, ISO code lists for country / currency / language ([§9](part-c/9-json-conventions-and-naming.md)–[§10](part-c/10-data-types-and-formats.md)). +- Recommended `camelCase` for GovStack-owned JSON, plus normative RFC 3339 timestamps, decimal-string monetary amounts, E.164 phone numbers, and ISO code lists for country / currency / language ([§9](part-c/9-json-conventions-and-naming.md)–[§10](part-c/10-data-types-and-formats.md)). - One ecosystem-wide HTTP error format based on RFC 9457 Problem Details, with a stable `https://govstack.global/problems/...` type URI, trace IDs, and field-level validation ([§11](part-c/11-errors.md)). - Cursor-based pagination by default, with a single envelope shape for collection responses ([§12](part-c/12-pagination-filtering-sorting.md)). - OAuth 2.0 + OIDC for citizen-facing operations, mutual TLS or OAuth client credentials for BB-to-BB calls ([§13](part-d/13-authentication-and-authorisation.md)). @@ -44,4 +51,4 @@ The substantive rules establish: - A single language model based on `Accept-Language` and `Content-Language` ([§19](part-e/19-localisation.md)). - Mechanical validation: every BB spec MUST pass schema validation and the machine-checkable GovStack Spectral ruleset rules ([§20](part-e/20-conformance-and-validation.md)). -Where the guide adopts an external standard or convention (RFC 9457, CloudEvents, OAuth 2.0 + OIDC, the health-check response convention, ISO code lists), that standard or convention takes precedence over the guide's generic rules ([§1.7](1-introduction.md#17-precedence-of-external-standards)). Rules use RFC 2119 keywords (see [§1.5](1-introduction.md#15-language)). Open design calls are consolidated in [Appendix B](appendix/b-open-questions.md). +Where the guide adopts an external standard or convention (RFC 9457, CloudEvents, OAuth 2.0 + OIDC, the health-check response convention, ISO code lists), that standard or convention takes precedence over the guide's generic rules ([§1.7](1-introduction.md#17-precedence-of-external-standards)). Rules use RFC 2119 keywords (see [§1.5](1-introduction.md#15-language)). diff --git a/api-design-guide/SUMMARY.md b/api-design-guide/SUMMARY.md index 25997c6..ee3f823 100644 --- a/api-design-guide/SUMMARY.md +++ b/api-design-guide/SUMMARY.md @@ -41,9 +41,7 @@ ## Appendices -* [Appendix A. Companion documents and artifacts](appendix/a-companion-documents.md) -* [Appendix B. Open questions (consolidated)](appendix/b-open-questions.md) -* [Appendix C. Normative references](appendix/c-normative-references.md) +* [Appendix. References](appendix/references.md) ## Guides diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md index 06b470c..52fc66d 100644 --- a/api-design-guide/all-rules.md +++ b/api-design-guide/all-rules.md @@ -37,7 +37,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | -| [4.1](part-a/4-documentation-requirements.md#41-every-schema-described) | M | MUST | Universal | Every schema described | +| [4.1](part-a/4-documentation-requirements.md#41-useful-schema-descriptions) | M | MUST | Universal | Useful schema descriptions | | [4.2](part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums) | M+R | MUST | Universal | Examples for bodies and enums | | [4.3](part-a/4-documentation-requirements.md#43-no-placeholder-text) | M+R | MUST | Universal | No placeholder text | | [4.4](part-a/4-documentation-requirements.md#44-accurate-operation-descriptions) | R | MUST | Universal | Accurate operation descriptions | @@ -49,13 +49,13 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | | [5.1](part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path) | M | MUST | OpenAPI | Major version in the path | -| [5.2](part-b/5-url-structure-and-versioning.md#52-plural-noun-resources) | M+R | MUST | OpenAPI | Plural noun resources | -| [5.3](part-b/5-url-structure-and-versioning.md#53-kebab-case-path-segments) | M | MUST | OpenAPI | Kebab-case path segments | +| [5.2](part-b/5-url-structure-and-versioning.md#52-plural-noun-resources) | M+R | SHOULD | OpenAPI | Plural noun resources | +| [5.3](part-b/5-url-structure-and-versioning.md#53-kebab-case-path-segments) | M | SHOULD | OpenAPI | Kebab-case path segments | | [5.4](part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | M | SHOULD | OpenAPI | Shallow path nesting | | [5.5](part-b/5-url-structure-and-versioning.md#55-identifiers-as-path-parameters) | M+R | MUST | OpenAPI | Identifiers as path parameters | -| [5.6](part-b/5-url-structure-and-versioning.md#56-query-parameter-naming) | M | MUST | OpenAPI | Query parameter naming | -| [5.7](part-b/5-url-structure-and-versioning.md#57-no-verbs-in-crud-paths) | M+R | MUST | OpenAPI | No verbs in CRUD paths | -| [5.8](part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources) | R | MUST | OpenAPI | Actions as sub-resources | +| [5.6](part-b/5-url-structure-and-versioning.md#56-query-parameter-naming) | M | SHOULD | OpenAPI | Query parameter naming | +| [5.7](part-b/5-url-structure-and-versioning.md#57-no-verbs-in-crud-paths) | M+R | SHOULD | OpenAPI | No verbs in CRUD paths | +| [5.8](part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources) | R | SHOULD | OpenAPI | Actions as sub-resources | | [5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | M+R | MUST | OpenAPI | Unversioned health endpoint | | [5.10](part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints) | M | MUST | OpenAPI | Standard unversioned endpoints | @@ -66,7 +66,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [6.1](part-b/6-http-methods.md#61-get-is-safe-and-idempotent) | M+R | MUST | OpenAPI | GET is safe and idempotent | | [6.2](part-b/6-http-methods.md#62-post-creates-or-performs-actions) | R | MUST | OpenAPI | POST creates or performs actions | | [6.3](part-b/6-http-methods.md#63-put-replaces-the-entire-resource) | R | MUST | OpenAPI | PUT replaces the entire resource | -| [6.4](part-b/6-http-methods.md#64-patch-uses-json-merge-patch) | M+R | MUST | OpenAPI | PATCH uses JSON Merge Patch | +| [6.4](part-b/6-http-methods.md#64-patch-uses-a-registered-patch-format) | M+R | MUST | OpenAPI | PATCH uses a registered patch format | | [6.5](part-b/6-http-methods.md#65-delete-response-semantics) | M+R | MUST | OpenAPI | DELETE response semantics | | [6.6](part-b/6-http-methods.md#66-post-search-for-complex-queries) | M+R | MUST | OpenAPI | POST search for complex queries | | [6.7](part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection) | M+R | MUST | OpenAPI | Bulk mutation needs explicit selection | @@ -114,10 +114,10 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | Rule | Class | Strength | Surface | Title | | --- | --- | --- | --- | --- | | [9.1](part-c/9-json-conventions-and-naming.md#91-json-as-default-media-type) | M+R | MUST | Universal | JSON as default media type | -| [9.2](part-c/9-json-conventions-and-naming.md#92-camelcase-field-names) | M | MUST | Universal | camelCase field names | +| [9.2](part-c/9-json-conventions-and-naming.md#92-camelcase-field-names) | M | SHOULD | Universal | camelCase field names | | [9.3](part-c/9-json-conventions-and-naming.md#93-real-json-booleans) | M | MUST | Universal | Real JSON booleans | | [9.4](part-c/9-json-conventions-and-naming.md#94-explicit-nullability) | M | MUST | Universal | Explicit nullability | -| [9.5](part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names) | M | MUST | Universal | No spaces or non-ASCII names | +| [9.5](part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names) | M | SHOULD | Universal | No spaces or non-ASCII names | | [9.6](part-c/9-json-conventions-and-naming.md#96-avoid-abbreviations) | R | SHOULD | Universal | Avoid abbreviations | | [9.7](part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values) | M | MUST | Universal | Screaming snake case enum values | | [9.8](part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas) | M | MUST | Universal | Forward-compatible schemas | @@ -241,7 +241,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [17.17](part-d/17-asyncapi-channel-rules.md#1717-declared-request-reply-correlation) | M+R | MUST | AsyncAPI | Declared request-reply correlation | | [17.18](part-d/17-asyncapi-channel-rules.md#1718-correlated-completion-signals) | M+R | MUST | AsyncAPI | Correlated completion signals | | [17.19](part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | M+R | MUST | AsyncAPI | Protocol bindings where relevant | -| [17.20](part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message) | M+R | MUST | AsyncAPI | Examples for every message | +| [17.20](part-d/17-asyncapi-channel-rules.md#1720-representative-message-examples) | M+R | SHOULD | AsyncAPI | Representative message examples | ## 18. Compatibility and lifecycle @@ -261,7 +261,7 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | --- | --- | --- | --- | --- | | [19.1](part-e/19-localisation.md#191-honour-the-request-language) | R | MUST | Universal | Honour the request language | | [19.2](part-e/19-localisation.md#192-never-translate-stable-content) | R | MUST | Universal | Never translate stable content | -| [19.3](part-e/19-localisation.md#193-english-as-default-language) | R | MUST | Universal | English as default language | +| [19.3](part-e/19-localisation.md#193-declared-default-language) | R | MUST | Universal | Declared default language | | [19.4](part-e/19-localisation.md#194-declare-the-response-language) | M+R | MUST | Universal | Declare the response language | ## 20. Conformance and validation diff --git a/api-design-guide/appendix/a-companion-documents.md b/api-design-guide/appendix/a-companion-documents.md deleted file mode 100644 index 29735ad..0000000 --- a/api-design-guide/appendix/a-companion-documents.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: "Companion documents and artifacts that pick up the topics this guide places out of scope." ---- - -# Appendix A. Companion documents and artifacts - -[§1.2](../1-introduction.md#12-scope) lists what is out of scope. This appendix names the companion documents and artifacts that pick up those topics. - -| Companion | Status | What it covers | -|---|---|---| -| **GovStack API Lifecycle & Governance** | Proposed outline, not a ratified GovStack artifact | Ratification, enforcement, exception lifecycle, transition timelines, conformance levels, companion-artifact ownership, BB editor support, self-amendment of this guide. Reuses the existing GovStack Specification Framework where applicable and defines only the missing API-specific lifecycle, exception, publication, and conformance processes. | -| **GovStack API Security & Operations** | Not yet drafted | Deployment details below the interface baseline in [§13](../part-d/13-authentication-and-authorisation.md): token and claim validation, certificate trust, TLS configuration, key rotation, replay enforcement, audit logging, log hygiene, algorithm allowlists, and FAPI conformance. It must not weaken the RFC 9700 and protected-transport requirements in this guide. | -| [`api/common/govstack-openapi-common.yaml`](../../api/common/govstack-openapi-common.yaml) | Draft schema-only artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Conditional reuse of `Problem`, `ValidationProblem`, `FieldError`, and `PageInfo` under [§2.8](../part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas). BBs own security schemes, parameters, headers, Response Objects, examples, and Operation resources. | -| [`api/common/govstack-asyncapi-common.yaml`](../../api/common/govstack-asyncapi-common.yaml) | Draft artifact vendored from the [`govstack-api-common`](https://github.com/jeremi/govstack-api-common) incubation repository; formal ratification pending | Schema-only shared CloudEvents envelope ([§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)), `GovStackAsyncError`, and `AsyncFieldError` ([§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors)). BBs own Message Objects, security, headers, examples, and protocol bindings. | -| `experimental/govstack-openapi-signing-profile.yaml` | Separate optional profile, incomplete and not ratified | Opt-in message-signing mechanism. Key discovery, key rotation, replay policy, protocol mappings, and conformance test vectors must be completed before promotion. It is not part of either baseline common schema artifact ([§16.8](../part-d/16-cloudevents-and-webhooks.md#168-separate-experimental-signing-profile)). | -| **Spectral ruleset** | Exact draft `0.1.0-draft` ships in-repo at [`linter/`](../linter/README.md); formal ratification pending | Machine-enforceable subset of the exact guide version declared under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version), including canonical discovery, `api/index.yaml`, and `api/coverage.yaml`. [`linter/coverage.yaml`](../linter/coverage.yaml) records per-rule coverage. | -| **Conformance test pack** | Future companion artifact | Governance-defined contract tests beyond schema and Spectral validation. | -| **Reference BB implementation** | Draft example ships in this template; formal ratification pending | Worked example applying the guide end-to-end through [`api/openapi.yaml`](../../api/openapi.yaml), [`api/index.yaml`](../../api/index.yaml), [`api/coverage.yaml`](../../api/coverage.yaml), and the [generic BB specification](../../spec/README.md). | diff --git a/api-design-guide/appendix/b-open-questions.md b/api-design-guide/appendix/b-open-questions.md deleted file mode 100644 index 69372c0..0000000 --- a/api-design-guide/appendix/b-open-questions.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: "Consolidated list of open committee questions referenced inline throughout the guide." ---- - -# Appendix B. Open questions (consolidated) - -The questions below are genuine committee decisions. Each appears inline as `[OPEN-N-X]` next to the relevant rule. - -The **Blocks v1.0?** column marks the questions whose answers shape the baseline shared schemas or every BB's URL surface; these need a committee decision before v1.0 ratification. The rest can be settled during the v1.0 drafting cycle without blocking pilot work. - -| ID | Topic | Default | Section | Blocks v1.0? | -|---|---|---|---|---| -| OPEN-5-A | BB code in URL path | No (rely on `servers` URL or mediator routing) | [§5](../part-b/5-url-structure-and-versioning.md) | Yes | -| OPEN-5-B | Path nesting depth: soft cap of two levels under `/v{N}/` | Keep as SHOULD with the soft cap | [§5.4](../part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | No | -| OPEN-7-A | 400 vs 422 boundary | Keep both (400 unparseable, 422 semantic) | [§7](../part-b/7-http-status-codes.md) | No | -| OPEN-13-A | OAuth scope syntax: `bb:{bb-code}:{resource}:{action}` vs reverse-DNS vs `resource.action` | `bb:` prefix for namespacing; reverse-DNS is the alternative | [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | Yes | -| OPEN-16-A | Event `type` naming convention | `global.govstack.{bb-code}.{resource}.{action}` | [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | No | -| OPEN-17-A | CloudEvents binding style for AsyncAPI | Structured CloudEvents JSON payload | [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | No | -| OPEN-17-B | AsyncAPI protocol-binding depth | Require bindings where they affect interoperability; future profiles may add deeper broker-specific rules | [§17.19](../part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | No | -| OPEN-18-A | AsyncAPI deprecation metadata | `x-govstack-deprecated` with `since`, `sunset`, `replacement`, `reason` | [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata) | No | -| OPEN-19-A | Mandated language coverage | Per BB | [§19](../part-e/19-localisation.md) | No | -| OPEN-9-A | BB-code register: where the canonical register of BB codes lives and who assigns them | Propose in the Lifecycle & Governance companion; until then, agree codes through the API Working Group | [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code) | No | - -Governance-side open questions (ratification process, enforcement actor, exception lifecycle, deviation board) are proposed for the **GovStack API Lifecycle & Governance** companion document, not here. diff --git a/api-design-guide/appendix/c-normative-references.md b/api-design-guide/appendix/c-normative-references.md deleted file mode 100644 index b1d5d36..0000000 --- a/api-design-guide/appendix/c-normative-references.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: "Normative references cited throughout the guide." ---- - -# Appendix C. Normative references - -- IETF RFC 2119, *Key words for use in RFCs to Indicate Requirement Levels* -- IETF RFC 8174, *Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words* -- IETF RFC 3339, *Date and Time on the Internet: Timestamps* -- IETF RFC 5322, *Internet Message Format* -- IETF RFC 6648, *Deprecating the "X-" Prefix in Application Protocols* -- IETF RFC 6749, *The OAuth 2.0 Authorization Framework* -- IETF RFC 6750, *The OAuth 2.0 Authorization Framework: Bearer Token Usage* (cited by [§7.6](../part-b/7-http-status-codes.md#76-401-with-www-authenticate)) -- IETF RFC 6585, *Additional HTTP Status Codes* (`428 Precondition Required`) -- IETF RFC 6901, *JavaScript Object Notation (JSON) Pointer* -- IETF RFC 6902, *JavaScript Object Notation (JSON) Patch* -- IETF RFC 7396, *JSON Merge Patch* -- IETF RFC 8594, *The Sunset HTTP Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) -- IETF RFC 8615, *Well-Known Uniform Resource Identifiers (URIs)* (cited by [§5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints)) -- IETF RFC 8705, *OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens* -- IETF RFC 9110, *HTTP Semantics* (obsoletes RFC 7231) -- IETF RFC 9396, *OAuth 2.0 Rich Authorization Requests* -- IETF RFC 9325 / BCP 195, *Recommendations for Secure Use of TLS and DTLS* -- IETF RFC 9449, *OAuth 2.0 Demonstrating Proof of Possession* -- IETF RFC 9457, *Problem Details for HTTP APIs* (obsoletes RFC 7807) -- IETF RFC 9700 / BCP 240, *Best Current Practice for OAuth 2.0 Security* -- IETF RFC 9745, *The Deprecation HTTP Response Header Field* (cited by [§18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) -- IETF draft `draft-ietf-httpapi-ratelimit-headers-11`, *RateLimit Header Fields for HTTP* (pinned work-in-progress revision; cited by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared)) -- IETF draft `draft-ietf-httpapi-idempotency-key-header-07`, *The Idempotency-Key HTTP Header Field* (pinned expired Internet-Draft revision used as a GovStack convention; cited by [§14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) -- OpenAPI Specification 3.1 patch series; guide/ruleset `0.1.0-draft` qualify 3.1.0, 3.1.1, and 3.1.2 -- AsyncAPI Specification 3.0 (cited by [§1.2](../1-introduction.md#12-scope), [§3](../part-a/3-asyncapi-document-standards.md), [§16.1](../part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented), [§17](../part-d/17-asyncapi-channel-rules.md), [§20](../part-e/20-conformance-and-validation.md)) -- OpenID Connect Core 1.0 -- CloudEvents v1.0.2 (CNCF), *CloudEvents Specification* and JSON Format (cited by [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)) -- CloudEvents, *Distributed Tracing Extension* (`traceparent`, `tracestate`) -- W3C Recommendation, *Trace Context* -- Google AIP-158, *Pagination* (cited by [§12.2](../part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default)) -- GraphQL Cursor Connections Specification (cited by [§12.3](../part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope)) -- IANA registries whose registered values keep their own casing under [§9.7](../part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values): *JSON Web Signature and Encryption Algorithms*, *JSON Web Key Elliptic Curve*, *COSE Algorithms*, and *Media Types* -- ISO 3166-1 alpha-2 (country codes) -- ISO 4217 (currency codes) -- BCP 47 (language tags) -- E.164 (international phone number format) -- Semantic Versioning 2.0.0 diff --git a/api-design-guide/appendix/references.md b/api-design-guide/appendix/references.md new file mode 100644 index 0000000..b71f295 --- /dev/null +++ b/api-design-guide/appendix/references.md @@ -0,0 +1,54 @@ +--- +description: "Standards and other sources referenced by the API Design Guide." +--- + +# Appendix. References + +## Normative standards + +- [RFC 2119, *Key words for use in RFCs to Indicate Requirement Levels*](https://www.rfc-editor.org/rfc/rfc2119) +- [RFC 8174, *Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words*](https://www.rfc-editor.org/rfc/rfc8174) +- [RFC 3339, *Date and Time on the Internet: Timestamps*](https://www.rfc-editor.org/rfc/rfc3339) +- [RFC 5322, *Internet Message Format*](https://www.rfc-editor.org/rfc/rfc5322) +- [RFC 6648, *Deprecating the "X-" Prefix in Application Protocols*](https://www.rfc-editor.org/rfc/rfc6648) +- [RFC 6749, *The OAuth 2.0 Authorization Framework*](https://www.rfc-editor.org/rfc/rfc6749) +- [RFC 6750, *OAuth 2.0 Bearer Token Usage*](https://www.rfc-editor.org/rfc/rfc6750) +- [RFC 6585, *Additional HTTP Status Codes*](https://www.rfc-editor.org/rfc/rfc6585) +- [RFC 6901, *JSON Pointer*](https://www.rfc-editor.org/rfc/rfc6901) +- [RFC 6902, *JSON Patch*](https://www.rfc-editor.org/rfc/rfc6902) +- [RFC 7396, *JSON Merge Patch*](https://www.rfc-editor.org/rfc/rfc7396) +- [RFC 8594, *The Sunset HTTP Header Field*](https://www.rfc-editor.org/rfc/rfc8594) +- [RFC 8615, *Well-Known Uniform Resource Identifiers*](https://www.rfc-editor.org/rfc/rfc8615) +- [RFC 8705, *OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens*](https://www.rfc-editor.org/rfc/rfc8705) +- [RFC 9110, *HTTP Semantics*](https://www.rfc-editor.org/rfc/rfc9110) +- [RFC 9325 / BCP 195, *Recommendations for Secure Use of TLS and DTLS*](https://www.rfc-editor.org/rfc/rfc9325) +- [RFC 9396, *OAuth 2.0 Rich Authorization Requests*](https://www.rfc-editor.org/rfc/rfc9396) +- [RFC 9449, *OAuth 2.0 Demonstrating Proof of Possession*](https://www.rfc-editor.org/rfc/rfc9449) +- [RFC 9457, *Problem Details for HTTP APIs*](https://www.rfc-editor.org/rfc/rfc9457) +- [RFC 9700 / BCP 240, *Best Current Practice for OAuth 2.0 Security*](https://www.rfc-editor.org/rfc/rfc9700) +- [RFC 9745, *The Deprecation HTTP Response Header Field*](https://www.rfc-editor.org/rfc/rfc9745) +- [OpenAPI Specification 3.1.0](https://spec.openapis.org/oas/v3.1.0.html), [3.1.1](https://spec.openapis.org/oas/v3.1.1.html), and [3.1.2](https://spec.openapis.org/oas/v3.1.2.html) +- [AsyncAPI Specification 3.0.0](https://www.asyncapi.com/docs/reference/specification/v3.0.0) +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +- [CloudEvents Specification 1.0.2, JSON format, and extensions](https://github.com/cloudevents/spec/tree/ce%40v1.0.2) +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) +- [ISO 3166 country codes](https://www.iso.org/iso-3166-country-codes.html) +- [ISO 4217 currency codes](https://www.iso.org/iso-4217-currency-codes.html) +- [BCP 47 language tags](https://www.rfc-editor.org/info/bcp47) +- [ITU-T E.164, *The international public telecommunication numbering plan*](https://www.itu.int/rec/T-REC-E.164/en) +- [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) +- IANA registries for [JOSE](https://www.iana.org/assignments/jose/jose.xhtml), [COSE](https://www.iana.org/assignments/cose/cose.xhtml), and [media types](https://www.iana.org/assignments/media-types/media-types.xhtml) + +## Pinned working drafts used as conventions + +The following documents are Internet-Drafts, not published standards. The +guide pins an exact revision so that later draft changes do not silently alter +its contract: + +- [`draft-ietf-httpapi-ratelimit-headers-11`, *RateLimit header fields for HTTP*](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-11) +- [`draft-ietf-httpapi-idempotency-key-header-07`, *The Idempotency-Key HTTP Header Field*](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07) + +## Informative influences + +- [Google AIP-158, *Pagination*](https://google.aip.dev/158) +- [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) diff --git a/api-design-guide/guides/README.md b/api-design-guide/guides/README.md index 3577dd2..67e4079 100644 --- a/api-design-guide/guides/README.md +++ b/api-design-guide/guides/README.md @@ -14,7 +14,5 @@ Four guides live here today: - [Maintaining this guide](../guides/maintaining-this-guide.md): where the canonical copy lives, how to edit a rule without breaking its anchor, and how to regenerate the machine-readable index. {% hint style="info" %} -This book is exact draft version `0.1.0-draft` and is not yet ratified. The matching draft Spectral ruleset ships in this repository; the common OpenAPI/AsyncAPI component files and conformance test pack remain publication prerequisites (see [Appendix A](../appendix/a-companion-documents.md)). +This book is exact draft version `0.1.0-draft` and is not yet ratified. The matching draft Spectral ruleset and common OpenAPI/AsyncAPI component files ship in this repository. {% endhint %} - -Before ratification, this section is expected to gain worked positive and negative examples for every numbered rule and a conformance walkthrough that takes one reference BB specification from a blank file to a passing run. diff --git a/api-design-guide/guides/maintaining-this-guide.md b/api-design-guide/guides/maintaining-this-guide.md index 0cd4d0e..f616632 100644 --- a/api-design-guide/guides/maintaining-this-guide.md +++ b/api-design-guide/guides/maintaining-this-guide.md @@ -45,10 +45,6 @@ COVERAGE_ENFORCE=1 node --test tests/coverage.test.mjs The second command fails if `rules.yaml` and `coverage.yaml` disagree about the set of rule ids, or if `coverage.yaml` and the shipped rulesets disagree about which Spectral rules exist. A new guide rule that was never triaged for linting is therefore a test failure, not a silent gap. Rule `documentationUrl`s in the ruleset embed each rule's page and anchor, which is one more reason anchors must stay frozen. -## Adding an open question - -Append a row to [Appendix B](../appendix/b-open-questions.md) using an ID of the form `OPEN-{section}-{letter}`, keyed to the current section numbering. Once the guide is published, an assigned identifier remains stable. - ## Versioning the guide itself This guide is versioned with SemVer, per [§1.10](../1-introduction.md#110-applicability-and-transition), and its current exact identifier is `0.1.0-draft`. A patch release may correct prose or tooling without changing conformance. A minor release may add optional guidance, deprecate a rule, or relax a requirement. Adding or strengthening a mandatory rule, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming requires a major release. A ruleset release is separately versioned and every canonical spec pins both exact versions under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version). Release notes begin with the first published version. diff --git a/api-design-guide/guides/spec-editor-checklist.md b/api-design-guide/guides/spec-editor-checklist.md index 6a06f13..f0d6d33 100644 --- a/api-design-guide/guides/spec-editor-checklist.md +++ b/api-design-guide/guides/spec-editor-checklist.md @@ -8,18 +8,18 @@ Run this before submitting a BB specification for review. Each item links to the ## Every specification -- [ ] `info` block is complete: SemVer `version`, `title`, `description`, and `contact` are all present. ([2.5](../part-a/2-openapi-document-standards.md#25-complete-info-block), [3.5](../part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block)) +- [ ] `info` has a SemVer `version`, `title`, and useful `description`; add `contact` when the contract is the right place for maintainer metadata. ([2.5](../part-a/2-openapi-document-standards.md#25-complete-info-block), [3.5](../part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block)) - [ ] `servers` is non-empty and meaningful: no `localhost`, no personal machines, no fake production domains; reference specs use parameterised template URLs. ([2.6](../part-a/2-openapi-document-standards.md#26-meaningful-servers-block), [3.6](../part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages)) -- [ ] Every operation has complete metadata: an `operationId` (or AsyncAPI operation key), `summary`, `description`, and at least one `tag`. ([2.7](../part-a/2-openapi-document-standards.md#27-complete-operation-metadata), [3.7](../part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata)) -- [ ] Every schema has a `description`. ([4.1](../part-a/4-documentation-requirements.md#41-every-schema-described)) -- [ ] Every request and response body has at least one `example`; every `enum` documents what its values mean. ([4.2](../part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums)) +- [ ] Every operation has a stable `operationId` (or AsyncAPI operation key), its required contract references, and an accurate `description`; add `summary` and tags when useful for navigation. ([2.7](../part-a/2-openapi-document-standards.md#27-complete-operation-metadata), [3.7](../part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata)) +- [ ] Add useful schema descriptions where names and structure do not make semantics clear. ([4.1](../part-a/4-documentation-requirements.md#41-useful-schema-descriptions)) +- [ ] Add representative request and response examples where useful; document enum values that are not self-explanatory. ([4.2](../part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums)) - [ ] No placeholder text anywhere: no `TBD`, no `Lorem ipsum`, no `a, b, c`, no leftover content copied from another BB. ([4.3](../part-a/4-documentation-requirements.md#43-no-placeholder-text)) - [ ] Canonical surfaces are discoverable at the default paths or through a valid `api/index.yaml`; `api/coverage.yaml` exactly traces every marked functional requirement to an operation, message, external contract, rationale, or tracked plan. ([4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory), [4.6](../part-a/4-documentation-requirements.md#46-functional-requirement-traceability)) - [ ] A security scheme is declared and applied to every operation by default, with per-operation overrides explicit. ([13.1](../part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation)) - [ ] `info.x-govstack-api-guide` pins exact guide and ruleset versions (`0.1.0-draft`) and every exception has all seven §20.3 fields: `rule`, `scope`, `rationale`, `record`, `reviewedBy`, `reviewedAt`, and `expiresAt`. ([20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)) - [ ] The file passes its validator (see [Validating your spec](../guides/validating-your-spec.md)). ([20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation)) - [ ] `info.version` follows SemVer. ([18.1](../part-d/18-compatibility-and-lifecycle.md#181-semver-versioning)) -- [ ] JSON field names are `camelCase`, applied consistently; check the [carve-outs](../part-c/9-json-conventions-and-naming.md#carve-out-from-92) before flagging fields imported from an external standard (RFC 9457, CloudEvents) as violations. ([9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names)) +- [ ] GovStack-owned JSON field names use the recommended `camelCase` convention consistently; fields imported from an external standard retain their standard spelling. ([9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names)) - [ ] Resource identifiers are opaque, server-generated strings; citizen records are never referred to by a personal identifier in a URL. ([10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) ## REST surfaces (OpenAPI 3.1) @@ -50,6 +50,6 @@ Run this before submitting a BB specification for review. Each item links to the - [ ] Logical channel IDs follow the versioned reverse-DNS convention, while Channel Object addresses use protocol-native syntax and bindings document the mapping. ([17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses)) - [ ] No logical channel ID, native address, topic, queue name, routing key, or channel parameter carries personal data. ([17.3](../part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels)) - [ ] Delivery, duplicate-handling, ordering, retention, and replay behaviour is documented only where consumers may rely on it, using standard protocol bindings where available. ([17.11](../part-d/17-asyncapi-channel-rules.md#1711-duplicate-delivery-contract)–[17.15](../part-d/17-asyncapi-channel-rules.md#1715-no-universal-delivery-extensions)) -- [ ] Every message has an example. ([17.20](../part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message)) +- [ ] Add representative message examples where the schema alone does not make the interaction clear. ([17.20](../part-d/17-asyncapi-channel-rules.md#1720-representative-message-examples)) Ticking every box above is the human half of conformance. See [Validating your spec](../guides/validating-your-spec.md) for the mechanical half. diff --git a/api-design-guide/how-to-use-this-guide.md b/api-design-guide/how-to-use-this-guide.md index cbded87..ebfa840 100644 --- a/api-design-guide/how-to-use-this-guide.md +++ b/api-design-guide/how-to-use-this-guide.md @@ -20,9 +20,9 @@ This is a strawman of a normative cross-BB API design guide. It is not yet ratif 1. **Scope of harmonisation** (see [§1.2](1-introduction.md#12-scope)). 2. **Structure and depth.** 20 numbered sections ([§1](1-introduction.md)–[§20](part-e/20-conformance-and-validation.md)), supported by examples and rule-specific linter coverage where mechanical enforcement is practical. 3. **Prescriptiveness.** RFC 2119 keywords and linter-backed conformance (see [§1.5](1-introduction.md#15-language) and [§20](part-e/20-conformance-and-validation.md)). -4. **The open questions.** [Appendix B](appendix/b-open-questions.md) consolidates every deliberate design call; the **Blocks v1.0?** column marks the ones that need a committee decision before ratification. +4. **CFR compatibility.** Confirm that the proposed parent relationships and pending CFR changes in [§1.3](1-introduction.md#13-relationship-to-existing-govstack-documents) are correct. -Companion documents (governance, security/operations, common YAML, Spectral ruleset, conformance pack) are referenced where relevant; their scope is in [Appendix A](appendix/a-companion-documents.md). +Review comments should challenge the draft's proposed rules directly through issues or pull-request feedback rather than relying on embedded drafting questions. ## About the rule titles <a href="#about-the-rule-titles" id="about-the-rule-titles"></a> diff --git a/api-design-guide/linter/README.md b/api-design-guide/linter/README.md index 8329050..b9926e6 100644 --- a/api-design-guide/linter/README.md +++ b/api-design-guide/linter/README.md @@ -2,9 +2,7 @@ The GovStack Spectral ruleset and lint tooling for the [Cross-BB API Design Guide](../README.md). This is the mechanical enforcement -behind rule [20.2](../part-e/20-conformance-and-validation.md) (draft; the -formal companion publication is tracked in -[Appendix A](../appendix/a-companion-documents.md)). It implements guide +behind draft rule [20.2](../part-e/20-conformance-and-validation.md). It implements guide version **0.1.0-draft** (`guide_version` in [coverage.yaml](coverage.yaml)). ## Quick start @@ -25,8 +23,16 @@ apis: path: api/public-openapi.yaml - type: asyncapi path: api/events-asyncapi.yaml + - type: standard + name: OpenID Connect + reference: https://openid.net/specs/openid-connect-core-1_0.html + discovery: /.well-known/openid-configuration ``` +The `standard` form is provisional. It is useful in advisory mode, but blocks +conformance until GovStack approves a recognised-standard registry or profile; +an HTTPS reference alone is not treated as conformance evidence. + A BB with no API surface must say so explicitly instead of keeping empty spec placeholders: @@ -41,9 +47,11 @@ The driver runs everything rule 20 asks for: 1. **File-tree checks** — `api/index.yaml` or canonical entrypoints, declaration/type consistency, legacy `swagger.*` names, and undeclared or divergent spec copies (guide 2.2/2.3/3.2/3.3). -2. **Requirement coverage** — every keyed requirement marker under - `spec/**/*.md` must have one disposition in `api/coverage.yaml`, and mapped - operation/message identifiers must exist and be unambiguous. +2. **Requirement coverage** — every active CFR-formatted REQUIRED or + RECOMMENDED requirement under `spec/**/*.md` must have one disposition in + `api/coverage.yaml`. DRAFT, DEPRECATED, and INAPPLICABLE requirements are + not active coverage obligations. Mapped operation/message identifiers must + exist and be unambiguous. 3. **Base validators** (20.1) — `openapi-spec-validator` and `@asyncapi/cli validate` are mandatory in conformance mode. 4. **The Spectral ruleset** — rules across both surfaces (OpenAPI 3.1, diff --git a/api-design-guide/linter/cli.mjs b/api-design-guide/linter/cli.mjs index fb1dda4..28a8915 100755 --- a/api-design-guide/linter/cli.mjs +++ b/api-design-guide/linter/cli.mjs @@ -208,6 +208,7 @@ function parseYamlObject(content, displayPath) { function validateIndexDocument(index, indexRel, repoRoot, findings) { const declarations = []; + const standardSurfaces = []; let noApi = false; if (index.version !== 1) { @@ -215,7 +216,7 @@ function validateIndexDocument(index, indexRel, repoRoot, findings) { } const hasApis = Object.prototype.hasOwnProperty.call(index, 'apis'); - const hasNoApi = index.noApi === true; + const hasNoApi = Object.prototype.hasOwnProperty.call(index, 'noApi'); if (hasApis === hasNoApi) { findings.push( driverFinding( @@ -224,22 +225,26 @@ function validateIndexDocument(index, indexRel, repoRoot, findings) { 'api/index.yaml must declare exactly one of a non-empty apis list or noApi: true.', ), ); - return { declarations, noApi }; + return { declarations, standardSurfaces, noApi }; } if (hasNoApi) { + if (index.noApi !== true) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'noApi must be true when declared.')); + return { declarations, standardSurfaces, noApi }; + } noApi = true; if (typeof index.reason !== 'string' || index.reason.trim().length < 3) { findings.push( driverFinding(indexRel, 'api-index-invalid', 'noApi: true requires a non-empty reason.'), ); } - return { declarations, noApi }; + return { declarations, standardSurfaces, noApi }; } if (!Array.isArray(index.apis) || index.apis.length === 0) { findings.push(driverFinding(indexRel, 'api-index-invalid', 'apis must be a non-empty array.')); - return { declarations, noApi }; + return { declarations, standardSurfaces, noApi }; } const seen = new Set(); @@ -249,12 +254,44 @@ function validateIndexDocument(index, indexRel, repoRoot, findings) { findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath} must be an object.`)); continue; } - if (entry.type !== 'openapi' && entry.type !== 'asyncapi') { + if (entry.type !== 'openapi' && entry.type !== 'asyncapi' && entry.type !== 'standard') { findings.push( - driverFinding(indexRel, 'api-index-invalid', `${itemPath}.type must be openapi or asyncapi.`), + driverFinding(indexRel, 'api-index-invalid', `${itemPath}.type must be openapi, asyncapi, or standard.`), ); continue; } + if (entry.type === 'standard') { + if (typeof entry.name !== 'string' || !entry.name.trim()) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath}.name is required.`)); + continue; + } + if (!isHttpsUrl(entry.reference)) { + findings.push( + driverFinding(indexRel, 'api-index-invalid', `${itemPath}.reference must be an absolute HTTPS URL.`), + ); + continue; + } + if (entry.discovery !== undefined && (typeof entry.discovery !== 'string' || !entry.discovery.trim())) { + findings.push( + driverFinding(indexRel, 'api-index-invalid', `${itemPath}.discovery must be a non-empty string when present.`), + ); + continue; + } + const key = `standard:${entry.name.trim()}:${entry.reference}`; + if (seen.has(key)) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath} duplicates a standard surface.`)); + continue; + } + seen.add(key); + standardSurfaces.push({ + kind: 'standard', + name: entry.name.trim(), + reference: entry.reference, + discovery: entry.discovery?.trim() ?? null, + declaredBy: indexRel, + }); + continue; + } if (typeof entry.path !== 'string' || !entry.path.trim()) { findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath}.path is required.`)); continue; @@ -278,14 +315,16 @@ function validateIndexDocument(index, indexRel, repoRoot, findings) { seen.add(abs); declarations.push({ kind: entry.type, abs, declaredBy: indexRel }); } - return { declarations, noApi }; + return { declarations, standardSurfaces, noApi }; } async function discoverApiDeclarations(cfg, rel, findings, notices) { const explicit = []; if (cfg.openapiPath) explicit.push({ kind: 'openapi', abs: cfg.openapiPath, declaredBy: 'CLI' }); if (cfg.asyncapiPath) explicit.push({ kind: 'asyncapi', abs: cfg.asyncapiPath, declaredBy: 'CLI' }); - if (explicit.length) return { declarations: explicit, noApi: false, indexPresent: false }; + if (explicit.length) { + return { declarations: explicit, standardSurfaces: [], noApi: false, indexPresent: false }; + } const indexAbs = path.join(cfg.repoRoot, 'api', 'index.yaml'); const indexRel = rel(indexAbs); @@ -293,7 +332,7 @@ async function discoverApiDeclarations(cfg, rel, findings, notices) { if (indexFile.exists) { if (!indexFile.content.trim()) { findings.push(driverFinding(indexRel, 'api-index-invalid', 'api/index.yaml must not be empty.')); - return { declarations: [], noApi: false, indexPresent: true }; + return { declarations: [], standardSurfaces: [], noApi: false, indexPresent: true }; } const index = parseYamlObject(indexFile.content, indexRel); return { ...validateIndexDocument(index, indexRel, cfg.repoRoot, findings), indexPresent: true }; @@ -311,14 +350,14 @@ async function discoverApiDeclarations(cfg, rel, findings, notices) { if (declarations.length === 0) { const message = - 'No API declaration found. Add api/openapi.yaml or api/asyncapi.yaml, or declare noApi: true with a reason in api/index.yaml.'; + 'No API declaration found. Add api/openapi.yaml or api/asyncapi.yaml, declare a standard-defined surface in api/index.yaml, or declare noApi: true with a reason.'; if (cfg.mode === 'conformance') { findings.push(driverFinding('api/index.yaml', 'api-declaration-required', message)); } else { notices.push(message); } } - return { declarations, noApi: false, indexPresent: false }; + return { declarations, standardSurfaces: [], noApi: false, indexPresent: false }; } async function loadDeclaredSpecs(declarations, rel, findings) { @@ -501,11 +540,15 @@ async function scanDivergentCopies(repoRoot, skipAbs, rel, findings) { // Requirement-to-contract coverage (api/coverage.yaml) // -------------------------------------------------------------------------------------- -const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/; -const DISPOSITIONS = new Set(['operation', 'message', 'external', 'not-applicable', 'planned']); -const REQUIREMENT_MARKER_RE = - /^\s*-\s+\*\*([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)\*\*\s+\*\*(REQUIRED|RECOMMENDED|OPTIONAL)\*\*:\s+(.\S|\S.*)$/; -const LEGACY_REQUIREMENT_RE = /\((REQUIRED|RECOMMENDED|OPTIONAL)\)/; +const REQUIREMENT_ID_RE = /^govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-[1-9][0-9]*$/; +const DISPOSITIONS = new Set(['operation', 'message', 'external', 'non-api', 'planned']); +const REQUIREMENT_HEADING_RE = + /^\s*###\s+#([1-9][0-9]*)\s+(.+?)\s+\((REQUIRED|RECOMMENDED|DRAFT|DEPRECATED)\s+(IMMUTABLE|EXTENSIBLE|REPLACEABLE|INAPPLICABLE)\s+(OBSERVABLE|AUDITABLE)\)\s*$/; +const REQUIREMENT_REFERENCE_RE = + /^\s*`(govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-([1-9][0-9]*))(?:\s+(extends|replaces)\s+(govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-[1-9][0-9]*))?`\s*$/; +const LEGACY_REQUIREMENT_RE = + /^\s*-\s+\*\*[^*]+\*\*\s+\*\*(REQUIRED|RECOMMENDED|OPTIONAL|DRAFT|DEPRECATED)\*\*:/; +const LOOKS_LIKE_REQUIREMENT_HEADING_RE = /^\s*#+\s+#\d+\s+/; function nonEmptyStrings(value) { return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item.trim()); @@ -533,6 +576,7 @@ function isHttpsUrl(value) { async function scanRequirementMarkers(repoRoot, rel, findings) { const specDir = path.join(repoRoot, 'spec'); const markers = new Map(); + const seenIds = new Map(); async function walk(dir) { let entries; @@ -559,21 +603,87 @@ async function scanRequirementMarkers(repoRoot, rel, findings) { continue; } if (fenced) continue; - const match = line.match(REQUIREMENT_MARKER_RE); + const match = line.match(REQUIREMENT_HEADING_RE); if (match) { - const [, id, strength, text] = match; - const location = `${rel(full)}:${i + 1}`; - if (markers.has(id)) { + const [, headingNumber, title, level, mutability, verification] = match; + const headingLocation = `${rel(full)}:${i + 1}`; + let referenceLine = i + 1; + while (referenceLine < lines.length && !lines[referenceLine].trim()) referenceLine += 1; + const reference = lines[referenceLine]?.match(REQUIREMENT_REFERENCE_RE); + if (!reference) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Requirement at ${headingLocation} must be followed by a canonical ` + + '`govstack-...#req-N` identifier, optionally with extends or replaces.', + ), + ); + continue; + } + const [, id, referenceNumber, relation, parent] = reference; + const referenceLocation = `${rel(full)}:${referenceLine + 1}`; + let bodyLine = referenceLine + 1; + while (bodyLine < lines.length && !lines[bodyLine].trim()) bodyLine += 1; + while (/^KF:\s+\S/.test(lines[bodyLine]?.trim() ?? '')) { + bodyLine += 1; + while (bodyLine < lines.length && !lines[bodyLine].trim()) bodyLine += 1; + } + const body = lines[bodyLine]?.trim() ?? ''; + if (!body || /^#{1,6}\s+/.test(body)) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Requirement ${id} at ${headingLocation} must include body text after its canonical identifier and optional KF lines.`, + ), + ); + i = referenceLine; + continue; + } + if (headingNumber !== referenceNumber) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Requirement number #${headingNumber} at ${headingLocation} does not match ${id} at ${referenceLocation}.`, + ), + ); + } + if (mutability === 'INAPPLICABLE' && !relation) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `INAPPLICABLE requirement ${id} must identify a parent requirement with extends or replaces.`, + ), + ); + } + if (seenIds.has(id)) { findings.push( driverFinding( rel(full), 'requirements-duplicate-id', - `Requirement id "${id}" is duplicated at ${markers.get(id).location} and ${location}.`, + `Requirement id "${id}" is duplicated at ${seenIds.get(id)} and ${referenceLocation}.`, ), ); } else { - markers.set(id, { id, strength, text: text.trim(), location }); + seenIds.set(id, referenceLocation); + const active = (level === 'REQUIRED' || level === 'RECOMMENDED') && mutability !== 'INAPPLICABLE'; + if (active) { + markers.set(id, { + id, + title: title.trim(), + level, + mutability, + verification, + relation: relation ?? null, + parent: parent ?? null, + location: headingLocation, + }); + } } + i = referenceLine; continue; } if (LEGACY_REQUIREMENT_RE.test(line)) { @@ -581,15 +691,15 @@ async function scanRequirementMarkers(repoRoot, rel, findings) { driverFinding( rel(full), 'requirements-unkeyed', - `Legacy unkeyed requirement at ${rel(full)}:${i + 1}; use "- **REQ-ID** **REQUIRED|RECOMMENDED|OPTIONAL**: text".`, + `Legacy requirement marker at ${rel(full)}:${i + 1}; use the GovStack requirement heading, classifiers, and canonical identifier.`, ), ); - } else if (/\*\*(REQUIRED|RECOMMENDED|OPTIONAL)\*\*/.test(line)) { + } else if (LOOKS_LIKE_REQUIREMENT_HEADING_RE.test(line)) { findings.push( driverFinding( rel(full), 'requirements-invalid-marker', - `Invalid requirement marker at ${rel(full)}:${i + 1}.`, + `Invalid GovStack requirement heading at ${rel(full)}:${i + 1}; expected all three CFR classifiers.`, ), ); } @@ -665,7 +775,7 @@ function collectReferenceInventory(specs, rel, findings, coverageRel) { return { operationOwners, messageOwners }; } -async function validateRequirementCoverage(cfg, specs, noApi, rel, findings) { +async function validateRequirementCoverage(cfg, specs, hasDeclaredSurface, noApi, rel, findings) { const coverageAbs = path.join(cfg.repoRoot, 'api', 'coverage.yaml'); const coverageRel = rel(coverageAbs); const file = await readOptionalText(coverageAbs); @@ -682,14 +792,14 @@ async function validateRequirementCoverage(cfg, specs, noApi, rel, findings) { } return; } - if (specs.length === 0) return; + if (!hasDeclaredSurface) return; const markers = await scanRequirementMarkers(cfg.repoRoot, rel, findings); if (markers.size === 0) { findings.push( driverFinding( 'spec/', 'requirements-missing', - 'Declared API surfaces require at least one keyed requirement marker under spec/**/*.md.', + 'Declared API surfaces require at least one active CFR-formatted requirement under spec/**/*.md.', ), ); } @@ -761,7 +871,7 @@ async function validateRequirementCoverage(cfg, specs, noApi, rel, findings) { operation: new Set(['id', 'disposition', 'operations']), message: new Set(['id', 'disposition', 'messages']), external: new Set(['id', 'disposition', 'reference']), - 'not-applicable': new Set(['id', 'disposition', 'rationale']), + 'non-api': new Set(['id', 'disposition', 'rationale']), planned: new Set(['id', 'disposition', 'issue']), }[entry.disposition]; const extras = Object.keys(entry).filter((key) => !allowedKeys.has(key)); @@ -803,19 +913,10 @@ async function validateRequirementCoverage(cfg, specs, noApi, rel, findings) { if (!isHttpUrl(entry.reference)) { findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.reference must be an http(s) URL.`)); } - } else if (entry.disposition === 'not-applicable') { + } else if (entry.disposition === 'non-api') { if (typeof entry.rationale !== 'string' || entry.rationale.trim().length < 3) { findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.rationale is required.`)); } - if (markers.get(entry.id)?.strength === 'REQUIRED') { - findings.push( - driverFinding( - coverageRel, - 'coverage-required-not-applicable', - `${label} cannot mark REQUIRED requirement "${entry.id}" as not-applicable.`, - ), - ); - } } else { if (!isHttpUrl(entry.issue)) { findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.issue must be an http(s) URL.`)); @@ -1275,6 +1376,7 @@ async function main(argv) { // --- Discover and load declared spec files ------------------------------------------- const discovery = await discoverApiDeclarations(cfg, rel, findings, notices); const specs = await loadDeclaredSpecs(discovery.declarations, rel, findings); + const hasDeclaredSurface = discovery.declarations.length > 0 || discovery.standardSurfaces.length > 0; // --- File-tree checks (§2.2/§2.3/§3.2/§3.3) ----------------------------------------- await checkLegacySwagger(cfg.repoRoot, rel, findings); @@ -1282,10 +1384,26 @@ async function main(argv) { skipAbs.add(path.join(cfg.repoRoot, 'api', 'swagger.yaml')); skipAbs.add(path.join(cfg.repoRoot, 'api', 'swagger.json')); await scanDivergentCopies(cfg.repoRoot, skipAbs, rel, findings); - await validateRequirementCoverage(cfg, specs, discovery.noApi, rel, findings); + await validateRequirementCoverage(cfg, specs, hasDeclaredSurface, discovery.noApi, rel, findings); if (discovery.noApi) notices.push('api/index.yaml explicitly declares that this BB exposes no API surface.'); + for (const surface of discovery.standardSurfaces) { + const message = + `Standard-defined API surface "${surface.name}" is inventoried at ${surface.reference}; ` + + 'its protocol-specific conformance is not evaluated by this OpenAPI/AsyncAPI linter.'; + if (cfg.mode === 'conformance') { + findings.push( + driverFinding( + 'api/index.yaml', + 'standard-surface-unverified', + `${message} Conformance remains blocked until GovStack approves a standard-surface registry or profile.`, + ), + ); + } else { + notices.push(message); + } + } - const noSpec = specs.length === 0; + const noSpec = !hasDeclaredSurface; // --- Spectral (§20.2) + base validators (§20.1) + guide declaration (§20.3) ---------- let spectral; diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml index 33f27e5..8f82907 100644 --- a/api-design-guide/linter/coverage.yaml +++ b/api-design-guide/linter/coverage.yaml @@ -38,7 +38,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-2.5, govstack-2.5-semver] - note: "info has title/version/description/contact; version matches SemVer regex" + note: "info has title/version/description; version matches SemVer regex; contact is advisory" - id: "2.6" class: "M+R" status: partial-proxy @@ -48,7 +48,7 @@ rules: class: "M+R" status: implemented spectral_rules: [govstack-2.7] - note: "each operation has operationId(camelCase)/summary/description/>=1 tag" + note: "each operation has stable non-empty operationId and description; casing, summary, and tags are advisory" - id: "2.8" class: "M+R" status: needs-context @@ -78,7 +78,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-3.5, govstack-3.5-semver] - note: "info has title/version(SemVer)/description/contact" + note: "info has title/version(SemVer)/description; contact is advisory" - id: "3.6" class: "M+R" status: implemented @@ -88,7 +88,7 @@ rules: class: "M+R" status: implemented spectral_rules: [govstack-3.7] - note: "each op has action(send/receive)/summary/description/>=1 tag/channel ref/>=1 msg ref; verify op msgs resolve to channel's messages" + note: "each op has action(send/receive), description, channel ref, and >=1 msg ref; verify op msgs resolve to channel's messages; summary and tags are advisory" - id: "3.8" class: "M" status: needs-context @@ -101,14 +101,14 @@ rules: note: "proxy: payload schemas parse as JSON Schema + apply §9/§10 checks to payload props" - id: "4.1" class: "M" - status: implemented + status: partial-proxy spectral_rules: [govstack-4.1] - note: "recursive traversal: every schema node has non-empty description" + note: "advisory proxy: warns on every missing schema description; whether a non-obvious schema has a useful description is not mechanically decidable" - id: "4.2" class: "M+R" status: partial-proxy spectral_rules: [govstack-4.2] - note: "body-example presence (strong); enum \"values documented\" proxied by description presence" + note: "advisory body-example presence; enum \"values documented\" proxied by description presence" - id: "4.3" class: "M+R" status: partial-proxy @@ -123,17 +123,17 @@ rules: class: "M+R" status: driver spectral_rules: [] - note: "driver validates canonical discovery, api/index.yaml shape and paths, declared document types, explicit noApi reasons, and undeclared spec copies" + note: "driver validates canonical discovery, OpenAPI/AsyncAPI paths, explicit noApi reasons, and undeclared spec copies; protocol-standard entries block conformance until an approved registry/profile can validate them" - id: "4.6" class: "M+R" status: driver spectral_rules: [] - note: "driver validates exact Markdown marker and coverage ID sets, dispositions and compatible fields, REQUIRED/not-applicable conflicts, operation/message references, uniqueness across surfaces, and planned gaps" + note: "driver validates CFR requirement headings, bodies, canonical IDs and classifiers; active coverage ID sets; disposition evidence; operation/message references; uniqueness; and planned gaps" - id: "5.1" class: "M" - status: implemented + status: partial-proxy spectral_rules: [govstack-5.1] - note: "every path key matches ^/v\\d+/...; exempts the 5.9 unversioned operational endpoints /health and /ready" + note: "advisory proxy: checks the recommended /v{N}/ path default and standard endpoint exemptions; does not prove that a different declared mechanism exposes the major version unambiguously" - id: "5.2" class: "M+R" status: strict-only @@ -143,7 +143,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-5.3] - note: "each non-param path segment kebab-case" + note: "advisory default: each non-param path segment kebab-case" - id: "5.4" class: "M" status: implemented @@ -158,7 +158,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-5.6] - note: "query-param names camelCase" + note: "advisory default: query-param names camelCase" - id: "5.7" class: "M+R" status: strict-only @@ -196,9 +196,9 @@ rules: note: "replace-entire-resource/idempotent is runtime" - id: "6.4" class: "M+R" - status: implemented + status: partial-proxy spectral_rules: [govstack-6.4] - note: "PATCH requestBody content includes application/merge-patch+json (only merge/json-patch allowed)" + note: "advisory proxy: recognises the Merge Patch default; registered alternative patch media types and documented semantics require review" - id: "6.5" class: "M+R" status: partial-proxy @@ -363,7 +363,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-9.2] - note: "recursive: all reusable and inline OpenAPI body fields plus AsyncAPI payload fields use camelCase" + note: "advisory: reusable and inline OpenAPI body fields plus AsyncAPI payload fields use camelCase" - id: "9.3" class: "M" status: partial-proxy @@ -378,7 +378,7 @@ rules: class: "M" status: implemented spectral_rules: [govstack-9.5] - note: "recursive: reusable and inline OpenAPI body fields plus AsyncAPI payload fields contain no spaces/non-ASCII" + note: "advisory: reusable and inline OpenAPI body fields plus AsyncAPI payload fields use ASCII without spaces" - id: "9.6" class: "R" status: strict-only @@ -526,9 +526,9 @@ rules: note: "if offset param present, envelope {items,offset,limit,total} with total required" - id: "12.7" class: "M" - status: implemented + status: partial-proxy spectral_rules: [govstack-12.7-name, govstack-12.7-grammar] - note: "sort param named sort (flag orderBy/sortBy); value grammar field/-field" + note: "advisory proxy: checks the recommended sort name and field/-field grammar; allowed fields, direction documentation, default order, and stable tie-breaker require review" - id: "12.8" class: "M+R" status: partial-proxy @@ -631,9 +631,9 @@ rules: note: "proxy: canonical GET /v1/operations/{operationId} present when Operations used. strengths empty in rules.yaml (class M+R); treated as MUST-flavored." - id: "15.5" class: "M+R" - status: implemented + status: partial-proxy spectral_rules: [govstack-15.5] - note: "cancellation path exactly POST /v1/operations/{operationId}/cancel" + note: "advisory proxy: checks the conventional cancellation path; does not prove that cancellation support or non-support is documented and discoverable by another mechanism" - id: "15.6" class: "R" status: human @@ -656,9 +656,9 @@ rules: note: "event payload schema requires specversion(const \"1.0\")/id/source/type + data, and the webhooks surface must declare application/cloudevents+json rather than a binary-mode application/json body" - id: "16.3" class: "M" - status: implemented - spectral_rules: [govstack-16.3] - note: "event type const matches reverse-DNS global.govstack.{bb-code}.{resource}.{action}, no version" + status: partial-proxy + spectral_rules: [govstack-16.3, govstack-16.3-no-version] + note: "split proxy: warns on the reverse-DNS default and errors on a pinned major-version segment; BB-code registry membership, long-term stability, and global collision resistance require context" - id: "16.4" class: "M+R" status: partial-proxy @@ -708,7 +708,7 @@ rules: class: "M+R" status: partial-proxy spectral_rules: [govstack-17.2] - note: "logical channel keys match global.govstack.{bb-code}.v{major}.{resource}.{event}; native address syntax and binding mapping require protocol-aware review" + note: "advisory proxy: logical channel keys match the GovStack reverse-DNS default; stability and native-address mapping require review" - id: "17.3" class: "R" status: strict-only @@ -796,19 +796,19 @@ rules: note: "proxy: per server protocol, channels/ops declare matching kafka/mqtt/amqp/ws bindings" - id: "17.20" class: "M+R" - status: implemented + status: partial-proxy spectral_rules: [govstack-17.20] - note: "every components.messages entry has >=1 example" + note: "advisory proxy: warns when any components.messages entry lacks an example; whether the interaction is already obvious from the schema requires review" - id: "18.1" class: "M" - status: implemented + status: partial-proxy spectral_rules: [govstack-18.1] - note: "info.version matches SemVer regex. Not checked: that it is the contract version rather than the implementation version, which no document reveals" + note: "proxy: info.version matches SemVer; whether it identifies the contract rather than the implementation and agrees with a nonstandard version mechanism requires context" - id: "18.2" class: "M" - status: implemented + status: partial-proxy spectral_rules: [govstack-18.2-openapi, govstack-18.2-asyncapi] - note: "OpenAPI paths and AsyncAPI logical channel IDs carry / match info.version major; the alternative common-schema version field is not checked" + note: "advisory proxy: checks recommended OpenAPI path and AsyncAPI channel forms; does not prove that another declared mechanism exposes the major version unambiguously" - id: "18.3" class: "M+R" status: needs-context diff --git a/api-design-guide/linter/functions/s03-asyncOperation.js b/api-design-guide/linter/functions/s03-asyncOperation.js index 845cde1..4d27cdb 100644 --- a/api-design-guide/linter/functions/s03-asyncOperation.js +++ b/api-design-guide/linter/functions/s03-asyncOperation.js @@ -35,9 +35,7 @@ function resolveLocalRef(root, ref) { * * For every entry under `operations`, asserts it declares: * - `action` == "send" or "receive" - * - a non-empty `summary` * - a non-empty `description` - * - at least one `tag` * - a `channel` Reference Object ({$ref: <string>}) * - a non-empty `messages` array of Reference Objects * - each `messages[i]` $ref points into the operation's referenced channel's @@ -69,15 +67,9 @@ export default function s03AsyncOperation(targetVal, _options, context) { if (!ACTIONS.has(op.action)) { findings.push({ message: `operation "${opId}" must declare action "send" or "receive"`, path: at('action') }); } - if (!isNonEmptyString(op.summary)) { - findings.push({ message: `operation "${opId}" must declare a non-empty summary`, path: at('summary') }); - } if (!isNonEmptyString(op.description)) { findings.push({ message: `operation "${opId}" must declare a non-empty description`, path: at('description') }); } - if (!Array.isArray(op.tags) || op.tags.length < 1) { - findings.push({ message: `operation "${opId}" must declare at least one tag`, path: at('tags') }); - } const channelRef = refString(op.channel); if (!channelRef) { diff --git a/api-design-guide/linter/functions/s12-sortParam.js b/api-design-guide/linter/functions/s12-sortParam.js index f05dd22..ebd0a96 100644 --- a/api-design-guide/linter/functions/s12-sortParam.js +++ b/api-design-guide/linter/functions/s12-sortParam.js @@ -5,7 +5,7 @@ import { isObject, asArray } from './lib/util.js'; * assert its schema is a string carrying a `pattern` (guide 12.7's * `field` / `-field`, comma-separated grammar). A no-op when the operation * declares no `sort` parameter at all: sorting support is optional per - * operation, only its shape is mandated once offered. + * operation. The guide recommends this shape once sorting is offered. * * Does NOT verify that the declared `pattern` actually encodes the exact * field/-field/comma grammar, only that a pattern is present; validating an @@ -31,11 +31,11 @@ export default function sortParamShape(targetVal, options, context) { const results = []; if (schema.type !== 'string') { - results.push({ message: '"sort" parameter schema must declare type "string"', path }); + results.push({ message: '"sort" parameter schema should declare type "string"', path }); } if (typeof schema.pattern !== 'string' || schema.pattern.length === 0) { results.push({ - message: '"sort" parameter schema must declare a "pattern" encoding the field/-field, comma-separated grammar (guide 12.7)', + message: '"sort" parameter schema should declare a "pattern" encoding the field/-field, comma-separated grammar (guide 12.7)', path, }); } diff --git a/api-design-guide/linter/functions/s15-cancelPath.js b/api-design-guide/linter/functions/s15-cancelPath.js index 5ed0c68..5f6fcfe 100644 --- a/api-design-guide/linter/functions/s15-cancelPath.js +++ b/api-design-guide/linter/functions/s15-cancelPath.js @@ -32,14 +32,14 @@ export default function cancelPath(targetVal, _options, context) { if (!CANONICAL.test(key)) { results.push({ - message: `cancellation path "${key}" must be POST /v{N}/operations/{operationId}/cancel`, + message: `cancellation path "${key}" should use POST /v{N}/operations/{operationId}/cancel`, path: [...base, key], }); continue; } if (!isObject(item) || !isObject(item.post)) { results.push({ - message: `cancellation at "${key}" must be declared as a POST operation`, + message: `cancellation at "${key}" should use a POST operation`, path: [...base, key], }); } diff --git a/api-design-guide/linter/rulesets/s02.yaml b/api-design-guide/linter/rulesets/s02.yaml index f2dfc2d..acea584 100644 --- a/api-design-guide/linter/rulesets/s02.yaml +++ b/api-design-guide/linter/rulesets/s02.yaml @@ -28,11 +28,11 @@ rules: openapi: enum: ["3.1.0", "3.1.1", "3.1.2"] - # 2.5 [M] — info MUST include title, version, description, contact. + # 2.5 [M] — info MUST include title, version, and description. Contact is advisory. # Split per the documented suffix convention: presence here, SemVer below. # formats [oas3] so the info block is checked even on a wrong-version doc. govstack-2.5: - description: "info block must include title, version, description, contact (guide 2.5, [M])." + description: "info block must include title, version, and description (guide 2.5, [M])." message: "[2.5][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#25-complete-info-block severity: error @@ -44,7 +44,7 @@ rules: allErrors: true schema: type: object - required: [title, version, description, contact] + required: [title, version, description] properties: title: { type: string, minLength: 1 } description: { type: string, minLength: 1 } @@ -99,11 +99,10 @@ rules: - not: pattern: '/v[0-9]+(?:/|$)' - # 2.7 [M+R] — every operation MUST include operationId (camelCase), summary, - # description, and >=1 tag. The verb-noun convention for operationId is not - # mechanically verified (only camelCase is). + # 2.7 [M+R] — every operation MUST include a stable operationId and description. + # Casing, summary, and tags are advisory. govstack-2.7: - description: "every operation must declare operationId(camelCase), summary, description, >=1 tag (guide 2.7, [M+R])." + description: "every operation must declare a stable operationId and description (guide 2.7, [M+R])." message: "[2.7][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#27-complete-operation-metadata severity: error @@ -115,13 +114,11 @@ rules: allErrors: true schema: type: object - required: [operationId, summary, description, tags] + required: [operationId, description] properties: operationId: type: string minLength: 1 - pattern: '^[a-z][a-zA-Z0-9]*$' - summary: { type: string, minLength: 1 } description: { type: string, minLength: 1 } tags: type: array diff --git a/api-design-guide/linter/rulesets/s03.yaml b/api-design-guide/linter/rulesets/s03.yaml index 6a9908a..dc3c2d5 100644 --- a/api-design-guide/linter/rulesets/s03.yaml +++ b/api-design-guide/linter/rulesets/s03.yaml @@ -32,11 +32,11 @@ rules: asyncapi: enum: ["3.0.0", "3.1.0"] - # 3.5 [M] — info MUST include title, version, description, contact. + # 3.5 [M] — info MUST include title, version, and description. Contact is advisory. # Split per the suffix convention: presence here, SemVer below. # formats [aas2, aas3] so the info block is checked even on a wrong-version doc. govstack-3.5: - description: "AsyncAPI info block must include title, version, description, contact (guide 3.5, [M])." + description: "AsyncAPI info block must include title, version, and description (guide 3.5, [M])." message: "[3.5][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block severity: error @@ -48,7 +48,7 @@ rules: allErrors: true schema: type: object - required: [title, version, description, contact] + required: [title, version, description] properties: title: { type: string, minLength: 1 } description: { type: string, minLength: 1 } @@ -111,14 +111,14 @@ rules: name: server.host notMatch: 'localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?' - # 3.7 [M+R] — every operation MUST declare action(send/receive), summary, - # description, >=1 tag, a referenced channel, and >=1 referenced message that + # 3.7 [M+R] — every operation MUST declare action(send/receive), description, + # a referenced channel, and >=1 referenced message that # resolves to a message on that channel. resolved:false so the raw $ref # pointers are visible for the channel-membership cross-check. The # CloudEvents-ness of the message (a §17.6 concern) and the MAY about channel # messages re-referencing components.messages are not verified here. govstack-3.7: - description: "every AsyncAPI operation must declare action/summary/description/>=1 tag/channel/>=1 channel message (guide 3.7, [M+R])." + description: "every AsyncAPI operation must declare action/description/channel/>=1 channel message (guide 3.7, [M+R])." message: "[3.7][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata severity: error @@ -135,7 +135,7 @@ rules: # the full §10 field-format battery, or payload conventions on non-JSON-Schema # payload formats. govstack-3.9: - description: "AsyncAPI message payload property names must be camelCase (guide 3.9, [M], proxy for §9/§10)." + description: "GovStack-owned AsyncAPI message payload properties should be camelCase (guide 3.9, [M], proxy for §9/§10)." message: "[3.9][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#39-json-schema-payload-conventions severity: warn diff --git a/api-design-guide/linter/rulesets/s04.yaml b/api-design-guide/linter/rulesets/s04.yaml index 9f9492b..b4f4e8d 100644 --- a/api-design-guide/linter/rulesets/s04.yaml +++ b/api-design-guide/linter/rulesets/s04.yaml @@ -14,16 +14,16 @@ functions: - s04-bodyExamplesEnums - s04-noPlaceholderText rules: - # 4.1 [M] — every schema MUST have a description. Recursive: every subschema - # node (not just declared properties) needs one, via schemaDescriptions' + # 4.1 [M] — schema descriptions are conditional/advisory. This heuristic warns + # on every missing description because semantic obviousness is not decidable. # mode "all". Scope: components.schemas plus inline request/response body # schemas (OpenAPI) and message payload schemas (AsyncAPI). Parameter/header # schemas are out of scope (not swept). govstack-4.1: - description: "every schema must have a description (guide 4.1, [M])." + description: "schemas should have useful descriptions where semantics are not obvious (guide 4.1, [M], proxy)." message: "[4.1][M] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#41-every-schema-described - severity: error + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#41-useful-schema-descriptions + severity: warn formats: [oas3_1, aas3] given: - $.components.schemas[*] diff --git a/api-design-guide/linter/rulesets/s05-strict.yaml b/api-design-guide/linter/rulesets/s05-strict.yaml index 46384ee..e559504 100644 --- a/api-design-guide/linter/rulesets/s05-strict.yaml +++ b/api-design-guide/linter/rulesets/s05-strict.yaml @@ -40,7 +40,7 @@ rules: # 5.8 [R] MUST — non-CRUD actions expressed as /{collection}/{id}/{verb} # sub-resources. govstack-5.8: - description: "Non-CRUD actions must be expressed as /{collection}/{id}/{verb} sub-resources (guide 5.8, [R], strict heuristic)." + description: "Non-CRUD actions should be expressed as /{collection}/{id}/{verb} sub-resources (guide 5.8, [R], strict heuristic)." message: "[5.8][R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources severity: warn diff --git a/api-design-guide/linter/rulesets/s05.yaml b/api-design-guide/linter/rulesets/s05.yaml index ed291d0..311718e 100644 --- a/api-design-guide/linter/rulesets/s05.yaml +++ b/api-design-guide/linter/rulesets/s05.yaml @@ -12,27 +12,27 @@ functions: - operationResponses - securityCoverage rules: - # 5.1 [M] MUST — major version prefix /v{N}/... on every path. The guide 5.10 + # 5.1 [M] SHOULD — major version prefix /v{N}/... on GovStack resource paths. The guide 5.10 # standard unversioned endpoints cannot carry a /v{N}/ prefix and are skipped # inside `pathSegments` (functions/lib/standardEndpoints.js), which is the one # place that closed set is written down. govstack-5.1: - description: "Every path must start with a major version prefix /v{N}/ (guide 5.1, [M])." + description: "GovStack resource paths should start with a major version prefix /v{N}/ (guide 5.1, [M])." message: "[5.1][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path - severity: error + severity: warn formats: [oas3_1] given: $.paths then: function: pathSegments functionOptions: { check: versionPrefix } - # 5.3 [M] MUST — multi-word path segments must be kebab-case. + # 5.3 [M] SHOULD — multi-word path segments should be kebab-case. govstack-5.3: - description: "Path segments must be kebab-case (guide 5.3, [M])." + description: "Path segments should be kebab-case (guide 5.3, [M])." message: "[5.3][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#53-kebab-case-path-segments - severity: error + severity: warn formats: [oas3_1] given: $.paths then: @@ -78,14 +78,14 @@ rules: name: "query parameter" forbidPattern: '(^id$)|([a-z0-9]Id$)|(_id$)' - # 5.6 [M] MUST — query parameter names follow the §9 JSON naming + # 5.6 [M] SHOULD — query parameter names follow the §9 JSON naming # convention (camelCase). Covers both path-item-level (shared) and # operation-level query parameters. govstack-5.6: - description: "Query parameter names must be camelCase per §9 (guide 5.6, [M])." + description: "Query parameter names should be camelCase per §9 (guide 5.6, [M])." message: "[5.6][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#56-query-parameter-naming - severity: error + severity: warn formats: [oas3_1] given: - "$.paths[*].parameters[?(@.in=='query')].name" diff --git a/api-design-guide/linter/rulesets/s06.yaml b/api-design-guide/linter/rulesets/s06.yaml index 99cab2a..3980f55 100644 --- a/api-design-guide/linter/rulesets/s06.yaml +++ b/api-design-guide/linter/rulesets/s06.yaml @@ -31,10 +31,10 @@ rules: # enumerated here (not decidable which ones are "wrong" in general), so # this forbid list is illustrative, not exhaustive. govstack-6.4: - description: "PATCH request bodies must use application/merge-patch+json (guide 6.4, [M+R])." + description: "PATCH request bodies should use application/merge-patch+json by default (guide 6.4, [M+R])." message: "[6.4][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#64-patch-uses-json-merge-patch - severity: error + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#64-patch-uses-a-registered-patch-format + severity: warn formats: [oas3_1] given: $.paths[*].patch.requestBody.content then: diff --git a/api-design-guide/linter/rulesets/s09.yaml b/api-design-guide/linter/rulesets/s09.yaml index b9554ee..f45410c 100644 --- a/api-design-guide/linter/rulesets/s09.yaml +++ b/api-design-guide/linter/rulesets/s09.yaml @@ -47,12 +47,12 @@ rules: functionOptions: requireOneOf: ['json'] - # 9.2 [M] — every declared property name (recursively) MUST be camelCase. + # 9.2 [M] — GovStack-owned property names SHOULD be camelCase. govstack-9.2: - description: "JSON field names must be camelCase (guide 9.2, [M])." + description: "GovStack-owned JSON field names should be camelCase (guide 9.2, [M])." message: "[9.2][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#92-camelcase-field-names - severity: error + severity: warn formats: [oas3_1, aas3] given: "#DataSchemas" then: @@ -89,12 +89,12 @@ rules: then: function: undefined - # 9.5 [M] — field names MUST NOT contain spaces or non-ASCII characters. + # 9.5 [M] — GovStack-owned field names SHOULD use ASCII without spaces. govstack-9.5: - description: "Field names must not contain spaces or non-ASCII characters (guide 9.5, [M])." + description: "GovStack-owned field names should use ASCII without spaces (guide 9.5, [M])." message: "[9.5][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names - severity: error + severity: warn formats: [oas3_1, aas3] given: "#DataSchemas" then: @@ -109,7 +109,7 @@ rules: # and §12.7 sort keys are lowerCamelCase field names indistinguishable from a # mis-cased state name, so a sort-key schema is a known false positive. govstack-9.7: - description: "Enum values must be SCREAMING_SNAKE_CASE (guide 9.7, [M], proxy)." + description: "BB-defined enum values should be SCREAMING_SNAKE_CASE (guide 9.7, [M], proxy)." message: "[9.7][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values severity: warn diff --git a/api-design-guide/linter/rulesets/s12.yaml b/api-design-guide/linter/rulesets/s12.yaml index b010320..42159c3 100644 --- a/api-design-guide/linter/rulesets/s12.yaml +++ b/api-design-guide/linter/rulesets/s12.yaml @@ -111,16 +111,15 @@ rules: functionOptions: mode: offsetEnvelope - # 12.7 [M] — the sort parameter MUST be named `sort` (not `orderBy`/ - # `sortBy`), values `field`/`-field`, comma-separated. Split into a naming - # check (any operation, not just collection GETs: the wrong name is wrong - # wherever it appears) and a shape check (only fires when a `sort` - # parameter exists; does not require every collection to support sorting). + # 12.7 [M] — ADVISORY PROXY: the sort parameter SHOULD be named `sort` + # (rather than `orderBy`/`sortBy`) and use comma-separated `field`/`-field` + # values. Split into a naming check and a shape check. Neither check proves + # that allowed fields, default order, or a stable tie-breaker are documented. govstack-12.7-name: - description: "sort parameter must be named 'sort', not 'orderBy'/'sortBy' (guide 12.7, [M])." - message: "[12.7][M] parameter name must be \"sort\", not \"orderBy\"/\"sortBy\" (guide 12.7)" + description: "sort parameter should use the GovStack default name 'sort' (guide 12.7, [M])." + message: "[12.7][M] parameter name should use the GovStack default \"sort\" (guide 12.7)" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention - severity: error + severity: warn formats: [oas3_1] given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" then: @@ -139,10 +138,10 @@ rules: enum: [orderBy, sortBy] govstack-12.7-grammar: - description: "sort parameter schema must be a string with a field/-field, comma-separated pattern (guide 12.7, [M])." + description: "sort parameter should use the GovStack field/-field comma-separated grammar (guide 12.7, [M])." message: "[12.7][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention - severity: error + severity: warn formats: [oas3_1] given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" then: diff --git a/api-design-guide/linter/rulesets/s15.yaml b/api-design-guide/linter/rulesets/s15.yaml index 4cd5bdc..a1db609 100644 --- a/api-design-guide/linter/rulesets/s15.yaml +++ b/api-design-guide/linter/rulesets/s15.yaml @@ -49,10 +49,10 @@ rules: # does not declare a POST, is flagged. Domain-level cancels not under # /operations/ are out of scope. govstack-15.5: - description: "Operation cancellation must be POST /v{N}/operations/{operationId}/cancel (guide 15.5, [M+R])." + description: "Operation cancellation should use POST /v{N}/operations/{operationId}/cancel (guide 15.5, [M+R])." message: "[15.5][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#155-cancellation-via-cancel-sub-resource - severity: error + severity: warn formats: [oas3_1] given: $.paths then: diff --git a/api-design-guide/linter/rulesets/s16.yaml b/api-design-guide/linter/rulesets/s16.yaml index f7d6704..fab225b 100644 --- a/api-design-guide/linter/rulesets/s16.yaml +++ b/api-design-guide/linter/rulesets/s16.yaml @@ -80,15 +80,15 @@ rules: require: '^application/cloudevents\+json\s*(;|$)' forbid: '^application/json\s*(;|$)' - # 16.3 [M] — the pinned event `type` value MUST follow reverse-DNS - # global.govstack.{bb-code}.{resource}.{action} and MUST NOT carry a version - # segment. Only pinned const/enum values are checked (a free-form `type` is a - # 16.2 presence concern, not verifiable here). + # 16.3 [M] — ADVISORY PROXY: the pinned event `type` value SHOULD follow the + # reverse-DNS global.govstack.{bb-code}.{resource}.{action} default. Registry + # membership and global stability need review. Only pinned const/enum values + # are checked (a free-form `type` is a 16.2 presence concern). govstack-16.3: - description: "event type must be reverse-DNS global.govstack.{bb-code}.{resource}.{action} with no version (guide 16.3, [M])." + description: "event type should use the GovStack reverse-DNS default (guide 16.3, [M], advisory proxy)." message: "[16.3][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types - severity: error + severity: warn formats: [oas3_1] given: $.webhooks[*][post,put,patch].requestBody.content[*].schema then: @@ -96,8 +96,25 @@ rules: functionOptions: property: type name: event type - expected: "reverse-DNS global.govstack.{bb-code}.{resource}.{action} with no major-version segment" + expected: "reverse-DNS global.govstack.{bb-code}.{resource}.{action}" match: '^global\.govstack\.[a-z][a-z0-9-]{1,30}(?:\.[a-z][a-zA-Z0-9]*){2,}$' + + # 16.3 [M] — a pinned event `type` MUST NOT carry a major-version segment. + # This deterministic invariant remains an error independently of the + # advisory reverse-DNS default above. + govstack-16.3-no-version: + description: "event type must not contain a major-version segment (guide 16.3, [M])." + message: "[16.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types + severity: error + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: s16-eventField + functionOptions: + property: type + name: event type + expected: "no major-version segment" forbidSegments: '^v[0-9]+$' # 16.4 [M+R] — PROXY (bucket B), notched MUST/MUST NOT -> warn. diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml index 1ebacb0..0fab47b 100644 --- a/api-design-guide/linter/rulesets/s17.yaml +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -41,10 +41,10 @@ rules: # reverse-DNS global.govstack.{bb-code}.v{major}.{resource}.{event}. Channel # Object addresses intentionally retain protocol-native destination syntax. govstack-17.2: - description: "Logical channel IDs must follow reverse-DNS global.govstack.{bb-code}.v{major}.{resource}.{event} (guide 17.2, [M+R])." + description: "Logical channel IDs should follow the GovStack reverse-DNS grammar (guide 17.2, [M+R], proxy)." message: "[17.2][M+R] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses - severity: error + severity: warn formats: [aas3] given: $.channels then: @@ -205,10 +205,10 @@ rules: # example. The SHOULD to show headers+payload per message family # (command/event/error/completion) is not mechanically verified. govstack-17.20: - description: "Every message must define at least one example (guide 17.20, [M+R])." + description: "Messages should define representative examples (guide 17.20, [M+R])." message: "[17.20][M+R] {{error}}" - documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1720-examples-for-every-message - severity: error + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1720-representative-message-examples + severity: warn formats: [aas3] given: $.components.messages[*] then: diff --git a/api-design-guide/linter/rulesets/s18.yaml b/api-design-guide/linter/rulesets/s18.yaml index 47e0a43..c08fcac 100644 --- a/api-design-guide/linter/rulesets/s18.yaml +++ b/api-design-guide/linter/rulesets/s18.yaml @@ -42,10 +42,10 @@ rules: # info.version's major segment. Paths lacking a version prefix entirely are # guide 5.1's concern, not this rule's. govstack-18.2-openapi: - description: "OpenAPI path version prefixes must match info.version's major segment (guide 18.2, [M])." + description: "OpenAPI path version prefixes should match info.version's major segment (guide 18.2, [M], default convention)." message: "[18.2][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel - severity: error + severity: warn formats: [oas3_1] given: $ then: @@ -59,10 +59,10 @@ rules: # govstack-asyncapi-common.yaml" (needs the vendored common file, out of # scope here); only the logical-ID convention is checked. govstack-18.2-asyncapi: - description: "AsyncAPI logical channel IDs must include a major version matching info.version's major segment (guide 18.2, [M])." + description: "AsyncAPI logical channel IDs should include a major version matching info.version (guide 18.2, [M], default convention)." message: "[18.2][M] {{error}}" documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel - severity: error + severity: warn formats: [aas3] given: $ then: diff --git a/api-design-guide/linter/tests/driver.test.mjs b/api-design-guide/linter/tests/driver.test.mjs index d83348f..3f9a4f1 100644 --- a/api-design-guide/linter/tests/driver.test.mjs +++ b/api-design-guide/linter/tests/driver.test.mjs @@ -25,11 +25,17 @@ function makeRepo(files) { } if (files['api/coverage.yaml'] && !files['spec/requirements.md']) { const ids = [ - ...files['api/coverage.yaml'].matchAll(/\bid:\s*["']?([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)/g), - ].map((match) => match[1]); + ...files['api/coverage.yaml'].matchAll( + /\bid:\s*["']?(govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-([1-9][0-9]*))["']?/g, + ), + ].map((match) => ({ id: match[1], number: match[2] })); if (ids.length) { - const requirements = [...new Set(ids)] - .map((id) => `- **${id}** **REQUIRED**: Test requirement ${id}.`) + const requirements = [...new Map(ids.map((item) => [item.id, item])).values()] + .map( + ({ id, number }) => + `### #${number} Test requirement ${number} (REQUIRED EXTENSIBLE OBSERVABLE)\n\n` + + `\`${id}\`\n\nTest requirement ${number}.`, + ) .join('\n'); const abs = path.join(dir, 'spec', 'requirements.md'); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -73,7 +79,7 @@ function fakeOpenapiValidator(dir) { const VALID_COVERAGE = `version: 1 requirements: - - id: REQ-TEST-001 + - id: "govstack-bb-test-fr#req-1" disposition: external reference: https://example.org/requirements/test `; @@ -180,10 +186,10 @@ apis: 'api/events.yaml': cleanAsyncapi(), 'api/coverage.yaml': `version: 1 requirements: - - id: REQ-API-001 + - id: "govstack-bb-test-fr#req-1" disposition: operation operations: [listThings, receiveThing] - - id: REQ-API-002 + - id: "govstack-bb-test-fr#req-2" disposition: message messages: [ThingReceived] `, @@ -197,6 +203,88 @@ requirements: } }); +test('api/index.yaml supports a protocol-standard surface without synthetic OpenAPI', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: standard + name: OpenID Connect + reference: https://openid.net/specs/openid-connect-core-1_0.html + discovery: /.well-known/openid-configuration +`, + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr#req-1" + disposition: external + reference: https://openid.net/specs/openid-connect-core-1_0.html +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + assert.equal(r.json.summary.filesLinted, 0); + assert.ok(r.json.notices.some((notice) => notice.includes('Standard-defined API surface'))); + } finally { + cleanup(dir); + } +}); + +test('protocol-standard surfaces remain blocked in conformance until a registry is approved', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: standard + name: Example protocol + reference: https://example.org/specification +`, + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('standard-surface-unverified')); + } finally { + cleanup(dir); + } +}); + +test('standard surface inventory requires an HTTPS normative reference', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: standard + name: OpenID Connect + reference: http://example.org/oidc +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('api-index-invalid')); + } finally { + cleanup(dir); + } +}); + +test('api/index.yaml rejects a noApi field alongside declared surfaces', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +noApi: false +apis: + - type: openapi + path: api/openapi.yaml +`, + 'api/openapi.yaml': cleanOpenapi(), + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('api-index-invalid')); + } finally { + cleanup(dir); + } +}); + test('missing and empty index-declared specs fail', () => { const dir = makeRepo({ 'api/index.yaml': `version: 1 @@ -315,7 +403,7 @@ apis: 'api/b.yaml': cleanAsyncapi('sameOperation', 'SameMessage'), 'api/coverage.yaml': `version: 1 requirements: - - { id: REQ-API-001, disposition: operation, operations: [sameOperation] } + - { id: "govstack-bb-test-fr#req-1", disposition: operation, operations: [sameOperation] } `, }); try { @@ -332,14 +420,24 @@ test('coverage is an exact projection of keyed Markdown requirements and flags l 'api/openapi.yaml': cleanOpenapi(), 'api/coverage.yaml': `version: 1 requirements: - - { id: REQ-SPEC-001, disposition: external, reference: https://example.org/one } - - { id: REQ-EXTRA-001, disposition: external, reference: https://example.org/extra } + - { id: "govstack-bb-test-fr#req-1", disposition: external, reference: https://example.org/one } + - { id: "govstack-bb-extra-fr#req-1", disposition: external, reference: https://example.org/extra } `, 'spec/requirements.md': `# Requirements -- **REQ-SPEC-001** **REQUIRED**: The API exposes the first contract. -- **REQ-SPEC-002** **RECOMMENDED**: The API exposes the second contract. -- Old prose requirement (REQUIRED) +### #1 The API exposes the first contract (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +The API exposes the first contract. + +### #2 The API exposes the second contract (RECOMMENDED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-2\` + +The API exposes the second contract. + +- **OLD-FR-003** **OPTIONAL**: Old prose requirement. `, }); try { @@ -353,12 +451,51 @@ requirements: } }); +test('draft, deprecated, and inapplicable requirements are not active coverage obligations', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + 'spec/requirements.md': `# Requirements + +### #1 Active requirement (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +Active contract. + +### #2 Proposed requirement (DRAFT EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-2\` + +Proposed contract. + +### #3 Retired requirement (DEPRECATED REPLACEABLE AUDITABLE) + +\`govstack-bb-test-fr#req-3\` + +Retired contract. + +### #4 Inherited exclusion (RECOMMENDED INAPPLICABLE AUDITABLE) + +\`govstack-bb-test-cfr#req-4 replaces govstack-cfr-quality#req-4\` + +The parent does not apply because this surface uses a protocol-native contract. +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + } finally { + cleanup(dir); + } +}); + test('planned coverage is blocking in conformance and advisory-only in advisory mode', () => { const dir = makeRepo({ 'api/openapi.yaml': cleanOpenapi(), 'api/coverage.yaml': `version: 1 requirements: - - id: REQ-PLAN-001 + - id: "govstack-bb-test-fr#req-1" disposition: planned issue: https://example.org/issues/123 `, @@ -390,7 +527,7 @@ test('coverage rejects disposition-incompatible extra fields', () => { 'api/openapi.yaml': cleanOpenapi(), 'api/coverage.yaml': `version: 1 requirements: - - id: REQ-EXTERNAL-001 + - id: "govstack-bb-test-fr#req-1" disposition: external reference: https://example.org/requirement operations: [listThings] @@ -405,25 +542,75 @@ requirements: } }); -test('coverage cannot mark a REQUIRED requirement not-applicable', () => { +test('coverage can trace a REQUIRED requirement to non-API verification', () => { const dir = makeRepo({ 'api/openapi.yaml': cleanOpenapi(), 'api/coverage.yaml': `version: 1 requirements: - - id: REQ-NA-001 - disposition: not-applicable - rationale: This is incorrectly excluded. + - id: "govstack-bb-test-fr#req-1" + disposition: non-api + rationale: This requirement is verified through the BB audit procedure. `, }); try { const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); - assert.equal(r.status, 1); - assert.ok(codes(r.json).includes('coverage-required-not-applicable')); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); } finally { cleanup(dir); } }); +test('coverage accepts version-qualified CFR requirement identifiers', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr-2.3.0#req-7" + disposition: external + reference: https://example.org/requirements/7 +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + } finally { + cleanup(dir); + } +}); + +test('requirement markers require an exact level-three heading and body text', () => { + const wrongDepth = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'spec/requirements.md': `## #1 Wrong depth (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +This requirement has body text. +`, + }); + const missingBody = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'spec/requirements.md': `### #1 Empty requirement (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +### Notes +`, + }); + try { + const depthResult = runCliJson(['--repo-root', wrongDepth, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(depthResult.status, 1); + assert.ok(codes(depthResult.json).includes('requirements-invalid-marker')); + + const bodyResult = runCliJson(['--repo-root', missingBody, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(bodyResult.status, 1); + assert.ok(codes(bodyResult.json).includes('requirements-invalid-marker')); + } finally { + cleanup(wrongDepth); + cleanup(missingBody); + } +}); + test('valid reviewed, unexpired exception suppresses only its known guide rule', () => { const exception = ` exceptions: diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/fail.yaml new file mode 100644 index 0000000..659eade --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type embeds a forbidden major-version segment. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "global.govstack.payments.v1.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/pass.yaml new file mode 100644 index 0000000..2839700 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type has no major-version segment. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml index 02f01d4..8ff8139 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Payments Events API version: 1.0.0 - description: Event type embeds a version segment (v1), which is forbidden. + description: Event type does not use the recommended GovStack reverse-DNS shape. contact: name: Sample BB Team url: https://example.org/contact @@ -25,7 +25,7 @@ webhooks: type: string type: type: string - const: "global.govstack.payments.v1.payment.completed" + const: "payments.payment.completed" data: type: object responses: diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml index ff19c84..e1a0a05 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml @@ -1,9 +1,8 @@ -# 3.5 fail: info block is missing `contact` (title/version/description present). +# 3.5 fail: info block is missing the required description. asyncapi: 3.0.0 info: title: Identity Events version: 1.2.0 - description: Emits identity lifecycle events for the Identity building block. servers: production: host: '{brokerHost}' diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml index 9d54a2e..2df0bd1 100644 --- a/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml +++ b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml @@ -1,4 +1,4 @@ -# 3.7 fail: the operation is missing summary, description and tags. Its action, +# 3.7 fail: the operation is missing its required description. Its action, # channel reference and message reference are otherwise valid and resolvable. asyncapi: 3.0.0 info: diff --git a/api-design-guide/part-a/2-openapi-document-standards.md b/api-design-guide/part-a/2-openapi-document-standards.md index 40614ee..3f3a6ba 100644 --- a/api-design-guide/part-a/2-openapi-document-standards.md +++ b/api-design-guide/part-a/2-openapi-document-standards.md @@ -30,7 +30,7 @@ An operation-free shared component library under `api/common/` is referenced sup ## 2.5 Complete info block <a href="#25-complete-info-block" id="25-complete-info-block"></a> -**[M]** The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), `description`, and `contact`. Where a BB ships per-surface canonical files ([2.2](#22-one-canonical-openapi-entrypoint)), each surface carries its own `info.version` and versions independently. +**[M]** The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract. Where a BB ships per-surface canonical files ([2.2](#22-one-canonical-openapi-entrypoint)), each surface carries its own `info.version` and versions independently. ## 2.6 Meaningful servers block <a href="#26-meaningful-servers-block" id="26-meaningful-servers-block"></a> @@ -38,7 +38,7 @@ An operation-free shared component library under `api/common/` is referenced sup ## 2.7 Complete operation metadata <a href="#27-complete-operation-metadata" id="27-complete-operation-metadata"></a> -**[M+R]** Every operation **MUST** include `operationId` (camelCase, verb-noun), `summary`, `description`, and at least one `tag`. +**[M+R]** Every operation **MUST** include a stable, non-empty `operationId` and an accurate `description`. An `operationId` **SHOULD** use a readable camelCase verb-noun form. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation. ## 2.8 Conditional vendored OpenAPI schemas <a href="#28-conditional-vendored-openapi-schemas" id="28-conditional-vendored-openapi-schemas"></a> diff --git a/api-design-guide/part-a/3-asyncapi-document-standards.md b/api-design-guide/part-a/3-asyncapi-document-standards.md index ec7cf53..4fb2ee6 100644 --- a/api-design-guide/part-a/3-asyncapi-document-standards.md +++ b/api-design-guide/part-a/3-asyncapi-document-standards.md @@ -30,7 +30,7 @@ An operation-free shared component library under `api/common/` is referenced sup ## 3.5 Complete AsyncAPI info block <a href="#35-complete-asyncapi-info-block" id="35-complete-asyncapi-info-block"></a> -**[M]** The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), `description`, and `contact`. +**[M]** The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract. ## 3.6 Servers channels operations and messages <a href="#36-servers-channels-operations-and-messages" id="36-servers-channels-operations-and-messages"></a> @@ -38,7 +38,7 @@ An operation-free shared component library under `api/common/` is referenced sup ## 3.7 Complete AsyncAPI operation metadata <a href="#37-complete-asyncapi-operation-metadata" id="37-complete-asyncapi-operation-metadata"></a> -**[M+R]** Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads)–[§17.7](../part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema) define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema. +**[M+R]** Every AsyncAPI operation **MUST** have a stable operation identifier (the key under `operations`), an `action` (`send` or `receive`), an accurate `description`, a referenced `channel`, and at least one referenced message. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads)–[§17.7](../part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema) define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema. ## 3.8 Pinned vendored AsyncAPI components <a href="#38-pinned-vendored-asyncapi-components" id="38-pinned-vendored-asyncapi-components"></a> diff --git a/api-design-guide/part-a/4-documentation-requirements.md b/api-design-guide/part-a/4-documentation-requirements.md index 8ed1173..f367654 100644 --- a/api-design-guide/part-a/4-documentation-requirements.md +++ b/api-design-guide/part-a/4-documentation-requirements.md @@ -10,13 +10,13 @@ description: "Documentation requirements for schemas, examples, and operation de **Applies to:** Universal (OpenAPI and AsyncAPI surfaces). {% endhint %} -## 4.1 Every schema described <a href="#41-every-schema-described" id="41-every-schema-described"></a> +## 4.1 Useful schema descriptions <a href="#41-useful-schema-descriptions" id="41-useful-schema-descriptions"></a> -**[M]** Every schema **MUST** have a `description`. +**[M]** A schema **MUST** have a `description` when its name and structure do not make its semantics clear. Other schemas **SHOULD** have concise descriptions. Filler text added only to satisfy a universal presence check is not useful documentation. ## 4.2 Examples for bodies and enums <a href="#42-examples-for-bodies-and-enums" id="42-examples-for-bodies-and-enums"></a> -**[M+R]** Every request body and response body **MUST** have at least one `example`. Every `enum` **MUST** document what its values mean (an `example` alone is insufficient when the values are not self-explanatory). +**[M+R]** Request and response bodies **SHOULD** have representative examples, especially when conditional fields or multi-step behavior are involved. Every `enum` whose values are not self-explanatory **MUST** document what its values mean; an example alone is insufficient. ## 4.3 No placeholder text <a href="#43-no-placeholder-text" id="43-no-placeholder-text"></a> @@ -28,7 +28,13 @@ description: "Documentation requirements for schemas, examples, and operation de ## 4.5 API surface inventory <a href="#45-api-surface-inventory" id="45-api-surface-inventory"></a> -**[M+R]** A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`. Each `apis` entry **MUST** contain only the discovery fields needed here: `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`). Every listed path **MUST** resolve to a canonical specification of the declared type. `apis` and `noApi` **MUST NOT** coexist. A repository with neither a discoverable canonical specification nor an explicit `noApi` declaration is non-conformant. +**[M+R]** A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`. + +An OpenAPI or AsyncAPI entry **MUST** contain `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`), and every listed path **MUST** resolve to a canonical specification of the declared type. A surface governed directly by a recognised protocol standard **MAY** instead use `type: standard` with a non-empty `name`, an absolute HTTPS `reference` to its normative specification or profile, and an optional `discovery` value naming its standard discovery endpoint. For example, an OpenID Connect provider can point to the OIDC specification and `/.well-known/openid-configuration`; it does not need a synthetic OpenAPI description of standard protocol endpoints. + +The `standard` inventory form is provisional while CFR issue [#7](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/7) is under review. Until GovStack approves a registry or profile for recognised standards and their evidence, these entries **MAY** be used during advisory review but **MUST NOT** produce a passing conformance result. An arbitrary HTTPS reference is not proof that a surface implements the named standard. + +`apis` and `noApi` **MUST NOT** coexist. `noApi: true` means that the BB exposes no service interface at all; it **MUST NOT** be used to mean only that no OpenAPI or AsyncAPI file exists. A repository with neither a discoverable contract nor an explicit `noApi` declaration is non-conformant. **Example (informative).** A BB with two independently versioned surfaces: @@ -41,24 +47,37 @@ apis: path: api/events/asyncapi.yaml ``` +**Example (informative).** A standards-defined interface: + +```yaml +version: 1 +apis: + - type: standard + name: OpenID Connect + reference: https://openid.net/specs/openid-connect-core-1_0.html + discovery: /.well-known/openid-configuration +``` + ## 4.6 Functional-requirement traceability <a href="#46-functional-requirement-traceability" id="46-functional-requirement-traceability"></a> -**[M+R]** Every normative functional requirement in `spec/**/*.md` **MUST** use the exact list-item marker `- **<stable-ID>** **REQUIRED|RECOMMENDED|OPTIONAL**: <text>`, with a stable ID unique across the BB. Every BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`, and its requirement entries **MUST** match that marker-derived ID set exactly: no missing or extra IDs. A repository that uses `noApi: true` under [§4.5](#45-api-surface-inventory) **MUST NOT** contain `api/coverage.yaml`. +**[M+R]** Every GovStack requirement in `spec/**/*.md` **MUST** follow the GovStack Requirements Model. Its heading **MUST** use `### #<number> <title> (<level> <mutability> <verification>)`, where level is `REQUIRED`, `RECOMMENDED`, `DRAFT`, or `DEPRECATED`; mutability is `IMMUTABLE`, `EXTENSIBLE`, `REPLACEABLE`, or `INAPPLICABLE`; and verification is `OBSERVABLE` or `AUDITABLE`. The next non-empty line **MUST** contain its canonical `govstack-...#req-<number>` identifier, the two numbers **MUST** match, and body text **MUST** follow any optional `KF:` metadata lines. A child requirement that changes a parent **MUST** identify the parent with `extends` or `replaces` as defined by the GovStack Specification Framework. + +Every BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`. Its requirement entries **MUST** match the active REQUIRED and RECOMMENDED requirement IDs exactly: no missing or extra IDs. DRAFT and DEPRECATED requirements, and requirements classified INAPPLICABLE, are not active coverage obligations. A repository that uses `noApi: true` under [§4.5](#45-api-surface-inventory) **MUST NOT** contain `api/coverage.yaml`. -Each coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `not-applicable` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated. +Each coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `non-api` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. `non-api` means the active requirement is verified outside the service-interface contract; it is not the Requirements Model's formal `INAPPLICABLE` classifier. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated. -A requirement marked **REQUIRED** **MUST NOT** use the `not-applicable` disposition. If it does not belong in the BB contract, the specification **MUST** change its requirement strength or scope through the normal specification review process instead of bypassing it in the coverage file. +A REQUIRED requirement **MAY** use `non-api` when it remains applicable but its evidence lives outside the service-interface contract, for example in an audit procedure or policy artifact. The rationale **MUST** identify that verification boundary. `non-api` is traceability, not a waiver and not an `INAPPLICABLE` classification. **Example (informative).** ```yaml version: 1 requirements: - - id: REG-FR-001 + - id: "govstack-bb-registration-fr#req-1" disposition: operation operations: - createApplication - - id: REG-FR-002 + - id: "govstack-bb-registration-fr#req-2" disposition: planned issue: https://github.com/GovStackWorkingGroup/example/issues/42 ``` diff --git a/api-design-guide/part-b/5-url-structure-and-versioning.md b/api-design-guide/part-b/5-url-structure-and-versioning.md index 53c4e21..c3f33a8 100644 --- a/api-design-guide/part-b/5-url-structure-and-versioning.md +++ b/api-design-guide/part-b/5-url-structure-and-versioning.md @@ -12,19 +12,19 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.1 Major version in the path <a href="#51-major-version-in-the-path" id="51-major-version-in-the-path"></a> -**[M]** Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) are the only exception. [`[OPEN-5-A]`](../appendix/b-open-questions.md) +**[M]** A versioned HTTP surface **MUST** expose its major contract version unambiguously. New GovStack resource APIs **SHOULD** place it in the URL path as `/v{N}/...` (for example, `/v1/policies`). A recognised protocol standard may use its own version-negotiation mechanism. The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) do not carry the API major version. ## 5.2 Plural noun resources <a href="#52-plural-noun-resources" id="52-plural-noun-resources"></a> -**[M+R]** Resource paths **MUST** use plural nouns (`/policies`, not `/policy`). +**[M+R]** Resource paths **SHOULD** use plural nouns (`/policies`, not `/policy`). ## 5.3 Kebab-case path segments <a href="#53-kebab-case-path-segments" id="53-kebab-case-path-segments"></a> -**[M]** Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`). The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) keep the segment spelling their own definition gives them, including the `.well-known` prefix that RFC 8615 fixes. +**[M]** Multi-word path segments **SHOULD** use kebab-case (`/event-subscriptions`). A surface governed by an external standard keeps that standard's spelling, including the `.well-known` prefix that RFC 8615 fixes. ## 5.4 Shallow path nesting <a href="#54-shallow-path-nesting" id="54-shallow-path-nesting"></a> -**[M]** Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. [`[OPEN-5-B]`](../appendix/b-open-questions.md) +**[M]** Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. ## 5.5 Identifiers as path parameters <a href="#55-identifiers-as-path-parameters" id="55-identifiers-as-path-parameters"></a> @@ -32,15 +32,15 @@ description: "Rules governing URL path structure, resource naming, and version p ## 5.6 Query parameter naming <a href="#56-query-parameter-naming" id="56-query-parameter-naming"></a> -**[M]** Query parameter names **MUST** follow the JSON naming convention defined in [§9](../part-c/9-json-conventions-and-naming.md). +**[M]** Query parameter names **SHOULD** follow the JSON naming convention defined in [§9](../part-c/9-json-conventions-and-naming.md). ## 5.7 No verbs in CRUD paths <a href="#57-no-verbs-in-crud-paths" id="57-no-verbs-in-crud-paths"></a> -**[M+R]** Verbs **MUST NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.) +**[M+R]** Verbs **SHOULD NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.) ## 5.8 Actions as sub-resources <a href="#58-actions-as-sub-resources" id="58-actions-as-sub-resources"></a> -**[R]** Non-CRUD actions **MUST** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`. +**[R]** Non-CRUD actions **SHOULD** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`. ## 5.9 Unversioned health endpoint <a href="#59-unversioned-health-endpoint" id="59-unversioned-health-endpoint"></a> diff --git a/api-design-guide/part-b/6-http-methods.md b/api-design-guide/part-b/6-http-methods.md index e3a976b..f9967d9 100644 --- a/api-design-guide/part-b/6-http-methods.md +++ b/api-design-guide/part-b/6-http-methods.md @@ -22,9 +22,9 @@ description: "Rules defining the meaning, safety, and idempotency guarantees of **[R]** `PUT` **MUST** replace the entire resource and **MUST** be idempotent. -## 6.4 PATCH uses JSON Merge Patch <a href="#64-patch-uses-json-merge-patch" id="64-patch-uses-json-merge-patch"></a> +## 6.4 PATCH uses a registered patch format <a href="#64-patch-uses-a-registered-patch-format" id="64-patch-uses-a-registered-patch-format"></a> -**[M+R]** `PATCH` partially updates a resource. Request bodies **MUST** use JSON Merge Patch (RFC 7396) with media type `application/merge-patch+json`. Note that under RFC 7396 a member set to `null` means "remove this member", so Merge Patch cannot set a nullable field ([§9.4](../part-c/9-json-conventions-and-naming.md#94-explicit-nullability)) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** additionally support RFC 6902 JSON Patch via `application/json-patch+json`; such endpoints **MUST** document which media type carries which semantics. +**[M+R]** `PATCH` partially updates a resource. Its request body **MUST** use a registered patch media type and the operation **MUST** document the selected patch semantics. JSON Merge Patch (RFC 7396) with `application/merge-patch+json` **SHOULD** be the default for simple object updates. Under RFC 7396 a member set to `null` means "remove this member", so Merge Patch cannot set a nullable field ([§9.4](../part-c/9-json-conventions-and-naming.md#94-explicit-nullability)) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** use RFC 6902 JSON Patch via `application/json-patch+json` or another registered format suited to the contract. ## 6.5 DELETE response semantics <a href="#65-delete-response-semantics" id="65-delete-response-semantics"></a> diff --git a/api-design-guide/part-b/7-http-status-codes.md b/api-design-guide/part-b/7-http-status-codes.md index f25c2cb..7f628f3 100644 --- a/api-design-guide/part-b/7-http-status-codes.md +++ b/api-design-guide/part-b/7-http-status-codes.md @@ -52,7 +52,7 @@ description: "Rules mapping API outcomes to standard HTTP status codes, caching, ## 7.11 422 for semantic errors <a href="#711-422-for-semantic-errors" id="711-422-for-semantic-errors"></a> -**[R]** A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"). The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). [`[OPEN-7-A]`](../appendix/b-open-questions.md) +**[R]** A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"). The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). ## 7.12 429 for rate limits <a href="#712-429-for-rate-limits" id="712-429-for-rate-limits"></a> @@ -84,7 +84,7 @@ description: "Rules mapping API outcomes to standard HTTP status codes, caching, ## 7.19 415 for unsupported media types <a href="#719-415-for-unsupported-media-types" id="719-415-for-unsupported-media-types"></a> -**[M+R]** `415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints ([§6.4](../part-b/6-http-methods.md#64-patch-uses-json-merge-patch)) **MUST** return `415` when the patch media type is neither `application/merge-patch+json` nor a documented `application/json-patch+json`. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header. +**[M+R]** `415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints ([§6.4](../part-b/6-http-methods.md#64-patch-uses-a-registered-patch-format)) **MUST** return `415` when the request does not use one of the registered patch media types documented by that operation. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header. ## 7.20 No-store on error responses <a href="#720-no-store-on-error-responses" id="720-no-store-on-error-responses"></a> diff --git a/api-design-guide/part-c/12-pagination-filtering-sorting.md b/api-design-guide/part-c/12-pagination-filtering-sorting.md index beab327..9fa0f79 100644 --- a/api-design-guide/part-c/12-pagination-filtering-sorting.md +++ b/api-design-guide/part-c/12-pagination-filtering-sorting.md @@ -63,15 +63,15 @@ description: "Mandatory pagination for collections, cursor and offset envelopes, ## 12.7 Sort parameter convention <a href="#127-sort-parameter-convention" id="127-sort-parameter-convention"></a> -**[M]** Sort parameter **MUST** be `sort`, values `field` (ascending) or `-field` (descending); multiple criteria separated by commas. +**[M]** An API that supports sorting **MUST** document the parameter, allowed fields, direction syntax, default order, and a stable tie-breaker. The GovStack default **SHOULD** be `sort`, with `field` for ascending, `-field` for descending, and commas between multiple criteria. ## 12.8 Simple equality filtering <a href="#128-simple-equality-filtering" id="128-simple-equality-filtering"></a> -**[M+R]** Simple equality filtering on non-personal, non-secret fields **MUST** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by [§8.6](../part-b/8-headers.md#86-no-personal-data-in-addressable-locations) **MUST NOT** use a query parameter and **MUST** use the body-based search pattern in [§12.9](#129-complex-filtering-via-search). +**[M+R]** Simple equality filtering on non-personal, non-secret fields **SHOULD** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by [§8.6](../part-b/8-headers.md#86-no-personal-data-in-addressable-locations) **MUST NOT** use a query parameter and **MUST** use a documented body-based search contract such as [§12.9](#129-complex-filtering-via-search). ## 12.9 Complex filtering via search <a href="#129-complex-filtering-via-search" id="129-complex-filtering-via-search"></a> -**[M+R]** Complex filtering and any filtering that contains personal data **MUST** use `POST /v1/{collection}/search` per [§6.6](../part-b/6-http-methods.md#66-post-search-for-complex-queries). Pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the [§12.3](#123-cursor-pagination-envelope) envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor. +**[M+R]** Complex filtering and any filtering that contains personal data **SHOULD** use a request body, conventionally at `POST /v1/{collection}/search` per [§6.6](../part-b/6-http-methods.md#66-post-search-for-complex-queries). When a body-based search is used, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the [§12.3](#123-cursor-pagination-envelope) envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor. ## 12.10 Sparse fieldsets out of scope <a href="#1210-sparse-fieldsets-out-of-scope" id="1210-sparse-fieldsets-out-of-scope"></a> diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md index 6799dd6..cb5f94c 100644 --- a/api-design-guide/part-c/9-json-conventions-and-naming.md +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -16,7 +16,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.2 camelCase field names <a href="#92-camelcase-field-names" id="92-camelcase-field-names"></a> -**[M]** JSON field names **MUST** use `camelCase`, applied consistently across the entire ecosystem. (See [note below](#note-on-92) on the choice of casing.) +**[M]** GovStack-owned JSON field names **SHOULD** use `camelCase` consistently within a surface. Fields adopted from an external standard retain that standard's spelling. (See [note below](#note-on-92) on the choice of casing.) ## 9.3 Real JSON booleans <a href="#93-real-json-booleans" id="93-real-json-booleans"></a> @@ -28,7 +28,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.5 No spaces or non-ASCII names <a href="#95-no-spaces-or-non-ascii-names" id="95-no-spaces-or-non-ascii-names"></a> -**[M]** Field names **MUST NOT** contain spaces or non-ASCII characters. +**[M]** GovStack-owned field names **SHOULD** use ASCII identifiers without spaces. Fields adopted from an external standard retain that standard's spelling. ## 9.6 Avoid abbreviations <a href="#96-avoid-abbreviations" id="96-avoid-abbreviations"></a> @@ -36,7 +36,7 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.7 Screaming snake case enum values <a href="#97-screaming-snake-case-enum-values" id="97-screaming-snake-case-enum-values"></a> -**[M]** Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it. +**[M]** Enum values that name a BB-defined state or category **SHOULD** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. ## 9.8 Forward-compatible schemas <a href="#98-forward-compatible-schemas" id="98-forward-compatible-schemas"></a> @@ -52,11 +52,11 @@ description: "Field naming, JSON representation, forward-compatibility, and spec ## 9.11 Single registered BB code <a href="#911-single-registered-bb-code" id="911-single-registered-bb-code"></a> -**[M+R]** Every namespace that embeds a BB code (HTTP problem-type URLs [§11.2](../part-c/11-errors.md#112-stable-http-problem-type-uri), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), logical channel IDs [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses), and any transport-neutral asynchronous error code that embeds one [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The BB-code register is proposed for the Lifecycle & Governance companion ([Appendix A](../appendix/a-companion-documents.md)); until it exists, codes **SHOULD** be agreed through the API Working Group. [`[OPEN-9-A]`](../appendix/b-open-questions.md) +**[M+R]** Every namespace that embeds a BB code (HTTP problem-type URLs [§11.2](../part-c/11-errors.md#112-stable-http-problem-type-uri), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), logical channel IDs [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses), and any transport-neutral asynchronous error code that embeds one [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. Until GovStack publishes a canonical register, codes **SHOULD** be agreed through the API Working Group. ## Note on 9.2 <a href="#note-on-92" id="note-on-92"></a> -`camelCase` aligns with OAuth/OIDC, OpenID Federation, JSON:API, and the majority of public REST APIs. `snake_case` would align with the Python ecosystem. Either is defensible; consistency across BBs is what matters most. Migration cost for BBs already using snake_case is proposed for the Lifecycle & Governance companion. +`camelCase` aligns with OAuth/OIDC, OpenID Federation, JSON:API, and the majority of public REST APIs. `snake_case` would align with the Python ecosystem. Either is defensible; consistency across BBs is what matters most. Existing published surfaces follow the compatibility rules in [§18](../part-d/18-compatibility-and-lifecycle.md) rather than renaming fields in place. ## Carve-out from 9.2 <a href="#carve-out-from-92" id="carve-out-from-92"></a> diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md index 6f23bf8..68d4014 100644 --- a/api-design-guide/part-d/13-authentication-and-authorisation.md +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -24,7 +24,7 @@ description: "Rules for how BB API specs declare security schemes, OAuth scopes, ## 13.4 Namespaced OAuth scopes <a href="#134-namespaced-oauth-scopes" id="134-namespaced-oauth-scopes"></a> -**[M]** Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. [`[OPEN-13-A]`](../appendix/b-open-questions.md) +**[M]** Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation and namespaced so one BB's scopes cannot collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. New GovStack scopes **SHOULD** use `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). ## 13.5 Authorization is the credential channel <a href="#135-authorization-is-the-credential-channel" id="135-authorization-is-the-credential-channel"></a> @@ -40,4 +40,4 @@ Cross-service propagation of end-user consent or authorisation context (for exam ## 13.7 Protected transport <a href="#137-protected-transport" id="137-protected-transport"></a> -**[M+R]** Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion. +**[M+R]** Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration are outside this guide. diff --git a/api-design-guide/part-d/14-idempotency.md b/api-design-guide/part-d/14-idempotency.md index 58f2a37..a89cf2b 100644 --- a/api-design-guide/part-d/14-idempotency.md +++ b/api-design-guide/part-d/14-idempotency.md @@ -9,7 +9,7 @@ description: "Rules for the Idempotency-Key header contract that lets clients sa **Applies to:** Universal. On the AsyncAPI surface the same idempotency concept applies using the message metadata rules in [§17.8](../part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata). -**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§14.1](#141-idempotency-key-on-non-idempotent-posts) and [§14.3](#143-documented-replay-window) constrain the specification: declare the header and document the replay-window contract. [§14.2](#142-opaque-client-generated-keys) and [§14.4](#144-replay-returns-original-response)–[§14.6](#146-naturally-idempotent-designs) are behavioural-contract rules: they bind a conforming implementation at run time and are verified by the conformance test pack, not by spec linting. Concrete replay-window values, key-store retention, and the maximum accepted key length are deployment values for implementation profiles; replay enforcement and key storage are operational concerns for the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§14.1](#141-idempotency-key-on-non-idempotent-posts) and [§14.3](#143-documented-replay-window) constrain the specification: declare the header and document the replay-window contract. [§14.2](#142-opaque-client-generated-keys) and [§14.4](#144-replay-returns-original-response)–[§14.6](#146-naturally-idempotent-designs) are behavioural-contract rules: they bind a conforming implementation at run time and require implementation-level tests rather than spec linting. Concrete replay-window values, key-store retention, and the maximum accepted key length are deployment values for implementation profiles; replay enforcement and key storage are operational concerns outside this guide ([§1.2](../1-introduction.md#12-scope)). {% endhint %} ## 14.1 Idempotency-Key on non-idempotent POSTs <a href="#141-idempotency-key-on-non-idempotent-posts" id="141-idempotency-key-on-non-idempotent-posts"></a> diff --git a/api-design-guide/part-d/15-asynchronous-operations.md b/api-design-guide/part-d/15-asynchronous-operations.md index fef801c..ccc5b39 100644 --- a/api-design-guide/part-d/15-asynchronous-operations.md +++ b/api-design-guide/part-d/15-asynchronous-operations.md @@ -36,11 +36,11 @@ description: "The local Operation resource shape and polling pattern BBs use for ## 15.4 Polling the Operation resource <a href="#154-polling-the-operation-resource" id="154-polling-the-operation-resource"></a> -**[M+R]** A BB exposing an Operation resource **MUST** expose polling via `GET /v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval. +**[M+R]** A BB exposing an Operation resource **MUST** make it pollable with `GET` at the URI returned in `Location`. The conventional path **SHOULD** be `/v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval. ## 15.5 Cancellation via cancel sub-resource <a href="#155-cancellation-via-cancel-sub-resource" id="155-cancellation-via-cancel-sub-resource"></a> -**[M+R]** Cancellation, when supported, **MUST** be `POST /v{major}/operations/{operationId}/cancel`. +**[M+R]** Cancellation, when supported, **MUST** be documented and discoverable from the Operation contract. The conventional action **SHOULD** be `POST /v{major}/operations/{operationId}/cancel`. ## 15.6 Webhook completion notification <a href="#156-webhook-completion-notification" id="156-webhook-completion-notification"></a> diff --git a/api-design-guide/part-d/16-cloudevents-and-webhooks.md b/api-design-guide/part-d/16-cloudevents-and-webhooks.md index 3c31f42..2e2ed50 100644 --- a/api-design-guide/part-d/16-cloudevents-and-webhooks.md +++ b/api-design-guide/part-d/16-cloudevents-and-webhooks.md @@ -22,7 +22,7 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.3 Reverse-DNS event types <a href="#163-reverse-dns-event-types" id="163-reverse-dns-event-types"></a> -**[M]** Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). [`[OPEN-16-A]`](../appendix/b-open-questions.md) +**[M]** Event `type` names **MUST** be stable, globally collision-resistant, and include the BB's registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). New GovStack event types **SHOULD** use the reverse-DNS shape `global.govstack.{bb-code}.{resource}.{action}`. The event type identifies the semantic event kind and **MUST NOT** include the major API version; transport-contract versioning is carried separately ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). ## 16.4 Stable CloudEvents source <a href="#164-stable-cloudevents-source" id="164-stable-cloudevents-source"></a> @@ -64,7 +64,7 @@ description: "Rules governing the CloudEvents envelope, event-type and source na ## 16.9 Readiness for a shared signature profile <a href="#169-readiness-for-a-shared-signature-profile" id="169-readiness-for-a-shared-signature-profile"></a> -A future guide version should promote a shared signature profile only after it defines key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages. Those operational concerns belong in the Security & Operations companion ([§1.2](../1-introduction.md#12-scope)). +A shared signature profile is outside the baseline. It should be considered only after key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages exist. Operational signing controls remain outside this guide ([§1.2](../1-introduction.md#12-scope)). ## 16.10 Documented delivery-failure contract <a href="#1610-documented-delivery-failure-contract" id="1610-documented-delivery-failure-contract"></a> diff --git a/api-design-guide/part-d/17-asyncapi-channel-rules.md b/api-design-guide/part-d/17-asyncapi-channel-rules.md index e116a80..c860ace 100644 --- a/api-design-guide/part-d/17-asyncapi-channel-rules.md +++ b/api-design-guide/part-d/17-asyncapi-channel-rules.md @@ -16,7 +16,7 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.2 Stable logical channel IDs and native addresses <a href="#172-stable-logical-channel-ids-and-native-addresses" id="172-stable-logical-channel-ids-and-native-addresses"></a> -**[M+R]** Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `global.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address. +**[M+R]** Each entry under AsyncAPI `channels` **MUST** use a stable logical ID and document its mapping to the protocol-native address. New GovStack channel IDs **SHOULD** use `global.govstack.{bb-code}.v{major}.{resource}.{event}`, with the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. ## 17.3 No personal data in channels <a href="#173-no-personal-data-in-channels" id="173-no-personal-data-in-channels"></a> @@ -32,7 +32,7 @@ description: "Rules governing AsyncAPI channel addressing, payload structure, me ## 17.6 Structured CloudEvents JSON payloads <a href="#176-structured-cloudevents-json-payloads" id="176-structured-cloudevents-json-payloads"></a> -**[M]** AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports. [`[OPEN-17-A]`](../appendix/b-open-questions.md) +**[M]** AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports. ## 17.7 Shared CloudEvents envelope schema <a href="#177-shared-cloudevents-envelope-schema" id="177-shared-cloudevents-envelope-schema"></a> @@ -61,7 +61,7 @@ components: ## 17.8 Message headers and idempotency metadata <a href="#178-message-headers-and-idempotency-metadata" id="178-message-headers-and-idempotency-metadata"></a> -**[M+R]** GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-signature-metadata-when-used). Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`. +**[M+R]** GovStack-owned transport/application message headers **SHOULD** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-signature-metadata-when-used). Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **SHOULD** use `idempotencyKey`. ## 17.9 Message localisation headers <a href="#179-message-localisation-headers" id="179-message-localisation-headers"></a> @@ -105,8 +105,8 @@ components: ## 17.19 Protocol bindings where relevant <a href="#1719-protocol-bindings-where-relevant" id="1719-protocol-bindings-where-relevant"></a> -**[M+R]** Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. [`[OPEN-17-B]`](../appendix/b-open-questions.md) +**[M+R]** Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. -## 17.20 Examples for every message <a href="#1720-examples-for-every-message" id="1720-examples-for-every-message"></a> +## 17.20 Representative message examples <a href="#1720-representative-message-examples" id="1720-representative-message-examples"></a> -**[M+R]** AsyncAPI documents **MUST** define examples for every message and **SHOULD** include at least one example showing headers plus payload for each common message family: command, event, error, and operation-completion where applicable. +**[M+R]** AsyncAPI documents **SHOULD** define representative message examples, including headers plus payload where the interaction is not obvious from the schema. Examples for command, event, error, and operation-completion families are especially useful, but filler examples are not required. diff --git a/api-design-guide/part-d/18-compatibility-and-lifecycle.md b/api-design-guide/part-d/18-compatibility-and-lifecycle.md index a36ebcf..b033bf4 100644 --- a/api-design-guide/part-d/18-compatibility-and-lifecycle.md +++ b/api-design-guide/part-d/18-compatibility-and-lifecycle.md @@ -12,11 +12,11 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.1 SemVer versioning <a href="#181-semver-versioning" id="181-semver-versioning"></a> -**[M]** `info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under [§18.2](#182-major-version-in-path-or-channel). An API served under `/v1` therefore carries an `info.version` of `1.x.y`. The version of the software that implements the contract is a separate number that this guide does not constrain: an implementation may be at `0.16.3` while the contract it serves is at `1.4.0`, and `info.version` **MUST NOT** be set to the implementation version. +**[M]** `info.version` **MUST** follow SemVer and identify the version of that surface's published contract, not the implementation version. When a surface also exposes a major version in a path, channel, or protocol field, the two **MUST** agree. An implementation may therefore be at `0.16.3` while the contract it serves is at `1.4.0`. ## 18.2 Major version in path or channel <a href="#182-major-version-in-path-or-channel" id="182-major-version-in-path-or-channel"></a> -**[M]** A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses); protocol-native channel addresses **MUST NOT** be rewritten solely to carry it. +**[M]** A major version increment **MUST** be visible in the canonical contract through the surface's declared versioning mechanism. New GovStack OpenAPI surfaces **SHOULD** carry it in each versioned path key (`/v2/`) rather than duplicating it in `servers`. New AsyncAPI surfaces **SHOULD** carry it in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses). Protocol-native addresses **MUST NOT** be rewritten solely to carry a guide-specific version shape. ## 18.3 Backward-compatible minor changes <a href="#183-backward-compatible-minor-changes" id="183-backward-compatible-minor-changes"></a> @@ -28,7 +28,7 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.5 Deprecation and Sunset headers <a href="#185-deprecation-and-sunset-headers" id="185-deprecation-and-sunset-headers"></a> -**[M+R]** Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The minimum deprecation window and maximum concurrent major versions remain policy for the Lifecycle & Governance companion. +**[M+R]** Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The GovStack governance process owns the minimum deprecation window and maximum number of concurrent major versions. ## 18.6 Clients ignore unknown fields <a href="#186-clients-ignore-unknown-fields" id="186-clients-ignore-unknown-fields"></a> @@ -36,8 +36,8 @@ description: "Rules governing SemVer versioning, backward-compatible and breakin ## 18.7 AsyncAPI deprecation metadata <a href="#187-asyncapi-deprecation-metadata" id="187-asyncapi-deprecation-metadata"></a> -**[M+R]** AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. [`[OPEN-18-A]`](../appendix/b-open-questions.md) +**[M+R]** AsyncAPI channels, operations, and messages **MUST** declare consumer-visible deprecation and replacement guidance in their `description`. They **MAY** also use the experimental `x-govstack-deprecated` extension with `since`, `sunset`, `replacement`, and `reason` where supported by tooling. ## Note on retrofitting <a href="#note-on-retrofitting" id="note-on-retrofitting"></a> -Bringing an existing BB into conformance with the JSON conventions ([§9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names), [§10.x](../part-c/10-data-types-and-formats.md)) or the opaque-identifier rule ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) changes the wire contract and is therefore a breaking change under [§18.4](#184-breaking-changes-bump-major-version): it **MUST** be released as a new major version. The transition schedule for existing BBs is governance, not design (Lifecycle & Governance companion). +Bringing an existing BB into conformance with the JSON conventions ([§9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names), [§10.x](../part-c/10-data-types-and-formats.md)) or the opaque-identifier rule ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) changes the wire contract and is therefore a breaking change under [§18.4](#184-breaking-changes-bump-major-version): it **MUST** be released as a new major version. The transition schedule for existing BBs is governance, not design. diff --git a/api-design-guide/part-e/19-localisation.md b/api-design-guide/part-e/19-localisation.md index 00a46ac..11a22a0 100644 --- a/api-design-guide/part-e/19-localisation.md +++ b/api-design-guide/part-e/19-localisation.md @@ -9,7 +9,7 @@ description: "Rules governing localisation of API content: request-language hand **Applies to:** Universal. On the OpenAPI surface localisation uses `Accept-Language` and `Content-Language` HTTP headers. On the AsyncAPI surface it uses the `acceptLanguage` and `contentLanguage` message headers defined in [§17](../part-d/17-asyncapi-channel-rules.md). -**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§19.4](#194-declare-the-response-language) constrains the specification: declare the response language header on localised responses. [§19.1](#191-honour-the-request-language)–[§19.3](#193-english-as-default-language) are behavioural-contract rules: they bind a conforming implementation at run time (honour the request language, never translate stable fields, default to English) and are verified by the conformance test pack. The set of languages a given BB must support is per-BB and per-deployment policy ([`[OPEN-19-A]`](../appendix/b-open-questions.md)), not fixed here. +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§19.3](#193-declared-default-language)–[§19.4](#194-declare-the-response-language) constrain the specification: declare the default and selected response languages. [§19.1](#191-honour-the-request-language)–[§19.2](#192-never-translate-stable-content) are behavioural-contract rules verified by implementation-level conformance tests. The set of languages a given BB must support is per-BB and per-deployment policy, not fixed here. {% endhint %} ## 19.1 Honour the request language <a href="#191-honour-the-request-language" id="191-honour-the-request-language"></a> @@ -20,9 +20,9 @@ description: "Rules governing localisation of API content: request-language hand **[R]** Stable content (HTTP Problem `type`, transport-neutral asynchronous error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated. -## 19.3 English as default language <a href="#193-english-as-default-language" id="193-english-as-default-language"></a> +## 19.3 Declared default language <a href="#193-declared-default-language" id="193-declared-default-language"></a> -**[R]** Default language **MUST** be English. [`[OPEN-19-A]`](../appendix/b-open-questions.md) +**[R]** The specification **MUST** declare its default response language. English **SHOULD** be used as the cross-border fallback when deployment policy or law does not select another default. ## 19.4 Declare the response language <a href="#194-declare-the-response-language" id="194-declare-the-response-language"></a> diff --git a/api-design-guide/part-e/20-conformance-and-validation.md b/api-design-guide/part-e/20-conformance-and-validation.md index 14c40a9..1b693b9 100644 --- a/api-design-guide/part-e/20-conformance-and-validation.md +++ b/api-design-guide/part-e/20-conformance-and-validation.md @@ -41,4 +41,4 @@ info: ## Note on governance <a href="#note-on-governance" id="note-on-governance"></a> -Publication gates, conformance levels, exception handling, transition timelines, and CI implementation are governance questions, proposed for the **GovStack API Lifecycle & Governance** companion document. +Publication gates, conformance levels, exception handling, transition timelines, and CI implementation are owned by the GovStack governance process and are outside this guide. diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml index 8ee119b..dd7c483 100644 --- a/api-design-guide/rules.yaml +++ b/api-design-guide/rules.yaml @@ -15,7 +15,6 @@ rules: page: part-a/2-openapi-document-standards.md anchor: 21-openapi-31-required text: "The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." - open_questions: [] - id: "2.2" title: "One canonical OpenAPI entrypoint" class: M+R @@ -24,7 +23,6 @@ rules: page: part-a/2-openapi-document-standards.md anchor: 22-one-canonical-openapi-entrypoint text: "In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface. The legacy `api/swagger.yaml` and `api/swagger.json` names still used by some BBs are not canonical under this guide." - open_questions: [] - id: "2.3" title: "No divergent OpenAPI copies" class: R @@ -33,7 +31,6 @@ rules: page: part-a/2-openapi-document-standards.md anchor: 23-no-divergent-openapi-copies text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it.\nAn operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy." - open_questions: [] - id: "2.4" title: "Passes openapi-spec-validator" class: M @@ -42,16 +39,14 @@ rules: page: part-a/2-openapi-document-standards.md anchor: 24-passes-openapi-spec-validator text: "The canonical entrypoint **MUST** pass `openapi-spec-validator` for its declared OpenAPI 3.1 patch version, with every local reference resolved. Referenced JSON Schema fragments **MUST** validate against their declared dialect but are not required to be standalone OpenAPI documents." - open_questions: [] - id: "2.5" title: "Complete info block" class: M - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 25-complete-info-block - text: "The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), `description`, and `contact`. Where a BB ships per-surface canonical files (2.2), each surface carries its own `info.version` and versions independently." - open_questions: [] + text: "The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract. Where a BB ships per-surface canonical files (2.2), each surface carries its own `info.version` and versions independently." - id: "2.6" title: "Meaningful servers block" class: M+R @@ -60,16 +55,14 @@ rules: page: part-a/2-openapi-document-standards.md anchor: 26-meaningful-servers-block text: "The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Every non-local server URL **MUST** use `https`. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}`). Because §5.1 places `/v{N}` in each OpenAPI path key, a server URL **MUST NOT** repeat that version segment. Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples and **MUST** be labelled as non-production." - open_questions: [] - id: "2.7" title: "Complete operation metadata" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-a/2-openapi-document-standards.md anchor: 27-complete-operation-metadata - text: "Every operation **MUST** include `operationId` (camelCase, verb-noun), `summary`, `description`, and at least one `tag`." - open_questions: [] + text: "Every operation **MUST** include a stable, non-empty `operationId` and an accurate `description`. An `operationId` **SHOULD** use a readable camelCase verb-noun form. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation." - id: "2.8" title: "Conditional vendored OpenAPI schemas" class: M+R @@ -78,7 +71,6 @@ rules: page: part-a/2-openapi-document-standards.md anchor: 28-conditional-vendored-openapi-schemas text: "A BB **MAY** reuse the schema-only `govstack-openapi-common.yaml` artifact for `Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`. If it does, the file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml`, its version **MUST** be pinned explicitly, and the BB **MUST** reference the named schemas rather than copy them. A BB that does not reuse the artifact **MUST** define equivalent schemas locally that satisfy §11 and §12.\nSecurity schemes, parameters, headers, Response Objects, examples, and Operation resources **MUST** be defined locally because their values and semantics belong to the BB contract. They are not part of the shared OpenAPI artifact.\nVendoring is required when reuse is chosen because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable." - open_questions: [] - id: "3.1" title: "AsyncAPI 3.0.0 required" class: M @@ -87,7 +79,6 @@ rules: page: part-a/3-asyncapi-document-standards.md anchor: 31-asyncapi-300-required text: "An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." - open_questions: [] - id: "3.2" title: "One canonical AsyncAPI entrypoint" class: M+R @@ -96,7 +87,6 @@ rules: page: part-a/3-asyncapi-document-standards.md anchor: 32-one-canonical-asyncapi-entrypoint text: "In the absence of `api/index.yaml`, the canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned event-driven surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface." - open_questions: [] - id: "3.3" title: "No divergent AsyncAPI copies" class: R @@ -105,7 +95,6 @@ rules: page: part-a/3-asyncapi-document-standards.md anchor: 33-no-divergent-asyncapi-copies text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it.\nAn operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy." - open_questions: [] - id: "3.4" title: "Passes an AsyncAPI validator" class: M @@ -114,16 +103,14 @@ rules: page: part-a/3-asyncapi-document-standards.md anchor: 34-passes-an-asyncapi-validator text: "The file **MUST** pass an AsyncAPI 3.0 parser/validator (for example, `@asyncapi/parser` or the AsyncAPI CLI)." - open_questions: [] - id: "3.5" title: "Complete AsyncAPI info block" class: M - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 35-complete-asyncapi-info-block - text: "The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), `description`, and `contact`." - open_questions: [] + text: "The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract." - id: "3.6" title: "Servers channels operations and messages" class: M+R @@ -132,16 +119,14 @@ rules: page: part-a/3-asyncapi-document-standards.md anchor: 36-servers-channels-operations-and-messages text: "The file **MUST** declare non-empty `servers`, `channels`, `operations`, and `components.messages`. A document with only schemas and no operations is not an API contract. AsyncAPI `servers` **MUST** describe the intended broker or transport endpoint pattern using AsyncAPI 3.0 server fields (`host`, `protocol`, optional `pathname`, variables, security, and protocol bindings). Reference specifications that are not tied to a live broker **SHOULD** use parameterised server hosts and variables (for example, `host: \"{brokerHost}\"` with `protocol: mqtt`, `protocol: amqp`, `protocol: kafka`, or `protocol: wss`). Server definitions **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production brokers. Reserved documentation domains **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production." - open_questions: [] - id: "3.7" title: "Complete AsyncAPI operation metadata" class: M+R - strengths: ["MUST", "MAY"] + strengths: ["MUST", "SHOULD", "MAY"] surface: AsyncAPI page: part-a/3-asyncapi-document-standards.md anchor: 37-complete-asyncapi-operation-metadata - text: "Every AsyncAPI operation **MUST** include an operation identifier (the key under `operations`), `action` (`send` or `receive`), `summary`, `description`, at least one `tag`, a referenced `channel`, and at least one referenced message. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. §17.6–§17.7 define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema." - open_questions: [] + text: "Every AsyncAPI operation **MUST** have a stable operation identifier (the key under `operations`), an `action` (`send` or `receive`), an accurate `description`, a referenced `channel`, and at least one referenced message. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. §17.6–§17.7 define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema." - id: "3.8" title: "Pinned vendored AsyncAPI components" class: M @@ -150,7 +135,6 @@ rules: page: part-a/3-asyncapi-document-standards.md anchor: 38-pinned-vendored-asyncapi-components text: "A BB that documents GovStack domain events **MUST** reference `CloudEventEnvelope` from a pinned version of `govstack-asyncapi-common.yaml`. A BB that documents asynchronous rejections **MUST** reference `GovStackAsyncError`, and **MUST** reuse `AsyncFieldError` when it exposes field-level errors. The common file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml`, and the pinned version **MUST** be explicit. BB specifications own their Message Objects, security schemes, headers, examples, and protocol bindings because those objects require BB- and transport-specific values." - open_questions: [] - id: "3.9" title: "JSON Schema payload conventions" class: M @@ -159,25 +143,22 @@ rules: page: part-a/3-asyncapi-document-standards.md anchor: 39-json-schema-payload-conventions text: "AsyncAPI documents **MUST** use JSON Schema compatible with AsyncAPI 3.0 for payload schemas and **MUST** follow the JSON conventions in §9 and §10 for GovStack-owned payload fields." - open_questions: [] - id: "4.1" - title: "Every schema described" + title: "Useful schema descriptions" class: M - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: Universal page: part-a/4-documentation-requirements.md - anchor: 41-every-schema-described - text: "Every schema **MUST** have a `description`." - open_questions: [] + anchor: 41-useful-schema-descriptions + text: "A schema **MUST** have a `description` when its name and structure do not make its semantics clear. Other schemas **SHOULD** have concise descriptions. Filler text added only to satisfy a universal presence check is not useful documentation." - id: "4.2" title: "Examples for bodies and enums" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: Universal page: part-a/4-documentation-requirements.md anchor: 42-examples-for-bodies-and-enums - text: "Every request body and response body **MUST** have at least one `example`. Every `enum` **MUST** document what its values mean (an `example` alone is insufficient when the values are not self-explanatory)." - open_questions: [] + text: "Request and response bodies **SHOULD** have representative examples, especially when conditional fields or multi-step behavior are involved. Every `enum` whose values are not self-explanatory **MUST** document what its values mean; an example alone is insufficient." - id: "4.3" title: "No placeholder text" class: M+R @@ -186,7 +167,6 @@ rules: page: part-a/4-documentation-requirements.md anchor: 43-no-placeholder-text text: "The spec **MUST NOT** contain placeholder text: `TBD`, `Lorem ipsum`, `a, b, c`, content from other BBs with the original BB name still present, or test plans with literal placeholder steps." - open_questions: [] - id: "4.4" title: "Accurate operation descriptions" class: R @@ -195,7 +175,6 @@ rules: page: part-a/4-documentation-requirements.md anchor: 44-accurate-operation-descriptions text: "Operation `description` **MUST** describe what the operation actually does. (Copy-pasted descriptions that document a different endpoint than the one they sit on are a recurring problem in existing BB specifications.)" - open_questions: [] - id: "4.5" title: "API surface inventory" class: M+R @@ -203,44 +182,39 @@ rules: surface: Universal page: part-a/4-documentation-requirements.md anchor: 45-api-surface-inventory - text: "A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`. Each `apis` entry **MUST** contain only the discovery fields needed here: `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`). Every listed path **MUST** resolve to a canonical specification of the declared type. `apis` and `noApi` **MUST NOT** coexist. A repository with neither a discoverable canonical specification nor an explicit `noApi` declaration is non-conformant." - open_questions: [] + text: "A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`.\nAn OpenAPI or AsyncAPI entry **MUST** contain `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`), and every listed path **MUST** resolve to a canonical specification of the declared type. A surface governed directly by a recognised protocol standard **MAY** instead use `type: standard` with a non-empty `name`, an absolute HTTPS `reference` to its normative specification or profile, and an optional `discovery` value naming its standard discovery endpoint. For example, an OpenID Connect provider can point to the OIDC specification and `/.well-known/openid-configuration`; it does not need a synthetic OpenAPI description of standard protocol endpoints.\nThe `standard` inventory form is provisional while CFR issue #7 is under review. Until GovStack approves a registry or profile for recognised standards and their evidence, these entries **MAY** be used during advisory review but **MUST NOT** produce a passing conformance result. An arbitrary HTTPS reference is not proof that a surface implements the named standard.\n`apis` and `noApi` **MUST NOT** coexist. `noApi: true` means that the BB exposes no service interface at all; it **MUST NOT** be used to mean only that no OpenAPI or AsyncAPI file exists. A repository with neither a discoverable contract nor an explicit `noApi` declaration is non-conformant." - id: "4.6" title: "Functional-requirement traceability" class: M+R - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "MAY"] surface: Universal page: part-a/4-documentation-requirements.md anchor: 46-functional-requirement-traceability - text: "Every normative functional requirement in `spec/**/*.md` **MUST** use the exact list-item marker `- **<stable-ID>** **REQUIRED|RECOMMENDED|OPTIONAL**: <text>`, with a stable ID unique across the BB. Every BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`, and its requirement entries **MUST** match that marker-derived ID set exactly: no missing or extra IDs. A repository that uses `noApi: true` under §4.5 **MUST NOT** contain `api/coverage.yaml`.\nEach coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `not-applicable` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated.\nA requirement marked **REQUIRED** **MUST NOT** use the `not-applicable` disposition. If it does not belong in the BB contract, the specification **MUST** change its requirement strength or scope through the normal specification review process instead of bypassing it in the coverage file." - open_questions: [] + text: "Every GovStack requirement in `spec/**/*.md` **MUST** follow the GovStack Requirements Model. Its heading **MUST** use `### #<number> <title> (<level> <mutability> <verification>)`, where level is `REQUIRED`, `RECOMMENDED`, `DRAFT`, or `DEPRECATED`; mutability is `IMMUTABLE`, `EXTENSIBLE`, `REPLACEABLE`, or `INAPPLICABLE`; and verification is `OBSERVABLE` or `AUDITABLE`. The next non-empty line **MUST** contain its canonical `govstack-...#req-<number>` identifier, the two numbers **MUST** match, and body text **MUST** follow any optional `KF:` metadata lines. A child requirement that changes a parent **MUST** identify the parent with `extends` or `replaces` as defined by the GovStack Specification Framework.\nEvery BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`. Its requirement entries **MUST** match the active REQUIRED and RECOMMENDED requirement IDs exactly: no missing or extra IDs. DRAFT and DEPRECATED requirements, and requirements classified INAPPLICABLE, are not active coverage obligations. A repository that uses `noApi: true` under §4.5 **MUST NOT** contain `api/coverage.yaml`.\nEach coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `non-api` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. `non-api` means the active requirement is verified outside the service-interface contract; it is not the Requirements Model's formal `INAPPLICABLE` classifier. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated.\nA REQUIRED requirement **MAY** use `non-api` when it remains applicable but its evidence lives outside the service-interface contract, for example in an audit procedure or policy artifact. The rationale **MUST** identify that verification boundary. `non-api` is traceability, not a waiver and not an `INAPPLICABLE` classification." - id: "5.1" title: "Major version in the path" class: M - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 51-major-version-in-the-path - text: "Major version **MUST** appear in the URL path as `/v{N}/...` (e.g., `/v1/policies`). The standard unversioned endpoints of §5.10 are the only exception. `[OPEN-5-A]`" - open_questions: ["OPEN-5-A"] + text: "A versioned HTTP surface **MUST** expose its major contract version unambiguously. New GovStack resource APIs **SHOULD** place it in the URL path as `/v{N}/...` (for example, `/v1/policies`). A recognised protocol standard may use its own version-negotiation mechanism. The standard unversioned endpoints of §5.10 do not carry the API major version." - id: "5.2" title: "Plural noun resources" class: M+R - strengths: ["MUST"] + strengths: ["SHOULD"] surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 52-plural-noun-resources - text: "Resource paths **MUST** use plural nouns (`/policies`, not `/policy`)." - open_questions: [] + text: "Resource paths **SHOULD** use plural nouns (`/policies`, not `/policy`)." - id: "5.3" title: "Kebab-case path segments" class: M - strengths: ["MUST"] + strengths: ["SHOULD"] surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 53-kebab-case-path-segments - text: "Multi-word path segments **MUST** use kebab-case (`/event-subscriptions`). The standard unversioned endpoints of §5.10 keep the segment spelling their own definition gives them, including the `.well-known` prefix that RFC 8615 fixes." - open_questions: [] + text: "Multi-word path segments **SHOULD** use kebab-case (`/event-subscriptions`). A surface governed by an external standard keeps that standard's spelling, including the `.well-known` prefix that RFC 8615 fixes." - id: "5.4" title: "Shallow path nesting" class: M @@ -248,8 +222,7 @@ rules: surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 54-shallow-path-nesting - text: "Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. `[OPEN-5-B]`" - open_questions: ["OPEN-5-B"] + text: "Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources." - id: "5.5" title: "Identifiers as path parameters" class: M+R @@ -258,34 +231,30 @@ rules: page: part-b/5-url-structure-and-versioning.md anchor: 55-identifiers-as-path-parameters text: "Resource identifiers **MUST** be path parameters, not query parameters. (`DELETE /v1/events/{eventId}`, not `DELETE /v1/event?event_id=...`.)" - open_questions: [] - id: "5.6" title: "Query parameter naming" class: M - strengths: ["MUST"] + strengths: ["SHOULD"] surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 56-query-parameter-naming - text: "Query parameter names **MUST** follow the JSON naming convention defined in §9." - open_questions: [] + text: "Query parameter names **SHOULD** follow the JSON naming convention defined in §9." - id: "5.7" title: "No verbs in CRUD paths" class: M+R - strengths: ["MUST NOT"] + strengths: ["SHOULD NOT"] surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 57-no-verbs-in-crud-paths - text: "Verbs **MUST NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.)" - open_questions: [] + text: "Verbs **SHOULD NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.)" - id: "5.8" title: "Actions as sub-resources" class: R - strengths: ["MUST"] + strengths: ["SHOULD"] surface: OpenAPI page: part-b/5-url-structure-and-versioning.md anchor: 58-actions-as-sub-resources - text: "Non-CRUD actions **MUST** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`." - open_questions: [] + text: "Non-CRUD actions **SHOULD** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`." - id: "5.9" title: "Unversioned health endpoint" class: M+R @@ -294,7 +263,6 @@ rules: page: part-b/5-url-structure-and-versioning.md anchor: 59-unversioned-health-endpoint text: "Each BB **MUST** expose an unversioned operational liveness endpoint at `/health`. Health is carried by the HTTP status: `200` when the service is healthy and able to accept work, `503` when it is temporarily unable to. A consumer **MUST** determine health from the status code; response body fields are informational and **MUST NOT** be required for that determination. The `200` response **MUST** use media type `application/json` and **SHOULD** be minimal; the `503` is an error response and carries the problem envelope of §11.1 like any other. The endpoint **MUST** be cheap and bounded, **MUST NOT** carry citizen authentication, and **MUST NOT** expose system-internal detail such as hostnames, versions, stack traces, or dependency topology. It **MUST NOT** probe an external dependency unless that dependency genuinely determines whether the service can accept work. A separate `/ready` endpoint **MAY** be exposed where readiness and liveness semantics differ, under the same rules." - open_questions: [] - id: "5.10" title: "Standard unversioned endpoints" class: M @@ -303,7 +271,6 @@ rules: page: part-b/5-url-structure-and-versioning.md anchor: 510-standard-unversioned-endpoints text: "A closed set of endpoints sits outside the versioned business surface, because their location or spelling is fixed by something other than this guide. That set is: well-known URIs under `/.well-known/`, whose location RFC 8615 roots at that exact prefix; the operational endpoints of §5.9; and a runtime specification-discovery endpoint where a BB serves one, conventionally `/openapi.json` or `/asyncapi.json`. These endpoints are exempt from §5.1, §5.3, and the collection rules of §12, and they satisfy §13.1 with an explicit empty security requirement (`security: []`) where they are unauthenticated. No other endpoint **MAY** claim this exemption, and a BB **MUST NOT** place a business resource under one of these paths in order to escape the rules above: they are read-only and **MUST NOT** declare `POST`, `PUT`, `PATCH`, or `DELETE`." - open_questions: [] - id: "6.1" title: "GET is safe and idempotent" class: M+R @@ -312,7 +279,6 @@ rules: page: part-b/6-http-methods.md anchor: 61-get-is-safe-and-idempotent text: "`GET` **MUST** be safe and idempotent. Requests **MUST NOT** carry a body." - open_questions: [] - id: "6.2" title: "POST creates or performs actions" class: R @@ -321,7 +287,6 @@ rules: page: part-b/6-http-methods.md anchor: 62-post-creates-or-performs-actions text: "`POST` **MUST** be used to create a server-assigned resource or to perform an action that is not expressed by another HTTP method. A creation completed during the request **MUST** return `201 Created`; work accepted but not completed **MUST** return `202 Accepted` with an Operation resource; a completed non-creation action **MUST** return `200 OK` with a result or `204 No Content` without one. A POST action **MAY** be naturally idempotent or made retry-safe under §14." - open_questions: [] - id: "6.3" title: "PUT replaces the entire resource" class: R @@ -330,16 +295,14 @@ rules: page: part-b/6-http-methods.md anchor: 63-put-replaces-the-entire-resource text: "`PUT` **MUST** replace the entire resource and **MUST** be idempotent." - open_questions: [] - id: "6.4" - title: "PATCH uses JSON Merge Patch" + title: "PATCH uses a registered patch format" class: M+R - strengths: ["MUST", "MAY"] + strengths: ["MUST", "SHOULD", "MAY"] surface: OpenAPI page: part-b/6-http-methods.md - anchor: 64-patch-uses-json-merge-patch - text: "`PATCH` partially updates a resource. Request bodies **MUST** use JSON Merge Patch (RFC 7396) with media type `application/merge-patch+json`. Note that under RFC 7396 a member set to `null` means \"remove this member\", so Merge Patch cannot set a nullable field (§9.4) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** additionally support RFC 6902 JSON Patch via `application/json-patch+json`; such endpoints **MUST** document which media type carries which semantics." - open_questions: [] + anchor: 64-patch-uses-a-registered-patch-format + text: "`PATCH` partially updates a resource. Its request body **MUST** use a registered patch media type and the operation **MUST** document the selected patch semantics. JSON Merge Patch (RFC 7396) with `application/merge-patch+json` **SHOULD** be the default for simple object updates. Under RFC 7396 a member set to `null` means \"remove this member\", so Merge Patch cannot set a nullable field (§9.4) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** use RFC 6902 JSON Patch via `application/json-patch+json` or another registered format suited to the contract." - id: "6.5" title: "DELETE response semantics" class: M+R @@ -348,7 +311,6 @@ rules: page: part-b/6-http-methods.md anchor: 65-delete-response-semantics text: "`DELETE` removes a resource. Synchronous hard delete **MUST** return `204 No Content` with no body. Soft delete or async delete (audit retention, undo window) **MAY** return `200` with a body describing the resulting state, or `202` with an Operation per §15." - open_questions: [] - id: "6.6" title: "POST search for complex queries" class: M+R @@ -357,7 +319,6 @@ rules: page: part-b/6-http-methods.md anchor: 66-post-search-for-complex-queries text: "Complex queries that cannot fit in a URL query string **MAY** use `POST /v1/{collection}/search` with a request body. The response **MUST** return `200`, not `201`." - open_questions: [] - id: "6.7" title: "Bulk mutation needs explicit selection" class: M+R @@ -366,7 +327,6 @@ rules: page: part-b/6-http-methods.md anchor: 67-bulk-mutation-needs-explicit-selection text: "A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (Mutable audit logs and filter-less bulk update or delete operations that could rewrite or destroy an entire registry have both been observed in existing BB specifications.)" - open_questions: [] - id: "7.1" title: "200 for successful reads" class: R @@ -375,7 +335,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 71-200-for-successful-reads text: "A successful read or completed non-creation action that returns a representation **MUST** use `200 OK`." - open_questions: [] - id: "7.2" title: "201 Created with Location" class: M @@ -384,7 +343,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 72-201-created-with-location text: "A resource creation that completes during the request **MUST** use `201 Created`. The response **MUST** include a `Location` header pointing to the created resource. An operation that has only been accepted for later processing **MUST NOT** return `201`; it **MUST** use `202` under §7.3." - open_questions: [] - id: "7.3" title: "202 Accepted for async operations" class: M+R @@ -393,7 +351,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 73-202-accepted-for-async-operations text: "Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the local schema defined under §15. `202` **MUST NOT** claim that the requested work succeeded." - open_questions: [] - id: "7.4" title: "204 for void responses" class: R @@ -402,7 +359,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 74-204-for-void-responses text: "A successful synchronous DELETE or other successful operation with no response representation **MUST** use `204 No Content` and **MUST NOT** include a response body." - open_questions: [] - id: "7.5" title: "400 for malformed requests" class: R @@ -411,7 +367,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 75-400-for-malformed-requests text: "An operation that accepts path, query, header, or body input **MUST** declare `400 Bad Request` for malformed, unparseable, or structurally invalid input. Well-formed input that violates domain semantics **MUST** use `422` under §7.11." - open_questions: [] - id: "7.6" title: "401 with WWW-Authenticate" class: M+R @@ -420,7 +375,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 76-401-with-www-authenticate text: "Every operation requiring authentication **MUST** declare `401 Unauthorized` for missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For an OAuth 2.0 bearer scheme (§13.2), a challenge for an invalid token **SHOULD** carry the RFC 6750 `invalid_token` error; a challenge for a request containing no authentication credentials **SHOULD NOT** include an OAuth error code." - open_questions: [] - id: "7.7" title: "403 when not authorised" class: R @@ -429,7 +383,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 77-403-when-not-authorised text: "Every secured operation that can reject an authenticated caller for insufficient permission **MUST** declare `403 Forbidden`. A BB **MAY** return `404` instead when concealing the existence of a forbidden resource is part of its documented security contract, as allowed by RFC 9110." - open_questions: [] - id: "7.8" title: "404 for missing resources" class: R @@ -438,7 +391,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 78-404-for-missing-resources text: "Every operation that addresses a specific resource **MUST** declare `404 Not Found` for an absent resource or for a resource whose existence is intentionally concealed under §7.7. An empty collection **MUST** return `200` with an empty `items` array, not `404`." - open_questions: [] - id: "7.9" title: "409 for state conflicts" class: R @@ -447,7 +399,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 79-409-for-state-conflicts text: "An operation that creates a uniquely keyed resource, performs a state transition, or supports idempotent retry **MUST** declare `409 Conflict` when it can conflict with current resource or processing state, including an illegal transition or concurrent in-flight retry under §14.5. A failed conditional request precondition **MUST** use `412` (§7.15), not `409`." - open_questions: [] - id: "7.10" title: "410 for permanent removal" class: R @@ -456,7 +407,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 710-410-for-permanent-removal text: "A server **MUST** use `410 Gone` only when it knows that a resource or endpoint has been permanently removed; otherwise it **MUST** use `404`." - open_questions: [] - id: "7.11" title: "422 for semantic errors" class: R @@ -464,8 +414,7 @@ rules: surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 711-422-for-semantic-errors - text: "A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"). The idempotency-fingerprint use of `422` is in §14.5. `[OPEN-7-A]`" - open_questions: ["OPEN-7-A"] + text: "A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"). The idempotency-fingerprint use of `422` is in §14.5." - id: "7.12" title: "429 for rate limits" class: R @@ -474,7 +423,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 712-429-for-rate-limits text: "An operation that enforces a caller-visible rate limit **MUST** declare `429 Too Many Requests` and the headers required by §8.7." - open_questions: [] - id: "7.13" title: "Server errors documented" class: M @@ -483,7 +431,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 713-server-errors-documented text: "Every operation **MUST** declare `500 Internal Server Error`. Operations exposed through a gateway or dependent service **SHOULD** additionally declare the applicable `502`, `503`, and `504` responses." - open_questions: [] - id: "7.14" title: "All status codes declared" class: M @@ -492,7 +439,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 714-all-status-codes-declared text: "Every operation **MUST** declare the status codes it can return. Declaring only `200` is forbidden." - open_questions: [] - id: "7.15" title: "412 for failed preconditions" class: R @@ -501,7 +447,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 715-412-for-failed-preconditions text: "A failed conditional request precondition such as `If-Match` **MUST** use `412 Precondition Failed`." - open_questions: [] - id: "7.16" title: "ETag and If-None-Match" class: M+R @@ -510,7 +455,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 716-etag-and-if-none-match text: "Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the selected representation. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change. An endpoint using ETags for write concurrency **MUST** provide a strong validator suitable for the strong comparison required by `If-Match`." - open_questions: [] - id: "7.17" title: "Optimistic concurrency with If-Match" class: M+R @@ -519,7 +463,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 717-optimistic-concurrency-with-if-match text: "`PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: <strong-etag>` and the server returns `412 Precondition Failed` (§7.15) if the resource has changed. An endpoint that requires a conditional write **SHOULD** return `428 Precondition Required` when `If-Match` is absent. A failed `If-Match` precondition **MUST** use `412`, not `409`; `409` (§7.9) is reserved for conflicts not expressed by a conditional precondition." - open_questions: [] - id: "7.18" title: "405 with Allow header" class: M @@ -528,7 +471,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 718-405-with-allow-header text: "`405 Method Not Allowed`: the target resource does not support the request method. The response **MUST** include an `Allow` header listing the supported methods (RFC 9110)." - open_questions: [] - id: "7.19" title: "415 for unsupported media types" class: M+R @@ -536,8 +478,7 @@ rules: surface: OpenAPI page: part-b/7-http-status-codes.md anchor: 719-415-for-unsupported-media-types - text: "`415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints (§6.4) **MUST** return `415` when the patch media type is neither `application/merge-patch+json` nor a documented `application/json-patch+json`. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header." - open_questions: [] + text: "`415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints (§6.4) **MUST** return `415` when the request does not use one of the registered patch media types documented by that operation. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header." - id: "7.20" title: "No-store on error responses" class: M @@ -546,7 +487,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 720-no-store-on-error-responses text: "Error responses (`application/problem+json`) and Operation-status responses (§15) **SHOULD** declare `Cache-Control: no-store`, so a shared cache cannot replay a transient failure or stale operation state. Broader caching behaviour is operational and out of scope (§1.2)." - open_questions: [] - id: "7.21" title: "Schemas for successful response bodies" class: M @@ -555,7 +495,6 @@ rules: page: part-b/7-http-status-codes.md anchor: 721-schemas-for-successful-response-bodies text: "Every declared `2xx` response that carries a body **MUST** declare at least one concrete media type and a response schema for each declared media type. An empty schema or description without a schema **MUST NOT** stand in for a response contract. Responses whose HTTP semantics prohibit a body, including `204`, **MUST NOT** declare response content." - open_questions: [] - id: "8.1" title: "Credentials in Authorization header" class: M+R @@ -564,7 +503,6 @@ rules: page: part-b/8-headers.md anchor: 81-credentials-in-authorization-header text: "Authentication credentials **MUST** travel in the `Authorization` header. They **MUST NOT** appear in query parameters, fragments, or URL paths." - open_questions: [] - id: "8.2" title: "Accept-Language and Content-Language" class: M+R @@ -573,7 +511,6 @@ rules: page: part-b/8-headers.md anchor: 82-accept-language-and-content-language text: "Localisation requests **MUST** use `Accept-Language`; a localised response **MUST** identify the language actually selected using `Content-Language`, which is not necessarily the request's first preference. A cacheable response selected using `Accept-Language` **MUST** declare `Vary: Accept-Language`." - open_questions: [] - id: "8.3" title: "Idempotency-Key header accepted" class: M+R @@ -582,7 +519,6 @@ rules: page: part-b/8-headers.md anchor: 83-idempotency-key-header-accepted text: "POST endpoints that require idempotency under §14 **MUST** accept an `Idempotency-Key` header, unless §14.6 applies." - open_questions: [] - id: "8.4" title: "W3C Trace Context correlation" class: M+R @@ -591,7 +527,6 @@ rules: page: part-b/8-headers.md anchor: 84-w3c-trace-context-correlation text: "Every cross-service HTTP operation **MUST** declare the W3C Trace Context `traceparent` request header and **MAY** declare `tracestate`. A conforming implementation **MUST** propagate a valid received trace context on downstream calls and **MUST** create a valid new context when none is present or the received value is invalid. `tracestate` **MUST NOT** contain personal data. The RFC 9457 `traceId` extension in §11.3 **MUST** equal the 32-hex-digit trace-id component of the request's effective `traceparent`. A separate business or support correlation identifier **MAY** be defined, but **MUST NOT** replace Trace Context." - open_questions: [] - id: "8.5" title: "No new X- prefixed headers" class: M @@ -600,7 +535,6 @@ rules: page: part-b/8-headers.md anchor: 85-no-new-x--prefixed-headers text: "New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix, per RFC 6648. Existing private `X-` headers **MAY** remain only on an unchanged legacy major version and **MUST NOT** be introduced on a new surface or new major version." - open_questions: [] - id: "8.6" title: "No personal data in addressable locations" class: R @@ -609,7 +543,6 @@ rules: page: part-b/8-headers.md anchor: 86-no-personal-data-in-addressable-locations text: "Personal data (national identifier, phone, email, name, date of birth, exact address) **MUST NOT** appear in path segments, query parameters, or header values other than purpose-specific signed assertions (e.g., an OIDC ID token). Opaque server-generated IDs (§10.1) **MUST** be used to refer to citizen records in URLs." - open_questions: [] - id: "8.7" title: "Rate-limit headers declared" class: M+R @@ -618,7 +551,6 @@ rules: page: part-b/8-headers.md anchor: 87-rate-limit-headers-declared text: "An endpoint rate-limited by the BB itself **MUST** document the quota scope and **MUST** declare the `RateLimit` response header using the Structured Field syntax pinned from `draft-ietf-httpapi-ratelimit-headers-11`. A BB that advertises quota-policy details **MUST** use `RateLimit-Policy`. The server **MAY** omit these advisory headers on individual responses as allowed by the draft, but a `429` response **MUST** declare `Retry-After`; when `Retry-After` and `RateLimit` are both present, clients **MUST** treat `Retry-After` as authoritative. Where an API gateway or interoperability mediator owns rate limiting, the BB specification **MUST** state that fact instead of claiming to emit headers it does not control. The legacy `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` fields **MUST NOT** be described as conforming to the pinned draft." - open_questions: [] - id: "9.1" title: "JSON as default media type" class: M+R @@ -627,16 +559,14 @@ rules: page: part-c/9-json-conventions-and-naming.md anchor: 91-json-as-default-media-type text: "Default response media type **MUST** be `application/json` unless the resource is binary or a document export." - open_questions: [] - id: "9.2" title: "camelCase field names" class: M - strengths: ["MUST"] + strengths: ["SHOULD"] surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 92-camelcase-field-names - text: "JSON field names **MUST** use `camelCase`, applied consistently across the entire ecosystem. (See note below on the choice of casing.)" - open_questions: [] + text: "GovStack-owned JSON field names **SHOULD** use `camelCase` consistently within a surface. Fields adopted from an external standard retain that standard's spelling. (See note below on the choice of casing.)" - id: "9.3" title: "Real JSON booleans" class: M @@ -645,7 +575,6 @@ rules: page: part-c/9-json-conventions-and-naming.md anchor: 93-real-json-booleans text: "Boolean fields **MUST** be JSON booleans (`true`/`false`), not strings." - open_questions: [] - id: "9.4" title: "Explicit nullability" class: M @@ -654,16 +583,14 @@ rules: page: part-c/9-json-conventions-and-naming.md anchor: 94-explicit-nullability text: "Nullability **MUST** be explicit (`type: [..., \"null\"]` per OpenAPI 3.1)." - open_questions: [] - id: "9.5" title: "No spaces or non-ASCII names" class: M - strengths: ["MUST NOT"] + strengths: ["SHOULD"] surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 95-no-spaces-or-non-ascii-names - text: "Field names **MUST NOT** contain spaces or non-ASCII characters." - open_questions: [] + text: "GovStack-owned field names **SHOULD** use ASCII identifiers without spaces. Fields adopted from an external standard retain that standard's spelling." - id: "9.6" title: "Avoid abbreviations" class: R @@ -672,16 +599,14 @@ rules: page: part-c/9-json-conventions-and-naming.md anchor: 96-avoid-abbreviations text: "Abbreviations **SHOULD NOT** be used (prefer `quantity` over `qty`)." - open_questions: [] - id: "9.7" title: "Screaming snake case enum values" class: M - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "SHOULD"] surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 97-screaming-snake-case-enum-values - text: "Enum values that name a BB-defined state or category **MUST** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per §16.3, sort keys per §12.7), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. The distinction is ownership, not appearance: a value this BB defines is re-cased to satisfy this rule, a value defined elsewhere is reproduced exactly as its own registry or specification spells it." - open_questions: [] + text: "Enum values that name a BB-defined state or category **SHOULD** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per §16.3, sort keys per §12.7), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types." - id: "9.8" title: "Forward-compatible schemas" class: M @@ -690,7 +615,6 @@ rules: page: part-c/9-json-conventions-and-naming.md anchor: 98-forward-compatible-schemas text: "Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values **MUST** be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in §18.6 as a non-normative reader expectation." - open_questions: [] - id: "9.9" title: "No closed enums for growing sets" class: R @@ -699,7 +623,6 @@ rules: page: part-c/9-json-conventions-and-naming.md anchor: 99-no-closed-enums-for-growing-sets text: "Fields whose value set is expected to grow **MUST NOT** be declared as a closed OpenAPI `enum`, because client code generated from a closed enum typically rejects values added later, which would make the \"adding enum values is non-breaking\" guarantee of §18.3 false in practice. Such fields **MUST** either be declared as an open `type: string` annotated with `x-extensible-enum` (carrying the known values), or define an explicit fallback member (e.g., `UNKNOWN`) that conforming clients map unrecognised values to. Truly fixed value sets (e.g., ISO-defined codes) **MAY** remain closed enums." - open_questions: [] - id: "9.10" title: "GovStack extension prefix" class: M+R @@ -708,7 +631,6 @@ rules: page: part-c/9-json-conventions-and-naming.md anchor: 910-govstack-extension-prefix text: "GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-deprecated` in §18.7, and `x-govstack-api-guide` in §20.3). A prefix rule does not create or standardise an extension; each GovStack extension still requires an explicit schema and governing rule." - open_questions: [] - id: "9.11" title: "Single registered BB code" class: M+R @@ -716,8 +638,7 @@ rules: surface: Universal page: part-c/9-json-conventions-and-naming.md anchor: 911-single-registered-bb-code - text: "Every namespace that embeds a BB code (HTTP problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, logical channel IDs §17.2, and any transport-neutral asynchronous error code that embeds one §11.6) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. The BB-code register is proposed for the Lifecycle & Governance companion (Appendix A); until it exists, codes **SHOULD** be agreed through the API Working Group. `[OPEN-9-A]`" - open_questions: ["OPEN-9-A"] + text: "Every namespace that embeds a BB code (HTTP problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, logical channel IDs §17.2, and any transport-neutral asynchronous error code that embeds one §11.6) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. Until GovStack publishes a canonical register, codes **SHOULD** be agreed through the API Working Group." - id: "10.1" title: "Opaque server-generated identifiers" class: M+R @@ -726,7 +647,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 101-opaque-server-generated-identifiers text: "Resource identifiers **MUST** be opaque, URL-safe strings, server-generated, and globally unique within the BB. UUID v4 (`format: uuid`) **SHOULD** be the default; ULID, KSUID, or other opaque IDs **MAY** be used where ordering or sortability matters. Clients **MUST** treat all IDs as opaque. **Exception:** registries with statutory identifiers (civil registry numbers, parcel IDs, business numbers, licence numbers) **MAY** use those identifiers in URL paths provided §8.6 is satisfied (the identifier does not constitute personal data; a parcel number or business number is acceptable, a national ID or passport number is not). The opacity requirement applies to BB-generated identifiers; statutory identifiers are by definition not opaque to clients." - open_questions: [] - id: "10.2" title: "RFC 3339 timestamps" class: M @@ -735,7 +655,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 102-rfc-3339-timestamps text: "Timestamps **MUST** be RFC 3339 in UTC, serialized with the `Z` designator and declared as `format: date-time`. A non-UTC offset **MUST NOT** be used in an API payload; where a local time zone is significant to the consumer, it is carried in a separate field alongside the UTC value. UTC-only is inherited from `govstack-cfr-data#req-2` and cannot be relaxed by a BB specification." - open_questions: [] - id: "10.3" title: "RFC 3339 calendar dates" class: M @@ -744,7 +663,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 103-rfc-3339-calendar-dates text: "Dates without time **MUST** be RFC 3339 calendar dates, declared as `format: date`." - open_questions: [] - id: "10.4" title: "Decimal-string monetary amounts" class: M+R @@ -753,7 +671,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 104-decimal-string-monetary-amounts text: "Monetary amounts **MUST** use the object `{ amount: string (decimal), currency: string (ISO 4217) }`. Floats **MUST NOT** be used for money. Decimal-string is chosen over minor-units because GovStack-adopting countries may include currencies with non-decimal subunits and zero-subunit currencies, which a minor-units convention handles inconsistently." - open_questions: [] - id: "10.5" title: "E.164 phone numbers" class: M+R @@ -762,7 +679,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 105-e164-phone-numbers text: "Phone numbers **MUST** be E.164 strings." - open_questions: [] - id: "10.6" title: "RFC 5322 email addresses" class: M+R @@ -771,7 +687,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 106-rfc-5322-email-addresses text: "Email addresses **MUST** be RFC 5322 strings, declared as `format: email`." - open_questions: [] - id: "10.7" title: "Binary uploads and base64 payloads" class: M+R @@ -780,7 +695,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 107-binary-uploads-and-base64-payloads text: "Large binary uploads **MUST** use `multipart/form-data` or a dedicated binary endpoint. Small inline payloads (signatures, certificates, QR codes, attestations) **MAY** be base64-encoded in JSON bodies; the field **MUST** then be declared with `contentEncoding: base64` and a documented size limit." - open_questions: [] - id: "10.8" title: "ISO 3166-1 country codes" class: M+R @@ -789,7 +703,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 108-iso-3166-1-country-codes text: "Country codes **MUST** be ISO 3166-1 alpha-2." - open_questions: [] - id: "10.9" title: "BCP 47 language codes" class: M+R @@ -798,7 +711,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 109-bcp-47-language-codes text: "Language codes **MUST** be BCP 47." - open_questions: [] - id: "10.10" title: "ISO 4217 currency codes" class: M+R @@ -807,7 +719,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 1010-iso-4217-currency-codes text: "Currency codes **MUST** be ISO 4217." - open_questions: [] - id: "10.11" title: "UTF-8 text encoding" class: M @@ -816,7 +727,6 @@ rules: page: part-c/10-data-types-and-formats.md anchor: 1011-utf-8-text-encoding text: "Text in API payloads **MUST** be UTF-8. A media type declared anywhere in the specification **MUST NOT** carry a `charset` parameter naming any other encoding. `charset=utf-8` **MAY** be stated explicitly, though it is redundant on JSON media types, whose encoding RFC 8259 already fixes at UTF-8. This is inherited from `govstack-cfr-data#req-1` and cannot be relaxed by a BB specification." - open_questions: [] - id: "11.1" title: "RFC 9457 problem details" class: M @@ -825,7 +735,6 @@ rules: page: part-c/11-errors.md anchor: 111-rfc-9457-problem-details text: "HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; §11.6 defines the separate asynchronous model." - open_questions: [] - id: "11.2" title: "Stable HTTP problem type URI" class: M+R @@ -834,7 +743,6 @@ rules: page: part-c/11-errors.md anchor: 112-stable-http-problem-type-uri text: "Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be the sole machine identifier for the problem and **MUST** use `https://govstack.global/problems/{bb-code}/{problem-slug}`. `{bb-code}` is the registered code from §9.11; `{problem-slug}` **MUST** be stable kebab-case, for example `bad-request`, `invalid-field`, or `internal-error`. The URI **SHOULD** dereference to human-readable documentation. A GovStack HTTP Problem **MUST NOT** add a duplicate machine identifier such as `code`.\n`status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. HTTP problems **MUST NOT** add a `timestamp`; use response metadata and trace correlation for occurrence diagnostics." - open_questions: [] - id: "11.3" title: "Trace identifier" class: M @@ -843,7 +751,6 @@ rules: page: part-c/11-errors.md anchor: 113-trace-identifier text: "Every GovStack HTTP problem **MUST** include `traceId`, containing the W3C trace-id defined by §8.4." - open_questions: [] - id: "11.4" title: "Field-level errors array" class: M+R @@ -852,7 +759,6 @@ rules: page: part-c/11-errors.md anchor: 114-field-level-errors-array text: "Where an HTTP failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry **MUST** contain `pointer` (JSON Pointer) and `message`. It **MUST NOT** add a field-level `code`; the enclosing Problem `type` identifies the problem class. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, §14.5)." - open_questions: [] - id: "11.5" title: "Stable HTTP problem fields across languages" class: R @@ -861,7 +767,6 @@ rules: page: part-c/11-errors.md anchor: 115-stable-http-problem-fields-across-languages text: "HTTP Problem `title`, `detail`, and field-error `message` **MAY** be localised. The `type` URI, `status`, `traceId`, `instance`, and field-error `pointer` **MUST NOT** be translated." - open_questions: [] - id: "11.6" title: "Transport-neutral asynchronous errors" class: M+R @@ -870,7 +775,6 @@ rules: page: part-c/11-errors.md anchor: 116-transport-neutral-asynchronous-errors text: "An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. This transport-neutral schema remains separate from the HTTP Problem model and **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`; it **MAY** contain `detail` and `errors`. Each asynchronous field error contains `pointer`, `code`, and `message` as defined by the shared `AsyncFieldError` schema. When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines." - open_questions: [] - id: "12.1" title: "Collections must paginate" class: M+R @@ -879,7 +783,6 @@ rules: page: part-c/12-pagination-filtering-sorting.md anchor: 121-collections-must-paginate text: "Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden. A collection whose size is fixed by the specification itself **MAY** be returned unpaginated, provided the bound is declared in the schema with `maxItems`; an undeclared expectation that a collection stays small does not qualify, because an integrator cannot see it and a linter cannot check it. The standard unversioned endpoints of §5.10 are not collections and this section does not apply to them." - open_questions: [] - id: "12.2" title: "Cursor pagination by default" class: M+R @@ -888,7 +791,6 @@ rules: page: part-c/12-pagination-filtering-sorting.md anchor: 122-cursor-pagination-by-default text: "Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable Problem `type`. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries." - open_questions: [] - id: "12.3" title: "Cursor pagination envelope" class: M @@ -897,7 +799,6 @@ rules: page: part-c/12-pagination-filtering-sorting.md anchor: 123-cursor-pagination-envelope text: "The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, total? } }`. `nextCursor` **MUST** be a non-empty string when another page is available and **MUST** be `null` on the final page; its schema therefore **MUST** declare explicit nullability. Clients determine whether another page is available from `nextCursor` and no separate `hasMore` field is used. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and one continuation field." - open_questions: [] - id: "12.4" title: "Documented pageSize bounds" class: M+R @@ -906,7 +807,6 @@ rules: page: part-c/12-pagination-filtering-sorting.md anchor: 124-documented-pagesize-bounds text: "`pageSize` **MUST** have a documented default and maximum. Specific numeric values are per-BB." - open_questions: [] - id: "12.5" title: "Optional total count" class: R @@ -915,7 +815,6 @@ rules: page: part-c/12-pagination-filtering-sorting.md anchor: 125-optional-total-count text: "`total` **MAY** be omitted when computing it is expensive." - open_questions: [] - id: "12.6" title: "Offset pagination envelope" class: M+R @@ -924,34 +823,30 @@ rules: page: part-c/12-pagination-filtering-sorting.md anchor: 126-offset-pagination-envelope text: "Offset pagination **MAY** be used for admin or fixed-size lists. In that case the envelope **MUST** be the flat shape `{ items, offset, limit, total }`, distinct from the cursor `pageInfo` envelope in §12.3; `total` is required here (unlike §12.3, where it is optional)." - open_questions: [] - id: "12.7" title: "Sort parameter convention" class: M - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 127-sort-parameter-convention - text: "Sort parameter **MUST** be `sort`, values `field` (ascending) or `-field` (descending); multiple criteria separated by commas." - open_questions: [] + text: "An API that supports sorting **MUST** document the parameter, allowed fields, direction syntax, default order, and a stable tie-breaker. The GovStack default **SHOULD** be `sort`, with `field` for ascending, `-field` for descending, and commas between multiple criteria." - id: "12.8" title: "Simple equality filtering" class: M+R - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 128-simple-equality-filtering - text: "Simple equality filtering on non-personal, non-secret fields **MUST** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by §8.6 **MUST NOT** use a query parameter and **MUST** use the body-based search pattern in §12.9." - open_questions: [] + text: "Simple equality filtering on non-personal, non-secret fields **SHOULD** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by §8.6 **MUST NOT** use a query parameter and **MUST** use a documented body-based search contract such as §12.9." - id: "12.9" title: "Complex filtering via search" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-c/12-pagination-filtering-sorting.md anchor: 129-complex-filtering-via-search - text: "Complex filtering and any filtering that contains personal data **MUST** use `POST /v1/{collection}/search` per §6.6. Pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the §12.3 envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor." - open_questions: [] + text: "Complex filtering and any filtering that contains personal data **SHOULD** use a request body, conventionally at `POST /v1/{collection}/search` per §6.6. When a body-based search is used, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the §12.3 envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor." - id: "12.10" title: "Sparse fieldsets out of scope" class: informative @@ -960,7 +855,6 @@ rules: page: part-c/12-pagination-filtering-sorting.md anchor: 1210-sparse-fieldsets-out-of-scope text: "Sparse fieldsets (response field selection) are out of scope for v1.0." - open_questions: [] - id: "13.1" title: "Default security on every operation" class: M @@ -969,7 +863,6 @@ rules: page: part-d/13-authentication-and-authorisation.md anchor: 131-default-security-on-every-operation text: "Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit. The standard unversioned endpoints of §5.10 satisfy this rule with an explicit empty security requirement (`security: []`) where they are unauthenticated: they are exempt from carrying a scheme, not from declaring what they carry." - open_questions: [] - id: "13.2" title: "OAuth and OIDC for citizen operations" class: M+R @@ -978,7 +871,6 @@ rules: page: part-d/13-authentication-and-authorisation.md anchor: 132-oauth-and-oidc-for-citizen-operations text: "Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. An API that is purely a resource server, validating access tokens issued by an authorization server it does not own and whose endpoints are not part of its own contract, **MAY** instead declare `type: http` with `scheme: bearer` and `bearerFormat: JWT`, because declaring an `oauth2` flow it does not operate would misdescribe the deployed API. That declaration **MUST** document, in the scheme description or an adjacent `/.well-known/` metadata document, the issuers it accepts and the audience value it requires, so an integrator can still discover how to obtain a usable token. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles." - open_questions: [] - id: "13.3" title: "Distinct scheme for BB-to-BB calls" class: M+R @@ -987,16 +879,14 @@ rules: page: part-d/13-authentication-and-authorisation.md anchor: 133-distinct-scheme-for-bb-to-bb-calls text: "BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service authentication. On OpenAPI this **MUST** be `type: mutualTLS` or an OAuth client-credentials scheme using confidential-client authentication; on AsyncAPI it **MUST** be `type: X509`, OAuth client credentials, or a documented protocol-specific scheme such as SASL. OAuth access tokens **MUST** be audience-restricted to the intended BB and scope-restricted to the operation. Sender-constrained access tokens under RFC 8705 or RFC 9449 **SHOULD** be used across an inter-BB trust boundary. The spec **MUST** distinguish citizen-facing from inter-BB operations." - open_questions: [] - id: "13.4" title: "Namespaced OAuth scopes" class: M - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 134-namespaced-oauth-scopes - text: "Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation, and **MUST** follow a single ecosystem-wide naming convention so that one BB's scope does not collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. The default shape is `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The `bb:` prefix is retained for OAuth scopes because colon-separated scope strings are widely supported and readable in OAuth tooling; reverse-DNS (`global.govstack.{bb-code}.{resource}.{action}`) and `resource.action` are alternatives. `[OPEN-13-A]`" - open_questions: ["OPEN-13-A"] + text: "Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation and namespaced so one BB's scopes cannot collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. New GovStack scopes **SHOULD** use `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11." - id: "13.5" title: "Authorization is the credential channel" class: M+R @@ -1005,7 +895,6 @@ rules: page: part-d/13-authentication-and-authorisation.md anchor: 135-authorization-is-the-credential-channel text: "Credentials **MUST NOT** be declared in URL paths, query parameters, cookies, or fragments. The `Authorization` header is the only declared credential channel for token-based schemes." - open_questions: [] - id: "13.6" title: "API keys only for operational endpoints" class: R @@ -1014,7 +903,6 @@ rules: page: part-d/13-authentication-and-authorisation.md anchor: 136-api-keys-only-for-operational-endpoints text: "API keys **MAY** be declared on operational endpoints (`/health` and similar per §5.9). They **MUST NOT** be declared on operations that read or write personal data." - open_questions: [] - id: "13.7" title: "Protected transport" class: M+R @@ -1022,8 +910,7 @@ rules: surface: Universal page: part-d/13-authentication-and-authorisation.md anchor: 137-protected-transport - text: "Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration remain in the Security & Operations companion." - open_questions: [] + text: "Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration are outside this guide." - id: "14.1" title: "Idempotency-Key on non-idempotent POSTs" class: M+R @@ -1032,7 +919,6 @@ rules: page: part-d/14-idempotency.md anchor: 141-idempotency-key-on-non-idempotent-posts text: "Except where §14.6 applies, POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** require and accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support it unless their naturally idempotent contract documents duplicate handling. Read-like POSTs such as search **MAY** support it. GovStack pins the header syntax and error semantics from `draft-ietf-httpapi-idempotency-key-header-07`; this guide is the stable GovStack profile if that work-in-progress draft changes or expires." - open_questions: [] - id: "14.2" title: "Opaque client-generated keys" class: R @@ -1041,7 +927,6 @@ rules: page: part-d/14-idempotency.md anchor: 142-opaque-client-generated-keys text: "The key **MUST** be an opaque, client-generated, high-entropy value and **SHOULD** be a UUID. On the wire it **MUST** use the Structured Field String syntax pinned from draft revision 07, including the required quotation marks. The specification **MUST** document the accepted syntax and maximum length, and the server **MUST** reject a malformed, missing-required, or oversized key with `400 Bad Request` before processing the operation." - open_questions: [] - id: "14.3" title: "Documented replay window" class: R @@ -1050,7 +935,6 @@ rules: page: part-d/14-idempotency.md anchor: 143-documented-replay-window text: "The spec **MUST** document the idempotency replay-window contract, the required minimum replay window or controlling configuration parameter, and what happens after expiry. Within the documented window the key **MUST** retain the semantics in §14.4 and §14.5; after expiry the server **MAY** process the same key as a new request only if that behaviour is stated explicitly. Concrete retention values belong in implementation profiles." - open_questions: [] - id: "14.4" title: "Replay returns original response" class: R @@ -1059,7 +943,6 @@ rules: page: part-d/14-idempotency.md anchor: 144-replay-returns-original-response text: "A completed repeated request with the same lookup scope, key, and fingerprint within the documented window **MUST** return the original operation result: the same status, body, and result-defining representation headers such as `Content-Type` and `Location`. Per-attempt, temporal, security, tracing, rate-limit, retry, and hop-by-hop headers **MUST** be regenerated or omitted rather than replayed; this includes `Date`, `traceparent`, `tracestate`, `RateLimit`, `Retry-After`, and `Set-Cookie`." - open_questions: [] - id: "14.5" title: "Key reuse and fingerprint mismatch" class: R @@ -1068,7 +951,6 @@ rules: page: part-d/14-idempotency.md anchor: 145-key-reuse-and-fingerprint-mismatch text: "The server's idempotency lookup scope **MUST** include the effective HTTP method, canonical target URI, key, and, for authenticated operations, the authenticated client and tenant or equivalent authorization partition. The request fingerprint **MUST** include the method, canonical target URI, request content type, canonicalised body, and every documented header that changes operation semantics. Reusing a key in the same lookup scope with a different fingerprint **MUST** return `422 Unprocessable Content`; a matching retry received while the original remains in flight **MUST** return `409 Conflict`. These are GovStack **MUST** requirements even though draft revision 07 expresses the status-code choices as **SHOULD**." - open_questions: [] - id: "14.6" title: "Naturally idempotent designs" class: R @@ -1077,7 +959,6 @@ rules: page: part-d/14-idempotency.md anchor: 146-naturally-idempotent-designs text: "Naturally idempotent designs **MAY** satisfy this section without an `Idempotency-Key` header when idempotency is already guaranteed by the resource contract, for example `PUT /v1/resources/{clientProvidedId}` or creation with a documented unique business key that returns the existing resource or a stable conflict on duplicate submission. The spec **MUST** document that duplicate-handling behaviour per operation. Because §10.1 makes BB-generated identifiers server-assigned by default, `PUT`-with-client-id creation is available only where the resource is keyed by a client-supplied or statutory identifier (§10.1)." - open_questions: [] - id: "15.1" title: "202 with Operation Location" class: M+R @@ -1086,7 +967,6 @@ rules: page: part-d/15-asynchronous-operations.md anchor: 151-202-with-operation-location text: "Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource and **MUST** return the current Operation representation in the response body." - open_questions: [] - id: "15.2" title: "Local Operation resource shape" class: M+R @@ -1095,7 +975,6 @@ rules: page: part-d/15-asynchronous-operations.md anchor: 152-local-operation-resource-shape text: "A BB that exposes long-running work **MUST** define its Operation schema locally. Its identifier **MUST** be an opaque string and clients **MUST NOT** infer a UUID or any other internal format. The local schema **MUST** document how the identifier, lifecycle state, result, error, and any progress metadata are represented, including when each state-dependent field is present. This guide does not fix those field names or shapes. A shared Operation schema is deferred until multiple BBs demonstrate a stable reusable contract." - open_questions: [] - id: "15.3" title: "Documented Operation lifecycle" class: M+R @@ -1104,7 +983,6 @@ rules: page: part-d/15-asynchronous-operations.md anchor: 153-documented-operation-lifecycle text: "The local Operation contract **MUST** distinguish terminal from non-terminal states and **MUST** document the result, error, polling, and cancellation semantics for each applicable state. This guide does not prescribe a status enum or lifecycle model." - open_questions: [] - id: "15.4" title: "Polling the Operation resource" class: M+R @@ -1112,17 +990,15 @@ rules: surface: OpenAPI page: part-d/15-asynchronous-operations.md anchor: 154-polling-the-operation-resource - text: "A BB exposing an Operation resource **MUST** expose polling via `GET /v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval." - open_questions: [] + text: "A BB exposing an Operation resource **MUST** make it pollable with `GET` at the URI returned in `Location`. The conventional path **SHOULD** be `/v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval." - id: "15.5" title: "Cancellation via cancel sub-resource" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: OpenAPI page: part-d/15-asynchronous-operations.md anchor: 155-cancellation-via-cancel-sub-resource - text: "Cancellation, when supported, **MUST** be `POST /v{major}/operations/{operationId}/cancel`." - open_questions: [] + text: "Cancellation, when supported, **MUST** be documented and discoverable from the Operation contract. The conventional action **SHOULD** be `POST /v{major}/operations/{operationId}/cancel`." - id: "15.6" title: "Webhook completion notification" class: R @@ -1131,7 +1007,6 @@ rules: page: part-d/15-asynchronous-operations.md anchor: 156-webhook-completion-notification text: "Long-running operations **SHOULD** support completion notification via webhook (§16) rather than requiring clients to poll indefinitely. The threshold at which notification becomes expected is operational and per-BB." - open_questions: [] - id: "15.7" title: "Documented result retention" class: R @@ -1140,7 +1015,6 @@ rules: page: part-d/15-asynchronous-operations.md anchor: 157-documented-result-retention text: "Operation result availability **MUST** be documented as a consumer-visible contract. A reference specification **SHOULD** state the required minimum retention or the configuration parameter that controls it. Concrete retention values belong in implementation profiles." - open_questions: [] - id: "16.1" title: "Event surfaces documented" class: M+R @@ -1149,7 +1023,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 161-event-surfaces-documented text: "Event-driven APIs **MUST** be documented. HTTP push **MUST** use OpenAPI 3.1 `webhooks`; brokered transports and event streams (MQTT, AMQP, Kafka, WebSockets, SSE) **MUST** use AsyncAPI 3.0." - open_questions: [] - id: "16.2" title: "CloudEvents envelope required" class: M @@ -1158,16 +1031,14 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 162-cloudevents-envelope-required text: "GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `\"1.0\"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used. §17.6 imposes the same requirement on the AsyncAPI surface." - open_questions: [] - id: "16.3" title: "Reverse-DNS event types" class: M - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 163-reverse-dns-event-types - text: "Event `type` names **MUST** follow a single ecosystem-wide convention. The default shape is reverse-DNS: `global.govstack.{bb-code}.{resource}.{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11. The event type identifies the semantic event kind and **MUST NOT** include the major API version; the versioned transport contract is carried by the AsyncAPI logical channel ID or equivalent version metadata (§18.2). `[OPEN-16-A]`" - open_questions: ["OPEN-16-A"] + text: "Event `type` names **MUST** be stable, globally collision-resistant, and include the BB's registered code from §9.11. New GovStack event types **SHOULD** use the reverse-DNS shape `global.govstack.{bb-code}.{resource}.{action}`. The event type identifies the semantic event kind and **MUST NOT** include the major API version; transport-contract versioning is carried separately (§18.2)." - id: "16.4" title: "Stable CloudEvents source" class: M+R @@ -1176,7 +1047,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 164-stable-cloudevents-source text: "The CloudEvents `source` field **MUST** be a stable, non-empty URI-reference identifying the publishing BB or BB surface; an absolute URI or URN **SHOULD** be used. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment." - open_questions: [] - id: "16.5" title: "Optional signed event delivery" class: R @@ -1185,7 +1055,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 165-optional-signed-event-delivery text: "Event delivery **MAY** use message-level signing when the BB's threat model requires authenticity or integrity beyond authenticated transport. Signing is not part of the baseline GovStack event contract. Requirements for an explicitly adopted profile are in §16.6–§16.8." - open_questions: [] - id: "16.6" title: "Signature metadata when used" class: R @@ -1194,7 +1063,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 166-signature-metadata-when-used text: "When the optional profile uses GovStack-owned metadata, an OpenAPI webhook signature **MUST** travel in `GovStack-Signature` and an AsyncAPI transport/application metadata field **MUST** be named `govstackSignature`. When a protocol binding defines a standard signature field, that field **SHOULD** be used and its mapping **MUST** be documented. A surface that does not adopt signing **MUST NOT** require signature metadata." - open_questions: [] - id: "16.7" title: "Replay-detectable signed material" class: R @@ -1203,7 +1071,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 167-replay-detectable-signed-material text: "When signing is adopted, the signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers have the inputs needed to detect replays." - open_questions: [] - id: "16.8" title: "Separate experimental signing profile" class: R @@ -1212,7 +1079,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 168-separate-experimental-signing-profile text: "Event signing belongs in the separate optional `experimental/govstack-openapi-signing-profile.yaml` artifact, not in either baseline common schema artifact. A BB **MAY** adopt this profile explicitly, but it **MUST** pin the profile version and **MUST** document its key discovery, key rotation, replay policy, protocol mapping, and conformance tests. The profile remains incomplete pending a shared key-discovery and replay-policy contract and **MUST NOT** be presented as an ecosystem-wide baseline." - open_questions: [] - id: "16.9" title: "Readiness for a shared signature profile" class: informative @@ -1220,8 +1086,7 @@ rules: surface: Event-driven page: part-d/16-cloudevents-and-webhooks.md anchor: 169-readiness-for-a-shared-signature-profile - text: "A future guide version should promote a shared signature profile only after it defines key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages. Those operational concerns belong in the Security & Operations companion (§1.2)." - open_questions: [] + text: "A shared signature profile is outside the baseline. It should be considered only after key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages exist. Operational signing controls remain outside this guide (§1.2)." - id: "16.10" title: "Documented delivery-failure contract" class: R @@ -1230,7 +1095,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 1610-documented-delivery-failure-contract text: "HTTP webhook delivery-failure behaviour **MUST** be documented per subscription. A reference specification **MUST** define the portable contract fields: whether redelivery is attempted, whether failed deliveries are stored, and how a subscriber can identify or recover failed deliveries. Concrete retry counts, backoff intervals, and failure-store retention values belong in implementation profiles. Failed deliveries **SHOULD** end in a dead-letter queue or equivalent failure store accessible to the subscription owner." - open_questions: [] - id: "16.11" title: "Subscription management interfaces" class: M+R @@ -1239,7 +1103,6 @@ rules: page: part-d/16-cloudevents-and-webhooks.md anchor: 1611-subscription-management-interfaces text: "Subscription management **MUST** expose documented interfaces to create, list, and delete a subscription. If the subscription adopts message signing, it **MUST** also expose or document how to rotate or redistribute the verification material used by the selected profile. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane." - open_questions: [] - id: "17.1" title: "Send and receive perspective" class: M+R @@ -1248,16 +1111,14 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 171-send-and-receive-perspective text: "Each AsyncAPI document **MUST** define the BB's perspective. An operation with `action: send` means the BB publishes that message to the channel. An operation with `action: receive` means the BB consumes that message from the channel." - open_questions: [] - id: "17.2" title: "Stable logical channel IDs and native addresses" class: M+R - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 172-stable-logical-channel-ids-and-native-addresses - text: "Each entry under AsyncAPI `channels` **MUST** use a stable logical channel ID with reverse-DNS shape `global.govstack.{bb-code}.v{major}.{resource}.{event}`. The `{bb-code}` segment **MUST** be the registered code from §9.11. The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. Protocol bindings **MUST** document the mapping from logical ID to native address." - open_questions: [] + text: "Each entry under AsyncAPI `channels` **MUST** use a stable logical ID and document its mapping to the protocol-native address. New GovStack channel IDs **SHOULD** use `global.govstack.{bb-code}.v{major}.{resource}.{event}`, with the registered code from §9.11. The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics." - id: "17.3" title: "No personal data in channels" class: R @@ -1266,7 +1127,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 173-no-personal-data-in-channels text: "Logical channel IDs, native addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead." - open_questions: [] - id: "17.4" title: "Declared channel parameters" class: M+R @@ -1275,7 +1135,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 174-declared-channel-parameters text: "Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object with a non-empty `description` stating its routing semantics. The AsyncAPI 3 Parameter Object carries no `schema` field: a parameter whose permitted values form a closed set **MUST** declare them with `enum`, and one whose values are open **SHOULD** carry `examples`." - open_questions: [] - id: "17.5" title: "No environment names in addresses" class: M+R @@ -1284,7 +1143,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 175-no-environment-names-in-addresses text: "Environment names (`dev`, `test`, `prod`), broker implementation names, and deployment-specific prefixes **SHOULD NOT** appear in channel addresses. They belong in `servers`, server variables, broker configuration, or deployment routing unless a protocol profile explicitly requires them." - open_questions: [] - id: "17.6" title: "Structured CloudEvents JSON payloads" class: M @@ -1292,8 +1150,7 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 176-structured-cloudevents-json-payloads - text: "AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports. `[OPEN-17-A]`" - open_questions: ["OPEN-17-A"] + text: "AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports." - id: "17.7" title: "Shared CloudEvents envelope schema" class: M @@ -1302,16 +1159,14 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 177-shared-cloudevents-envelope-schema text: "Each BB **MUST** define its Message Objects locally. A domain-event message payload **MUST** compose `#/components/schemas/CloudEventEnvelope` from the pinned `govstack-asyncapi-common.yaml` with a local schema that specialises the event `type` and, when present, `data`. A rejection message **MUST** either reference the shared `GovStackAsyncError` schema directly or use it as CloudEvent `data`. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0. Security schemes, headers, examples, correlation, and protocol bindings remain local because they require BB- or transport-specific values." - open_questions: [] - id: "17.8" title: "Message headers and idempotency metadata" class: M+R - strengths: ["MUST NOT", "MUST", "MAY"] + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 178-message-headers-and-idempotency-metadata - text: "GovStack-owned transport/application message headers **MUST** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase; equivalent GovStack-owned transport/application headers are camelCase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow §16.6. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **MUST** use `idempotencyKey`." - open_questions: [] + text: "GovStack-owned transport/application message headers **SHOULD** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow §16.6. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **SHOULD** use `idempotencyKey`." - id: "17.9" title: "Message localisation headers" class: M+R @@ -1320,7 +1175,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 179-message-localisation-headers text: "Message headers used for localisation **MUST** be `acceptLanguage` on inbound command/request messages and `contentLanguage` on outbound localised messages. Stable fields such as identifiers, enum values, timestamps, currency codes, and error codes **MUST NOT** be translated." - open_questions: [] - id: "17.10" title: "Security schemes cover every operation" class: M+R @@ -1329,7 +1183,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1710-security-schemes-cover-every-operation text: "AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Optional message signing (§16.5) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication." - open_questions: [] - id: "17.11" title: "Duplicate delivery contract" class: R @@ -1338,7 +1191,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1711-duplicate-delivery-contract text: "When the selected protocol or deployment can redeliver a message and consumers need to handle duplicates, the operation **MUST** document the duplicate-handling contract. For CloudEvents, the default duplicate identity is the pair `source` plus `id`. Protocol QoS, acknowledgement, and redelivery fields **MUST** use the applicable AsyncAPI binding when available. Specifications **MUST NOT** claim `effectivelyOnce` as a portable transport guarantee; they **MAY** document application-level idempotency or de-duplication instead." - open_questions: [] - id: "17.12" title: "Ordering only when promised" class: R @@ -1347,7 +1199,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1712-ordering-only-when-promised text: "Ordering **MUST** be documented only when consumers are allowed to rely on it. When ordering is promised, the applicable protocol binding or operation description **MUST** identify its scope and key, such as a Kafka partition key or an ordered queue. A specification with no ordering promise does not need a placeholder declaration." - open_questions: [] - id: "17.13" title: "Public delivery-management capabilities" class: R @@ -1356,7 +1207,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1713-public-delivery-management-capabilities text: "Redelivery, dead-letter handling, retention, and replay **MUST** be documented when they are part of the public contract available to a consumer. The specification **MUST** use the applicable protocol binding, channel configuration, or a linked protocol profile where one exists. Capabilities that are deployment-internal or unavailable to consumers **MAY** be omitted." - open_questions: [] - id: "17.14" title: "Implementation values in protocol profiles" class: R @@ -1365,7 +1215,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1714-implementation-values-in-protocol-profiles text: "Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings **SHOULD** live in protocol or implementation profiles unless a value is a stable promise to every conforming consumer. The core cross-BB specification **MUST NOT** imply that a broker-specific setting is portable across protocols." - open_questions: [] - id: "17.15" title: "No universal delivery extensions" class: R @@ -1374,7 +1223,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1715-no-universal-delivery-extensions text: "`govstack-asyncapi-common.yaml` does not define universal delivery, ordering, redelivery, dead-letter, retention, or replay extensions. A specification **MUST** use standard AsyncAPI bindings first and **MUST** state any remaining consumer-visible promise in `description` or a linked protocol profile. The presence of a custom extension alone **MUST NOT** be treated as an interoperable delivery contract." - open_questions: [] - id: "17.16" title: "Async rejection error messages" class: M+R @@ -1383,7 +1231,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1716-async-rejection-error-messages text: "Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from §11.6, not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using §17.8 or an equivalent protocol binding." - open_questions: [] - id: "17.17" title: "Declared request-reply correlation" class: M+R @@ -1392,7 +1239,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1717-declared-request-reply-correlation text: "Request-reply over messaging **MAY** be used where the protocol and use case support it. When used, the AsyncAPI operation **MUST** declare the reply channel or reply address pattern and the correlation mechanism. Fire-and-forget event publication **MUST NOT** pretend to be request-reply." - open_questions: [] - id: "17.18" title: "Correlated completion signals" class: M+R @@ -1401,7 +1247,6 @@ rules: page: part-d/17-asyncapi-channel-rules.md anchor: 1718-correlated-completion-signals text: "Long-running asynchronous work triggered by a message **MUST** expose completion through either an operation-status message based on the §15 Operation resource or an operation-completed domain event. The spec **MUST** document how clients correlate the completion signal to the initiating message." - open_questions: [] - id: "17.19" title: "Protocol bindings where relevant" class: M+R @@ -1409,35 +1254,31 @@ rules: surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md anchor: 1719-protocol-bindings-where-relevant - text: "Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. `[OPEN-17-B]`" - open_questions: ["OPEN-17-B"] + text: "Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide." - id: "17.20" - title: "Examples for every message" + title: "Representative message examples" class: M+R - strengths: ["MUST", "SHOULD"] + strengths: ["SHOULD"] surface: AsyncAPI page: part-d/17-asyncapi-channel-rules.md - anchor: 1720-examples-for-every-message - text: "AsyncAPI documents **MUST** define examples for every message and **SHOULD** include at least one example showing headers plus payload for each common message family: command, event, error, and operation-completion where applicable." - open_questions: [] + anchor: 1720-representative-message-examples + text: "AsyncAPI documents **SHOULD** define representative message examples, including headers plus payload where the interaction is not obvious from the schema. Examples for command, event, error, and operation-completion families are especially useful, but filler examples are not required." - id: "18.1" title: "SemVer versioning" class: M - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST"] surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 181-semver-versioning - text: "`info.version` **MUST** follow SemVer. As a GovStack convention, it is the version of that surface's published API contract and its canonical description together; the major component **MUST** match the major version exposed under §18.2. An API served under `/v1` therefore carries an `info.version` of `1.x.y`. The version of the software that implements the contract is a separate number that this guide does not constrain: an implementation may be at `0.16.3` while the contract it serves is at `1.4.0`, and `info.version` **MUST NOT** be set to the implementation version." - open_questions: [] + text: "`info.version` **MUST** follow SemVer and identify the version of that surface's published contract, not the implementation version. When a surface also exposes a major version in a path, channel, or protocol field, the two **MUST** agree. An implementation may therefore be at `0.16.3` while the contract it serves is at `1.4.0`." - id: "18.2" title: "Major version in path or channel" class: M - strengths: ["MUST NOT", "MUST"] + strengths: ["MUST NOT", "MUST", "SHOULD"] surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 182-major-version-in-path-or-channel - text: "A major version increment **MUST** be reflected in every versioned OpenAPI path key (`/v2/`) and **MUST NOT** be duplicated in the OpenAPI `servers` URL. The unversioned operational endpoints of §5.9 carry no major version and are unaffected by an increment. On AsyncAPI, the major version **MUST** appear in the logical channel ID defined by §17.2; protocol-native channel addresses **MUST NOT** be rewritten solely to carry it." - open_questions: [] + text: "A major version increment **MUST** be visible in the canonical contract through the surface's declared versioning mechanism. New GovStack OpenAPI surfaces **SHOULD** carry it in each versioned path key (`/v2/`) rather than duplicating it in `servers`. New AsyncAPI surfaces **SHOULD** carry it in the logical channel ID defined by §17.2. Protocol-native addresses **MUST NOT** be rewritten solely to carry a guide-specific version shape." - id: "18.3" title: "Backward-compatible minor changes" class: M+R @@ -1446,7 +1287,6 @@ rules: page: part-d/18-compatibility-and-lifecycle.md anchor: 183-backward-compatible-minor-changes text: "Compatibility **MUST** be evaluated as an existing client communicating with a newer server, separately for input and output. A patch increment **MUST** be limited to a backward-compatible correction that does not add public functionality. A minor increment **MAY** add an endpoint; add an optional request field whose absence preserves the old behaviour; broaden values accepted in a request; or add a response field that conforming clients ignore under §9.8. A response enum value **MAY** be added only when the field was declared extensibly under §9.9. For messaging, the same test **MUST** be applied from publisher to existing consumer for sent messages and from existing publisher to consumer for received messages. Additive syntax **MUST NOT** be called compatible when it changes defaults, pagination boundaries, ordering, authorization, delivery guarantees, or other observable semantics." - open_questions: [] - id: "18.4" title: "Breaking changes bump major version" class: M+R @@ -1455,7 +1295,6 @@ rules: page: part-d/18-compatibility-and-lifecycle.md anchor: 184-breaking-changes-bump-major-version text: "A breaking change **MUST** be released as a new major version. Breaking changes include removing or renaming an endpoint, operation, message, field, or enum value; adding a required request field; rejecting a previously accepted request; widening the type, length, format, or closed-enum values a server may emit beyond the old response schema; ceasing to emit a required response field; changing a success status, media type, default, identifier construction, field presence, pagination or sort behaviour, error-code meaning, idempotency behaviour, security requirement or scope, event meaning, delivery guarantee, or ordering guarantee. Moving a contract component in a way that breaks generated-client references **MUST** also be treated as breaking even when the serialized wire shape is unchanged." - open_questions: [] - id: "18.5" title: "Deprecation and Sunset headers" class: M+R @@ -1463,8 +1302,7 @@ rules: surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 185-deprecation-and-sunset-headers - text: "Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The minimum deprecation window and maximum concurrent major versions remain policy for the Lifecycle & Governance companion." - open_questions: [] + text: "Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The GovStack governance process owns the minimum deprecation window and maximum number of concurrent major versions." - id: "18.6" title: "Clients ignore unknown fields" class: informative @@ -1473,16 +1311,14 @@ rules: page: part-d/18-compatibility-and-lifecycle.md anchor: 186-clients-ignore-unknown-fields text: "(informative) Conforming clients are expected to ignore unknown JSON fields and unknown enum values. This is what makes the additive changes in 18.3 non-breaking; the design constraint that enables it is in §9.8." - open_questions: [] - id: "18.7" title: "AsyncAPI deprecation metadata" class: M+R - strengths: ["MUST"] + strengths: ["MUST", "MAY"] surface: Universal page: part-d/18-compatibility-and-lifecycle.md anchor: 187-asyncapi-deprecation-metadata - text: "AsyncAPI channels, operations, and messages **MUST** declare deprecation in their `description` and, where supported by tooling, with a specification extension `x-govstack-deprecated` containing `since`, `sunset`, `replacement`, and `reason`. `[OPEN-18-A]`" - open_questions: ["OPEN-18-A"] + text: "AsyncAPI channels, operations, and messages **MUST** declare consumer-visible deprecation and replacement guidance in their `description`. They **MAY** also use the experimental `x-govstack-deprecated` extension with `since`, `sunset`, `replacement`, and `reason` where supported by tooling." - id: "19.1" title: "Honour the request language" class: R @@ -1491,7 +1327,6 @@ rules: page: part-e/19-localisation.md anchor: 191-honour-the-request-language text: "Localisable content (error `title`/`detail`, enum display labels, free-text status messages) **MUST** respect the request language header appropriate to the surface: `Accept-Language` for HTTP, `acceptLanguage` for AsyncAPI messages." - open_questions: [] - id: "19.2" title: "Never translate stable content" class: R @@ -1500,16 +1335,14 @@ rules: page: part-e/19-localisation.md anchor: 192-never-translate-stable-content text: "Stable content (HTTP Problem `type`, transport-neutral asynchronous error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated." - open_questions: [] - id: "19.3" - title: "English as default language" + title: "Declared default language" class: R - strengths: ["MUST"] + strengths: ["MUST", "SHOULD"] surface: Universal page: part-e/19-localisation.md - anchor: 193-english-as-default-language - text: "Default language **MUST** be English. `[OPEN-19-A]`" - open_questions: ["OPEN-19-A"] + anchor: 193-declared-default-language + text: "The specification **MUST** declare its default response language. English **SHOULD** be used as the cross-border fallback when deployment policy or law does not select another default." - id: "19.4" title: "Declare the response language" class: M+R @@ -1518,7 +1351,6 @@ rules: page: part-e/19-localisation.md anchor: 194-declare-the-response-language text: "Responses or messages with localised content **MUST** include the response language header appropriate to the surface: `Content-Language` for HTTP, `contentLanguage` for AsyncAPI messages." - open_questions: [] - id: "20.1" title: "Every file passes validation" class: M @@ -1527,7 +1359,6 @@ rules: page: part-e/20-conformance-and-validation.md anchor: 201-every-file-passes-validation text: "Every canonical OpenAPI entrypoint **MUST** pass `openapi-spec-validator` for its declared qualified 3.1 patch, with local references resolved. Every canonical AsyncAPI entrypoint **MUST** pass an AsyncAPI 3.0 parser/validator such as `@asyncapi/parser`. A referenced schema fragment **MUST** validate against its own declared schema dialect and **MUST NOT** be rejected merely because it is not a standalone OpenAPI or AsyncAPI document." - open_questions: [] - id: "20.2" title: "Passes the GovStack Spectral ruleset" class: M @@ -1536,7 +1367,6 @@ rules: page: part-e/20-conformance-and-validation.md anchor: 202-passes-the-govstack-spectral-ruleset text: "Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under §20.3 for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules (§1.9). Ruleset `0.1.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility." - open_questions: [] - id: "20.3" title: "Declared guide conformance version" class: M @@ -1545,4 +1375,3 @@ rules: page: part-e/20-conformance-and-validation.md anchor: 203-declared-guide-conformance-version text: "Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.1.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under §1.6 **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI." - open_questions: [] diff --git a/api-design-guide/tools/build_rules_index.py b/api-design-guide/tools/build_rules_index.py index 1a50af5..ae630d9 100644 --- a/api-design-guide/tools/build_rules_index.py +++ b/api-design-guide/tools/build_rules_index.py @@ -5,7 +5,7 @@ `part-*/` and regenerates two artifacts at the book root: * `rules.yaml` - one structured entry per rule (id, class, strengths, - surface, page, anchor, rule text, open questions). + surface, page, anchor, and rule text). * `all-rules.md` - a human-facing "rules at a glance" page with one GFM table per section. @@ -46,9 +46,6 @@ LOOKS_LIKE_RULE_RE = re.compile(r"^## \d+\.\d+(\s|$)") # Enforcement-class badge at the very start of a rule body. BADGE_RE = re.compile(r"^\*\*\[(?P<cls>M\+R|M|R)\]\*\* ") -# Open-question identifiers. -OPEN_QUESTION_RE = re.compile(r"OPEN-\d+-[A-Z]") - # RFC 2119 strength keywords, in output order. Longer forms are listed before # their prefixes so "MUST NOT" is considered before "MUST". STRENGTH_TOKENS = [ @@ -88,7 +85,7 @@ def unwrap_links(text): """Replace every markdown link `[text](target)` with its link text. The scanner tracks bracket and paren depth so link text that itself contains - brackets (for example ``[`[OPEN-4-B]`](...)``) is handled correctly. + nested brackets is handled correctly. """ result = [] i = 0 @@ -191,18 +188,8 @@ def strongest_strength(strengths): return "—" # em dash placeholder rendered as a single character -def extract_open_questions(text): - """OPEN-N-X ids referenced in the rule text, deduplicated, first-seen order.""" - seen = [] - for match in OPEN_QUESTION_RE.finditer(text): - oid = match.group(0) - if oid not in seen: - seen.append(oid) - return seen - - def parse_body(body_lines): - """Return (class, strengths, text, open_questions) for one rule body.""" + """Return (class, strengths, text) for one rule body.""" kept = strip_example_blocks(body_lines) paragraphs = [unwrap_links(p) for p in group_paragraphs(kept)] rule_class = "informative" @@ -212,7 +199,7 @@ def parse_body(body_lines): rule_class = match.group("cls") paragraphs[0] = paragraphs[0][match.end() :] text = "\n".join(paragraphs) - return rule_class, extract_strengths(text), text, extract_open_questions(text) + return rule_class, extract_strengths(text), text def parse_section_number(filename): @@ -316,7 +303,7 @@ def collect_page(book_root, page, seen_ids): next_heads = [h for h in all_heads if h > idx] end = next_heads[0] if next_heads else len(lines) body_lines = lines[idx + 1 : end] - rule_class, strengths, text, open_qs = parse_body(body_lines) + rule_class, strengths, text = parse_body(body_lines) rules.append( { @@ -328,7 +315,6 @@ def collect_page(book_root, page, seen_ids): "page": page_rel, "anchor": anchor_id, "text": text, - "open_questions": open_qs, } ) return {"page": page_rel, "h1_title": h1_title, "rules": rules} @@ -375,7 +361,6 @@ def render_rules_yaml(rules): lines.append(f" page: {rule['page']}") lines.append(f" anchor: {rule['anchor']}") lines.append(f" text: {js(rule['text'])}") - lines.append(f" open_questions: {inline_list(rule['open_questions'])}") return "\n".join(lines) + "\n" diff --git a/api-design-guide/tools/check_links.py b/api-design-guide/tools/check_links.py index 748b283..e67cf46 100644 --- a/api-design-guide/tools/check_links.py +++ b/api-design-guide/tools/check_links.py @@ -13,9 +13,7 @@ 4. SUMMARY.md lists every page exactly once (README.md first) and nothing that is missing. 5. No page is an orphan (unreachable from SUMMARY.md). - 6. Every OPEN-N-X id referenced in the book is defined exactly once as a row in - appendix/b-open-questions.md. - 7. Every page opens with `---`, carries a double-quoted `description:` line, and + 6. Every page opens with `---`, carries a double-quoted `description:` line, and closes its frontmatter. Exit status is 0 with a one-line summary when everything passes, or 1 with every @@ -31,16 +29,11 @@ # Explicit anchor tag as emitted on heading lines. ANCHOR_RE = re.compile(r'<a href="#(?P<href>[^"]*)" id="(?P<id>[^"]*)"></a>') -OPEN_QUESTION_RE = re.compile(r"OPEN-\d+-[A-Z]") -OPEN_QUESTION_EXACT_RE = re.compile(r"^OPEN-\d+-[A-Z]$") DESCRIPTION_RE = re.compile(r'^description:\s*".*"\s*$') SKIP_LINK_PREFIXES = ("http://", "https://", "mailto:") SLUG_KEEP = set("abcdefghijklmnopqrstuvwxyz0123456789-") -APPENDIX_OPEN_QUESTIONS = "appendix/b-open-questions.md" - - def slugify(heading_text): """GitHub-style slug shared with build_rules_index.py; the two MUST be identical. @@ -62,8 +55,8 @@ def slugify(heading_text): def iter_markdown_links(line): """Yield (target, column) for every `[text](target)` link on a line. - A bracket/paren depth scanner is used so link text that contains brackets - (for example ``[`[OPEN-4-B]`](...)``) is parsed correctly. + A bracket/paren depth scanner is used so nested link text is parsed + correctly. """ i = 0 n = len(line) @@ -116,7 +109,6 @@ def __init__(self, book_root): # Per-file data keyed by absolute Path. self.anchors = {} # path -> set of anchor ids self.links = [] # (path, lineno, target) - self.open_mentions = [] # (path, lineno, open_id) self.link_count = 0 self.anchor_count = 0 @@ -138,7 +130,7 @@ def scan_files(self): if not is_summary: self.check_frontmatter(path, lines) self.collect_anchors(path, lines) - self.collect_links_and_mentions(path, lines) + self.collect_links(path, lines) def check_frontmatter(self, path, lines): if not lines or lines[0].strip() != "---": @@ -189,12 +181,10 @@ def collect_anchors(self, path, lines): ids.add(anchor_id) self.anchors[path] = ids - def collect_links_and_mentions(self, path, lines): + def collect_links(self, path, lines): for lineno, line in enumerate(lines, start=1): for target, _col in iter_markdown_links(line): self.links.append((path, lineno, target)) - for match in OPEN_QUESTION_RE.finditer(line): - self.open_mentions.append((path, lineno, match.group(0))) # -- pass 2: validate links and fragments --------------------------------- @@ -311,47 +301,6 @@ def check_summary(self): f"lists a file outside the book's page set: {self.rel(resolved)}", ) - # -- OPEN-question integrity --------------------------------------------- - - def check_open_questions(self): - appendix = self.book_root / APPENDIX_OPEN_QUESTIONS - if not appendix.exists(): - self.failures.append( - f"{APPENDIX_OPEN_QUESTIONS}: file is missing (check 6 cannot run)" - ) - return - defined = {} # open_id -> first defining line number - for lineno, line in enumerate(appendix.read_text(encoding="utf-8").split("\n"), 1): - stripped = line.strip() - if not stripped.startswith("|"): - continue - cells = [c.strip() for c in stripped.strip("|").split("|")] - if not cells: - continue - first = cells[0] - match = OPEN_QUESTION_RE.search(first) - if not match: - continue - open_id = match.group(0) - if open_id in defined: - self.fail( - appendix, - lineno, - f"duplicate OPEN id definition '{open_id}' " - f"(first at line {defined[open_id]})", - ) - else: - defined[open_id] = lineno - - for path, lineno, open_id in self.open_mentions: - if open_id not in defined: - self.fail( - path, - lineno, - f"references undefined open question '{open_id}' " - f"(no row in {APPENDIX_OPEN_QUESTIONS})", - ) - # -- driver --------------------------------------------------------------- def run(self): @@ -361,8 +310,6 @@ def run(self): self.scan_files() self.validate_links() self.check_summary() - self.check_open_questions() - if self.failures: print( f"check_links: {len(self.failures)} failure(s):", diff --git a/api/coverage.yaml b/api/coverage.yaml index 7b87ba3..b297d94 100644 --- a/api/coverage.yaml +++ b/api/coverage.yaml @@ -1,40 +1,17 @@ version: 1 requirements: - - id: BB-TPL-FR-001 + - id: "govstack-bb-template-fr#req-1" disposition: operation operations: - listRecords - - id: BB-TPL-FR-002 + - id: "govstack-bb-template-fr#req-2" disposition: operation operations: - createRecord - getRecord - - id: BB-TPL-FR-003 + - id: "govstack-bb-template-fr#req-3" disposition: operation operations: - requestRecordExport - getOperation - cancelOperation - - id: BB-TPL-XR-001 - disposition: operation - operations: - - getHealth - - id: BB-TPL-XR-002 - disposition: operation - operations: - - listRecords - - createRecord - - getRecord - - requestRecordExport - - getOperation - - cancelOperation - - getHealth - - id: BB-TPL-XR-003 - disposition: operation - operations: - - listRecords - - createRecord - - getRecord - - requestRecordExport - - getOperation - - cancelOperation diff --git a/spec/1-version-history.md b/spec/1-version-history.md index 197301d..b7f223f 100644 --- a/spec/1-version-history.md +++ b/spec/1-version-history.md @@ -1,8 +1,7 @@ # 1 Version History -Record published specification versions only. Describe material requirement and -interface changes and link the approval record or pull request. +Record published specification versions only. The template itself has no +published specification version. | Version | Date | Editors | Change and approval reference | |---|---|---|---| -| 0.1.0 | 2026-07-10 | GovStack template maintainers | Introduced the generic, traceable reference specification. | diff --git a/spec/5-cross-cutting-requirements.md b/spec/5-cross-cutting-requirements.md index aaaf0da..02463bd 100644 --- a/spec/5-cross-cutting-requirements.md +++ b/spec/5-cross-cutting-requirements.md @@ -1,18 +1,23 @@ # 5 Cross-Cutting Requirements -List requirements that apply across functional areas. Use -`{bb-code}-XR-{number}` IDs, RFC 2119 language, and a verification method. +Every GovStack Building Block inherits `govstack-cfr`. Do not repeat inherited +requirements here. Define a Building Block cross-functional requirement only +when this specification extends or replaces a parent requirement, and state the +parent relationship explicitly. ## 5.1 Requirements -- **BB-TPL-XR-001** **REQUIRED**: The BB MUST expose the unauthenticated `/health` contract defined in the canonical OpenAPI document without returning internal system detail. -- **BB-TPL-XR-002** **REQUIRED**: The canonical API documents MUST pass their base schema validators and the GovStack API Design Guide ruleset targeted by the documents. -- **BB-TPL-XR-003** **REQUIRED**: Non-operational operations MUST declare OAuth 2.0 security and W3C Trace Context as defined by the canonical API contract. +The template defines no additional cross-functional requirements. A real BB +adds one in the same format as Section 6, using a canonical +`govstack-bb-{name}-cfr#req-{number}` identifier and an `extends` or `replaces` +relationship to the applicable `govstack-cfr-*#req-{number}` parent. -## 5.2 Exceptions to architectural cross-cutting requirements +## 5.2 Parent requirement relationships -State each exception, its rationale, approver, and expiry or review date. The -template declares no exceptions. +An inherited IMMUTABLE requirement cannot be changed. An EXTENSIBLE requirement +may be tightened, and a REPLACEABLE requirement may be replaced while +preserving its external contract. Use INAPPLICABLE only where the GovStack +Requirements Model permits it and include the rationale in the requirement. ## 5.3 Standards diff --git a/spec/6-functional-requirements.md b/spec/6-functional-requirements.md index 068644e..fc559b3 100644 --- a/spec/6-functional-requirements.md +++ b/spec/6-functional-requirements.md @@ -1,21 +1,44 @@ # 6 Functional Requirements Functional requirements state observable capabilities and remain independent of -a specific product. Use stable IDs of the form `{bb-code}-FR-{number}`, identify -the related KDF, state REQUIRED, RECOMMENDED, or OPTIONAL, and define acceptance -evidence. Never silently delete or reuse a published ID. +a specific product. Follow the GovStack Requirements Model: give every +requirement a canonical `govstack-bb-{name}-fr#req-{number}` identifier and +exactly one level, mutability, and observability classifier. Never silently +delete or reuse a published requirement number. The reference requirements below are implemented by `api/openapi.yaml` and mapped in `api/coverage.yaml`. Replace them for a real BB. ## 6.1 Reference record lifecycle -- **BB-TPL-FR-001** **REQUIRED**: To support `BB-TPL-KDF-001`, an authorised caller MUST be able to retrieve a bounded, cursor-paginated collection of reference records. -- **BB-TPL-FR-002** **REQUIRED**: To support `BB-TPL-KDF-001`, an authorised caller MUST be able to create a record synchronously and retrieve it by its opaque identifier; successful creation MUST identify the created resource. +### #1 Retrieve reference records (REQUIRED EXTENSIBLE OBSERVABLE) + +`govstack-bb-template-fr#req-1` + +KF: Manage reference records + +An authorised caller can retrieve a bounded, cursor-paginated collection of +reference records. + +### #2 Create and retrieve a reference record (REQUIRED EXTENSIBLE OBSERVABLE) + +`govstack-bb-template-fr#req-2` + +KF: Manage reference records + +An authorised caller can create a record synchronously and retrieve it by its +opaque identifier. Successful creation identifies the created resource. ## 6.2 Long-running work -- **BB-TPL-FR-003** **REQUIRED**: To support `BB-TPL-KDF-002`, an authorised service MUST be able to request an asynchronous record export, poll the returned Operation, and request cancellation. +### #3 Request and observe a record export (REQUIRED EXTENSIBLE OBSERVABLE) + +`govstack-bb-template-fr#req-3` + +KF: Run long-running work + +An authorised service can request an asynchronous record export, poll the +returned Operation, and request cancellation. ## 6.3 Components diff --git a/spec/8-service-apis.md b/spec/8-service-apis.md index 1505dbb..31be3d0 100644 --- a/spec/8-service-apis.md +++ b/spec/8-service-apis.md @@ -6,22 +6,19 @@ REST contract is [`api/openapi.yaml`](../api/openapi.yaml). ## 8.1 Requirement traceability -Every normative interface requirement in Sections 5 and 6 has exactly one -disposition in [`api/coverage.yaml`](../api/coverage.yaml). The coverage file is -the single authoritative requirement-to-interface mapping. A BB records a -planned, external-standard, or non-applicable interface explicitly rather than -silently omitting it. +Every active REQUIRED or RECOMMENDED interface requirement in Sections 5 and 6 +has exactly one disposition in [`api/coverage.yaml`](../api/coverage.yaml). +DRAFT, DEPRECATED, and INAPPLICABLE requirements are not active coverage +obligations. The coverage file is the authoritative requirement-to-interface +mapping. The template reference maps: | Requirement | Canonical operations | |---|---| -| `BB-TPL-FR-001` | `listRecords` | -| `BB-TPL-FR-002` | `createRecord`, `getRecord` | -| `BB-TPL-FR-003` | `requestRecordExport`, `getOperation`, `cancelOperation` | -| `BB-TPL-XR-001` | `getHealth` | -| `BB-TPL-XR-002` | All reference operations through schema and guide validation | -| `BB-TPL-XR-003` | All non-health operations | +| `govstack-bb-template-fr#req-1` | `listRecords` | +| `govstack-bb-template-fr#req-2` | `createRecord`, `getRecord` | +| `govstack-bb-template-fr#req-3` | `requestRecordExport`, `getOperation`, `cancelOperation` | ## 8.2 Contract ownership diff --git a/spec/9-workflows.md b/spec/9-workflows.md index bf5bbbe..9b10db1 100644 --- a/spec/9-workflows.md +++ b/spec/9-workflows.md @@ -6,7 +6,7 @@ observable. Name the requirement IDs exercised by each workflow. ## 9.1 Create and retrieve a record -Requirement: `BB-TPL-FR-002`. +Requirement: `govstack-bb-template-fr#req-2`. ```mermaid sequenceDiagram @@ -20,7 +20,7 @@ sequenceDiagram ## 9.2 Request long-running work -Requirement: `BB-TPL-FR-003`. +Requirement: `govstack-bb-template-fr#req-3`. ```mermaid sequenceDiagram diff --git a/spec/README.md b/spec/README.md index 0b1cb4b..ff45e56 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,12 +1,17 @@ # Building Block Specification Template +**Specification:** `govstack-bb-template`<br> +**Version:** `0.1.0`<br> +**Extends:** `govstack-cfr` + Use this book to define one GovStack Building Block. Replace the reference examples with domain-specific content before publication and remove authoring instructions that no longer apply. -The normative specification consists of the requirements in Sections 5 and 6, -the interface data in Sections 7 and 8, and the workflows in Section 9. Every -normative requirement has a stable ID and exactly one disposition in +The normative specification consists of the active requirements in Sections 5 +and 6, the interface data in Sections 7 and 8, and the workflows in Section 9. +Every active requirement has a canonical GovStack ID, the three classifiers +defined by the GovStack Requirements Model, and exactly one disposition in [`api/coverage.yaml`](../api/coverage.yaml). Record the editors and their organisational affiliations here when the BB From 8eb6b3e2f45fbe1ceb8a13b8b228b41a61cf0acc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Mon, 10 Aug 2026 15:15:57 +0700 Subject: [PATCH 19/19] Narrow API guide template integration Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- README.md | 76 +- api-design-guide/all-rules.md | 1 - api-design-guide/linter/README.md | 2 +- api-design-guide/tools/build_rules_index.py | 2 +- api/coverage.yaml | 17 - api/index.yaml | 5 +- api/openapi.yaml | 721 ------------------ .../Screen Shot 2023-04-07 at 11.59.49 AM.png | Bin 0 -> 143890 bytes spec/1-version-history.md | 10 +- spec/10-other-resources.md | 24 +- spec/2-description.md | 21 +- spec/3-terminology.md | 14 +- spec/4-key-digital-functionalities.md | 28 +- spec/5-cross-cutting-requirements.md | 55 +- spec/6-functional-requirements.md | 53 +- spec/7-data-structures.md | 55 +- spec/8-service-apis.md | 45 +- spec/9-workflows.md | 64 +- spec/README.md | 20 +- spec/SUMMARY.md | 2 +- test/plan.md | 52 +- 21 files changed, 276 insertions(+), 991 deletions(-) delete mode 100644 api/coverage.yaml delete mode 100644 api/openapi.yaml create mode 100644 spec/.gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png diff --git a/README.md b/README.md index 2d7e4c7..6bf5ca5 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,51 @@ # GovStack Building Block Template -This repository is the starting point for a GovStack Building Block (BB) -specification. Replace the generic reference domain with the BB's real -requirements while preserving its traceability and conformance structure. - -## Start a BB specification - -1. Give every normative requirement a stable identifier based on the BB code, - such as `REGISTRY-FR-001`. Never reuse an identifier for a different - requirement. -2. Replace the reference contract at `api/openapi.yaml`. Keep `api/index.yaml` - as the canonical API registry. Keep the shared files under `api/common/` - pinned to their recorded upstream version and revision. -3. Map every normative interface requirement in `api/coverage.yaml`. -4. Run the checks in `test/plan.md` before requesting specification review. - -The reference API is intentionally small. It demonstrates synchronous creation, -pagination, long-running operations, standard errors, trace context, and OAuth -2.0 without prescribing a domain model for real BBs. - -## Repository structure - -```text -spec/ GitBook specification and stable requirements -api/index.yaml registry of canonical API documents -api/openapi.yaml canonical OpenAPI 3.1 reference contract -api/coverage.yaml authoritative requirement-to-interface mapping -api/common/ pinned, vendored cross-BB contract components -api-design-guide/ cross-BB API design rules and lint tooling -test/plan.md specification and implementation conformance plan -examples/ deployable implementation examples +This template is intended to be used by the various GovStack building block +repos. Each building block repo will have at least 4 main sections, outlined in +the directory structure below. + +## Gitbook and the published "Building Block Specifications" document + +Note that pushes to the `main` branch will automatically trigger a Gitbook build +and deployment from the `/spec` directory. + +## Repo Structure + +```sh +README.md +/spec # the markdown files which are used to build the specification in GitBook +/api # the API inventory, contracts, coverage mapping, and common components +/api-design-guide # cross-BB API design guidance and validation tooling +/test # the test plan and tests + plan.md +/examples # examples for deploying, configuring, and testing applications which implement the behaviors specified by this building block + /application-a + README.md # instructions for deployment/testing + docker-compose.yaml # example deployment file + db + web + adaptor + security-server + Caddyfile # example config for "adaptor" + Dockerfile # dockerfile to build "adaptor" + /application-b + /application-c ``` -Pushes to `main` publish the GitBook content under `spec/`. The API contract in -`api/` remains the machine-readable source of truth for operations and schemas. +## API contracts + +The template repository itself does not define a Building Block API surface, so +[`api/index.yaml`](api/index.yaml) declares `noApi`. When creating a Building +Block specification, replace that declaration with an inventory of every +OpenAPI, AsyncAPI, or normative protocol-standard surface. A Building Block +that genuinely has no API keeps an explicit `noApi` declaration. + +When one or more API surfaces are declared, map active interface requirements +to their operations, messages, or non-API verification in `api/coverage.yaml`. +Follow the +[GovStack Cross-BB API Design Guide](api-design-guide/README.md) and use its +[validation instructions](api-design-guide/guides/validating-your-spec.md) +before requesting review. Reusable schemas are available under `api/common/`. ## ORB setup diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md index 52fc66d..e7ab288 100644 --- a/api-design-guide/all-rules.md +++ b/api-design-guide/all-rules.md @@ -271,4 +271,3 @@ This page is generated from the section pages by `tools/build_rules_index.py`; d | [20.1](part-e/20-conformance-and-validation.md#201-every-file-passes-validation) | M | MUST | Universal | Every file passes validation | | [20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) | M | MUST | Universal | Passes the GovStack Spectral ruleset | | [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) | M | MUST | Universal | Declared guide conformance version | - diff --git a/api-design-guide/linter/README.md b/api-design-guide/linter/README.md index b9926e6..4307496 100644 --- a/api-design-guide/linter/README.md +++ b/api-design-guide/linter/README.md @@ -10,7 +10,7 @@ version **0.1.0-draft** (`guide_version` in [coverage.yaml](coverage.yaml)). ```bash cd api-design-guide/linter npm ci -node cli.mjs --repo-root ../.. # lints api/openapi.yaml + api/asyncapi.yaml +node cli.mjs --repo-root ../.. # follows the repository's api/index.yaml declaration ``` For multiple API surfaces, declare every document explicitly in diff --git a/api-design-guide/tools/build_rules_index.py b/api-design-guide/tools/build_rules_index.py index ae630d9..75cc75a 100644 --- a/api-design-guide/tools/build_rules_index.py +++ b/api-design-guide/tools/build_rules_index.py @@ -398,7 +398,7 @@ def render_all_rules_md(page_infos): f"| {rule_cell} | {class_cell} | {strength_cell} | {surface_cell} | {title_cell} |" ) lines.append("") - return "\n".join(lines) + "\n" + return "\n".join(lines).rstrip("\n") + "\n" def write_lf(path, content): diff --git a/api/coverage.yaml b/api/coverage.yaml deleted file mode 100644 index b297d94..0000000 --- a/api/coverage.yaml +++ /dev/null @@ -1,17 +0,0 @@ -version: 1 -requirements: - - id: "govstack-bb-template-fr#req-1" - disposition: operation - operations: - - listRecords - - id: "govstack-bb-template-fr#req-2" - disposition: operation - operations: - - createRecord - - getRecord - - id: "govstack-bb-template-fr#req-3" - disposition: operation - operations: - - requestRecordExport - - getOperation - - cancelOperation diff --git a/api/index.yaml b/api/index.yaml index 152ec4d..7c2ea5d 100644 --- a/api/index.yaml +++ b/api/index.yaml @@ -1,4 +1,3 @@ version: 1 -apis: - - type: openapi - path: api/openapi.yaml +noApi: true +reason: This repository is a template and does not define a Building Block API surface. diff --git a/api/openapi.yaml b/api/openapi.yaml deleted file mode 100644 index 2844a9b..0000000 --- a/api/openapi.yaml +++ /dev/null @@ -1,721 +0,0 @@ -openapi: 3.1.0 -info: - title: GovStack Building Block Template Reference API - version: 1.0.0 - description: >- - Small reference contract demonstrating the cross-BB API conventions. A real - Building Block replaces the Record domain while preserving conformance and - requirement traceability. - contact: - name: GovStack API Working Group - url: https://www.govstack.global/ - x-govstack-api-guide: - version: 0.1.0-draft - rulesetVersion: 0.1.0-draft - x-govstack-common-components: - openapi: 0.1.0-draft - x-govstack-bb-code: template -servers: - - url: https://{gatewayHost}/{bbCode} - description: Non-production parameterised gateway pattern for the reference API. - variables: - gatewayHost: - default: api.example.org - description: Deployment gateway host; example.org is a reserved non-production default. - bbCode: - default: template - description: Registered code of the deployed Building Block. -security: - - BuildingBlockOAuth: - - bb:template:records:read -tags: - - name: Records - description: Reference resource operations used to demonstrate synchronous API conventions. - - name: Exports - description: Reference long-running export request. - - name: Operations - description: Polling and cancellation of long-running work. - - name: Health - description: Operational liveness contract. -paths: - /v1/records: - get: - operationId: listRecords - summary: List reference records - description: Returns a bounded cursor-paginated collection of reference records. - tags: - - Records - security: - - CitizenOAuth: - - bb:template:records:read - - BuildingBlockOAuth: - - bb:template:records:read - parameters: - - $ref: '#/components/parameters/Traceparent' - - $ref: '#/components/parameters/PageSize' - - $ref: '#/components/parameters/Cursor' - responses: - '200': - description: A page of reference records. - headers: - ETag: - $ref: '#/components/headers/ETag' - content: - application/json: - schema: - $ref: '#/components/schemas/RecordCollection' - example: - items: - - id: 7d9ad9df-bfc3-451a-94e0-24afae30750f - name: Reference record - status: ACTIVE - createdAt: '2026-07-10T10:00:00Z' - updatedAt: '2026-07-10T10:00:00Z' - pageInfo: - nextCursor: pgn_7JpQ9m2W4xK8fR3cT6vN1 - '304': - $ref: '#/components/responses/NotModified' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '500': - $ref: '#/components/responses/InternalError' - post: - operationId: createRecord - summary: Create a reference record - description: Creates a reference record synchronously and returns its representation. - tags: - - Records - security: - - CitizenOAuth: - - bb:template:records:write - - BuildingBlockOAuth: - - bb:template:records:write - parameters: - - $ref: '#/components/parameters/Traceparent' - - $ref: '#/components/parameters/IdempotencyKey' - requestBody: - required: true - description: Values supplied by the caller for the new reference record. - content: - application/json: - schema: - $ref: '#/components/schemas/CreateRecordRequest' - example: - name: Reference record - status: ACTIVE - responses: - '201': - description: Reference record created. - headers: - Location: - $ref: '#/components/headers/Location' - ETag: - $ref: '#/components/headers/ETag' - content: - application/json: - schema: - $ref: '#/components/schemas/Record' - example: - id: 7d9ad9df-bfc3-451a-94e0-24afae30750f - name: Reference record - status: ACTIVE - createdAt: '2026-07-10T10:00:00Z' - updatedAt: '2026-07-10T10:00:00Z' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/Conflict' - '422': - $ref: '#/components/responses/UnprocessableContent' - '500': - $ref: '#/components/responses/InternalError' - /v1/records/{recordId}: - get: - operationId: getRecord - summary: Get a reference record - description: Retrieves one reference record by its opaque server-generated identifier. - tags: - - Records - security: - - CitizenOAuth: - - bb:template:records:read - - BuildingBlockOAuth: - - bb:template:records:read - parameters: - - $ref: '#/components/parameters/RecordId' - - $ref: '#/components/parameters/Traceparent' - responses: - '200': - description: The requested reference record. - headers: - ETag: - $ref: '#/components/headers/ETag' - content: - application/json: - schema: - $ref: '#/components/schemas/Record' - example: - id: 7d9ad9df-bfc3-451a-94e0-24afae30750f - name: Reference record - status: ACTIVE - createdAt: '2026-07-10T10:00:00Z' - updatedAt: '2026-07-10T10:00:00Z' - '304': - $ref: '#/components/responses/NotModified' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - /v1/exports: - post: - operationId: requestRecordExport - summary: Request a record export - description: Accepts a long-running record export and returns a pollable Operation. - tags: - - Exports - security: - - BuildingBlockOAuth: - - bb:template:exports:write - parameters: - - $ref: '#/components/parameters/Traceparent' - - $ref: '#/components/parameters/IdempotencyKey' - requestBody: - required: true - description: Criteria and output format for the record export. - content: - application/json: - schema: - $ref: '#/components/schemas/ExportRequest' - example: - format: JSON_LINES - status: ACTIVE - responses: - '202': - description: Export accepted for asynchronous processing. - headers: - Location: - $ref: '#/components/headers/Location' - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/json: - schema: - $ref: '#/components/schemas/Operation' - example: - id: op_01J2P8WTM4YH7K6Q3N5R9C0XBF - status: PENDING - createdAt: '2026-07-10T10:05:00Z' - updatedAt: '2026-07-10T10:05:00Z' - progress: 0 - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/Conflict' - '422': - $ref: '#/components/responses/UnprocessableContent' - '500': - $ref: '#/components/responses/InternalError' - /v1/operations/{operationId}: - get: - operationId: getOperation - summary: Get operation status - description: Retrieves current state, result metadata, or error for long-running work. - tags: - - Operations - security: - - BuildingBlockOAuth: - - bb:template:operations:read - parameters: - - $ref: '#/components/parameters/OperationId' - - $ref: '#/components/parameters/Traceparent' - responses: - '200': - description: Current Operation state. - headers: - ETag: - $ref: '#/components/headers/ETag' - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/json: - schema: - $ref: '#/components/schemas/Operation' - example: - id: op_01J2P8WTM4YH7K6Q3N5R9C0XBF - status: RUNNING - createdAt: '2026-07-10T10:05:00Z' - updatedAt: '2026-07-10T10:05:10Z' - progress: 40 - '304': - $ref: '#/components/responses/NotModified' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - /v1/operations/{operationId}/cancel: - post: - operationId: cancelOperation - summary: Cancel an operation - description: Requests cancellation of long-running work that has not reached a terminal state. - tags: - - Operations - security: - - BuildingBlockOAuth: - - bb:template:operations:write - parameters: - - $ref: '#/components/parameters/OperationId' - - $ref: '#/components/parameters/Traceparent' - - $ref: '#/components/parameters/IdempotencyKey' - responses: - '400': - $ref: '#/components/responses/BadRequest' - '200': - description: Operation after the cancellation request was applied. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/json: - schema: - $ref: '#/components/schemas/Operation' - example: - id: op_01J2P8WTM4YH7K6Q3N5R9C0XBF - status: CANCELLED - createdAt: '2026-07-10T10:05:00Z' - updatedAt: '2026-07-10T10:06:00Z' - progress: 40 - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalError' - /health: - get: - operationId: getHealth - summary: Get liveness status - description: Reports operational liveness without authentication or internal system detail. - tags: - - Health - security: [] - parameters: - - $ref: '#/components/parameters/Traceparent' - responses: - '200': - description: The Building Block is live. - content: - application/json: - schema: - $ref: '#/components/schemas/Health' - example: - status: PASS - description: Reference API is live. - '503': - $ref: '#/components/responses/ServiceUnavailable' - '500': - $ref: '#/components/responses/InternalError' -components: - securitySchemes: - CitizenOAuth: - type: oauth2 - description: >- - OAuth 2.0 authorization-code access for authenticated end-user clients. - Clients use PKCE with S256. The API accepts access tokens and never - treats an OpenID Connect ID token as an API access token. - flows: - authorizationCode: - authorizationUrl: https://identity.example.org/oauth2/authorize - tokenUrl: https://identity.example.org/oauth2/token - scopes: - bb:template:records:read: Read reference records. - bb:template:records:write: Create reference records. - BuildingBlockOAuth: - type: oauth2 - description: OAuth 2.0 client-credentials access for service-to-service calls. - flows: - clientCredentials: - tokenUrl: https://identity.example.org/oauth2/token - scopes: - bb:template:records:read: Read reference records. - bb:template:records:write: Create reference records. - bb:template:exports:write: Request reference record exports. - bb:template:operations:read: Read long-running Operation state. - bb:template:operations:write: Request cancellation of long-running Operations. - parameters: - Traceparent: - name: traceparent - in: header - required: false - description: W3C Trace Context parent identifier propagated across service boundaries. - schema: - type: string - description: W3C Trace Context traceparent value. - pattern: '^00-(?!0{32})[\da-f]{32}-(?!0{16})[\da-f]{16}-0[01]$' - IdempotencyKey: - name: Idempotency-Key - in: header - required: true - description: Client-generated opaque key that makes this non-idempotent request safe to retry. - schema: - type: string - minLength: 1 - maxLength: 255 - description: Opaque key retained for this API's documented replay window. - PageSize: - name: pageSize - in: query - required: false - description: Maximum number of records returned in one page by this API. - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - Cursor: - name: cursor - in: query - required: false - description: Opaque cursor returned as pageInfo.nextCursor by the previous page. - schema: - type: string - minLength: 1 - OperationId: - name: operationId - in: path - required: true - description: Opaque server-generated identifier of a long-running Operation resource. - schema: - type: string - minLength: 1 - RecordId: - name: recordId - in: path - required: true - description: Opaque server-generated identifier of a reference record. - schema: - type: string - format: uuid - description: UUID assigned to a reference record by the server. - headers: - Location: - description: URI reference of the created resource or accepted Operation. - schema: - type: string - format: uri-reference - ETag: - description: Entity tag representing the version of the returned resource. - schema: - type: string - minLength: 1 - CacheControl: - description: Cache directive preventing storage of transient errors or Operation state. - schema: - type: string - example: no-store - WwwAuthenticate: - description: OAuth 2.0 Bearer authentication challenge. - schema: - type: string - example: 'Bearer realm="govstack", error="invalid_token"' - responses: - NotModified: - description: The resource has not changed since the supplied entity tag. - headers: - ETag: - $ref: '#/components/headers/ETag' - BadRequest: - description: The request is malformed or cannot be interpreted. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - example: - type: https://govstack.global/problems/template/bad-request - title: Bad request - status: 400 - detail: The request could not be interpreted. - instance: /v1/records - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - Unauthorized: - description: Authentication is missing or invalid. - headers: - WWW-Authenticate: - $ref: '#/components/headers/WwwAuthenticate' - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - example: - type: https://govstack.global/problems/template/unauthorized - title: Authentication required - status: 401 - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - Forbidden: - description: The authenticated caller is not authorised for the operation. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - example: - type: https://govstack.global/problems/template/forbidden - title: Permission denied - status: 403 - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - NotFound: - description: The addressed resource does not exist. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - example: - type: https://govstack.global/problems/template/not-found - title: Resource not found - status: 404 - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - Conflict: - description: The request conflicts with the current resource state. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - example: - type: https://govstack.global/problems/template/conflict - title: State conflict - status: 409 - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - UnprocessableContent: - description: The request is well formed but contains invalid field values. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/ValidationProblem' - example: - type: https://govstack.global/problems/template/invalid-field - title: Request validation failed - status: 422 - detail: The requested record status is invalid. - instance: /v1/records - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - errors: - - pointer: /status - message: Status must be ACTIVE or ARCHIVED. - InternalError: - description: An unexpected server error occurred. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - example: - type: https://govstack.global/problems/template/internal-error - title: Internal error - status: 500 - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - ServiceUnavailable: - description: The service is temporarily unavailable. - headers: - Cache-Control: - $ref: '#/components/headers/CacheControl' - content: - application/problem+json: - schema: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - example: - type: https://govstack.global/problems/template/service-unavailable - title: Service unavailable - status: 503 - traceId: 4bf92f3577b34da6a3ce929d0e0e4736 - schemas: - CreateRecordRequest: - type: object - description: Caller-controlled values used to create a reference record. - required: - - name - - status - properties: - name: - type: string - minLength: 1 - maxLength: 200 - description: Human-readable label that contains no personal data. - status: - type: string - description: 'Requested state: ACTIVE for current records or ARCHIVED for retained records.' - enum: - - ACTIVE - - ARCHIVED - x-extensible-enum: true - Record: - type: object - description: Generic resource used to demonstrate the template API conventions. - required: - - id - - name - - status - - createdAt - - updatedAt - properties: - id: - type: string - format: uuid - description: Opaque server-generated record identifier. - name: - type: string - description: Human-readable record label without personal data. - status: - type: string - description: 'Current state: ACTIVE for current records or ARCHIVED for retained records.' - enum: - - ACTIVE - - ARCHIVED - x-extensible-enum: true - createdAt: - type: string - format: date-time - description: RFC 3339 time at which the record was created. - updatedAt: - type: string - format: date-time - description: RFC 3339 time at which the record last changed. - RecordCollection: - type: object - description: Cursor-paginated collection of reference records. - required: - - items - - pageInfo - properties: - items: - type: array - description: Reference records in the current page. - items: - $ref: '#/components/schemas/Record' - pageInfo: - description: >- - Page metadata. total, when present, is exact and describes the - collection at the time this page is generated. - $ref: './common/govstack-openapi-common.yaml#/components/schemas/PageInfo' - ExportRequest: - type: object - description: Criteria and representation requested for a long-running record export. - required: - - format - properties: - format: - type: string - description: 'Export representation: JSON_LINES for newline-delimited JSON or CSV for comma-separated values.' - enum: - - JSON_LINES - - CSV - x-extensible-enum: true - status: - type: string - description: 'Optional equality filter: ACTIVE for current records or ARCHIVED for retained records.' - enum: - - ACTIVE - - ARCHIVED - x-extensible-enum: true - Operation: - type: object - description: Pollable representation of long-running work owned by this API. - required: - - id - - status - - createdAt - - updatedAt - properties: - id: - type: string - minLength: 1 - description: Opaque server-generated Operation identifier. - status: - type: string - description: 'Current lifecycle state: PENDING, RUNNING, SUCCEEDED, FAILED, or CANCELLED.' - enum: - - PENDING - - RUNNING - - SUCCEEDED - - FAILED - - CANCELLED - x-extensible-enum: true - result: - type: object - description: Result metadata present when the Operation succeeds. - error: - $ref: './common/govstack-openapi-common.yaml#/components/schemas/Problem' - createdAt: - type: string - format: date-time - description: RFC 3339 time at which the Operation was created. - updatedAt: - type: string - format: date-time - description: RFC 3339 time at which the Operation last changed. - progress: - type: integer - minimum: 0 - maximum: 100 - description: Optional completion percentage from zero through one hundred. - Health: - type: object - description: Minimal operational liveness response without internal details. - required: - - status - properties: - status: - type: string - description: Template-defined informational liveness state. - enum: - - PASS - - FAIL - - WARN - x-extensible-enum: false - description: - type: string - description: Public, non-sensitive summary of the liveness state. diff --git a/spec/.gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png b/spec/.gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png new file mode 100644 index 0000000000000000000000000000000000000000..9a2c484ba2621c2f8b4ef5768b4059f3381715ec GIT binary patch literal 143890 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 YfBXBKoLl_!dw`#bk%eKU!QIFI1!rdcV*mgE literal 0 HcmV?d00001 diff --git a/spec/1-version-history.md b/spec/1-version-history.md index b7f223f..87291b5 100644 --- a/spec/1-version-history.md +++ b/spec/1-version-history.md @@ -1,7 +1,9 @@ # 1 Version History -Record published specification versions only. The template itself has no -published specification version. +{% hint style="success" %} +Add a line to the version history table describing the major changes to the specifications between _published_ versions. Ideally, include links to issues and authors on GitHub. +{% endhint %} -| Version | Date | Editors | Change and approval reference | -|---|---|---|---| +_\<Example Version history>_ + +<table><thead><tr><th width="138.33333333333331">Version</th><th width="274">Authors</th><th>Comment</th></tr></thead><tbody><tr><td>0.7</td><td>Steve Conrad</td><td>Initial Revision</td></tr><tr><td>0.8</td><td>Max Carlson, Steve Conrad, Dr. Ramkumar, Trevor Kinsey from the Architecture Group</td><td>Applied document standards to template</td></tr><tr><td>0.9</td><td>Architecture Team</td><td>Added sections for review comments, updated to match formatting numbering/fonts/etc. scheme agreed upon</td></tr><tr><td>1.0</td><td>Steve Conrad, GovStack Technical Committee</td><td>Update format for GitBook, revisions for GovStack 1.0 release</td></tr></tbody></table> diff --git a/spec/10-other-resources.md b/spec/10-other-resources.md index 796ee07..0ed32a3 100644 --- a/spec/10-other-resources.md +++ b/spec/10-other-resources.md @@ -1,19 +1,21 @@ # 10 Other Resources -Link only maintained resources that help implementers interpret the normative -specification. Explain the authority and version of each external document. +{% hint style="success" %} +This section can be used to link to any external documents that may be relevant, such as standards documents or other descriptions of this building block that may be useful -## 10.1 Cross-Building Block workflows +This section should contain at minimum, links to the Cross-BB Workflows that have been defined for this BB, the Key Decision Log (Confluence), and Future Considerations (Confluence) +{% endhint %} -List approved workflows that depend on this BB and identify the requirements -they exercise. +_\<Example Other Resources>_ -## 10.2 Key decision log +## 10.1 Example Cross-Building Block Workflows -Link the working group's durable decision log. API design exceptions should also -be declared in the canonical API document. +Some common workflows that leverage the Consent Building Block can be found here: [https://govstack.gitbook.io/workflows-capabilities/consent](https://govstack.gitbook.io/workflows-capabilities/consent) -## 10.3 Future considerations +## 10.2 Key Decision Log -Link proposed future capabilities without presenting them as current normative -requirements. Planned interface gaps still belong in `api/coverage.yaml`. +A historical log of key decisions regarding this Building Block can be found here: [https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183238674/Key+Decision+Log+Consent](https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183238674/Key+Decision+Log+Consent) + +## 10.3 Future Considerations + +A list of topics that may be relevant to future versions of this Building Block are documented here: [https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183205908/Future+Considerations+Consent](https://govstack-global.atlassian.net/wiki/spaces/GH/pages/183205908/Future+Considerations+Consent) diff --git a/spec/2-description.md b/spec/2-description.md index e6c99a1..24bf272 100644 --- a/spec/2-description.md +++ b/spec/2-description.md @@ -1,14 +1,11 @@ # 2 Description -Describe the public-purpose problem this BB solves in language understandable to -a reader who is new to GovStack. Cover: - -- the outcome and users of the BB; -- its responsibilities and explicit non-responsibilities; -- the systems or BBs it depends on; -- the trust boundaries and sensitive data it handles; -- assumptions that affect country adoption. - -Do not describe an implementation product as the standard. Link each claimed -capability to a Key Digital Functionality in Section 4 and a testable functional -requirement in Section 6. +{% hint style="success" %} +Set the context of the Building Block for the reader. The description should not assume that the reader has any experience of the GovStack system other than that found on the GovStack website. + +If there are assumptions or context for this building block that may be needed for the reader, it can be provided in this section. +{% endhint %} + +_\<Example Description below>_ + +Registration services attribute a unique functional ID to a person, place or other entity to identify and access information about it. According to the World Bank, functional IDs are those that evolve out of a single use-case, such as voter IDs, health records, or bank cards, and are created with a specific purpose in mind, differing from foundational IDs which are created with a general purpose in mind. Registration services can also use the foundational ID or map it to the functional ID where such an identity exists. Examples of specific registration services include immunization, disease and citizenship records, as well as birth and death registration. The ensemble of utilities for capturing, recording, profiling, searching, retrieving and verifying this identity information is encapsulated as registration services. The information itself will be deposited into and retrieved from corresponding functional registries (see the Registries ICT Building Blocks). Registration services help profile entities by enabling the registration of different categories or groups and documenting their access to various services. These services also onboard users into a programme or service offered by an organization (eg rural advisory service), capturing related demography, profile and citizen ID information. diff --git a/spec/3-terminology.md b/spec/3-terminology.md index 7605c49..ab5f0c8 100644 --- a/spec/3-terminology.md +++ b/spec/3-terminology.md @@ -1,11 +1,9 @@ # 3 Terminology -Define domain terms that a reader needs to interpret requirements and API -fields. Reuse terminology from adopted standards and cite the source. Avoid -giving a familiar term a BB-specific meaning without saying so. +{% hint style="success" %} +Terminology/glossary used within the specification. The terms can be laid out in a table format +{% endhint %} -| Term | Definition | Source or note | -|---|---|---| -| Building Block (BB) | An independently useful, reusable GovStack capability with specified interfaces. | GovStack terminology | -| Conformance | Satisfaction of the normative requirements and interface contracts identified by this specification. | Verify through Section 5 and `test/plan.md`. | -| Reference record | A deliberately generic resource used only to demonstrate the template API pattern. | Replace with the BB's domain resource. | +_\<Example Terminology>_ + +<table data-header-hidden><thead><tr><th width="234"></th><th></th></tr></thead><tbody><tr><td><strong>Term</strong></td><td><strong>Description</strong></td></tr><tr><td><strong>Configuration</strong></td><td>technical implementation of all the content and process conditions as defined by the Data Policy for Consent Agreement creation, reading, updating and deletion, as well as for providing all necessary actors with the required operations</td></tr><tr><td><strong>Consent Agreement</strong></td><td>is the agreement to be signed by the Individual and the Data Controller as prescribed by Data Policy, based on which the Data Providing System may transmit the data to the Data Consuming System for the purposes described in the Consent Agreement.</td></tr><tr><td><strong>Consent Record</strong></td><td>is created when an individual signs a consent agreement. It represents a signed consent agreement.</td></tr><tr><td><strong>Consent Reference</strong></td><td>a unique identifier used to locate and verify the validity of the Consent Agreement.</td></tr><tr><td><strong>Data Providers</strong></td><td>is a legal entity that stores and provides access to an Individual's data, which requires the Individual's consent for processing (outside of its primary purpose/location).</td></tr><tr><td><strong>Data Consumers</strong></td><td>is a legal entity that requires the Individual's data from the Data Providers according to the consent of the Individual.</td></tr><tr><td><strong>Data Disclosure Agreements</strong></td><td>A Data Disclosure Agreement (DDA) exists between two organisations where one organisation acts as a Data Provider and the other as a Data Consumer. The DDA captures how data is shared between the two organisations and what role and obligation each party has.</td></tr><tr><td><strong>Data Policy</strong></td><td>is a formal description of the purpose, nature and extent of consent-based personal data processing, covering the configuration needs by Data Providing System and Data Consuming System and the conditions defined by law.</td></tr><tr><td><strong>Data Processing Auditor</strong></td><td>is an entity (a person or an organisation) verifying the legitimacy of personal data processing by Data Controllers and Data Processors based on the Data Policies and performed tasks. The entity is not to be confused with a data policy auditor that is independent of the actors involved in the operations of consent management and can engage directly with the Consent Management service operator.</td></tr><tr><td><strong>Delegate</strong></td><td>the person giving consent (signing Consent Agreement); on behalf of the Individual,</td></tr><tr><td><strong>Individual</strong></td><td>is a person about whom the personal data is stored in an information system (a.k.a. “Data Subject”) and who agrees or not with the use of this data outside of its primary purpose/location.</td></tr><tr><td><strong>Legal Entity</strong></td><td>is an organisation (public or private) ​that has the rights and obligations to define standards for personal data processing. E.g. a public health authority</td></tr><tr><td><strong>Personal data</strong></td><td>Is any information that (a) can be used to identify the Individual to whom such information relates, or (b) is or might be directly or indirectly linked to the Individual (ISO(IEC 29100:2011)</td></tr><tr><td><strong>Regulations</strong></td><td>are broadly defined as rules followed by any system: could be laws, bylaws, ​norms or architectures (Defintion inspired by Lessig’s modalities of regulation: https://lessig.org/images/resources/1999-Code.pdf) that ​ regulates a given system.</td></tr></tbody></table> diff --git a/spec/4-key-digital-functionalities.md b/spec/4-key-digital-functionalities.md index e8451e8..0de665b 100644 --- a/spec/4-key-digital-functionalities.md +++ b/spec/4-key-digital-functionalities.md @@ -1,19 +1,23 @@ # 4 Key Digital Functionalities -Key Digital Functionalities describe public-purpose capabilities, not endpoints -or product features. Give each KDF a stable ID of the form -`{bb-code}-KDF-{number}` and do not renumber published IDs. Normative, testable -obligations derived from each KDF belong in Section 6. +{% hint style="success" %} +The Key Digital Functionalities (KDFs) describe the core (required) functions that this building block must be able to perform. These functionalities should be described as business processes as opposed to technical specifications or API definitions. -The following KDFs belong to the template reference domain. Replace them when -creating a real BB. +The KDFs provides an overview of functionality that should be provided by the Building Block. These KDFs should be organized by area of functionality and should be numbered so that they can be referenced in other sections. -## BB-TPL-KDF-001 Manage reference records +Note, any assumptions or context that are needed for this Building Block should be provided in Section 2 (Description). +{% endhint %} -An authorised actor can discover, create, and retrieve reference records. This -demonstrates a conventional resource lifecycle without imposing a domain model. +_\<Example Key Digital Functionalities (based on Consent Building Block)>_ -## BB-TPL-KDF-002 Run long-running work +The functionalities are derived from the [consent agreement lifecycle](broken-reference) and categorised according to the [Actors](broken-reference) described above. While the consenting workflows (as described above) are implicitly considered the centerpiece of the Consent Building Block, it is important to realise that the integrity of consent management can only be achieved if robust configuration before and auditing after the Consent Agreement signing and Consent Record verification activities are in place. -An authorised system can request work that completes asynchronously and can -observe or cancel that work through a standard Operation resource. +### 4.1 Administration of Consent Agreements + +The Consent Building block should allow for the administration of Consent Agreements based on the Data Policy agreements that have been defined. + +### 4.2 User Consent + +An individual user must be able to view a consent agreement and provide or withdraw consent for that agreement. The Consent Building Block should allow the user to determine the time period for which the consent is valid + +### diff --git a/spec/5-cross-cutting-requirements.md b/spec/5-cross-cutting-requirements.md index 02463bd..62efb1b 100644 --- a/spec/5-cross-cutting-requirements.md +++ b/spec/5-cross-cutting-requirements.md @@ -1,28 +1,47 @@ # 5 Cross-Cutting Requirements -Every GovStack Building Block inherits `govstack-cfr`. Do not repeat inherited -requirements here. Define a Building Block cross-functional requirement only -when this specification extends or replaces a parent requirement, and state the -parent relationship explicitly. +{% hint style="success" %} +The Cross-cutting requirements described in this section are an extension of the cross-cutting requirements defined in the architecture blueprint and nonfunctional requirements document. This section will describe any additional cross-cutting requirements that apply to this building block, or any requirements that are defined in the non-functional requirements document that are NOT applicable to this Building Block. + +Cross-cutting requirements will use the same language (REQUIRED, RECOMMENDED or OPTIONAL) as specified in the architecture document. + +Note: this section will contain 3 parts. The first is a list of Requirements, followed by any exceptions to the cross-cutting requirements for this building block (this section may be skipped if not needed). The third part is a list of relevant standards to this domain that should be used for any Building Block implementation. +{% endhint %} + +_\<Example Cross-Cutting Requirements from Payments Building Block>_ ## 5.1 Requirements -The template defines no additional cross-functional requirements. A real BB -adds one in the same format as Section 6, using a canonical -`govstack-bb-{name}-cfr#req-{number}` identifier and an `extends` or `replaces` -relationship to the applicable `govstack-cfr-*#req-{number}` parent. +### 5.1.1 Follow all Statutory and Operational Requirements (REQUIRED) + +The Payments Building Block assumes that the statutory and operational requirements around accounts (i.e. know your customer/anti-money laundering/counter-terrorist financing) must have been completed by an outside system, which is capable of communicating that status in appropriate timeframes. + +### 5.1.2 All Participants should be previously registered (RECOMMENDED) + +The Payment System or Scheme in a country may require that participating payor or payee entities, whether health clinics, ministries, or individuals must have been registered with a regulated banking or non-banking entity prior to the use of the Payments Building Block. + +## 5.2 Exceptions to Architectural Cross-Cutting Specifications + +Cross-Cutting specifications for all Building Blocks are detailed in the [GovStack non-functional requirements document](https://govstack.gitbook.io/specification/architecture-and-nonfunctional-requirements/5-cross-cutting-requirements). However, for this Building Block the following Cross-Cutting Specifications are not required: + +**5.17 Databases should not Include Business Logic or Stored Procedures** + +Several mundane localized operations on data such as searching, filtering, and format transformations may find a better performance by being collocated with the database itself in form of stored procedures in typical SQL databases. Such procedures must be configured to handle the concurrent processing of multiple requests, with an appropriate mechanism(e.g. SQL agents/SSIS packages/service brokers/etc.). Since data is collocated with the code, when scaled up to clusters of multiple instances of database servers, each instance will utilize local Safeguard for Privileged Sessions. However, this will create an additional burden on maintenance and update of source code as applications may have part of logic in backend code and partially embedded in database servers. To host complex queries related to data from different databases it is recommended to implement it in business logic rather than stored procedures. In this case, scalability must be ensured by suitable application infrastructure scaling mechanisms such as Virtual Machine-level scaling and automatic elastic frameworks. -## 5.2 Parent requirement relationships -An inherited IMMUTABLE requirement cannot be changed. An EXTENSIBLE requirement -may be tightened, and a REPLACEABLE requirement may be replaced while -preserving its external contract. Use INAPPLICABLE only where the GovStack -Requirements Model permits it and include the rationale in the requirement. ## 5.3 Standards -- [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) for synchronous HTTP APIs. -- [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) for HTTP problem details. -- [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749) for authorised API access. -- [W3C Trace Context](https://www.w3.org/TR/trace-context/) for distributed tracing. -- The [GovStack Cross-BB API Design Guide](../api-design-guide/README.md) for cross-BB conventions. +The following standards are applicable to data structures in the Workflow Building Block: + +### 5.3.1 BPMN (REQUIRED) + +The workflow Building Block should leverage [BPMN v2.0.2 - Business Process Model and Notation](https://www.omg.org/spec/BPMN/) + +### 5.3.2 OpenAPI + +[OpenAPI](https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md) + +### 5.3.3 REST APIs + +Rest APIs should use JSON payloads. Note that we are not using XML. diff --git a/spec/6-functional-requirements.md b/spec/6-functional-requirements.md index fc559b3..a740632 100644 --- a/spec/6-functional-requirements.md +++ b/spec/6-functional-requirements.md @@ -1,47 +1,44 @@ # 6 Functional Requirements -Functional requirements state observable capabilities and remain independent of -a specific product. Follow the GovStack Requirements Model: give every -requirement a canonical `govstack-bb-{name}-fr#req-{number}` identifier and -exactly one level, mutability, and observability classifier. Never silently -delete or reuse a published requirement number. +{% hint style="success" %} +The functional requirements section lists the technical capabilities that this building block should have. These requirements should be sufficient to deliver all functionality that is listed in the Key Digital Functionalities section. -The reference requirements below are implemented by `api/openapi.yaml` and -mapped in `api/coverage.yaml`. Replace them for a real BB. +These functional requirements do not define specific APIs - they provide a list of information about functionality that must be implemented within the building block. These requirements should be defined by subject-matter experts and don’t have to be highly technical in this section. -## 6.1 Reference record lifecycle +This section should contain 2 parts. The first provides the functional requirements for each functional area that is defined for the Building Block (described in Section 4). The functional requirements for each component should have its own sub-section. -### #1 Retrieve reference records (REQUIRED EXTENSIBLE OBSERVABLE) +The second section outlines the any components that make up the Building Block. Many Building Blocks are made up of multiple components. These can be described (and diagrams provided where appropriate) in this section. +{% endhint %} -`govstack-bb-template-fr#req-1` +_\<Example Functional Requirements>_ -KF: Manage reference records +The following functionalities must be provided by the Consent Building Block. These functional requirements are linked to the Key Digital Functionalities in Section 4. -An authorised caller can retrieve a bounded, cursor-paginated collection of -reference records. +### 6.1 Consent Agreements -### #2 Create and retrieve a reference record (REQUIRED EXTENSIBLE OBSERVABLE) +* An administrative user can create, update, and delete Consent Agreements (REQUIRED) +* Notifications should be provided to all parties when changes are made to a Consent Agreement (RECOMMENDED) -`govstack-bb-template-fr#req-2` +### 6.2 User Consent -KF: Manage reference records +* A user can view a consent agreement and give consent for that agreement (REQUIRED) +* A user can withdraw consent from an agreement that he/she has previously given consent to (REQUIRED) +* An audit log of all user consent given or withdrawn must be provided (REQUIRED) -An authorised caller can create a record synchronously and retrieve it by its -opaque identifier. Successful creation identifies the created resource. -## 6.2 Long-running work -### #3 Request and observe a record export (REQUIRED EXTENSIBLE OBSERVABLE) +## Building Block Components -`govstack-bb-template-fr#req-3` +Within the scope of Consent Building Block version 1.0, the required components are as given: -KF: Run long-running work +<figure><img src=".gitbook/assets/Screen Shot 2023-04-07 at 11.59.49 AM.png" alt=""><figcaption></figcaption></figure> -An authorised service can request an asynchronous record export, poll the -returned Operation, and request cancellation. +**Consent Agreement Configuration Handler** - handles the creation, updation & deletion of consent agreements for organisations. Organisations can be Data Providers or Data Consumers. -## 6.3 Components +**Consent Record Handler** - enables Individuals to view data usage and consent record. -Describe logical components only when they clarify responsibility or trust -boundaries. Do not require a deployer to reproduce an illustrative component -diagram or a particular internal architecture. +**Notification Handler** - Handles all notification configurations and notifications requested by different subscribers. + +**Administrative User Interface and client Software Development Kit** - These are readily available components that can configure and use the services offered, making integration easy and low code. + +**RESTful APIs**: All APIs are exposed as RESTful APIs. These are categorised into Organisation APIs, Individual APIs, and Auditing APIs. diff --git a/spec/7-data-structures.md b/spec/7-data-structures.md index b8a22ee..d65e061 100644 --- a/spec/7-data-structures.md +++ b/spec/7-data-structures.md @@ -1,23 +1,46 @@ # 7 Data Structures -Describe only information exchanged across the BB boundary. The OpenAPI or -AsyncAPI schema is normative when prose and machine-readable definitions differ. -Include a diagram when relationships cannot be expressed clearly in a table. +{% hint style="success" %} +This section provides information on the core data structures/data models that are used by a Building Block. These data structures describe information that is exchanged between building blocks - they do not dictate internal data structures for a particular implementation. These data structures should also describe the _minimum_ set of information that should be passed in an API call. The data structures can be extended for particular use cases. -## 7.1 Resource model +Data Structures should consist of two sections. The first section should provide an overall resource model that shows the various data structures that are used by the Building Block and how these structures are inter-related. -The template reference model has a `Record`, a paginated `RecordCollection`, and -an `Operation` representing long-running work. +The second section provides a more detailed breakdown of each data model. For each data model, the following information should be provided: -## 7.2 Reference record +* Name +* Description +* Fields - the various fields in this data structure. Each field definition should contain the following: + * Name + * Type (string, Boolean, number, date, etc) + * Description + * You can also reference any standards that must be adhered to (ie. UTC standard for date/times) + * Comments (any notes about this field) -| Field | Type | Required | Meaning | -|---|---|---|---| -| `id` | UUID string | Yes in responses | Opaque server-generated record identifier. | -| `name` | string | Yes | Human-readable label without personal data. | -| `status` | enum | Yes | `ACTIVE` or `ARCHIVED`; clients tolerate future values. | -| `createdAt` | RFC 3339 timestamp | Yes in responses | Time at which the record was created. | -| `updatedAt` | RFC 3339 timestamp | Yes in responses | Time of the latest change. | +Note that complete data structure definitions will be provided by the services APIs. +{% endhint %} -See [`api/openapi.yaml`](../api/openapi.yaml) for constraints, examples, error -schemas, pagination metadata, and the Operation resource. +_\<Example Data Elements>_ + +## 7.1 Resource Model + +_Note: Recommend using_ [_https://app.diagrams.net/_](https://app.diagrams.net/) _to create the resource model and store in BuildingBlock repository_ + + _\<Example Resource Model>_ + +<figure><img src="https://files.gitbook.com/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FzdXe8NbIMZIv5sydPBf6%2Fuploads%2Fgit-blob-736cd906cff209af7113b298653cb11a8b5935b6%2Fdata-structures.png?alt=media" alt=""><figcaption></figcaption></figure> + + + +## 7.2 Data Structures: <a href="#worklist-data-structure" id="worklist-data-structure"></a> + +_Note: any relevant standards that are applicable to the fields of the data structures can be defined here._ + +### 7.2.1 Worklist + +**Description:** The WorkList data structure is used to track a list of subscribers to a particular session or event. + +**Fields:** + +<table data-header-hidden><thead><tr><th width="115"></th><th width="115"></th><th width="242"></th><th></th><th data-hidden></th><th data-hidden></th><th data-hidden></th></tr></thead><tbody><tr><td><strong>Name</strong></td><td><strong>Type</strong></td><td><strong>Description</strong></td><td><strong>Notes</strong></td><td><strong>Foreign Key</strong></td><td><strong>Constraints</strong></td><td><strong>Required</strong></td></tr><tr><td>id</td><td>int</td><td>Unique identifier for this WorkList</td><td>Generated by building block on creation</td><td> </td><td>PK</td><td>Y</td></tr><tr><td>name</td><td>string</td><td>Name for this WorkList</td><td></td><td> </td><td>Uniq</td><td>Y</td></tr><tr><td>status</td><td>enum</td><td>Status of the WorkList</td><td>enum that is defined with the following fields: ACTIVE, SUSPENDED, CANCELED</td><td> </td><td> </td><td>Y</td></tr><tr><td>start_time</td><td>date</td><td>Start date/time for the WorkList</td><td></td><td> </td><td> </td><td>Y</td></tr><tr><td>end_time</td><td>date</td><td>End date/time for the WorkList</td><td></td><td> </td><td> </td><td>Y</td></tr><tr><td>alerts</td><td>integer array</td><td>Array of assigned alerts for this WorkList</td><td></td><td>Alert</td><td>FK</td><td>N</td></tr></tbody></table> + +## diff --git a/spec/8-service-apis.md b/spec/8-service-apis.md index 31be3d0..1c7c2c4 100644 --- a/spec/8-service-apis.md +++ b/spec/8-service-apis.md @@ -1,30 +1,19 @@ # 8 Service APIs -Machine-readable API documents are normative. Keep one canonical entrypoint per -surface and enumerate it in [`api/index.yaml`](../api/index.yaml). The reference -REST contract is [`api/openapi.yaml`](../api/openapi.yaml). - -## 8.1 Requirement traceability - -Every active REQUIRED or RECOMMENDED interface requirement in Sections 5 and 6 -has exactly one disposition in [`api/coverage.yaml`](../api/coverage.yaml). -DRAFT, DEPRECATED, and INAPPLICABLE requirements are not active coverage -obligations. The coverage file is the authoritative requirement-to-interface -mapping. - -The template reference maps: - -| Requirement | Canonical operations | -|---|---| -| `govstack-bb-template-fr#req-1` | `listRecords` | -| `govstack-bb-template-fr#req-2` | `createRecord`, `getRecord` | -| `govstack-bb-template-fr#req-3` | `requestRecordExport`, `getOperation`, `cancelOperation` | - -## 8.2 Contract ownership - -- API paths, parameters, schemas, responses, and examples belong in the - canonical OpenAPI or AsyncAPI file, not copied into Markdown. -- Review `api/coverage.yaml` whenever requirements or operations change. -- Pin shared cross-BB shapes from `api/common/` and record their upstream - revision in [`api/common/README.md`](../api/common/README.md); keep domain - schemas in the BB's canonical API document. +This section provides a reference for APIs that should be implemented by this Building Block. The APIs defined here establish a blueprint for how the Building Block will interact with other Building Blocks. Additional APIs may be implemented by the Building Block, but the listed APIs define a minimal set of functionality that should be provided by any implementation of this Building Block. + +The [GovStack non-functional requirements document](https://govstack.gitbook.io/specification/architecture-and-nonfunctional-requirements/6-onboarding) provides additional information on how 'adaptors' may be used to translate an existing API to the patterns described here. This section also provides guidance on how candidate products are tested and how GovStack validates a product's API against the API specifications defined here. + +{% hint style="success" %} +Keep the canonical interface inventory in [`api/index.yaml`](../api/index.yaml). Use OpenAPI for synchronous HTTP APIs, AsyncAPI for event-driven APIs, or a normative protocol-standard declaration where creating a synthetic OpenAPI document would be misleading. A Building Block with no API surface must declare that explicitly. + +When one or more API surfaces are declared, map each active interface requirement to its operations, messages, or non-API verification in `api/coverage.yaml`. Follow the [GovStack Cross-BB API Design Guide](../api-design-guide/README.md) for conventions and validation. + +Note that APIs should be grouped by functional area (from sections 4 and 6) where appropriate. + +This section may link to rendered API documentation, but do not embed a second copy of a canonical contract in the GitBook assets. +{% endhint %} + +## 8.1 Administrative APIs + +## 8.2 User APIs diff --git a/spec/9-workflows.md b/spec/9-workflows.md index 9b10db1..b082414 100644 --- a/spec/9-workflows.md +++ b/spec/9-workflows.md @@ -1,35 +1,57 @@ # 9 Internal Workflows -Describe externally observable sequences needed to understand a requirement. -Internal implementation steps are informative unless a requirement makes them -observable. Name the requirement IDs exercised by each workflow. +{% hint style="success" %} +This section describes standard _internal_ workflows that a building block should support. Each internal workflow must be linked to one of the Functional Requirements defined in section 6. -## 9.1 Create and retrieve a record +An internal workflow describes the internal processes that a Building Block needs to execute to complete a request from an external application or Building Block to fulfull the functional requirement +{% endhint %} -Requirement: `govstack-bb-template-fr#req-2`. +_\<Example Internal Workflows>_ + +### 9.1 Start a workflow process via API. + +This internal workflow is used by the Workflow Building Block to initiate a workflow process. An external application (Building Block) calls an API in the Workflow Building Block which will launch a workflow process. This functional requirement must also support submission of data payload through variables in the same API call. + +Examples: + +* [PostPartum and Infant Care Use Case, Payment Step](https://govstack-global.atlassian.net/wiki/spaces/GH/pages/49381394/PostPartum-01-Example+Implementation+Original+-+multiple+steps): Validate the mother has completed all steps (visited a pediatrician, procured medicine and nutrition supplies, and visited the therapy center) by connecting to MCTS registry +* [Unconditional Social Cash Transfer, Elibility Determination](https://govstack.gitbook.io/product-use-cases/product-use-case/inst-1-unconditional-social-cash-transfer): Send beneficiary data from Registration BB to Workflow BB ```mermaid sequenceDiagram - participant Client - participant BB - Client->>BB: POST /v1/records with OAuth and Idempotency-Key - BB-->>Client: 201 Created with Location - Client->>BB: GET Location - BB-->>Client: 200 Record + +External BB-->>Workflow BB: Call API to start workflow process +Workflow BB-->>Workflow BB: Launch process +Workflow BB-->>External BB: Return Process ID + ``` -## 9.2 Request long-running work -Requirement: `govstack-bb-template-fr#req-3`. + +### 9.2 Booking an appointment + +The first and somewhat unique use-case is related to the need for consent when the Individual is not yet provisioned in the System processing the data. In such cases, the workflow requires the creation of a valid and trusted Foundational ID to be linked with the Consent Record. Below is shown how a pre-registration use of consent workflow works. + +Examples: + +* Postpartum Use Case, Appointment scheduling step: In this case, a health care worker will book an appointment into a specific slot. The Scheduler Building Block will leverage the Messaging Building Block to send a message to the patient with an appointment confirmation. ```mermaid sequenceDiagram - participant Service - participant BB - Service->>BB: POST /v1/exports with client credentials - BB-->>Service: 202 Accepted with Operation Location - loop Until terminal status - Service->>BB: GET Operation Location - BB-->>Service: 200 Operation - end + +HCworker->>PPCP_APP: Request appointment<br />for consultation session<br /> with preferences<br />(date-time range,\nclinics, doctors,etc) +PPCP_APP->>Scheduler [planner]: Find unbooked session<br />slots in consultation event<br />for given preferrences +Scheduler [planner]->>PPCP_APP: Report available<br />session slots\n with terms of service +opt: + HCworker ->>PPCP_APP:Pay fee, if any + PPCP_APP->HCworker:payment receipt +end +HCworker->>PPCP_APP:Confirm slot +PPCP_APP->>Scheduler [planner]: Book appointment<br />for consultation session +Scheduler [planner]->>Scheduler [Worklist]: Update in<br />consultant's worklist +Scheduler [planner]->>PPCP_APP:Confirm booking of \n appointment +PPCP_APP->>HCworker:Publish booking details +Scheduler [planner]->>Messaging BB: Appointment confirmation message +Messaging BB->>Subscriber: Deliver message \n to Subscriber +Messaging BB->>Scheduler [planner]: delivery confirmation ``` diff --git a/spec/README.md b/spec/README.md index ff45e56..b8f32b4 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,18 +1,10 @@ -# Building Block Specification Template +# \<name of building block> -**Specification:** `govstack-bb-template`<br> -**Version:** `0.1.0`<br> -**Extends:** `govstack-cfr` +{% hint style="success" %} +Throughout this template are a series of these info callouts. They are designed to guide the type of content to add in each section and often contain example content from other building blocks. Delete them as no longer required. +{% endhint %} -Use this book to define one GovStack Building Block. Replace the reference -examples with domain-specific content before publication and remove authoring -instructions that no longer apply. -The normative specification consists of the active requirements in Sections 5 -and 6, the interface data in Sections 7 and 8, and the workflows in Section 9. -Every active requirement has a canonical GovStack ID, the three classifiers -defined by the GovStack Requirements Model, and exactly one disposition in -[`api/coverage.yaml`](../api/coverage.yaml). -Record the editors and their organisational affiliations here when the BB -working group is established. +\ +Developed by: `<Names and organization affiliations of working group members>` in cooperation with GIZ, ITU, DIAL, and the Government of Estonia diff --git a/spec/SUMMARY.md b/spec/SUMMARY.md index d2a5715..83dc68b 100644 --- a/spec/SUMMARY.md +++ b/spec/SUMMARY.md @@ -1,6 +1,6 @@ # Table of contents -* [Building Block Specification Template](README.md) +* [\<name of building block>](README.md) * [1 Version History](1-version-history.md) * [2 Description](2-description.md) * [3 Terminology](3-terminology.md) diff --git a/test/plan.md b/test/plan.md index 1c54d2e..b8746b2 100644 --- a/test/plan.md +++ b/test/plan.md @@ -1,46 +1,14 @@ -# Building Block conformance test plan +# Test plan for the _____________ building block. -This plan covers the specification and externally observable implementation -contract. A BB may add domain and deployment tests, but must not remove checks -for its REQUIRED requirements. +1. a +2. b +3. c -## 1. Specification checks +## Notes -1. Validate every document listed in `api/index.yaml` with its base schema - validator. -2. Run `node api-design-guide/linter/cli.mjs --repo-root . --fail-on error` - (install the base validators first; see - `api-design-guide/guides/validating-your-spec.md`). -3. Confirm every normative requirement ID is unique and has exactly one valid - disposition in `api/coverage.yaml`. -4. Confirm every `operation` disposition names existing, unique `operationId` - values. -5. Resolve every local `$ref` without network access. +At least three levels of testing... -## 2. Reference contract tests - -| Requirement | Test | -|---|---| -| `BB-TPL-FR-001` | List records with no cursor, follow `nextCursor`, enforce the `pageSize` maximum, and treat the integrity-protected cursor as opaque. | -| `BB-TPL-FR-002` | Create with a new `Idempotency-Key`, verify `201` and `Location`, retrieve the record, then replay the same request and receive the original result. | -| `BB-TPL-FR-003` | Request an export, verify `202` and Operation `Location`, poll to a terminal status, and exercise cancellation. | -| `BB-TPL-XR-001` | Call `/health` without credentials and verify `application/health+json` without internal details. | -| `BB-TPL-XR-003` | Reject missing or insufficient OAuth access tokens, propagate `traceparent`, and make an error `traceId` equal the effective W3C trace-id. | - -## 3. Error and resilience tests - -- Verify every documented 4xx and 5xx response uses - `application/problem+json`, has the RFC 9457 fields plus `code`, `traceId`, - and `timestamp`, and declares `Cache-Control: no-store`. -- Verify malformed input produces field-level JSON Pointer errors. -- Verify a reused idempotency key with a different body is rejected. -- Verify no credentials or personal data appear in URLs or logs collected as - test evidence. - -## 4. Integration and deployment evidence - -Record the implementation version, test environment, commands, timestamps, and -result artifacts. Where the BB communicates through an interoperability -mediator or adaptor, run the same contract suite through that boundary. A -release is conformant only when all REQUIRED requirement tests pass and no -undeclared interface exception remains. +1. can the BB be deployed via docker-compose? +2. can the BB interact with the IM? +3. can an adaptor be deployed alongside it to test API compliance? +4. do the required APIs respond to the required inputs and provide the required responses?