|
| 1 | +# HawkAPI DX audit vs FastAPI |
| 2 | + |
| 3 | +**Date:** 2026-04-18 |
| 4 | +**Scope:** Feature-parity snapshot of HawkAPI vs FastAPI. Research-only; no code changes here. |
| 5 | +**Spec:** [docs/plans/2026-04-18-dx-audit-design.md](../plans/2026-04-18-dx-audit-design.md) |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Executive summary |
| 10 | + |
| 11 | +HawkAPI currently **matches or exceeds FastAPI on ~85 % of the tutorial-level DX surface**, and meaningfully exceeds on differentiators FastAPI does not ship at all (API versioning, permission policies, built-in bulkhead/circuit-breaker/rate-limiter, observability, migration codemod, PEP 703 wheels). |
| 12 | + |
| 13 | +The five gaps below are the ones that hurt migration from a real FastAPI codebase the most — not because they are large engineering tasks, but because they are everywhere in FastAPI tutorials and copy-pasted into real production code: |
| 14 | + |
| 15 | +| # | Gap | Severity | Effort | |
| 16 | +|---|---|---|---| |
| 17 | +| 1 | **Yield-dependencies with per-request finalization** | Critical | M | |
| 18 | +| 2 | **Route-level `dependencies=[Depends(...)]`** on decorators | Important | S | |
| 19 | +| 3 | **`response_model_exclude_none/unset/defaults`** flags | Important | S | |
| 20 | +| 4 | **OAuth2 scopes enforcement + OpenAPI reflection** | Important | M | |
| 21 | +| 5 | **`status` module with HTTP_NNN constants** | Minor (cosmetic) | XS | |
| 22 | + |
| 23 | +Everything else is either already present or a known out-of-scope differentiator. The non-gap surplus (built-in versioning, bulkhead, observability, codemod, etc.) is strong; the five items above close the last mile of "FastAPI users land on HawkAPI and nothing is missing." |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## Feature-parity matrix |
| 28 | + |
| 29 | +Legend: ✅ full, ⚠️ partial, ❌ missing. |
| 30 | + |
| 31 | +### Routing & path operations |
| 32 | + |
| 33 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 34 | +|---|---|---|---| |
| 35 | +| `@app.get/post/put/patch/delete/head/options` | [src/hawkapi/routing/router.py](../../src/hawkapi/routing/router.py) | ✅ | | |
| 36 | +| `APIRouter` with `prefix=`, `tags=`, `dependencies=` | `Router` class + `include_router` | ⚠️ | `prefix`/`tags` supported; `dependencies=` on routers not wired | |
| 37 | +| Typed path params `{id}` with `int`/`str`/... inference | [routing/_radix_tree.py](../../src/hawkapi/routing/_radix_tree.py) | ✅ | `/items/{id:int}` | |
| 38 | +| Route-level `tags=`, `summary=`, `description=` | [routing/route.py](../../src/hawkapi/routing/route.py) | ✅ | | |
| 39 | +| Route-level `response_model=` | Router.add_route | ✅ | | |
| 40 | +| Route-level `status_code=` | Router.add_route | ✅ | | |
| 41 | +| Route-level `include_in_schema=False` | Router.add_route | ✅ | | |
| 42 | +| Route-level `dependencies=[...]` (side-effect deps) | — | ❌ | Must use app-level hooks or middleware today (**Gap #2**) | |
| 43 | +| `include_router(responses=...)` default-response map | — | ❌ | Not supported | |
| 44 | +| Sub-app mount `app.mount("/x", subapp)` | app.py | ✅ | | |
| 45 | + |
| 46 | +### Parameters (path / query / header / cookie / body) |
| 47 | + |
| 48 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 49 | +|---|---|---|---| |
| 50 | +| `Query()` marker (alias, validation) | [validation/constraints.py](../../src/hawkapi/validation/constraints.py) | ✅ | Via `Annotated[T, Query(...)]` | |
| 51 | +| `Path()` marker | validation/constraints.py | ✅ | | |
| 52 | +| `Header()` marker with `_`→`-` auto-conversion | validation/constraints.py + di/param_plan.py | ✅ | | |
| 53 | +| `Cookie()` marker | validation/constraints.py | ✅ | | |
| 54 | +| `Body()` marker | validation/constraints.py | ✅ | | |
| 55 | +| `Annotated[T, Query(...)]` form | di/param_plan.py | ✅ | First-class | |
| 56 | +| `Form()` marker | — | ⚠️ | Forms are parsed when `FormData` is declared, but no explicit `Form()` class for per-field validation | |
| 57 | +| `File()` / `UploadFile` | [requests/form_data.py](../../src/hawkapi/requests/form_data.py) | ✅ | `.read()`, `.seek()`, `.close()` | |
| 58 | +| Multiple body params in one handler | di/param_plan.py | ✅ | | |
| 59 | + |
| 60 | +### Dependency injection |
| 61 | + |
| 62 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 63 | +|---|---|---|---| |
| 64 | +| `Depends(callable)` | [src/hawkapi/di/depends.py](../../src/hawkapi/di/depends.py) | ✅ | | |
| 65 | +| Sub-dependencies (transitive) | di/param_plan.py | ✅ | Resolved recursively | |
| 66 | +| `yield` dependencies with teardown after response | — | ⚠️ | App-level `lifespan` only; no per-request yield-dep (**Gap #1**) | |
| 67 | +| Class-callable as dependency | di/param_plan.py | ✅ | | |
| 68 | +| Path-operation-level `dependencies=[...]` | — | ❌ | Requires middleware today (**Gap #2**) | |
| 69 | +| Global (app-level) dependencies | — | ❌ | Workaround: middleware | |
| 70 | +| Within-request caching of same `Depends(fn)` | di/scope.py | ✅ | Scope-level caching | |
| 71 | +| `dependency_overrides` for tests | [testing/overrides.py](../../src/hawkapi/testing/overrides.py) | ✅ | `override()` context manager | |
| 72 | + |
| 73 | +### Security |
| 74 | + |
| 75 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 76 | +|---|---|---|---| |
| 77 | +| `OAuth2PasswordBearer` | [security/oauth2.py](../../src/hawkapi/security/oauth2.py) | ✅ | | |
| 78 | +| `OAuth2PasswordRequestForm` | — | ❌ | Form helper class not shipped | |
| 79 | +| `HTTPBasic` / `HTTPBasicCredentials` | [security/http_basic.py](../../src/hawkapi/security/http_basic.py) | ✅ | | |
| 80 | +| `APIKeyHeader` / `APIKeyQuery` / `APIKeyCookie` | [security/api_key.py](../../src/hawkapi/security/api_key.py) | ✅ | | |
| 81 | +| OAuth2 scopes enforcement + OpenAPI reflection | security/oauth2.py | ❌ | `scopes` placeholder present but not enforced (**Gap #4**) | |
| 82 | +| `SecurityScheme` propagation into OpenAPI | security/base.py | ✅ | | |
| 83 | + |
| 84 | +### Responses |
| 85 | + |
| 86 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 87 | +|---|---|---|---| |
| 88 | +| `JSONResponse` | responses/json_response.py | ✅ | | |
| 89 | +| `HTMLResponse`, `PlainTextResponse`, `RedirectResponse`, `FileResponse`, `StreamingResponse` | `src/hawkapi/responses/` | ✅ | | |
| 90 | +| Return `Response` directly from handler (bypass serialization) | responses/response.py | ✅ | | |
| 91 | +| `response_model_exclude_none/unset/defaults` | — | ❌ | Not wired (**Gap #3**) | |
| 92 | +| `jsonable_encoder` equivalent | [serialization/encoder.py](../../src/hawkapi/serialization/encoder.py) | ✅ | `encode_response()` | |
| 93 | +| Content negotiation (Accept → JSON vs MessagePack) | serialization/negotiation.py | ✅ | Exceeds FastAPI | |
| 94 | + |
| 95 | +### Exception handling |
| 96 | + |
| 97 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 98 | +|---|---|---|---| |
| 99 | +| `HTTPException(status_code, detail, headers)` | [exceptions.py](../../src/hawkapi/exceptions.py) | ✅ | Returns RFC 7807 `application/problem+json` | |
| 100 | +| `@app.exception_handler(Cls)` registration | app.py | ✅ | | |
| 101 | +| Default `RequestValidationError` handler | validation/errors.py | ✅ | RFC 9457 `ProblemDetail` | |
| 102 | + |
| 103 | +### OpenAPI customization |
| 104 | + |
| 105 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 106 | +|---|---|---|---| |
| 107 | +| `title=`, `description=`, `version=` on constructor | app.py | ✅ | | |
| 108 | +| `contact=`, `license_info=` on constructor | — | ⚠️ | Not surfaced | |
| 109 | +| `openapi_tags=[{name, description, externalDocs}, ...]` | — | ❌ | Route-level tags only | |
| 110 | +| `servers=[{url, description}, ...]` | — | ❌ | | |
| 111 | +| Per-route `openapi_extra={}` | — | ❌ | No hook to inject arbitrary OpenAPI extensions per route | |
| 112 | +| Customizable `docs_url`, `redoc_url`, `openapi_url` (or `None` to disable) | app.py | ✅ | | |
| 113 | +| Swagger UI / ReDoc / **Scalar** shipped | _docs.py | ✅ | Scalar = exceeds FastAPI | |
| 114 | + |
| 115 | +### Middleware & hooks |
| 116 | + |
| 117 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 118 | +|---|---|---|---| |
| 119 | +| `app.add_middleware(Cls, **kwargs)` | app.py | ✅ | | |
| 120 | +| Raw ASGI middleware supported | middleware/base.py | ✅ | | |
| 121 | +| Lifespan context manager (`@asynccontextmanager` on app) | lifespan/manager.py | ✅ | | |
| 122 | +| Legacy `@app.on_event("startup"/"shutdown")` | — | ⚠️ | Have `on_startup()` / `on_shutdown()` decorators; not the `on_event()` string-discriminant form | |
| 123 | + |
| 124 | +### Testing |
| 125 | + |
| 126 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 127 | +|---|---|---|---| |
| 128 | +| `TestClient(app)` (sync httpx-style) | testing/client.py | ✅ | | |
| 129 | +| Async test helpers | testing/client.py | ✅ | `async_get()` et al. | |
| 130 | +| DI override mechanism | testing/overrides.py | ✅ | | |
| 131 | +| Lifespan fires in TestClient | testing/client.py | ✅ | | |
| 132 | + |
| 133 | +### Background tasks & lifespan |
| 134 | + |
| 135 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 136 | +|---|---|---|---| |
| 137 | +| `BackgroundTasks` injection + `add_task()` | [background.py](../../src/hawkapi/background.py) | ✅ | | |
| 138 | +| Tasks run after response sent | app.py | ✅ | | |
| 139 | +| Longer-running job primitive beyond per-request tasks | — | ❌ | No built-in scheduler / worker (also not in FastAPI) | |
| 140 | + |
| 141 | +### WebSockets |
| 142 | + |
| 143 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 144 | +|---|---|---|---| |
| 145 | +| `WebSocket` injected into handler | websocket/connection.py | ✅ | | |
| 146 | +| `Depends(...)` inside WebSocket handlers | websocket | ⚠️ | Basic resolution; security schemes not enforced the same way as HTTP | |
| 147 | +| WebSocket routes visible in OpenAPI/AsyncAPI | — | ⚠️ | No first-class AsyncAPI story | |
| 148 | + |
| 149 | +### Static files & templates |
| 150 | + |
| 151 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 152 | +|---|---|---|---| |
| 153 | +| `StaticFiles` mount | staticfiles.py | ✅ | | |
| 154 | +| `Jinja2Templates` + `TemplateResponse` | — | ❌ | Server-rendered apps need external integration | |
| 155 | + |
| 156 | +### Forms & file uploads |
| 157 | + |
| 158 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 159 | +|---|---|---|---| |
| 160 | +| `application/x-www-form-urlencoded` auto-parsing | requests/form_data.py | ✅ | Via `FormData` declaration | |
| 161 | +| `multipart/form-data` streaming `UploadFile` | requests/form_data.py | ✅ | | |
| 162 | + |
| 163 | +### Other DX conveniences |
| 164 | + |
| 165 | +| FastAPI feature | HawkAPI | Status | Notes | |
| 166 | +|---|---|---|---| |
| 167 | +| `from fastapi import status` → `status.HTTP_201_CREATED` | — | ❌ | No constants module (**Gap #5**) | |
| 168 | +| CORS middleware | middleware/cors.py | ✅ | | |
| 169 | +| GZip middleware | middleware/gzip.py | ✅ | | |
| 170 | +| Session (signed cookies) middleware | middleware/session.py | ✅ | | |
| 171 | +| TrustedHost middleware | middleware/trusted_host.py | ✅ | | |
| 172 | +| Official starter scaffold CLI | cli.py (`hawkapi new`) | ✅ | Exceeds FastAPI | |
| 173 | +| FastAPI → framework migration codemod | _migrate/codemod.py | ✅ | Exceeds FastAPI | |
| 174 | + |
| 175 | +--- |
| 176 | + |
| 177 | +## Top-5 gaps (detailed) |
| 178 | + |
| 179 | +### Gap #1 — Yield-dependencies with per-request finalization |
| 180 | + |
| 181 | +**Severity:** Critical **Effort:** M |
| 182 | + |
| 183 | +**What FastAPI has:** |
| 184 | + |
| 185 | +```python |
| 186 | +async def get_db() -> AsyncIterator[AsyncSession]: |
| 187 | + async with SessionLocal() as session: |
| 188 | + yield session # returned to handler |
| 189 | + |
| 190 | +@app.get("/users") |
| 191 | +async def list_users(db: AsyncSession = Depends(get_db)) -> list[User]: |
| 192 | + ... |
| 193 | +# After response is sent, session context manager exits; rolls back or commits. |
| 194 | +``` |
| 195 | + |
| 196 | +This is THE pattern in FastAPI tutorials and production code for database sessions, HTTP clients, Redis connections, transactions. Every SQLAlchemy/Databases example depends on it. |
| 197 | + |
| 198 | +**What HawkAPI has:** App-level `lifespan` context manager for long-lived resources. No per-request teardown. |
| 199 | + |
| 200 | +**Work required:** |
| 201 | +- Extend `src/hawkapi/di/param_plan.py` to detect generator/async-generator dependencies. |
| 202 | +- Register each yield-dep on a per-request stack (push on entry, pop + run remaining code after the handler's response body is sent). |
| 203 | +- Ensure cancellation and exception paths run the finalizers. |
| 204 | +- Wire teardown to run AFTER response headers+body flushed (like FastAPI) so the client isn't blocked. |
| 205 | +- Test: yield that raises during teardown — how should the error be reported? (FastAPI: logs, ignores.) |
| 206 | + |
| 207 | +**Payoff:** Single biggest migration-friction item for any FastAPI user with a database. |
| 208 | + |
| 209 | +--- |
| 210 | + |
| 211 | +### Gap #2 — Route-level `dependencies=[Depends(...)]` |
| 212 | + |
| 213 | +**Severity:** Important **Effort:** S |
| 214 | + |
| 215 | +**What FastAPI has:** |
| 216 | + |
| 217 | +```python |
| 218 | +@app.get("/admin/reports", dependencies=[Depends(require_admin)]) |
| 219 | +async def reports() -> ...: |
| 220 | + ... |
| 221 | +``` |
| 222 | + |
| 223 | +Used pervasively for auth guards, audit-log writers, rate-limit increments that don't need a return value. Also accepted on `APIRouter(dependencies=[...])` for whole-router guards. |
| 224 | + |
| 225 | +**What HawkAPI has:** Workaround via app-level hooks or middleware. No decorator-level kwarg. |
| 226 | + |
| 227 | +**Work required:** |
| 228 | +- Add `dependencies: Sequence[Depends] | None = None` kwarg to the route decorators in `src/hawkapi/routing/router.py`. |
| 229 | +- Add the same kwarg on the `Router` class constructor and `include_router` call; merge router-level + route-level lists. |
| 230 | +- On request: resolve each `Depends` before invoking the handler; any raised exception short-circuits (consistent with FastAPI behavior). |
| 231 | +- Make the results *not* injected into the handler's signature — they're executed for side effects only. |
| 232 | +- Tests: chain ordering, exceptions, interaction with yield-dependencies. |
| 233 | + |
| 234 | +**Payoff:** Biggest ergonomic win for anyone who reads FastAPI auth examples and copy-pastes. |
| 235 | + |
| 236 | +--- |
| 237 | + |
| 238 | +### Gap #3 — `response_model_exclude_none / _unset / _defaults` |
| 239 | + |
| 240 | +**Severity:** Important **Effort:** S |
| 241 | + |
| 242 | +**What FastAPI has:** |
| 243 | + |
| 244 | +```python |
| 245 | +@app.get( |
| 246 | + "/items/{id}", |
| 247 | + response_model=Item, |
| 248 | + response_model_exclude_none=True, # drop keys whose value is None |
| 249 | + response_model_exclude_unset=True, # drop keys the user didn't set |
| 250 | + response_model_exclude_defaults=True, # drop keys equal to their default |
| 251 | +) |
| 252 | +``` |
| 253 | + |
| 254 | +Used for: |
| 255 | +- APIs where optional fields shouldn't serialize as `"field": null` |
| 256 | +- Versioned responses where fields were added later and old clients shouldn't see them |
| 257 | +- Admin vs public response shapes |
| 258 | + |
| 259 | +**What HawkAPI has:** `response_model` accepted on routes; the three exclusion knobs are not wired through to the serializer. |
| 260 | + |
| 261 | +**Work required:** |
| 262 | +- Plumb three flags from route metadata → `src/hawkapi/serialization/encoder.py`. |
| 263 | +- msgspec already supports field exclusion at encode time; map the flags onto its API. |
| 264 | +- Tests per flag + combinations. |
| 265 | + |
| 266 | +**Payoff:** Low-effort closure of a feature 70 % of FastAPI response_model users eventually reach for. |
| 267 | + |
| 268 | +--- |
| 269 | + |
| 270 | +### Gap #4 — OAuth2 scopes enforcement + OpenAPI reflection |
| 271 | + |
| 272 | +**Severity:** Important **Effort:** M |
| 273 | + |
| 274 | +**What FastAPI has:** |
| 275 | + |
| 276 | +```python |
| 277 | +from fastapi import Security |
| 278 | +from fastapi.security import SecurityScopes |
| 279 | + |
| 280 | +oauth2_scheme = OAuth2PasswordBearer( |
| 281 | + tokenUrl="token", |
| 282 | + scopes={"read:items": "Read items", "admin": "Admin"} |
| 283 | +) |
| 284 | + |
| 285 | +async def get_current_user( |
| 286 | + scopes: SecurityScopes, |
| 287 | + token: str = Depends(oauth2_scheme), |
| 288 | +) -> User: |
| 289 | + # validate token, verify required scopes against user scopes |
| 290 | + ... |
| 291 | + |
| 292 | +@app.get("/items", dependencies=[Security(get_current_user, scopes=["read:items"])]) |
| 293 | +async def list_items() -> ...: |
| 294 | + ... |
| 295 | +``` |
| 296 | + |
| 297 | +- Scopes are declared in OpenAPI under `components.securitySchemes` with full descriptions. |
| 298 | +- Each route's required scopes appear in its `security` entry. |
| 299 | +- Framework enforces by collecting required scopes down the dep chain and passing them to the scheme's `authenticate()` call. |
| 300 | + |
| 301 | +**What HawkAPI has:** `OAuth2PasswordBearer` exists with a `scopes` dict placeholder, but nothing enforces or propagates scopes. |
| 302 | + |
| 303 | +**Work required:** |
| 304 | +- Add a `Security()` marker parallel to `Depends()` carrying `scopes: Sequence[str]`. |
| 305 | +- Track route-level required scopes; inject a `SecurityScopes`-like context into the scheme's callable. |
| 306 | +- Propagate `securitySchemes.oauth2.flows.password.scopes` into the OpenAPI document. |
| 307 | +- Annotate each route's OpenAPI `security` field with required scopes. |
| 308 | +- Tests: scope subset check; 403 on insufficient scope; multi-route aggregation. |
| 309 | + |
| 310 | +**Payoff:** Largest "production OAuth2 users" gap. A real-world API without scopes rolled-your-own is unusual. |
| 311 | + |
| 312 | +--- |
| 313 | + |
| 314 | +### Gap #5 — `status` module with HTTP_NNN constants |
| 315 | + |
| 316 | +**Severity:** Minor (but high frequency) **Effort:** XS |
| 317 | + |
| 318 | +**What FastAPI has:** |
| 319 | + |
| 320 | +```python |
| 321 | +from fastapi import status |
| 322 | + |
| 323 | +@app.post("/items", status_code=status.HTTP_201_CREATED) |
| 324 | +async def create_item(...): ... |
| 325 | +``` |
| 326 | + |
| 327 | +Pure cosmetic convenience re-exported from Starlette. Everyone uses it. |
| 328 | + |
| 329 | +**What HawkAPI has:** Users hardcode `201`. |
| 330 | + |
| 331 | +**Work required:** |
| 332 | +- Create `src/hawkapi/status.py` re-exporting the standard HTTP constants (can copy from Starlette's `starlette.status`, or use `http.HTTPStatus`'s integer values). |
| 333 | +- Export `status` from `hawkapi/__init__.py` so `from hawkapi import status` works. |
| 334 | +- One-line unit test. |
| 335 | + |
| 336 | +**Payoff:** Removes a paper cut. A one-hour job that eliminates a reason someone says "HawkAPI is missing things FastAPI has." |
| 337 | + |
| 338 | +--- |
| 339 | + |
| 340 | +## Where HawkAPI already exceeds FastAPI |
| 341 | + |
| 342 | +Not gaps — differentiators we should not accidentally dilute while closing the gaps above: |
| 343 | + |
| 344 | +- **API versioning** — `VersionRouter` with per-version OpenAPI specs. FastAPI has no first-class story. |
| 345 | +- **Permission policies** — `PermissionPolicy` with pluggable resolvers. FastAPI users hand-roll. |
| 346 | +- **Observability** — one-flag tracing, structured logs, Prometheus metrics out of the box. |
| 347 | +- **Bulkhead** (just shipped) + Redis-distributed variant. |
| 348 | +- **Circuit breaker** — local + Redis variant. |
| 349 | +- **Adaptive concurrency limiter** — Netflix gradient2 auto-tune. |
| 350 | +- **Rate limiter** — local + Redis variant. |
| 351 | +- **CSRF middleware** with double-submit cookie. |
| 352 | +- **Session middleware** with signed cookies. |
| 353 | +- **Content negotiation** — Accept-based JSON vs MessagePack serialization. |
| 354 | +- **Scalar UI** for API docs (in addition to Swagger + ReDoc). |
| 355 | +- **mypyc-compiled hot paths** — routing / route record / param converters / middleware pipeline. |
| 356 | +- **Free-threaded Python 3.13 wheels** (PEP 703). |
| 357 | +- **`hawkapi migrate` codemod** — FastAPI → HawkAPI AST rewriter. |
| 358 | +- **`hawkapi new` scaffold CLI** — project starter. |
| 359 | +- **Perf regression gate in CI** — 5 % mean threshold. |
| 360 | +- **Memory budget tests** — pytest-memray. |
| 361 | + |
| 362 | +--- |
| 363 | + |
| 364 | +## Follow-ups (not this project) |
| 365 | + |
| 366 | +Each of the top-5 gaps becomes its own design spec + implementation plan. Recommended order (smallest effort / highest ratio first, to accumulate shipped wins): |
| 367 | + |
| 368 | +1. **Gap #5** (`status` constants) — afternoon of work. |
| 369 | +2. **Gap #3** (`response_model_exclude_*`) — one spec, small impl. |
| 370 | +3. **Gap #2** (`dependencies=[...]` kwarg) — clean decorator change. |
| 371 | +4. **Gap #1** (yield-dependencies) — the big one, touches DI core. |
| 372 | +5. **Gap #4** (OAuth2 scopes) — biggest surface area, last. |
| 373 | + |
| 374 | +This order gives four visible DX-parity wins before the fifth (scopes) takes more effort. Each gets an independent cycle — spec → plan → implement — following the workflow used for Tier 1 and Tier 2. |
0 commit comments