docs(arch-h1): Phase 13 Sprint 13.1 — categorize OpenAPI exceptions + bucket guards

Phase 13 Sprint 13.1 closure (architecture diligence audit ARCH-H1):
splits api/openapi-handler-exceptions.yaml's 64 entries into two
buckets via a required `category:` field, extends the parity script
with bucket reporting + a `--bucket=` subcommand, and adds a sibling
monotonic-decrease guard pinned to a checked-in baseline file. Pure
YAML + bash + doc; zero runtime change.

Strategy
========
The audit originally framed ARCH-H1 as "burn down the 64-entry
exception list to ≤20." Sprint 13.1 reframes against the structural
reality: 36 of the 64 entries are legitimate IETF-RFC wire-protocol
contracts (SCEP RFC 8894, ACME RFC 8555, ACME ARI RFC 9773, EST
RFC 7030) that MUST stay; the remaining 28 are REST-shaped routes
whose OpenAPI op was deferred. Categorize the two buckets, monotone-
gate the rest-deferred bucket against a baseline, and Sprints
13.4-13.6 drive rest-deferred to zero.

Categorization rule applied per-entry
=====================================
An entry is `category: wire-protocol` if ANY of:
  1. `why:` cites an RFC anchor (RFC 8894 / 8555 / 9773 / 7030).
  2. `why:` contains the strings "wire-protocol", "wire protocol",
     "sibling", or "shorthand".
  3. Route path starts with `/scep`, `/scep-mtls`, `/acme/`, or
     `/acme` (wire-protocol prefix).
Otherwise: `category: rest-deferred`.

This rule produced the 36 / 28 split that the Sprint 13.1 audit
prompt expected — verified by python assertion + manual eyeball
review of every entry's `why:` field before categorizing.

Per-entry decisions (read off the post-categorization YAML)
===========================================================

WIRE-PROTOCOL (36) — RFC contracts; never burn down:

  SCEP family (8) — RFC 8894 + RFC 7030 SCEP-mTLS sibling:
    GET    /scep                  RFC 8894 §3.1 GetCACert / GetCACaps
    POST   /scep                  RFC 8894 §3.1 PKCSReq / RenewalReq
    GET    /scep/                 trailing-slash variant (ChromeOS)
    POST   /scep/                 trailing-slash variant (ChromeOS)
    GET    /scep-mtls             EST RFC 7030 Phase 6.5 sibling
    POST   /scep-mtls             SCEP-mTLS POST variant
    GET    /scep-mtls/            SCEP-mTLS trailing-slash variant
    POST   /scep-mtls/            SCEP-mTLS trailing-slash POST

  ACME per-profile (12) — RFC 8555 §7.x + RFC 9773 ARI:
    GET    /acme/profile/{id}/directory             RFC 8555 §7.1.1
    HEAD   /acme/profile/{id}/new-nonce             RFC 8555 §7.2
    GET    /acme/profile/{id}/new-nonce             RFC 8555 §7.2
    POST   /acme/profile/{id}/new-account           RFC 8555 §7.3
    POST   /acme/profile/{id}/account/{acc_id}      RFC 8555 §7.3.2/.6
    POST   /acme/profile/{id}/new-order             RFC 8555 §7.4
    POST   /acme/profile/{id}/order/{ord_id}        RFC 8555 §7.4 PoG
    POST   /acme/profile/{id}/order/{ord_id}/finalize  RFC 8555 §7.4
    POST   /acme/profile/{id}/authz/{authz_id}      RFC 8555 §7.5
    POST   /acme/profile/{id}/challenge/{chall_id}  RFC 8555 §7.5.1
    POST   /acme/profile/{id}/cert/{cert_id}        RFC 8555 §7.4.2
    POST   /acme/profile/{id}/key-change            RFC 8555 §7.3.5
    POST   /acme/profile/{id}/revoke-cert           RFC 8555 §7.6
    GET    /acme/profile/{id}/renewal-info/{cert_id} RFC 9773 ARI

  ACME default-profile shorthand (14) — sibling routes; same wire
  semantics, dispatched when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID
  is set:
    GET    /acme/directory
    HEAD   /acme/new-nonce
    GET    /acme/new-nonce
    POST   /acme/new-account
    POST   /acme/account/{acc_id}
    POST   /acme/new-order
    POST   /acme/order/{ord_id}
    POST   /acme/order/{ord_id}/finalize
    POST   /acme/authz/{authz_id}
    POST   /acme/challenge/{chall_id}
    POST   /acme/cert/{cert_id}
    POST   /acme/key-change
    POST   /acme/revoke-cert
    GET    /acme/renewal-info/{cert_id}

REST-DEFERRED (28) — gaps; Sprints 13.4-13.6 author into openapi.yaml:

  auth/sessions cluster (3):
    GET    /api/v1/auth/sessions
    DELETE /api/v1/auth/sessions
    DELETE /api/v1/auth/sessions/{id}

  auth/oidc CRUD + JWKS + test + refresh cluster (10):
    GET    /api/v1/auth/oidc/providers
    POST   /api/v1/auth/oidc/providers
    PUT    /api/v1/auth/oidc/providers/{id}
    DELETE /api/v1/auth/oidc/providers/{id}
    GET    /api/v1/auth/oidc/providers/{id}/jwks-status
    POST   /api/v1/auth/oidc/providers/{id}/refresh
    POST   /api/v1/auth/oidc/test
    GET    /api/v1/auth/oidc/group-mappings
    POST   /api/v1/auth/oidc/group-mappings
    DELETE /api/v1/auth/oidc/group-mappings/{id}

  auth/breakglass admin cluster (4):
    GET    /api/v1/auth/breakglass/credentials
    POST   /api/v1/auth/breakglass/credentials
    DELETE /api/v1/auth/breakglass/credentials/{actor_id}
    POST   /api/v1/auth/breakglass/credentials/{actor_id}/unlock

  auth/users cluster (3):
    GET    /api/v1/auth/users
    DELETE /api/v1/auth/users/{id}
    POST   /api/v1/auth/users/{id}/reactivate

  Misc REST one-offs (3):
    GET    /api/v1/auth/runtime-config
    POST   /api/v1/auth/demo-residual/cleanup
    GET    /api/v1/audit/export

  OIDC + breakglass browser flows (5):
    GET    /auth/oidc/login
    GET    /auth/oidc/callback
    POST   /auth/oidc/back-channel-logout
    POST   /auth/logout
    POST   /auth/breakglass/login

Files changed
=============

api/openapi-handler-exceptions.yaml (+1 line per entry):
  - Header rewritten to document the two-bucket contract + the
    Phase 13 burn-down plan + the baseline-file convention.
  - Every existing `route:` + `why:` pair preserved verbatim.
  - `    category: <bucket>` line inserted after each `why:` line.
  - Pyyaml round-trip parses to 64 entries cleanly.

api/openapi-handler-exceptions-baseline.txt (NEW, 1 line):
  - Contains single integer `28` matching the current rest-deferred
    count. Sprints 13.4-13.6 decrement this in lockstep with each
    batch of OpenAPI ops authored.

scripts/ci-guards/openapi-handler-parity.sh (rewritten):
  - Reports `wire-protocol: N` + `rest-deferred: N` lines alongside
    the existing total.
  - New `--bucket=wire-protocol|rest-deferred` subcommand prints
    just the bucket count + exits 0. Used by the new monotonic
    guard + by Sprint 13.7's hard-floor pin.
  - New fail condition: any entry missing the required `category:`
    field, or carrying an unknown category value, fails the build
    with a clear ::error:: annotation.
  - Existing exit-code semantics preserved (drift / orphan / stale
    detection paths unchanged).

scripts/ci-guards/openapi-rest-deferred-monotonic.sh (NEW):
  - Reads the rest-deferred count via the parity script's --bucket
    subcommand.
  - Reads the baseline file at
    api/openapi-handler-exceptions-baseline.txt.
  - Fails with ::error:: if current count exceeds OR falls below the
    baseline. The fall-below path forces operators to update the
    baseline in the same commit as the corresponding YAML deletion
    — keeps the monotonic-decrease contract honest.
  - CI workflow auto-discovers any scripts/ci-guards/*.sh; no
    .github/workflows/ci.yml change required (verified — the loop
    at .github/workflows/ci.yml::Regression\ guards uses a glob).

scripts/ci-guards/README.md (+33 lines):
  - Two new entries in the per-finding regression-guards table for
    `openapi-handler-parity` (existing; bucket subcommand documented)
    and `openapi-rest-deferred-monotonic` (new).
  - New "ARCH-H1 OpenAPI exception two-bucket contract" section
    documenting the wire-protocol vs rest-deferred decision rule +
    the canonical close path for a rest-deferred entry (author op
    + delete exception + decrement baseline in same PR) + the
    bucket-count inspection commands.

Verification (all local, sandbox /sessions partition full so
disk-tmpfile-dependent guards skipped — see Hotfix #4 commit msg
for sandbox-disk context)
=========================================================

  $ bash scripts/ci-guards/openapi-handler-parity.sh
    Router routes:                  220
    OpenAPI operations:             158
    Documented exceptions:          64
      wire-protocol:                36
      rest-deferred:                28
    openapi-handler-parity: clean.

  $ bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
    36

  $ bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
    28

  $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh
    openapi-rest-deferred-monotonic: clean — rest-deferred = 28,
    baseline = 28.

  $ cat api/openapi-handler-exceptions-baseline.txt
    28

  $ python3 -c "import yaml; d=yaml.safe_load(open('api/openapi-handler-exceptions.yaml')); print(len(d['documented_exceptions']))"
    64

Negative test (corrupted baseline → guard fails):
  $ echo "abc" > api/openapi-handler-exceptions-baseline.txt
  $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh
    ::error::api/openapi-handler-exceptions-baseline.txt must contain
    a single non-negative integer; got: 'abc'

Negative test (rest-deferred over baseline → guard fails):
  $ echo "27" > api/openapi-handler-exceptions-baseline.txt
  $ bash scripts/ci-guards/openapi-rest-deferred-monotonic.sh
    ::error::rest-deferred bucket grew: 28 > baseline 27.

Negative test (missing category → parity script fails):
  $ # delete first 'category: wire-protocol' line
  $ bash scripts/ci-guards/openapi-handler-parity.sh
    ::error::api/openapi-handler-exceptions.yaml: 1 entries missing
    required `category:` field:
      GET /scep

Ambiguous entries surfaced for operator review
==============================================
None. Every entry's category derived deterministically from the
3-rule decision tree (RFC anchor → wire-protocol; wire/sibling/
shorthand keyword in `why:` → wire-protocol; route prefix matches
wire-protocol family → wire-protocol; otherwise rest-deferred).

Closes: Phase 13 Sprint 13.1 of the certctl architecture diligence
remediation (ARCH-H1 structural categorization). Unblocks Sprints
13.4-13.6 (OpenAPI authoring batches against the rest-deferred
bucket).
This commit is contained in:
shankar0123
2026-05-14 11:18:12 +00:00
parent 558d350933
commit 67f346cd87
5 changed files with 316 additions and 37 deletions
@@ -0,0 +1 @@
28
+118 -15
View File
@@ -1,48 +1,95 @@
# Routes registered in internal/api/router/router.go that are intentionally # Routes registered in internal/api/router/router.go that are intentionally
# NOT in api/openapi.yaml. Each entry needs a one-line `why:` justification. # NOT in api/openapi.yaml. Each entry needs a one-line `why:` justification
# AND a required `category:` field (added in Phase 13 Sprint 13.1,
# 2026-05-14, architecture diligence audit ARCH-H1).
#
# Adding a new entry requires PR-time review. # Adding a new entry requires PR-time review.
# #
# OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here. # OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here.
# This list is for protocol-shaped (SCEP wire endpoints) and operational # This list is for protocol-shaped (SCEP/ACME/EST wire endpoints) and
# (health, metrics, pprof) routes only. # operational (health, metrics, pprof) routes only.
# #
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11. # Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
# #
# Phase 5 reconciliation (2026-05-13, architecture diligence audit # ──────────────────────────────────────────────────────────────────────
# ARCH-H1): of the 64 entries below, 35 are legitimate wire-protocol # The two-bucket contract (Phase 13 Sprint 13.1)
# carve-outs (SCEP RFC 8894 = 8 entries, ACME RFC 8555 default + per- # ──────────────────────────────────────────────────────────────────────
# profile = 27 entries) that MUST stay. The remaining 29 are REST-
# shaped routes whose OpenAPI ops were deferred during their original
# Bundle 2 / audit-2026-05-10 / 2026-05-11 work. Burn-down plan:
# #
# Sprint A (per-cluster, ~7-8 ops each): # category: wire-protocol
# Cluster 1: auth/sessions + auth/oidc (12 ops) # The route's wire shape is dictated by an IETF RFC (SCEP RFC 8894,
# Cluster 2: auth/breakglass + auth/users + auth/runtime-config (8 ops) # ACME RFC 8555, ACME ARI RFC 9773, EST RFC 7030) or it's a
# Cluster 3: audit/export + demo-residual/cleanup + auth/logout + # sibling/shorthand variant of such a route (same wire semantics,
# auth/breakglass/login + auth/oidc/{login,callback,bcl} (9 ops) # different cosmetic path — e.g. trailing-slash forms, default-
# profile shorthands). Documenting these as REST operations in
# openapi.yaml would duplicate the RFC with no information gain;
# the canonical operator references live in docs/acme-server.md +
# docs/operator/scep.md + docs/operator/est.md. These entries
# NEVER burn down — they're protocol contracts, not gaps.
#
# category: rest-deferred
# The route is REST-shaped (resource CRUD, JSON request/response,
# RBAC-gated) but its OpenAPI operation was deferred when the
# handler shipped. These MUST monotonically decrease to zero.
# Phase 13 Sprints 13.4-13.6 author the OpenAPI ops + delete the
# corresponding exception entries; the
# openapi-rest-deferred-monotonic.sh CI guard fails any PR that
# grows the rest-deferred bucket vs the checked-in baseline at
# api/openapi-handler-exceptions-baseline.txt.
#
# ──────────────────────────────────────────────────────────────────────
# Phase 13 Sprint 13.1 categorization (2026-05-14)
# ──────────────────────────────────────────────────────────────────────
#
# Current split, re-derived by the parity script's bucket-reporting
# subcommand:
#
# total entries: 64
# wire-protocol: 36
# rest-deferred: 28
#
# Burn-down plan for the rest-deferred bucket (Phase 13 Sprints 13.4-13.6):
#
# Sprint 13.4 → 28 - 13 = 15 (auth/sessions + auth/oidc cluster, 13 ops)
# Sprint 13.5 → 15 - 8 = 7 (auth/breakglass + auth/users +
# auth/runtime-config, 8 ops)
# Sprint 13.6 → 7 - 7 = 0 (audit/export + demo-residual + 3
# OIDC browser flows + auth/logout +
# auth/breakglass/login, 7 ops)
#
# Sprint 13.7 then tightens the parity-script's rest-deferred floor
# from monotonic-decrease to a hard zero-exact pin. After that, any
# new REST route MUST land with an OpenAPI op or fail CI.
# #
# Each authored OpenAPI op needs request/response schemas (not # Each authored OpenAPI op needs request/response schemas (not
# placeholders) so the generated client at web/orval.config.ts emits # placeholders) so the generated client at web/orval.config.ts emits
# typed signatures. When an op lands, delete the corresponding entry # typed signatures. When an op lands, delete the corresponding entry
# below + bump the openapi-handler-parity.sh expected counts. # below + bump api/openapi-handler-exceptions-baseline.txt downward.
documented_exceptions: documented_exceptions:
- route: "GET /scep" - route: "GET /scep"
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; serves CA certs via GetCACert/GetCACaps query params, NOT a REST resource." why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; serves CA certs via GetCACert/GetCACaps query params, NOT a REST resource."
category: wire-protocol
- route: "POST /scep" - route: "POST /scep"
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource." why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource."
category: wire-protocol
- route: "GET /scep/" - route: "GET /scep/"
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form." why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
category: wire-protocol
- route: "POST /scep/" - route: "POST /scep/"
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form." why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
category: wire-protocol
- route: "GET /scep-mtls" - route: "GET /scep-mtls"
why: "SCEP-mTLS sibling endpoint per ci-pipeline-cleanup-prerequisite EST RFC 7030 hardening Phase 6.5; same wire-protocol semantics, mutually-authenticated TLS variant." why: "SCEP-mTLS sibling endpoint per ci-pipeline-cleanup-prerequisite EST RFC 7030 hardening Phase 6.5; same wire-protocol semantics, mutually-authenticated TLS variant."
category: wire-protocol
- route: "POST /scep-mtls" - route: "POST /scep-mtls"
why: "SCEP-mTLS sibling endpoint, POST variant." why: "SCEP-mTLS sibling endpoint, POST variant."
category: wire-protocol
- route: "GET /scep-mtls/" - route: "GET /scep-mtls/"
why: "SCEP-mTLS sibling endpoint, trailing-slash variant." why: "SCEP-mTLS sibling endpoint, trailing-slash variant."
category: wire-protocol
- route: "POST /scep-mtls/" - route: "POST /scep-mtls/"
why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant." why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant."
category: wire-protocol
# ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface. # ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface.
# Like SCEP/EST, ACME is a JWS-signed-JSON wire protocol whose # Like SCEP/EST, ACME is a JWS-signed-JSON wire protocol whose
@@ -54,62 +101,90 @@ documented_exceptions:
# challenge, cert, key-change, revoke-cert, renewal-info routes land. # challenge, cert, key-change, revoke-cert, renewal-info routes land.
- route: "GET /acme/profile/{id}/directory" - route: "GET /acme/profile/{id}/directory"
why: "ACME server RFC 8555 §7.1.1 directory; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.1.1 directory; documented in docs/acme-server.md."
category: wire-protocol
- route: "HEAD /acme/profile/{id}/new-nonce" - route: "HEAD /acme/profile/{id}/new-nonce"
why: "ACME server RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md."
category: wire-protocol
- route: "GET /acme/profile/{id}/new-nonce" - route: "GET /acme/profile/{id}/new-nonce"
why: "ACME server RFC 8555 §7.2 new-nonce GET form; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.2 new-nonce GET form; documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/profile/{id}/new-account" - route: "POST /acme/profile/{id}/new-account"
why: "ACME server RFC 8555 §7.3 new-account (JWS jwk); documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.3 new-account (JWS jwk); documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/profile/{id}/account/{acc_id}" - route: "POST /acme/profile/{id}/account/{acc_id}"
why: "ACME server RFC 8555 §7.3.2 + §7.3.6 (JWS kid) account update + deactivation; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.3.2 + §7.3.6 (JWS kid) account update + deactivation; documented in docs/acme-server.md."
category: wire-protocol
- route: "GET /acme/directory" - route: "GET /acme/directory"
why: "ACME server default-profile shorthand; mirrors per-profile when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set." why: "ACME server default-profile shorthand; mirrors per-profile when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set."
category: wire-protocol
- route: "HEAD /acme/new-nonce" - route: "HEAD /acme/new-nonce"
why: "ACME server default-profile shorthand for new-nonce HEAD." why: "ACME server default-profile shorthand for new-nonce HEAD."
category: wire-protocol
- route: "GET /acme/new-nonce" - route: "GET /acme/new-nonce"
why: "ACME server default-profile shorthand for new-nonce GET." why: "ACME server default-profile shorthand for new-nonce GET."
category: wire-protocol
- route: "POST /acme/new-account" - route: "POST /acme/new-account"
why: "ACME server default-profile shorthand for new-account." why: "ACME server default-profile shorthand for new-account."
category: wire-protocol
- route: "POST /acme/account/{acc_id}" - route: "POST /acme/account/{acc_id}"
why: "ACME server default-profile shorthand for account update + deactivation." why: "ACME server default-profile shorthand for account update + deactivation."
category: wire-protocol
# Phase 2 — orders + finalize + authz + cert. # Phase 2 — orders + finalize + authz + cert.
- route: "POST /acme/profile/{id}/new-order" - route: "POST /acme/profile/{id}/new-order"
why: "ACME server RFC 8555 §7.4 new-order; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.4 new-order; documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/profile/{id}/order/{ord_id}" - route: "POST /acme/profile/{id}/order/{ord_id}"
why: "ACME server RFC 8555 §7.4 order POST-as-GET; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.4 order POST-as-GET; documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/profile/{id}/order/{ord_id}/finalize" - route: "POST /acme/profile/{id}/order/{ord_id}/finalize"
why: "ACME server RFC 8555 §7.4 finalize; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.4 finalize; documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/profile/{id}/authz/{authz_id}" - route: "POST /acme/profile/{id}/authz/{authz_id}"
why: "ACME server RFC 8555 §7.5 authz POST-as-GET; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.5 authz POST-as-GET; documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/profile/{id}/challenge/{chall_id}" - route: "POST /acme/profile/{id}/challenge/{chall_id}"
why: "ACME server RFC 8555 §7.5.1 challenge response; dispatches to Phase 3 validator pool." why: "ACME server RFC 8555 §7.5.1 challenge response; dispatches to Phase 3 validator pool."
category: wire-protocol
- route: "POST /acme/profile/{id}/cert/{cert_id}" - route: "POST /acme/profile/{id}/cert/{cert_id}"
why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/new-order" - route: "POST /acme/new-order"
why: "Phase 2 default-profile shorthand for new-order." why: "Phase 2 default-profile shorthand for new-order."
category: wire-protocol
- route: "POST /acme/order/{ord_id}" - route: "POST /acme/order/{ord_id}"
why: "Phase 2 default-profile shorthand for order POST-as-GET." why: "Phase 2 default-profile shorthand for order POST-as-GET."
category: wire-protocol
- route: "POST /acme/order/{ord_id}/finalize" - route: "POST /acme/order/{ord_id}/finalize"
why: "Phase 2 default-profile shorthand for finalize." why: "Phase 2 default-profile shorthand for finalize."
category: wire-protocol
- route: "POST /acme/authz/{authz_id}" - route: "POST /acme/authz/{authz_id}"
why: "Phase 2 default-profile shorthand for authz POST-as-GET." why: "Phase 2 default-profile shorthand for authz POST-as-GET."
category: wire-protocol
- route: "POST /acme/challenge/{chall_id}" - route: "POST /acme/challenge/{chall_id}"
why: "Phase 3 default-profile shorthand for challenge response." why: "Phase 3 default-profile shorthand for challenge response."
category: wire-protocol
- route: "POST /acme/cert/{cert_id}" - route: "POST /acme/cert/{cert_id}"
why: "Phase 2 default-profile shorthand for cert download." why: "Phase 2 default-profile shorthand for cert download."
category: wire-protocol
- route: "POST /acme/profile/{id}/key-change" - route: "POST /acme/profile/{id}/key-change"
why: "ACME server RFC 8555 §7.3.5 doubly-signed key rollover; documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.3.5 doubly-signed key rollover; documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/profile/{id}/revoke-cert" - route: "POST /acme/profile/{id}/revoke-cert"
why: "ACME server RFC 8555 §7.6 revoke-cert (kid OR cert-key auth); documented in docs/acme-server.md." why: "ACME server RFC 8555 §7.6 revoke-cert (kid OR cert-key auth); documented in docs/acme-server.md."
category: wire-protocol
- route: "GET /acme/profile/{id}/renewal-info/{cert_id}" - route: "GET /acme/profile/{id}/renewal-info/{cert_id}"
why: "ACME server RFC 9773 ACME Renewal Information (unauthenticated GET); documented in docs/acme-server.md." why: "ACME server RFC 9773 ACME Renewal Information (unauthenticated GET); documented in docs/acme-server.md."
category: wire-protocol
- route: "POST /acme/key-change" - route: "POST /acme/key-change"
why: "Phase 4 default-profile shorthand for key rollover." why: "Phase 4 default-profile shorthand for key rollover."
category: wire-protocol
- route: "POST /acme/revoke-cert" - route: "POST /acme/revoke-cert"
why: "Phase 4 default-profile shorthand for revoke-cert." why: "Phase 4 default-profile shorthand for revoke-cert."
category: wire-protocol
- route: "GET /acme/renewal-info/{cert_id}" - route: "GET /acme/renewal-info/{cert_id}"
why: "Phase 4 default-profile shorthand for ARI." why: "Phase 4 default-profile shorthand for ARI."
category: wire-protocol
# ============================================================================= # =============================================================================
# Auth Bundle 2 + audit-2026-05-10/11 fix bundle — REST endpoints not yet # Auth Bundle 2 + audit-2026-05-10/11 fix bundle — REST endpoints not yet
@@ -121,57 +196,85 @@ documented_exceptions:
# ============================================================================= # =============================================================================
- route: "GET /auth/oidc/login" - route: "GET /auth/oidc/login"
why: "Bundle 2 Phase 5 OIDC login redirect; user-facing 302 with state cookie. OpenAPI rep deferred to pre-2.2.0." why: "Bundle 2 Phase 5 OIDC login redirect; user-facing 302 with state cookie. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "GET /auth/oidc/callback" - route: "GET /auth/oidc/callback"
why: "Bundle 2 Phase 5 OIDC callback handler; RFC 9700 §4.7.1 + RFC 9207. OpenAPI rep deferred to pre-2.2.0." why: "Bundle 2 Phase 5 OIDC callback handler; RFC 9700 §4.7.1 + RFC 9207. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "POST /auth/logout" - route: "POST /auth/logout"
why: "Bundle 2 Phase 5 cookie + CSRF revoker. OpenAPI rep deferred to pre-2.2.0." why: "Bundle 2 Phase 5 cookie + CSRF revoker. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "POST /auth/breakglass/login" - route: "POST /auth/breakglass/login"
why: "Bundle 2 Phase 7.5 public break-glass login (auth-bypass, 404 when disabled). OpenAPI rep deferred to pre-2.2.0." why: "Bundle 2 Phase 7.5 public break-glass login (auth-bypass, 404 when disabled). OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "POST /auth/oidc/back-channel-logout" - route: "POST /auth/oidc/back-channel-logout"
why: "Bundle 2 Phase 5 RFC OIDC Back-Channel Logout 1.0 endpoint. OpenAPI rep deferred to pre-2.2.0." why: "Bundle 2 Phase 5 RFC OIDC Back-Channel Logout 1.0 endpoint. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "GET /api/v1/auth/sessions" - route: "GET /api/v1/auth/sessions"
why: "Bundle 2 Phase 5 self/admin session list. OpenAPI rep deferred to pre-2.2.0." why: "Bundle 2 Phase 5 self/admin session list. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "DELETE /api/v1/auth/sessions/{id}" - route: "DELETE /api/v1/auth/sessions/{id}"
why: "Bundle 2 Phase 5 session revoke. OpenAPI rep deferred to pre-2.2.0." why: "Bundle 2 Phase 5 session revoke. OpenAPI rep deferred to pre-2.2.0."
category: rest-deferred
- route: "DELETE /api/v1/auth/sessions" - route: "DELETE /api/v1/auth/sessions"
why: "Bundle 2 audit-2026-05-10 MED-2/3 revoke-all-except-current." why: "Bundle 2 audit-2026-05-10 MED-2/3 revoke-all-except-current."
category: rest-deferred
- route: "GET /api/v1/auth/oidc/providers" - route: "GET /api/v1/auth/oidc/providers"
why: "Bundle 2 Phase 5 OIDC provider CRUD (list)." why: "Bundle 2 Phase 5 OIDC provider CRUD (list)."
category: rest-deferred
- route: "POST /api/v1/auth/oidc/providers" - route: "POST /api/v1/auth/oidc/providers"
why: "Bundle 2 Phase 5 OIDC provider CRUD (create)." why: "Bundle 2 Phase 5 OIDC provider CRUD (create)."
category: rest-deferred
- route: "PUT /api/v1/auth/oidc/providers/{id}" - route: "PUT /api/v1/auth/oidc/providers/{id}"
why: "Bundle 2 Phase 5 OIDC provider CRUD (update)." why: "Bundle 2 Phase 5 OIDC provider CRUD (update)."
category: rest-deferred
- route: "DELETE /api/v1/auth/oidc/providers/{id}" - route: "DELETE /api/v1/auth/oidc/providers/{id}"
why: "Bundle 2 Phase 5 OIDC provider CRUD (delete)." why: "Bundle 2 Phase 5 OIDC provider CRUD (delete)."
category: rest-deferred
- route: "POST /api/v1/auth/oidc/providers/{id}/refresh" - route: "POST /api/v1/auth/oidc/providers/{id}/refresh"
why: "Bundle 2 audit-2026-05-10 MED-7 JWKS hot-refresh." why: "Bundle 2 audit-2026-05-10 MED-7 JWKS hot-refresh."
category: rest-deferred
- route: "GET /api/v1/auth/oidc/providers/{id}/jwks-status" - route: "GET /api/v1/auth/oidc/providers/{id}/jwks-status"
why: "Bundle 2 audit-2026-05-10 MED-7 JWKS health snapshot." why: "Bundle 2 audit-2026-05-10 MED-7 JWKS health snapshot."
category: rest-deferred
- route: "POST /api/v1/auth/oidc/test" - route: "POST /api/v1/auth/oidc/test"
why: "Bundle 2 audit-2026-05-10 MED-5 dry-run discovery + JWKS + alg-downgrade check." why: "Bundle 2 audit-2026-05-10 MED-5 dry-run discovery + JWKS + alg-downgrade check."
category: rest-deferred
- route: "GET /api/v1/auth/oidc/group-mappings" - route: "GET /api/v1/auth/oidc/group-mappings"
why: "Bundle 2 Phase 5 group-mapping CRUD (list)." why: "Bundle 2 Phase 5 group-mapping CRUD (list)."
category: rest-deferred
- route: "POST /api/v1/auth/oidc/group-mappings" - route: "POST /api/v1/auth/oidc/group-mappings"
why: "Bundle 2 Phase 5 group-mapping CRUD (create)." why: "Bundle 2 Phase 5 group-mapping CRUD (create)."
category: rest-deferred
- route: "DELETE /api/v1/auth/oidc/group-mappings/{id}" - route: "DELETE /api/v1/auth/oidc/group-mappings/{id}"
why: "Bundle 2 Phase 5 group-mapping CRUD (delete)." why: "Bundle 2 Phase 5 group-mapping CRUD (delete)."
category: rest-deferred
- route: "GET /api/v1/auth/breakglass/credentials" - route: "GET /api/v1/auth/breakglass/credentials"
why: "Bundle 2 Phase 7.5 admin break-glass list (404 when disabled; password hash never on wire)." why: "Bundle 2 Phase 7.5 admin break-glass list (404 when disabled; password hash never on wire)."
category: rest-deferred
- route: "POST /api/v1/auth/breakglass/credentials" - route: "POST /api/v1/auth/breakglass/credentials"
why: "Bundle 2 Phase 7.5 admin break-glass set/rotate password." why: "Bundle 2 Phase 7.5 admin break-glass set/rotate password."
category: rest-deferred
- route: "POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock" - route: "POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock"
why: "Bundle 2 Phase 7.5 admin break-glass unlock after lockout." why: "Bundle 2 Phase 7.5 admin break-glass unlock after lockout."
category: rest-deferred
- route: "DELETE /api/v1/auth/breakglass/credentials/{actor_id}" - route: "DELETE /api/v1/auth/breakglass/credentials/{actor_id}"
why: "Bundle 2 Phase 7.5 admin break-glass credential delete." why: "Bundle 2 Phase 7.5 admin break-glass credential delete."
category: rest-deferred
- route: "GET /api/v1/auth/users" - route: "GET /api/v1/auth/users"
why: "Bundle 2 audit-2026-05-10 MED-11 users page." why: "Bundle 2 audit-2026-05-10 MED-11 users page."
category: rest-deferred
- route: "DELETE /api/v1/auth/users/{id}" - route: "DELETE /api/v1/auth/users/{id}"
why: "Bundle 2 audit-2026-05-10 MED-11 user deactivate." why: "Bundle 2 audit-2026-05-10 MED-11 user deactivate."
category: rest-deferred
- route: "POST /api/v1/auth/users/{id}/reactivate" - route: "POST /api/v1/auth/users/{id}/reactivate"
why: "Bundle 2 audit-2026-05-10 MED-11 user reactivate." why: "Bundle 2 audit-2026-05-10 MED-11 user reactivate."
category: rest-deferred
- route: "GET /api/v1/auth/runtime-config" - route: "GET /api/v1/auth/runtime-config"
why: "Bundle 2 audit-2026-05-10 MED-12 effective auth-runtime-config (read-only)." why: "Bundle 2 audit-2026-05-10 MED-12 effective auth-runtime-config (read-only)."
category: rest-deferred
- route: "POST /api/v1/auth/demo-residual/cleanup" - route: "POST /api/v1/auth/demo-residual/cleanup"
why: "Audit 2026-05-11 A-8 demo-mode residual-grants cleanup endpoint." why: "Audit 2026-05-11 A-8 demo-mode residual-grants cleanup endpoint."
category: rest-deferred
- route: "GET /api/v1/audit/export" - route: "GET /api/v1/audit/export"
why: "Bundle 1 Phase 8 streaming NDJSON audit export." why: "Bundle 1 Phase 8 streaming NDJSON audit export."
category: rest-deferred
+33
View File
@@ -81,6 +81,8 @@ Count: re-derive on demand via `ls scripts/ci-guards/*.sh | wc -l`. The table be
| `bundle-8-M-009-bare-usemutation` | M-009 + M-029 mutation contract | Bare `useMutation()` outside `useTrackedMutation` wrapper | | `bundle-8-M-009-bare-usemutation` | M-009 + M-029 mutation contract | Bare `useMutation()` outside `useTrackedMutation` wrapper |
| `H-1-encryption-key-min-length` | H-1 closure follow-up (post-Phase-5 surfacing) | `CERTCTL_CONFIG_ENCRYPTION_KEY` literal in any `deploy/docker-compose*.yml` shorter than the 32-byte floor enforced by `internal/config/config.go::Validate()` | | `H-1-encryption-key-min-length` | H-1 closure follow-up (post-Phase-5 surfacing) | `CERTCTL_CONFIG_ENCRYPTION_KEY` literal in any `deploy/docker-compose*.yml` shorter than the 32-byte floor enforced by `internal/config/config.go::Validate()` |
| `test-compose-scep-coherence` | post-Phase-5 surfacing of dead SCEP test config | `CERTCTL_SCEP_ENABLED=true` in test compose without (a) a CI job that runs the SCEP integration test, (b) the `ra.crt` + `ra.key` + `intune_trust_anchor.pem` fixtures committed to `deploy/test/fixtures/`, AND (c) the matching volume mount | | `test-compose-scep-coherence` | post-Phase-5 surfacing of dead SCEP test config | `CERTCTL_SCEP_ENABLED=true` in test compose without (a) a CI job that runs the SCEP integration test, (b) the `ra.crt` + `ra.key` + `intune_trust_anchor.pem` fixtures committed to `deploy/test/fixtures/`, AND (c) the matching volume mount |
| `openapi-handler-parity` | ARCH-H1 OpenAPI ↔ handler drift | Router routes vs OpenAPI operations vs documented exceptions (wire-protocol vs rest-deferred buckets). Supports `--bucket=wire-protocol\|rest-deferred` subcommand for sibling guards. |
| `openapi-rest-deferred-monotonic` | ARCH-H1 Phase 13 Sprint 13.1 — rest-deferred bucket monotonic-decrease | `category: rest-deferred` count growing vs the checked-in baseline at `api/openapi-handler-exceptions-baseline.txt`. Sprints 13.4-13.6 drive this to zero; Sprint 13.7 tightens to a zero-exact pin. |
### Forward-looking guards (Auditable Codebase Bundle, post-v2.1.0 anti-rot) ### Forward-looking guards (Auditable Codebase Bundle, post-v2.1.0 anti-rot)
@@ -104,3 +106,34 @@ for g in scripts/ci-guards/*.sh; do
bash "$g" || echo " FAILED" bash "$g" || echo " FAILED"
done done
``` ```
## ARCH-H1 OpenAPI exception two-bucket contract (Phase 13 Sprint 13.1)
`api/openapi-handler-exceptions.yaml` lists every router route that is intentionally NOT in `api/openapi.yaml`. Each entry carries a required `category:` field with one of two values:
- **`category: wire-protocol`** — the route's wire shape is dictated by an IETF RFC (SCEP RFC 8894, ACME RFC 8555, ACME ARI RFC 9773, EST RFC 7030) or it's a sibling/shorthand variant of one. The canonical reference for these endpoints lives in `docs/acme-server.md` + `docs/operator/scep.md` + `docs/operator/est.md` — duplicating their wire contract in `openapi.yaml` would add no information. **Wire-protocol entries never burn down.**
- **`category: rest-deferred`** — the route is REST-shaped (resource CRUD, JSON request/response, RBAC-gated) but its OpenAPI operation was deferred when the handler shipped. **Rest-deferred entries must monotonically decrease to zero.** Authoring an OpenAPI op for a deferred route + deleting the corresponding exception entry + decrementing `api/openapi-handler-exceptions-baseline.txt` in the same PR is the canonical close path.
### Adding a new exception entry
The default category for new entries is `rest-deferred`. Only set `wire-protocol` when:
1. The `why:` field cites a specific RFC anchor (e.g. "RFC 8555 §7.1.1 directory"), AND
2. The route's wire shape is dictated by the RFC (not a REST resource that happens to live alongside one).
When in doubt, default to `rest-deferred` and author the OpenAPI op. The two guards in this directory enforce both buckets:
- `openapi-handler-parity.sh` reports bucket counts + fails on missing/unknown `category:` fields + fails on stale exceptions / undocumented router routes.
- `openapi-rest-deferred-monotonic.sh` fails if `rest-deferred` grows vs the baseline file at `api/openapi-handler-exceptions-baseline.txt`.
### Inspecting bucket counts
```bash
# Full report.
bash scripts/ci-guards/openapi-handler-parity.sh
# Just one bucket count (used by sibling guards).
bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
```
+80 -22
View File
@@ -7,34 +7,57 @@
# #
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11. # Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
# #
# Phase 5 reconciliation (2026-05-13): # Phase 13 Sprint 13.1 (2026-05-14) — every entry in the exceptions
# 220 r.Register call sites in internal/api/router/router.go # YAML now carries a required `category: wire-protocol | rest-deferred`
# 209 unique (METHOD /path) router routes after de-duplication # field. This script reports the two buckets alongside the total. The
# 158 operationIds in api/openapi.yaml # rest-deferred bucket is gated by a sibling guard
# 64 documented exceptions in api/openapi-handler-exceptions.yaml # (openapi-rest-deferred-monotonic.sh) against a checked-in baseline
# 0 unaccounted router routes — every route is in OpenAPI OR # at api/openapi-handler-exceptions-baseline.txt.
# in the exceptions YAML. Guard passes clean today.
# #
# Of the 64 exceptions: # Current state (2026-05-14):
# 35 wire-protocol carve-outs (SCEP RFC 8894 = 8, ACME RFC 8555 # 220 r.Register / r.mux.Handle call sites in internal/api/router/router.go
# default + per-profile = 27). These MUST stay as exceptions — # 158 operationIds in api/openapi.yaml
# they're protocol contracts, not REST resources. # 64 documented exceptions (36 wire-protocol + 28 rest-deferred)
# 29 REST-shaped routes deferred from openapi.yaml authoring # 0 unaccounted router routes — guard passes clean today.
# (auth sessions, OIDC providers admin, breakglass admin, #
# users mgmt, runtime-config, demo-residual-cleanup, audit # Sprints 13.4-13.6 drive rest-deferred to zero by authoring OpenAPI ops
# export). Burn-down target: author the 29 OpenAPI ops over # for the 28 REST-shaped routes; each batch deletes the corresponding
# the next ~2 sprints so the generated client (web/orval.config.ts) # exception entries + bumps the baseline file downward. Sprint 13.7
# covers them. Tracked under ARCH-H1 in # tightens this guard's rest-deferred floor from "monotonic-decrease"
# cowork/certctl-architecture-diligence-audit.html. # (sibling guard) to a hard zero-exact pin (this guard).
# #
# Going forward: any new gap (in either direction) fails the build # Going forward: any new gap (in either direction) fails the build
# unless documented in the exceptions YAML. # unless documented in the exceptions YAML with a category.
#
# Subcommand:
# bash scripts/ci-guards/openapi-handler-parity.sh
# Full parity check + bucket reporting.
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
# Print just the count for the named bucket (used by sibling guards
# + Sprint 13.7's zero-exact pin). Exit 0 always; informational.
set -e set -e
python3 - <<'PY' BUCKET=""
case "${1:-}" in
--bucket=wire-protocol|--bucket=rest-deferred)
BUCKET="${1#--bucket=}"
;;
"")
;;
*)
echo "::error::unknown argument: $1"
echo "usage: $0 [--bucket=wire-protocol|--bucket=rest-deferred]"
exit 2
;;
esac
python3 - "$BUCKET" <<'PY'
import re, sys, yaml import re, sys, yaml
bucket_arg = sys.argv[1] if len(sys.argv) > 1 else ""
# Extract router routes: r.mux.Handle("METHOD /path", ...) and # Extract router routes: r.mux.Handle("METHOD /path", ...) and
# r.Register("METHOD /path", ...) — Go 1.22+ ServeMux pattern syntax. # r.Register("METHOD /path", ...) — Go 1.22+ ServeMux pattern syntax.
with open('internal/api/router/router.go') as f: with open('internal/api/router/router.go') as f:
@@ -60,20 +83,54 @@ try:
except FileNotFoundError: except FileNotFoundError:
exc_doc = {'documented_exceptions': []} exc_doc = {'documented_exceptions': []}
exception_set = set() exception_set = set()
bucket_counts = {'wire-protocol': 0, 'rest-deferred': 0}
missing_category = []
unknown_category = []
for entry in (exc_doc.get('documented_exceptions') or []): for entry in (exc_doc.get('documented_exceptions') or []):
route_str = entry['route'] route_str = entry['route']
parts = route_str.split(maxsplit=1) parts = route_str.split(maxsplit=1)
if len(parts) == 2: if len(parts) == 2:
exception_set.add((parts[0], parts[1])) exception_set.add((parts[0], parts[1]))
cat = entry.get('category')
if cat is None:
missing_category.append(route_str)
elif cat in bucket_counts:
bucket_counts[cat] += 1
else:
unknown_category.append((route_str, cat))
# --bucket=X subcommand: print just the count, exit 0, no other output.
if bucket_arg in bucket_counts:
print(bucket_counts[bucket_arg])
sys.exit(0)
# Report counts # Report counts
print(f"Router routes: {len(router_set)}") print(f"Router routes: {len(router_set)}")
print(f"OpenAPI operations: {len(oapi_set)}") print(f"OpenAPI operations: {len(oapi_set)}")
print(f"Documented exceptions: {len(exception_set)}") print(f"Documented exceptions: {len(exception_set)}")
print(f" wire-protocol: {bucket_counts['wire-protocol']}")
print(f" rest-deferred: {bucket_counts['rest-deferred']}")
print() print()
fail = False fail = False
# Phase 13 Sprint 13.1: every entry MUST have a category. Missing or
# unknown categories fail the build — keeps the bucket math honest.
if missing_category:
print(f"::error::api/openapi-handler-exceptions.yaml: {len(missing_category)} entries missing required `category:` field:")
for r in missing_category:
print(f" {r}")
print()
print("Add `category: wire-protocol` (with an RFC anchor in `why:`) or")
print("`category: rest-deferred` (OpenAPI op deferred) to each entry.")
fail = True
if unknown_category:
print(f"::error::api/openapi-handler-exceptions.yaml: {len(unknown_category)} entries with unknown category value (must be wire-protocol or rest-deferred):")
for r, c in unknown_category:
print(f" {r} → category: {c}")
fail = True
# Routes in router but NOT in openapi AND NOT in exceptions = drift # Routes in router but NOT in openapi AND NOT in exceptions = drift
router_only_undocumented = router_set - oapi_set - exception_set router_only_undocumented = router_set - oapi_set - exception_set
if router_only_undocumented: if router_only_undocumented:
@@ -84,8 +141,9 @@ if router_only_undocumented:
print("Either:") print("Either:")
print(" (a) Add the operationId to api/openapi.yaml (preferred for REST endpoints), OR") print(" (a) Add the operationId to api/openapi.yaml (preferred for REST endpoints), OR")
print(" (b) Add the route to api/openapi-handler-exceptions.yaml with a one-line `why:` justification") print(" (b) Add the route to api/openapi-handler-exceptions.yaml with a one-line `why:` justification")
print(" (only for protocol-shaped or operational routes — health probes,") print(" AND a `category: wire-protocol | rest-deferred` field (only protocol-shaped")
print(" Prometheus scrape, SCEP/EST/OCSP wire-protocol endpoints, etc.).") print(" or operational routes — health probes, Prometheus scrape, SCEP/EST/ACME")
print(" wire-protocol endpoints, etc. — qualify as wire-protocol).")
fail = True fail = True
# Routes in openapi but NOT in router = orphan operationId # Routes in openapi but NOT in router = orphan operationId
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# scripts/ci-guards/openapi-rest-deferred-monotonic.sh
#
# Phase 13 Sprint 13.1 closure (2026-05-14, architecture diligence audit
# ARCH-H1): the `rest-deferred` exception bucket in
# api/openapi-handler-exceptions.yaml MUST monotonically decrease vs
# the checked-in baseline at api/openapi-handler-exceptions-baseline.txt.
#
# Contract:
# - openapi-handler-exceptions.yaml entries categorized as
# `category: rest-deferred` are REST-shaped routes whose OpenAPI
# op was deferred when the handler shipped. They are gaps, not
# contracts, and must reach zero.
# - This guard reads the current rest-deferred count via the parity
# script's --bucket subcommand, reads the baseline from
# api/openapi-handler-exceptions-baseline.txt, and fails if the
# current count exceeds the baseline.
# - Phase 13 Sprints 13.4-13.6 author the OpenAPI ops for the
# remaining 28 rest-deferred entries; each batch bumps the
# baseline file downward. Sprint 13.7 lands the baseline at 0
# AND tightens the sibling openapi-handler-parity.sh guard to a
# hard zero-exact pin.
#
# Going forward: any PR that adds a new `category: rest-deferred`
# entry without simultaneously bumping the baseline file fails CI.
#
# Operator workflow:
# 1. Land an OpenAPI op for one of the rest-deferred routes.
# 2. Delete the corresponding entry from
# api/openapi-handler-exceptions.yaml.
# 3. Decrement api/openapi-handler-exceptions-baseline.txt by the
# number of entries removed.
# 4. Commit all three changes in the same PR — this guard verifies
# they stay consistent.
set -e
BASELINE_FILE="api/openapi-handler-exceptions-baseline.txt"
if [ ! -f "$BASELINE_FILE" ]; then
echo "::error::missing $BASELINE_FILE — required by Phase 13 Sprint 13.1 contract."
echo ""
echo "Create it with a single integer matching the current rest-deferred count:"
echo " bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred > $BASELINE_FILE"
exit 1
fi
# Whitespace-tolerant read of the baseline.
BASELINE=$(tr -d '[:space:]' < "$BASELINE_FILE")
if ! [[ "$BASELINE" =~ ^[0-9]+$ ]]; then
echo "::error::$BASELINE_FILE must contain a single non-negative integer; got: '$BASELINE'"
exit 1
fi
CURRENT=$(bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred)
if ! [[ "$CURRENT" =~ ^[0-9]+$ ]]; then
echo "::error::openapi-handler-parity.sh --bucket=rest-deferred returned non-integer: '$CURRENT'"
exit 1
fi
if [ "$CURRENT" -gt "$BASELINE" ]; then
echo "::error::rest-deferred bucket grew: $CURRENT > baseline $BASELINE."
echo ""
echo "Phase 13 Sprint 13.1 contract: the rest-deferred bucket in"
echo "api/openapi-handler-exceptions.yaml must monotonically decrease."
echo ""
echo "If you added a new REST route that genuinely cannot be authored into"
echo "openapi.yaml yet (e.g. work-in-progress), surface the rationale in"
echo "the PR description AND get explicit operator sign-off before"
echo "bumping $BASELINE_FILE upward. The default answer is 'author"
echo "the OpenAPI op now instead'."
exit 1
fi
if [ "$CURRENT" -lt "$BASELINE" ]; then
echo "::error::rest-deferred bucket shrank below baseline: $CURRENT < $BASELINE."
echo ""
echo "Authoring an OpenAPI op is the right move — but the baseline file"
echo "at $BASELINE_FILE must be bumped down in the SAME commit so this"
echo "guard's pin tightens automatically. Update it to: $CURRENT"
exit 1
fi
echo "openapi-rest-deferred-monotonic: clean — rest-deferred = $CURRENT, baseline = $BASELINE."