Files
certctl/scripts/ci-guards/openapi-rest-deferred-monotonic.sh
shankar0123 67f346cd87 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).
2026-05-14 11:18:12 +00:00

85 lines
3.6 KiB
Bash
Executable File

#!/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."