From 67f346cd877c1c8d680f37fdf967476ef367ed4e Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 11:18:12 +0000 Subject: [PATCH] =?UTF-8?q?docs(arch-h1):=20Phase=2013=20Sprint=2013.1=20?= =?UTF-8?q?=E2=80=94=20categorize=20OpenAPI=20exceptions=20+=20bucket=20gu?= =?UTF-8?q?ards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ` 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). --- api/openapi-handler-exceptions-baseline.txt | 1 + api/openapi-handler-exceptions.yaml | 133 ++++++++++++++++-- scripts/ci-guards/README.md | 33 +++++ scripts/ci-guards/openapi-handler-parity.sh | 102 +++++++++++--- .../openapi-rest-deferred-monotonic.sh | 84 +++++++++++ 5 files changed, 316 insertions(+), 37 deletions(-) create mode 100644 api/openapi-handler-exceptions-baseline.txt create mode 100755 scripts/ci-guards/openapi-rest-deferred-monotonic.sh diff --git a/api/openapi-handler-exceptions-baseline.txt b/api/openapi-handler-exceptions-baseline.txt new file mode 100644 index 0000000..9902f17 --- /dev/null +++ b/api/openapi-handler-exceptions-baseline.txt @@ -0,0 +1 @@ +28 diff --git a/api/openapi-handler-exceptions.yaml b/api/openapi-handler-exceptions.yaml index f3dda85..df3fc4d 100644 --- a/api/openapi-handler-exceptions.yaml +++ b/api/openapi-handler-exceptions.yaml @@ -1,48 +1,95 @@ # 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. # # OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here. -# This list is for protocol-shaped (SCEP wire endpoints) and operational -# (health, metrics, pprof) routes only. +# This list is for protocol-shaped (SCEP/ACME/EST wire endpoints) and +# operational (health, metrics, pprof) routes only. # # 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 -# 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: +# ────────────────────────────────────────────────────────────────────── +# The two-bucket contract (Phase 13 Sprint 13.1) +# ────────────────────────────────────────────────────────────────────── # -# Sprint A (per-cluster, ~7-8 ops each): -# Cluster 1: auth/sessions + auth/oidc (12 ops) -# Cluster 2: auth/breakglass + auth/users + auth/runtime-config (8 ops) -# Cluster 3: audit/export + demo-residual/cleanup + auth/logout + -# auth/breakglass/login + auth/oidc/{login,callback,bcl} (9 ops) +# 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 such a route (same wire semantics, +# 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 # placeholders) so the generated client at web/orval.config.ts emits # 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: - 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." + category: wire-protocol - route: "POST /scep" why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource." + category: wire-protocol - route: "GET /scep/" why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form." + category: wire-protocol - route: "POST /scep/" why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form." + category: wire-protocol - 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." + category: wire-protocol - route: "POST /scep-mtls" why: "SCEP-mTLS sibling endpoint, POST variant." + category: wire-protocol - route: "GET /scep-mtls/" why: "SCEP-mTLS sibling endpoint, trailing-slash variant." + category: wire-protocol - route: "POST /scep-mtls/" why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant." + category: wire-protocol # ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface. # 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. - route: "GET /acme/profile/{id}/directory" 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" 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" 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" 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}" 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" 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" why: "ACME server default-profile shorthand for new-nonce HEAD." + category: wire-protocol - route: "GET /acme/new-nonce" why: "ACME server default-profile shorthand for new-nonce GET." + category: wire-protocol - route: "POST /acme/new-account" why: "ACME server default-profile shorthand for new-account." + category: wire-protocol - route: "POST /acme/account/{acc_id}" why: "ACME server default-profile shorthand for account update + deactivation." + category: wire-protocol # Phase 2 — orders + finalize + authz + cert. - route: "POST /acme/profile/{id}/new-order" 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}" 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" 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}" 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}" 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}" why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md." + category: wire-protocol - route: "POST /acme/new-order" why: "Phase 2 default-profile shorthand for new-order." + category: wire-protocol - route: "POST /acme/order/{ord_id}" why: "Phase 2 default-profile shorthand for order POST-as-GET." + category: wire-protocol - route: "POST /acme/order/{ord_id}/finalize" why: "Phase 2 default-profile shorthand for finalize." + category: wire-protocol - route: "POST /acme/authz/{authz_id}" why: "Phase 2 default-profile shorthand for authz POST-as-GET." + category: wire-protocol - route: "POST /acme/challenge/{chall_id}" why: "Phase 3 default-profile shorthand for challenge response." + category: wire-protocol - route: "POST /acme/cert/{cert_id}" why: "Phase 2 default-profile shorthand for cert download." + category: wire-protocol - 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." + category: wire-protocol - 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." + category: wire-protocol - 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." + category: wire-protocol - route: "POST /acme/key-change" why: "Phase 4 default-profile shorthand for key rollover." + category: wire-protocol - route: "POST /acme/revoke-cert" why: "Phase 4 default-profile shorthand for revoke-cert." + category: wire-protocol - route: "GET /acme/renewal-info/{cert_id}" 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 @@ -121,57 +196,85 @@ documented_exceptions: # ============================================================================= - 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." + category: rest-deferred - 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." + category: rest-deferred - route: "POST /auth/logout" why: "Bundle 2 Phase 5 cookie + CSRF revoker. OpenAPI rep deferred to pre-2.2.0." + category: rest-deferred - 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." + category: rest-deferred - 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." + category: rest-deferred - route: "GET /api/v1/auth/sessions" 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}" why: "Bundle 2 Phase 5 session revoke. OpenAPI rep deferred to pre-2.2.0." + category: rest-deferred - route: "DELETE /api/v1/auth/sessions" why: "Bundle 2 audit-2026-05-10 MED-2/3 revoke-all-except-current." + category: rest-deferred - route: "GET /api/v1/auth/oidc/providers" why: "Bundle 2 Phase 5 OIDC provider CRUD (list)." + category: rest-deferred - route: "POST /api/v1/auth/oidc/providers" why: "Bundle 2 Phase 5 OIDC provider CRUD (create)." + category: rest-deferred - route: "PUT /api/v1/auth/oidc/providers/{id}" why: "Bundle 2 Phase 5 OIDC provider CRUD (update)." + category: rest-deferred - route: "DELETE /api/v1/auth/oidc/providers/{id}" why: "Bundle 2 Phase 5 OIDC provider CRUD (delete)." + category: rest-deferred - route: "POST /api/v1/auth/oidc/providers/{id}/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" why: "Bundle 2 audit-2026-05-10 MED-7 JWKS health snapshot." + category: rest-deferred - route: "POST /api/v1/auth/oidc/test" 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" why: "Bundle 2 Phase 5 group-mapping CRUD (list)." + category: rest-deferred - route: "POST /api/v1/auth/oidc/group-mappings" why: "Bundle 2 Phase 5 group-mapping CRUD (create)." + category: rest-deferred - route: "DELETE /api/v1/auth/oidc/group-mappings/{id}" why: "Bundle 2 Phase 5 group-mapping CRUD (delete)." + category: rest-deferred - 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)." + category: rest-deferred - route: "POST /api/v1/auth/breakglass/credentials" 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" why: "Bundle 2 Phase 7.5 admin break-glass unlock after lockout." + category: rest-deferred - route: "DELETE /api/v1/auth/breakglass/credentials/{actor_id}" why: "Bundle 2 Phase 7.5 admin break-glass credential delete." + category: rest-deferred - route: "GET /api/v1/auth/users" why: "Bundle 2 audit-2026-05-10 MED-11 users page." + category: rest-deferred - route: "DELETE /api/v1/auth/users/{id}" why: "Bundle 2 audit-2026-05-10 MED-11 user deactivate." + category: rest-deferred - route: "POST /api/v1/auth/users/{id}/reactivate" why: "Bundle 2 audit-2026-05-10 MED-11 user reactivate." + category: rest-deferred - route: "GET /api/v1/auth/runtime-config" 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" why: "Audit 2026-05-11 A-8 demo-mode residual-grants cleanup endpoint." + category: rest-deferred - route: "GET /api/v1/audit/export" why: "Bundle 1 Phase 8 streaming NDJSON audit export." + category: rest-deferred diff --git a/scripts/ci-guards/README.md b/scripts/ci-guards/README.md index 0a25aa3..b416f49 100644 --- a/scripts/ci-guards/README.md +++ b/scripts/ci-guards/README.md @@ -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 | | `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 | +| `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) @@ -104,3 +106,34 @@ for g in scripts/ci-guards/*.sh; do bash "$g" || echo " FAILED" 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 +``` diff --git a/scripts/ci-guards/openapi-handler-parity.sh b/scripts/ci-guards/openapi-handler-parity.sh index dea5595..1b0d58b 100755 --- a/scripts/ci-guards/openapi-handler-parity.sh +++ b/scripts/ci-guards/openapi-handler-parity.sh @@ -7,34 +7,57 @@ # # Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11. # -# Phase 5 reconciliation (2026-05-13): -# 220 r.Register call sites in internal/api/router/router.go -# 209 unique (METHOD /path) router routes after de-duplication -# 158 operationIds in api/openapi.yaml -# 64 documented exceptions in api/openapi-handler-exceptions.yaml -# 0 unaccounted router routes — every route is in OpenAPI OR -# in the exceptions YAML. Guard passes clean today. +# Phase 13 Sprint 13.1 (2026-05-14) — every entry in the exceptions +# YAML now carries a required `category: wire-protocol | rest-deferred` +# field. This script reports the two buckets alongside the total. The +# rest-deferred bucket is gated by a sibling guard +# (openapi-rest-deferred-monotonic.sh) against a checked-in baseline +# at api/openapi-handler-exceptions-baseline.txt. # -# Of the 64 exceptions: -# 35 wire-protocol carve-outs (SCEP RFC 8894 = 8, ACME RFC 8555 -# default + per-profile = 27). These MUST stay as exceptions — -# they're protocol contracts, not REST resources. -# 29 REST-shaped routes deferred from openapi.yaml authoring -# (auth sessions, OIDC providers admin, breakglass admin, -# users mgmt, runtime-config, demo-residual-cleanup, audit -# export). Burn-down target: author the 29 OpenAPI ops over -# the next ~2 sprints so the generated client (web/orval.config.ts) -# covers them. Tracked under ARCH-H1 in -# cowork/certctl-architecture-diligence-audit.html. +# Current state (2026-05-14): +# 220 r.Register / r.mux.Handle call sites in internal/api/router/router.go +# 158 operationIds in api/openapi.yaml +# 64 documented exceptions (36 wire-protocol + 28 rest-deferred) +# 0 unaccounted router routes — guard passes clean today. +# +# Sprints 13.4-13.6 drive rest-deferred to zero by authoring OpenAPI ops +# for the 28 REST-shaped routes; each batch deletes the corresponding +# exception entries + bumps the baseline file downward. Sprint 13.7 +# tightens this guard's rest-deferred floor from "monotonic-decrease" +# (sibling guard) to a hard zero-exact pin (this guard). # # 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 -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 +bucket_arg = sys.argv[1] if len(sys.argv) > 1 else "" + # Extract router routes: r.mux.Handle("METHOD /path", ...) and # r.Register("METHOD /path", ...) — Go 1.22+ ServeMux pattern syntax. with open('internal/api/router/router.go') as f: @@ -60,20 +83,54 @@ try: except FileNotFoundError: exc_doc = {'documented_exceptions': []} exception_set = set() +bucket_counts = {'wire-protocol': 0, 'rest-deferred': 0} +missing_category = [] +unknown_category = [] for entry in (exc_doc.get('documented_exceptions') or []): route_str = entry['route'] parts = route_str.split(maxsplit=1) if len(parts) == 2: 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 print(f"Router routes: {len(router_set)}") print(f"OpenAPI operations: {len(oapi_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() 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 router_only_undocumented = router_set - oapi_set - exception_set if router_only_undocumented: @@ -84,8 +141,9 @@ if router_only_undocumented: print("Either:") 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(" (only for protocol-shaped or operational routes — health probes,") - print(" Prometheus scrape, SCEP/EST/OCSP wire-protocol endpoints, etc.).") + print(" AND a `category: wire-protocol | rest-deferred` field (only protocol-shaped") + print(" or operational routes — health probes, Prometheus scrape, SCEP/EST/ACME") + print(" wire-protocol endpoints, etc. — qualify as wire-protocol).") fail = True # Routes in openapi but NOT in router = orphan operationId diff --git a/scripts/ci-guards/openapi-rest-deferred-monotonic.sh b/scripts/ci-guards/openapi-rest-deferred-monotonic.sh new file mode 100755 index 0000000..48e2a78 --- /dev/null +++ b/scripts/ci-guards/openapi-rest-deferred-monotonic.sh @@ -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."