Full C++23 framework tree: runtime, observability, security, data, app, realtime, supervisor, clients, CLI, example plus enterprise tenancy, audit, operability, governance, supplychain. Postgres via docker compose for tests.
23 KiB
14 - API Governance
Title: API Governance Scope: plan.md sections 2, 6 partial. Topics: API keys with scopes/quotas/expiry, router versioning and deprecation policy, breaking-change detection from OpenAPI diff, rate-limit tiers, developer-facing API surface rules.
1. Purpose and philosophy
This file owns the contract between MoloApp applications and their API consumers: who may call (keys, scopes), how much they may call (quotas, tiers), which contract version they bind to (router versioning), how that contract retires (deprecation policy), how breakage is caught before release (OpenAPI diff gate), and what a conforming endpoint looks like (surface rules).
Philosophy:
- Explicit contracts, explicit versions. Every public route belongs to exactly one major API version. No unversioned public surface.
- Additive evolution inside a version, new version for breakage. Compatible changes ship in place; incompatible changes ship a new
/v{n}prefix and retire the old one on a schedule. - Machine-checkable governance. Keys, scopes, tiers, versions, deprecations and surface rules are declared data validated at startup and in CI, never tribal knowledge.
- Standards wire format, in-house enforcement. Deprecation/Sunset headers, OpenAPI flags and rate-limit headers follow their specs byte for byte; the checker, linter and gate are dependency-free C++23.
- This file declares policy and detection. Mechanism lives in siblings: crypto and base throttling in 03-security.md, routers and views in 05-request-routing.md, OpenAPI emission in 08-clients-openapi.md, audit trail in 12-audit-compliance.md.
2. Best practices (from research)
- Explicit versioning is mandatory and Major.Minor shaped. Microsoft requires: "All APIs compliant with the Microsoft REST API Guidelines MUST support explicit versioning", versioned "using a Major.Minor versioning scheme", with version in path or
?api-version=query; services that cannot keep URL paths stable across versions MUST embed the version in the URL path (Source: https://github.com/Microsoft/api-guidelines/blob/master/Guidelines.md, retrieved 2026-10-23). Triangulated with 08-clients-openapi.md (/v{n}path prefix rule) and OpenAPIinfo.versionbelow: MoloApp uses path prefix/v{n}(major only) as the single mechanism. - OpenAPI separates spec version from API version and flags retirement per item. The
openapifield "MUST be the version number of the OpenAPI Specification" and "is not related to the APIinfo.versionstring"; Operation and Parameter objects carrydeprecated: booleanwhere consumers "SHOULD refrain from usage" (Source: https://spec.openapis.org/oas/v3.1.0, retrieved 2026-10-23). MoloApp therefore versions the API ininfo.versionplus the/v{n}prefix, and marks deprecation withdeprecated: truederived from router declarations in 08-clients-openapi.md. - Runtime deprecation uses two headers plus a link. RFC 8594 "defines the Sunset HTTP response header field, which indicates that a URI is likely to become unresponsive at a specified point in the future" plus "a sunset link relation type" (Source: https://www.rfc-editor.org/rfc/rfc8594, retrieved 2026-10-23). RFC 9745 adds: "The Deprecation HTTP response header field is used to signal to consumers of a resource (identified by a URI) that the resource will be or has been deprecated", with a
deprecationlink relation; "The act of deprecation does not change any behavior of the resource";Deprecationvalue MUST be a Structured Field Date; "The timestamp given in theSunsetHTTP header field MUST NOT be earlier than the one given in theDeprecationheader field" (Source: https://www.rfc-editor.org/rfc/rfc9745, retrieved 2026-10-23). Triangulated with item 2: static flag in OpenAPI, runtime headers on responses, both from one router declaration. - Scopes are space-delimited least-privilege grants. RFC 6749 section 3.3: "The value of the scope parameter is expressed as a list of space-delimited, case-sensitive strings", issued scope may differ from requested scope per resource-owner policy, and violations use
invalid_scopeerrors (Source: https://www.rfc-editor.org/rfc/rfc6749, retrieved 2026-10-23). MoloApp API keys carry the same grammar (resource.action, space-delimited sets) enforced per route; under-scoped calls fail 403 with a problem-details body. - Rate-limit signalling is converging on two fields plus 429/Retry-After. Draft revision 11 "defines the RateLimit-Policy and RateLimit HTTP header fields for servers to advertise their quota policies" with quota parameters, available-quota parameters, and problem types including "quota-exceeded" (Source: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/, retrieved 2026-10-23). Until the draft becomes an RFC, MoloApp emits both the draft fields (revision pinned in config) and the de-facto
X-RateLimit-*trio, always withRetry-Afteron 429. - Breaking-change detection is a three-mode OpenAPI diff with a rule engine. oasdiff "compare[s] and detect[s] breaking changes in OpenAPI specs":
breakingshows "only the changes that break existing API clients",changelogthe human-readable consumer-affecting list,diffthe full machine-readable definition diff, backed by a customizable checks list (Source: https://github.com/oasdiff/oasdiff, retrieved 2026-10-23). Triangulated with Microsoft section 12.3 (breaking-change definition) and 10-workflow-example.md release gates: MoloApp ships an in-house checker with the same three outputs and fails CI onbreakingunless the major version bumped. - Surface rules stay boring: plural noun collections, verbs in methods, standard codes, problem details, pagination in query. Microsoft guidelines plus 05-request-routing.md/08-clients-openapi.md agree on this shape; this file turns that agreement into a startup linter with numbered checks rather than prose advice.
3. Structure
Root: src/molo/govern/, headers mirrored under include/molo/govern/. One class or concern per file pair. Namespace root molo::govern.
keys/ApiKey.h:struct ApiKey(id, prefix, hash ref, owner, scopes, tier, quota overrides, issued_at, expires_at, revoked flag, last_used).keys/Scope.h:class Scopegrammar (resource.action,*segment rules), parse,allows(route_scope)check.keys/KeyStore.h:class IKeyStoreinterface (lookup by prefix, record use);keys/MemoryKeyStore.h,keys/DbKeyStore.h(ORM-backed, see 04-data.md).keys/KeyIssuer.h: mint, rotate, revoke;keys/KeyAuth.h: middleware hook extractingAuthorization: BearerorX-Api-Key, constant-time verify via 03-security.md.versions/ApiVersion.h:struct ApiVersion(major, status: current/deprecated/sunset, deprecation_date, sunset_date, successor link).versions/VersionedRouter.h: binds aRouter(05-request-routing.md) to/v{n}, declares status and dates, validates uniqueness.versions/DeprecationMiddleware.h: injectsDeprecation,Sunset,Link: rel="deprecation"andrel="successor-version"per policy.diff/SpecSnapshot.h: stored canonical OpenAPI JSON per version (specs/api/v{n}.json).diff/SpecDiffer.h: path/method/schema diff engine over 08-clients-openapi.md documents.diff/BreakingRules.h: numbered check list (see 6.3), severity ERR/WARN/INFO.diff/GovernGate.h: CI verdict (pass/fail with reason codes), major-bump detector.tiers/TierTable.h:struct Tier(name, req/min, req/day, burst, concurrency, quota units);tiers/TierPolicy.h: key-to-tier resolution plus per-route overrides.tiers/QuotaLedger.h: usage counters interface over 04-data.md KV;tiers/TierHeaders.h: RateLimit-Policy/RateLimit plus X-RateLimit-* plus Retry-After emission.surface/SurfaceRules.h: numbered lint checks (see 6.5);surface/RouteLinter.h: runs checks over routers at startup and in CI.
4. Rules (hard, numbered)
- Every public API route belongs to exactly one major version mounted at
/v{n}. Unversioned public routes fail startup. - At most 3 live majors per app; a 4th requires retiring the oldest first. Deprecated majors keep serving until their Sunset date.
- Breaking changes (section 6.3 ERR list) inside one major without a new
/v{n}fail the CI gate. No override flag; ship a new major instead. - Every deprecated route/version emits
DeprecationplusSunsetplusLink rel="deprecation"on every response; Sunset date MUST NOT precede Deprecation date. After Sunset, the route returns 410 Gone, never silent 404. - OpenAPI
deprecated: trueand runtime headers derive from the same router declaration. Hand-editing one without the other is a defect caught by the diff gate. - API keys authenticate the calling application only, never a user. User context comes from 03-security.md sessions or bearer user tokens; mixing the two in one credential is rejected at issue time.
- Every key has scopes (space-delimited
resource.action), an expiry, and a tier. Keys without expiry are rejected at issue. Expired or revoked keys fail 401; valid keys lacking scope fail 403. - Key secrets are shown once at mint, stored as Argon2id hashes (03-security.md), looked up by public prefix. Plaintext secrets never appear in logs, errors, metrics or audit bodies (see 12-audit-compliance.md).
- Rate-limit tiers bind to the 03-security.md limiter; this file defines the tier table and headers, never a second counting algorithm. Every 429 carries
Retry-Afterand the tier headers. - Surface rules in section 6.5 are startup errors for new routes and CI warnings for deprecated ones. New violations never merge.
- All caps in section 6 are static constants with documented defaults. No autotuning of quotas, windows, versions or diff thresholds.
- Governance data (keys metadata, tiers, versions, snapshots) is tenant-scoped where 11-tenancy.md applies; cross-tenant key reuse is impossible by construction.
5. Development guidelines
- Declare first: version, scopes, tier and deprecation dates live on the router/handler declaration; middleware, headers, OpenAPI flags and docs all render from that one declaration.
- Build in section 7 order; each step ends green through the real stack per 10-workflow-example.md (no unit-only key checks, real HTTP with real headers).
- Reuse sibling machinery: hashing and constant-time compare from 03-security.md, routers and error shapes from 05-request-routing.md, OpenAPI builder from 08-clients-openapi.md, KV counters from 04-data.md, metrics/log macros from 02-observability.md.
- Keep the diff deterministic: canonical JSON (sorted keys, fixed floats) before compare, so identical apps produce byte-identical snapshots.
- Errors are RFC 9457 problem details (
application/problem+json) withtypeURIs per reason (expired_key, insufficient_scope, quota_exceeded, version_gone, version_deprecated is a warning header, not an error). - Document every new check (diff or surface) with id, severity, example violation and fix, in the same change that adds it.
6. Specifications (concrete behaviors, caps, formats)
6.1 API keys, scopes, quotas, expiry
- Key format:
molo_<base32id>_<base64urlsecret>; id 16 chars public prefix used for lookup and logs; secret 32 bytes from 03-security.md CSPRNG. Storage:{id, prefix, argon2id_hash, owner, scopes[], tier, quota_override?, issued_at, expires_at, revoked_at?, last_used_at, last_used_ip?}. - Scope grammar:
resource.actionsegments[a-z0-9_]+,*allowed as full segment only (billing.*,*.read); max 64 scopes per key, max 64 chars per scope. Route declaresrequired_scopes[]; request passes when key scopes cover all required (AND) with*expansion. Empty route list means key-only auth, no scope check. - Expiry: default 90 days, max 366 days, min 1 hour; rotation issues a successor with 24 h overlap (
supersedeslink), old key keeps serving 24 h then 401. Revocation is immediate and replicated per 07-supervisor-replication.md within 5 s p99. - Quota binding: key resolves tier from TierTable; per-key
quota_overridemay only lower limits, never raise above tier. Usage ledger keysgov:quota:{key_id}:{window}in 04-data.md KV, TTL = window length. - Caps: 100k keys per tenant, 10k lookups/s per process (in-memory prefix index, overflow spills to DB store with metric), secret verify budget under 1 s via off-loop Argon2id path.
6.2 Router versioning and deprecation policy
- Mount:
VersionedRouter(version=2, status=current)mounts all routes under/v2/...;url_forincludes the version. Major-only integers 1..99. Minor evolution is additive inside the major and tracked ininfo.version(2.3.0) without URL change. - Lifecycle: current -> deprecated (announce: headers on, OpenAPI flag on, changelog entry) -> sunset (date reached: 410 Gone with problem body +
Link rel="successor-version") -> removed (route gone, major slot freed). Minimum deprecation window 90 days; minimum announce-to-sunset 30 days even for pre-1.0 majors. - Headers:
Deprecation: @<unix-seconds>(Structured Field Date per RFC 9745),Sunset: <IMF-fixdate>(RFC 8594),Link: <{guide}>; rel="deprecation",Link: <{successor}>; rel="successor-version". Startup validation rejects Sunset earlier than Deprecation. - Version negotiation: path prefix only; no header or query negotiation in v1 (explicit non-goal, avoids the matrix Microsoft documents as optional). Wrong-major requests get 404 plus
Link rel="successor-version"to the current major, not a silent redirect. - Snapshots: one canonical
specs/api/v{n}.jsonper live major, committed in-repo, regenerated by 08-clients-openapi.md builder, diffed by 6.3 gate.
6.3 Breaking-change detection (OpenAPI diff)
- Modes mirror oasdiff:
molo apidiff breaking <old> <new>(ERR only, exit 1 on any),changelog(ERR+WARN human list),diff(full JSON incl. INFO/doc-only). Same taxonomy: ERR breaks clients, WARN may break, INFO is safe. - ERR (breaking): remove path/method; add required request param/property/header; Narrow type/format/enum (remove enum value, int32 to int, string to number, widen is WARN); raise required-ness; remove response status previously documented; change response property from optional to required; change auth scheme or add required security; reduce rate-limit tier defaults. WARN: add optional field, add enum value, add response status, widen type, deprecate flag flip, description-only edits are INFO.
- Gate: CI regenerates the live-major snapshot, runs
breakingagainst the committed one. Any ERR fails unless the change also adds a new/v{n+1}snapshot carrying the change (major-bump path). Deprecated majors accept INFO/WARN only; any ERR fails unconditionally. - Caps: diff input max 10 MB per document (matches 08-clients-openapi.md cap), max 5000 paths, rule run under 30 s on reference hardware, output max 1 MB (truncated with counter beyond).
6.4 Rate-limit tiers
- Default table:
free60 req/min, 10k req/day, burst 2x/10 s, concurrency 4;pro600 req/min, 1M req/day, burst 2x/60 s, concurrency 32;enterprise6000 req/min, 50M req/day, burst 1.5x/60 s, concurrency 256. All per key, all overridable per route downward only. - Headers on every keyed response:
RateLimit-Policy: "tier";q=<quota>;w=<window-s>andRateLimit: "tier";r=<remaining>;t=<reset-s>(draft-ietf-httpapi-ratelimit-headers rev 11 shape) plusX-RateLimit-Limit/Remaining/Resetfor legacy clients. 429 addsRetry-After: <s>(min(reset, cap 300)) andapplication/problem+jsonbody with typequota-exceeded. - Counting owned by 03-security.md limiter buckets keyed
(tier, key_id, route); this file supplies limits and emits headers. Cross-instance counts converge via 07-supervisor-replication.md shared counters; documented skew bound 2x window edge under partition. - Quota day is UTC midnight to midnight; minute windows are fixed 60 s; burst bucket refills linearly. Exceeding any one dimension returns 429 for that dimension (reason named in body).
6.5 Developer-facing API surface rules (linter)
- S1 nouns not verbs: collection paths are plural nouns (
/v1/users/{id}), no verbs (/getUsers,/users/createfail). S2 method semantics: GET read, POST create, PUT full replace, PATCH partial, DELETE remove. S3 status codes from the fixed set 200/201/204/400/401/403/404/405/406/409/410/415/422/429/500/503 with 410 reserved for sunset routes. - S4 errors are RFC 9457 bodies with stable
typeURIs; S5 pagination is?page=&per_page=(default 20, max 100) or cursor, envelope{data, meta, links}per 08-clients-openapi.md; S6 filtering/sorting in query only; S7 new errorcodevalues require a minor version note, new nested detail never breaks (Microsoft innererror pattern). - S8 auth declared: every route declares
auth: none|key|key+scopes|sessionplus scopes when applicable; undeclared auth fails startup. S9 idempotency: POST create endpoints acceptIdempotency-Key(24 h result store per 08-clients-openapi.md). S10 docs: every route has summary + one example; missing items renderdescription: TBDand fail the strict lint profile.
7. Execution steps (in build order)
- Scopes + key model: Scope grammar, ApiKey struct, IKeyStore + memory backend, KeyIssuer mint/rotate/revoke with 03-security.md hashing.
- Key auth path: KeyAuth middleware (Bearer + X-Api-Key), constant-time verify, 401/403 problem bodies, scope AND-check, per-request key context for tiers and audit.
- Versioned routers: ApiVersion + VersionedRouter mounting
/v{n}, uniqueness and max-3-majors validation,url_forwith version, 410 Gone handler. - Deprecation runtime: DeprecationMiddleware headers (Deprecation/Sunset/Link), date validation (Sunset >= Deprecation), OpenAPI
deprecated: truederivation via 08-clients-openapi.md builder hook. - Tier table + headers: TierTable defaults, key/tier resolution, QuotaLedger over 04-data.md KV, TierHeaders emission (draft + legacy + Retry-After), 429 mapping into 03-security.md limiter.
- Spec snapshots + differ: canonical JSON writer, SpecSnapshot store per major, SpecDiffer path/method/schema engine, INFO/WARN/ERR taxonomy.
- Breaking gate + CLI: BreakingRules full list, GovernGate verdict,
molo apidiff breaking|changelog|diffcommands (CLI surface follows 09-cli-ops.md conventions), CI wiring per 10-workflow-example.md. - Surface linter + docs: RouteLinter S1-S10, startup fail on new-route violations, strict CI profile, developer guide page in living example.
8. Validation (how to verify each step)
- Step 1: mint 1k keys, assert prefix lookup, hash-only storage (dump contains no secret), expiry/rotation windows (old serves 24 h then 401), revoke immediacy, scope parse/expand matrix incl.
*edges and 65-scope rejection. - Step 2: real-HTTP matrix: no key 401, expired 401, revoked 401, wrong scope 403 with
insufficient_scope, correct 200; key context visible to audit probe; verify latency within 03-security.md budget. - Step 3: mount v1+v2, assert prefix routing, triple-mount path merge still works under
/v{n}, 4th major fails startup, sunset date reached returns 410 with successor link,url_forround-trips. - Step 4: deprecated route returns all four headers with Sunset >= Deprecation; generated OpenAPI shows
deprecated: truefor the same routes; misconfigured dates refuse boot with named error. - Step 5: tier matrix per key (free/pro/enterprise + override-down) enforced through real burst traffic; every response carries both header families; 429 carries Retry-After and
quota-exceededbody; counters converge across two instances within the documented skew bound. - Step 6: golden vector diffs (remove path, add required field, widen type, doc-only edit) classify ERR/WARN/INFO exactly; determinism check (snapshot twice, diff empty); 10 MB + 5000-path caps enforced with clear errors.
- Step 7: fixture PRs: breaking change without new major fails CI with reason codes; same change with new
/v{n+1}snapshot passes; deprecated-major ERR always fails; CLI exit codes match 09-cli-ops.md mapping. - Step 8: lint fixtures per S1-S10 pass/fail exactly; new-route violation blocks startup; full integration suite green same session; living example exposes versioned, keyed, tiered routes with deprecation demo page.
9. References (sibling files this depends on or feeds)
- Depends on 01-runtime.md: header emission path, 429/410 status plumbing, request lifecycle hooks.
- Depends on 02-observability.md: key-use metrics, version/deprecation counters, quota gauges, trace linkage.
- Depends on 03-security.md: Argon2id hashing, CSPRNG, constant-time compare, base rate-limiter buckets, session/user auth boundary.
- Depends on 04-data.md: ORM/DB key store backend, KV quota ledger, invalidation hooks.
- Depends on 05-request-routing.md: Router, Route, middleware chain, error-to-status mapping, problem-details rendering.
- Depends on 08-clients-openapi.md: OpenAPI builder input (routers + models), canonical document,
info.version, security schemes. - Depends on 10-workflow-example.md: CI gate placement, spec/test templates, living-example coverage rule.
- Depends on 11-tenancy.md: tenant scope for keys/tiers/versions; feeds it the per-tenant governance shapes.
- Feeds 09-cli-ops.md:
molo apidiffcommand implementation target (UX owned there). - Feeds 12-audit-compliance.md: key lifecycle events (mint/rotate/revoke/use), gate verdicts, deprecation audit records.
- Feeds 13-operability.md: tier/quota export formats shared with external gateways.
- Parallel with 07-supervisor-replication.md (key/quota replication transport) and 15-supply-chain.md (snapshot provenance signing, if adopted).
10. Open questions
- Should v1 ship OAuth2 scopes beyond API-key scopes (user-delegated flows), or stay key-only with sessions covering users? Proposal: key scopes only, RFC 6749 flows deferred.
- Draft header shape is pinned to rev 11; when the IETF publishes the RFC, do we dual-emit old and final shapes during a window? Proposal: yes, one minor version of dual emission.
- Cross-instance quota exactness: is 2x edge skew acceptable for paid tiers, or do enterprise tiers need synchronous counting? Needs a joint decision with 07-supervisor-replication.md.
- Should the surface linter auto-fix (rename suggestions) or only report? Proposal: report with fix hints, no auto-rename in v1.
- Sunset 410 body: include machine-readable
successoroperation mapping (old path to new path table), or just the version link? Proposal: version link in v1, path map later.
11. Sources (exact URLs with retrieval dates)
- RFC 8594, The Sunset HTTP Header Field (E. Wilde, May 2019, Informational): https://www.rfc-editor.org/rfc/rfc8594 (retrieved 2026-10-23).
- RFC 9745, The Deprecation HTTP Response Header Field (S. Dalal, E. Wilde, Proposed Standard): https://www.rfc-editor.org/rfc/rfc9745 (retrieved 2026-10-23).
- OpenAPI Specification v3.1.0 (2021-02-15): https://spec.openapis.org/oas/v3.1.0 (retrieved 2026-10-23).
- IETF draft-ietf-httpapi-ratelimit-headers-11, RateLimit header fields for HTTP (May 2026, Internet-Draft): https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ (retrieved 2026-10-23).
- oasdiff repository, OpenAPI Diff and Breaking Changes (breaking/changelog/diff, checker rules): https://github.com/oasdiff/oasdiff (retrieved 2026-10-23).
- Microsoft REST API Guidelines (explicit versioning, Major.Minor, breaking-change definition): https://github.com/Microsoft/api-guidelines/blob/master/Guidelines.md (retrieved 2026-10-23).
- RFC 6749, The OAuth 2.0 Authorization Framework, section 3.3 Access Token Scope (Oct 2012, Standards Track): https://www.rfc-editor.org/rfc/rfc6749 (retrieved 2026-10-23).