From 2833ec1a24b509fc63b821e9a984914b49b0c65c Mon Sep 17 00:00:00 2001
From: Lee Cheneler
Date: Mon, 1 Dec 2025 01:01:31 +0000
Subject: [PATCH] docs: adrs + tabi name explainer
---
README.md | 5 +
docs/adr/001-deno-runtime.md | 48 +++++++++
docs/adr/002-minimal-philosophy.md | 76 +++++++++++++
docs/adr/003-monorepo-composable-modules.md | 75 +++++++++++++
docs/adr/004-standard-schema-validation.md | 74 +++++++++++++
docs/adr/005-linear-router-default.md | 80 ++++++++++++++
docs/adr/006-lazy-response-materialization.md | 80 ++++++++++++++
docs/adr/007-memoized-request-body.md | 76 +++++++++++++
docs/adr/008-csrf-fetch-metadata.md | 80 ++++++++++++++
docs/adr/009-signed-cookies-hmac.md | 83 ++++++++++++++
docs/adr/010-test-against-running-server.md | 91 ++++++++++++++++
docs/adr/011-context-based-state.md | 101 ++++++++++++++++++
.../adr/012-composable-security-middleware.md | 95 ++++++++++++++++
docs/adr/README.md | 63 +++++++++++
14 files changed, 1027 insertions(+)
create mode 100644 docs/adr/001-deno-runtime.md
create mode 100644 docs/adr/002-minimal-philosophy.md
create mode 100644 docs/adr/003-monorepo-composable-modules.md
create mode 100644 docs/adr/004-standard-schema-validation.md
create mode 100644 docs/adr/005-linear-router-default.md
create mode 100644 docs/adr/006-lazy-response-materialization.md
create mode 100644 docs/adr/007-memoized-request-body.md
create mode 100644 docs/adr/008-csrf-fetch-metadata.md
create mode 100644 docs/adr/009-signed-cookies-hmac.md
create mode 100644 docs/adr/010-test-against-running-server.md
create mode 100644 docs/adr/011-context-based-state.md
create mode 100644 docs/adr/012-composable-security-middleware.md
create mode 100644 docs/adr/README.md
diff --git a/README.md b/README.md
index b64d1c4..46d159b 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,11 @@
+> Tabi (旅) means "journey" in Japanese. The shortest path between two points is
+> a straight line. Tabi stays out of your way—no abstractions to fight, no magic
+> to reverse-engineer, no surprises in production. Every build should be a
+> stress free journey.
+
## Quick Start
```bash
diff --git a/docs/adr/001-deno-runtime.md b/docs/adr/001-deno-runtime.md
new file mode 100644
index 0000000..7374b28
--- /dev/null
+++ b/docs/adr/001-deno-runtime.md
@@ -0,0 +1,48 @@
+# ADR-001: Use Deno as the Single Runtime
+
+**Status:** Accepted
+
+## Context
+
+Modern web frameworks can target multiple JavaScript runtimes:
+
+- **Node.js** - Mature ecosystem, widespread adoption, CommonJS/ESM complexity
+- **Deno** - Native TypeScript, secure by default, modern standards
+- **Bun** - Fast startup, Node.js compatible, newer/less stable
+- **Runtime-agnostic** - Maximum compatibility, lowest common denominator
+ features
+
+Each approach has trade-offs in ecosystem access, security model, developer
+experience, and maintenance burden.
+
+## Decision
+
+Tabi targets Deno exclusively, requiring version 2.5.6 or higher.
+
+We will not maintain Node.js compatibility layers or abstract runtime-specific
+APIs behind adapters.
+
+## Consequences
+
+### Benefits
+
+- Native TypeScript execution without build step or configuration
+- Built-in security permissions model (network, filesystem, env access must be
+ explicit)
+- Standard library (`@std/*`) for common utilities without third-party
+ dependencies
+- Modern web standards (Fetch API, Web Crypto, Streams) as first-class citizens
+- Single lockfile format and dependency resolution strategy
+- Publish to JSR with native TypeScript support
+
+### Drawbacks
+
+- Smaller ecosystem than Node.js
+- Some npm packages may not work without compatibility layer
+- Users must install Deno (less ubiquitous than Node.js)
+- Deployment targets limited to Deno-supporting platforms
+
+### Neutral
+
+- Version pinning via `.dvmrc` for team consistency
+- Deno Deploy as natural deployment target (but not required)
diff --git a/docs/adr/002-minimal-philosophy.md b/docs/adr/002-minimal-philosophy.md
new file mode 100644
index 0000000..2e61124
--- /dev/null
+++ b/docs/adr/002-minimal-philosophy.md
@@ -0,0 +1,76 @@
+# ADR-002: Minimal Framework Philosophy
+
+**Status:** Accepted
+
+## Context
+
+Web frameworks exist on a spectrum:
+
+**Batteries-included** (Rails, Django, Nest.js):
+
+- Prescriptive structure and conventions
+- Built-in ORM, auth, sessions, templating
+- Faster initial development, steeper learning curve
+- Harder to deviate from conventions
+
+**Minimal** (Express, Fastify, Hono):
+
+- Core routing and middleware only
+- Bring your own everything else
+- More setup, more flexibility
+- Explicit over implicit
+
+Tabi needs to choose where on this spectrum to position itself.
+
+## Decision
+
+Tabi follows a minimal, explicit, no-magic philosophy.
+
+Core principles:
+
+- **No implicit behavior** - Every feature must be explicitly enabled
+- **No hidden state** - Request/response flow is predictable and traceable
+- **No framework lock-in** - Standard interfaces over proprietary abstractions
+- **Composition over inheritance** - Build applications by composing small
+ pieces
+
+The framework provides:
+
+- Request/response handling
+- Routing
+- Middleware composition
+- Core utilities (cookies, validation, compression)
+
+The framework does not provide:
+
+- Database/ORM layer
+- Authentication/authorization
+- Session management
+- Templating engine
+- Background jobs
+- Email sending
+
+## Consequences
+
+### Benefits
+
+- Smaller bundle size (only include what you use)
+- Easier to understand (less magic to learn)
+- Predictable behavior (explicit composition)
+- Flexible architecture (no prescribed patterns)
+- Easier to test (fewer hidden dependencies)
+- Security benefits (smaller attack surface)
+
+### Drawbacks
+
+- More initial setup for common features
+- Must choose and integrate third-party solutions
+- Less guidance for application structure
+- Potential for inconsistency across applications
+
+### Implications
+
+- Documentation must clearly show composition patterns
+- Example applications should demonstrate common setups
+- Middleware should be independently useful
+- Each feature should have single, clear responsibility
diff --git a/docs/adr/003-monorepo-composable-modules.md b/docs/adr/003-monorepo-composable-modules.md
new file mode 100644
index 0000000..fb5d769
--- /dev/null
+++ b/docs/adr/003-monorepo-composable-modules.md
@@ -0,0 +1,75 @@
+# ADR-003: Monorepo with Independently Composable Modules
+
+**Status:** Accepted
+
+## Context
+
+The framework consists of multiple features: core routing, validation, various
+middleware (CORS, CSRF, compression, etc.), and utilities. These can be
+organized as:
+
+**Single monolithic package:**
+
+- One import, one version
+- All features always available
+- Larger bundle if tree-shaking fails
+
+**Separate packages with independent versions:**
+
+- Import only what you need
+- Version compatibility matrix complexity
+- More release management overhead
+
+**Single package with submodule exports:**
+
+- One version to track
+- Import specific modules
+- Tree-shaking friendly
+- Simpler release process
+
+## Decision
+
+Organize as a monorepo with a single JSR package (`@tabirun/app`) that exposes
+submodule exports, each independently importable.
+
+```typescript
+// Import only what you need
+import { TabiApp } from "@tabirun/app/app";
+import { cors } from "@tabirun/app/cors";
+import { rateLimit } from "@tabirun/app/rate-limit";
+```
+
+Each module:
+
+- Has its own directory with `mod.ts` as public API
+- Contains colocated tests
+- Has independent README documentation
+- Exports only public types and functions
+
+## Consequences
+
+### Benefits
+
+- Single version number for entire framework
+- Simpler dependency management for users
+- Tree-shaking removes unused modules
+- Easier cross-module testing
+- Consistent release cycle
+- No version compatibility matrix
+
+### Drawbacks
+
+- Breaking change in any module bumps entire package version
+- Cannot patch individual modules independently
+- All modules must maintain same quality bar
+
+### Module Structure
+
+```
+module-name/
+├── mod.ts # Public exports only
+├── middleware.ts # Implementation
+├── types.ts # Types (if needed)
+├── README.md # Usage documentation
+└── tests/ # Module tests
+```
diff --git a/docs/adr/004-standard-schema-validation.md b/docs/adr/004-standard-schema-validation.md
new file mode 100644
index 0000000..865554b
--- /dev/null
+++ b/docs/adr/004-standard-schema-validation.md
@@ -0,0 +1,74 @@
+# ADR-004: Standard Schema for Validation Library Agnosticism
+
+**Status:** Accepted
+
+## Context
+
+Runtime validation at API boundaries is essential for security and type safety.
+Popular validation libraries include:
+
+- **Zod** - TypeScript-first, excellent inference, large bundle
+- **Valibot** - Smaller bundle, modular design
+- **ArkType** - Fast runtime, complex syntax
+- **Yup** - Mature, widespread adoption
+- **Joi** - Battle-tested, verbose API
+
+Each has different APIs, performance characteristics, and bundle sizes. Coupling
+the framework to one library forces that choice on all users.
+
+The `@standard-schema/spec` initiative defines a common interface that
+validation libraries can implement, enabling interoperability.
+
+## Decision
+
+Use the Standard Schema interface (`@standard-schema/spec`) for all validation
+integration. Provide Zod as the reference implementation in documentation and
+examples.
+
+```typescript
+import type { StandardSchemaV1 } from "@standard-schema/spec";
+
+interface ValidatorConfig {
+ json?: StandardSchemaV1;
+ query?: StandardSchemaV1;
+ params?: StandardSchemaV1;
+ form?: StandardSchemaV1;
+}
+```
+
+Users can use any Standard Schema-compatible library:
+
+```typescript
+// With Zod
+import { z } from "zod";
+const schema = z.object({ name: z.string() });
+
+// With Valibot
+import * as v from "valibot";
+const schema = v.object({ name: v.string() });
+
+// Both work with Tabi's validator
+const { validate, valid } = validator({ json: schema });
+```
+
+## Consequences
+
+### Benefits
+
+- No lock-in to specific validation library
+- Users choose based on their preferences (bundle size, API, performance)
+- Middleware remains library-agnostic
+- Future libraries automatically compatible if they implement Standard Schema
+
+### Drawbacks
+
+- Slightly more complex type signatures
+- Must document which libraries support Standard Schema
+- Error message format varies by library
+- Testing should cover multiple schema implementations
+
+### Implementation Notes
+
+- Validator middleware accepts any `StandardSchemaV1` compliant schema
+- Type inference works through Standard Schema's `InferOutput` type
+- Error reporting can be customized via `onError` option
diff --git a/docs/adr/005-linear-router-default.md b/docs/adr/005-linear-router-default.md
new file mode 100644
index 0000000..6c6b4f0
--- /dev/null
+++ b/docs/adr/005-linear-router-default.md
@@ -0,0 +1,80 @@
+# ADR-005: Linear Router as Default Implementation
+
+**Status:** Accepted
+
+## Context
+
+Route matching algorithms have different trade-offs:
+
+**Linear scan O(n):**
+
+- Simple implementation
+- Predictable matching order
+- Performance degrades with route count
+- Easy to debug
+
+**Radix tree O(k) where k = path length:**
+
+- Fast for large route sets
+- Complex implementation
+- Matching order less intuitive
+- Harder to debug edge cases
+
+**Compiled regex:**
+
+- Very fast after compilation
+- Complex to implement correctly
+- Startup cost for compilation
+
+For a minimal framework targeting small-to-medium applications, simplicity and
+predictability may outweigh raw performance.
+
+## Decision
+
+Implement `LinearRouter` as the default router, with a pluggable `TabiRouter`
+interface allowing alternative implementations.
+
+```typescript
+interface TabiRouter {
+ add(method: string, path: string, handler: TabiHandler): void;
+ match(method: string, path: string): RouteMatch | null;
+}
+```
+
+Route matching priority:
+
+1. Static routes (exact match)
+2. Parameterized routes (`:id`)
+3. Wildcard routes (`*`)
+
+Within each category, first registered wins.
+
+## Consequences
+
+### Benefits
+
+- Predictable matching (registration order within priority tiers)
+- Simple debugging (linear search, clear priority rules)
+- Easy to implement path traversal protection
+- Sufficient performance for typical applications (<100 routes)
+- Clear mental model for users
+
+### Drawbacks
+
+- O(n) matching per request
+- Not suitable for applications with thousands of routes
+- May need optimization for high-traffic scenarios
+
+### Future Options
+
+- `RadixRouter` can be added as alternative implementation
+- Users can implement custom routers via `TabiRouter` interface
+- Router choice doesn't affect application code (same API)
+
+### Security
+
+Linear router enables straightforward path traversal protection:
+
+- Decode URL parameters
+- Block `../`, `..\\`, and encoded variants
+- Return 400 Bad Request on traversal attempts
diff --git a/docs/adr/006-lazy-response-materialization.md b/docs/adr/006-lazy-response-materialization.md
new file mode 100644
index 0000000..0710037
--- /dev/null
+++ b/docs/adr/006-lazy-response-materialization.md
@@ -0,0 +1,80 @@
+# ADR-006: Lazy Response Materialization
+
+**Status:** Accepted
+
+## Context
+
+Response construction can be handled two ways:
+
+**Eager construction:**
+
+- Create `Response` object immediately when handler sets response
+- Simple mental model
+- Difficult for middleware to modify response after it's set
+- Multiple `Response` allocations if middleware transforms response
+
+**Lazy construction:**
+
+- Buffer response state (headers, body, status) throughout request lifecycle
+- Construct final `Response` object once at the end
+- Middleware can modify response at any point
+- Single allocation
+
+## Decision
+
+`TabiResponse` buffers response state until `finalize()` is called, which
+constructs the actual `Response` object.
+
+```typescript
+class TabiResponse {
+ // Buffer state
+ status(code: number): this;
+ header(name: string, value: string): this;
+ body(content: BodyInit): this;
+
+ // Construct final Response
+ finalize(): Response;
+}
+
+// Usage in handlers
+c.json({ data: "value" }); // Buffers, doesn't create Response yet
+c.header("X-Custom", "value"); // Can still modify
+
+// Framework calls finalize() after all middleware complete
+```
+
+## Consequences
+
+### Benefits
+
+- Middleware can modify response after handler sets it
+- Headers can be added/removed throughout request lifecycle
+- Single `Response` construction reduces allocations
+- Enables response transformation patterns (compression, caching headers)
+- Cleaner separation between "building response" and "sending response"
+
+### Drawbacks
+
+- Cannot stream response body until finalized
+- Must remember that response isn't sent until request completes
+- Slightly more complex response internals
+
+### Patterns Enabled
+
+```typescript
+// Compression middleware can check content-type before compressing
+app.use(async (c, next) => {
+ await next();
+ if (shouldCompress(c.res)) {
+ c.res.body(compress(c.res.getBody()));
+ }
+});
+
+// Cache-control middleware can add headers based on response
+app.use(async (c, next) => {
+ await next();
+ if (c.res.getStatus() === 200) {
+ c.res.header("Cache-Control", "max-age=3600");
+ }
+});
+```
diff --git a/docs/adr/007-memoized-request-body.md b/docs/adr/007-memoized-request-body.md
new file mode 100644
index 0000000..5a922ba
--- /dev/null
+++ b/docs/adr/007-memoized-request-body.md
@@ -0,0 +1,76 @@
+# ADR-007: Memoized Request Body Parsing
+
+**Status:** Accepted
+
+## Context
+
+The Fetch API's `Request` body can only be consumed once:
+
+```typescript
+const json = await request.json(); // Works
+const text = await request.text(); // Throws: body already consumed
+```
+
+In a middleware architecture, multiple middleware may need to read the request
+body:
+
+- Validation middleware reads JSON to validate
+- Logging middleware reads body for audit
+- Handler reads body to process
+
+Options:
+
+1. **Clone request** - Memory overhead, complexity
+2. **Parse once, pass through context** - Coupling between middleware
+3. **Memoize parsed results** - Parse on first access, cache result
+
+## Decision
+
+Memoize parsed request body per format. First access parses and caches;
+subsequent accesses return cached result.
+
+```typescript
+class TabiRequest {
+ private jsonCache?: unknown;
+ private textCache?: string;
+ // ... other formats
+
+ async json(): Promise {
+ if (this.jsonCache === undefined) {
+ this.jsonCache = await this.raw.json();
+ }
+ return this.jsonCache as T;
+ }
+}
+```
+
+Supported formats:
+
+- `json()` - Parse as JSON
+- `text()` - Parse as string
+- `formData()` - Parse as FormData
+- `arrayBuffer()` - Parse as ArrayBuffer
+- `blob()` - Parse as Blob
+
+## Consequences
+
+### Benefits
+
+- Multiple middleware can read same body
+- No "body already consumed" errors
+- Transparent to middleware authors
+- Consistent parsing across request lifecycle
+
+### Drawbacks
+
+- Memory overhead for cached parsed bodies
+- First accessor determines parse success/failure
+- Cannot re-parse with different options (e.g., different JSON reviver)
+- Large bodies stay in memory for request duration
+
+### Behavior Notes
+
+- Parsing happens lazily (only when accessed)
+- Parse errors throw on first access, cached for subsequent access
+- Different formats are independent (can call both `json()` and `text()`)
+- Original `Request` body is consumed on first parse of any format
diff --git a/docs/adr/008-csrf-fetch-metadata.md b/docs/adr/008-csrf-fetch-metadata.md
new file mode 100644
index 0000000..c2a6596
--- /dev/null
+++ b/docs/adr/008-csrf-fetch-metadata.md
@@ -0,0 +1,80 @@
+# ADR-008: CSRF Protection via Fetch Metadata Headers
+
+**Status:** Accepted
+
+## Context
+
+Cross-Site Request Forgery (CSRF) attacks trick authenticated users into
+submitting malicious requests. Traditional protections include:
+
+**Synchronizer token pattern:**
+
+- Server generates token, embeds in forms
+- Server validates token on submission
+- Requires server-side token storage
+- Session affinity or shared storage needed
+
+**Double-submit cookie:**
+
+- Token in cookie and request body/header
+- Stateless but vulnerable to subdomain attacks
+- Cookie scope issues
+
+**Fetch Metadata headers:**
+
+- Browser sends `Sec-Fetch-Site`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest`
+- Cannot be set by JavaScript (browser-enforced)
+- Stateless, no token management
+- Modern browsers only (Safari 16.4+, Chrome 76+, Firefox 90+)
+
+## Decision
+
+Implement CSRF protection using Fetch Metadata headers (`Sec-Fetch-Site`) with
+`Origin` header as fallback for older browsers.
+
+```typescript
+// Protection logic
+if (isStateChangingRequest(method) && hasFormContentType(contentType)) {
+ const site = headers.get("Sec-Fetch-Site");
+
+ if (site && site !== "same-origin" && site !== "same-site") {
+ return forbidden();
+ }
+
+ // Fallback: check Origin header
+ if (!site) {
+ const origin = headers.get("Origin");
+ if (origin && !isSameOrigin(origin, requestUrl)) {
+ return forbidden();
+ }
+ }
+}
+```
+
+## Consequences
+
+### Benefits
+
+- No server-side token storage
+- No token synchronization between server instances
+- Browser-enforced (cannot be spoofed by JavaScript)
+- Simpler implementation than token-based approaches
+- No token in forms or AJAX headers
+
+### Drawbacks
+
+- Older browsers fall back to Origin check (less reliable)
+- Only protects requests with form content types
+- Cross-origin APIs require explicit CORS configuration
+- Cannot protect GET requests (by design - GET should be safe)
+
+### Protected Scenarios
+
+- `POST`/`PUT`/`DELETE` with `application/x-www-form-urlencoded`
+- `POST`/`PUT`/`DELETE` with `multipart/form-data`
+
+### Not Protected (by design)
+
+- `GET` requests (should be idempotent)
+- JSON API requests (require explicit `Content-Type`, mitigates CSRF)
+- Requests from same origin/site
diff --git a/docs/adr/009-signed-cookies-hmac.md b/docs/adr/009-signed-cookies-hmac.md
new file mode 100644
index 0000000..fefc25b
--- /dev/null
+++ b/docs/adr/009-signed-cookies-hmac.md
@@ -0,0 +1,83 @@
+# ADR-009: Signed Cookies with HMAC-SHA256
+
+**Status:** Accepted
+
+## Context
+
+Cookies storing user data (preferences, session IDs, tokens) are vulnerable to
+tampering. A malicious user can modify cookie values to impersonate others or
+escalate privileges.
+
+Protection options:
+
+**Server-side sessions:**
+
+- Cookie contains only session ID
+- All data stored server-side
+- Requires shared storage for horizontal scaling
+
+**Encrypted cookies:**
+
+- Data encrypted, unreadable by client
+- Key management complexity
+- Larger cookies due to encryption overhead
+
+**Signed cookies:**
+
+- Data readable but tamper-evident
+- HMAC signature validates integrity
+- Stateless, no server storage needed
+
+## Decision
+
+Support signed cookies using HMAC-SHA256 with constant-time signature
+comparison.
+
+Format: `s:value.signature`
+
+```typescript
+// Setting a signed cookie
+setCookie(c, "userId", "12345", {
+ secret: COOKIE_SECRET,
+ httpOnly: true,
+ secure: true,
+ sameSite: "Strict",
+});
+// Result: userId=s:12345.HmacSignatureHere
+
+// Reading validates signature automatically
+const userId = getCookie(c, "userId", { secret: COOKIE_SECRET });
+// Returns "12345" if valid, undefined if tampered
+```
+
+## Consequences
+
+### Benefits
+
+- Detects tampering without server-side state
+- Web Crypto API provides native HMAC-SHA256
+- Constant-time comparison prevents timing attacks
+- Cookie value remains readable for debugging
+- Stateless - works with any number of server instances
+
+### Drawbacks
+
+- Secret key management required
+- Signed cookies are larger than unsigned (~44 chars for signature)
+- Key rotation requires supporting multiple keys temporarily
+- Value is visible (not encrypted) - don't store sensitive data
+
+### Security Requirements
+
+- Secret must be cryptographically random, minimum 32 bytes
+- Secret must not be committed to version control
+- Use constant-time comparison to prevent timing attacks
+- Always combine with `HttpOnly`, `Secure`, `SameSite` attributes
+
+### Key Rotation
+
+When rotating secrets:
+
+1. Accept signatures from both old and new keys
+2. Sign new cookies with new key only
+3. After cookie max-age expires, remove old key
diff --git a/docs/adr/010-test-against-running-server.md b/docs/adr/010-test-against-running-server.md
new file mode 100644
index 0000000..aced23c
--- /dev/null
+++ b/docs/adr/010-test-against-running-server.md
@@ -0,0 +1,91 @@
+# ADR-010: Test Against Running Server
+
+**Status:** Accepted
+
+## Context
+
+Framework tests can be structured as:
+
+**Unit tests with mocks:**
+
+- Mock request/response objects
+- Test functions in isolation
+- Fast execution
+- May miss integration issues
+
+**Integration tests against real server:**
+
+- Start actual HTTP server
+- Make real network requests
+- Slower but higher confidence
+- Tests match production behavior
+
+**Hybrid approach:**
+
+- Unit tests for pure functions
+- Integration tests for HTTP behavior
+
+## Decision
+
+All Tabi tests use `TabiTestServer` to run actual HTTP requests against a real
+server instance.
+
+```typescript
+describe("routing - parameters", () => {
+ let server: TabiTestServer;
+
+ beforeAll(() => {
+ const app = new TabiApp();
+ app.get("/users/:id", (c) => {
+ return c.json({ id: c.req.param("id") });
+ });
+
+ server = new TabiTestServer(app);
+ server.start(); // Random port
+ });
+
+ it("extracts route parameters", async () => {
+ const res = await fetch(server.url("/users/123"));
+ const json = await res.json();
+ expect(json.id).toBe("123");
+ });
+
+ afterAll(async () => {
+ await server.stop();
+ });
+});
+```
+
+## Consequences
+
+### Benefits
+
+- Tests verify actual behavior, not mocked behavior
+- Catches integration issues (header handling, encoding, content negotiation)
+- Test execution path matches production
+- No complex mocking infrastructure
+- Tests serve as executable documentation
+
+### Drawbacks
+
+- Slower than unit tests (network overhead)
+- Requires port management (use port 0 for random assignment)
+- Tests are inherently integration-level
+- Harder to test internal edge cases
+- Parallel test execution needs care (port conflicts)
+
+### Test Infrastructure
+
+`TabiTestServer` provides:
+
+- Automatic random port assignment
+- URL builder for requests
+- Clean start/stop lifecycle
+- Works with BDD-style tests (describe/it/beforeAll/afterAll)
+
+### Test Philosophy
+
+- Test behavior, not implementation
+- One test file per feature
+- Realistic test data (no "foo", "bar", "test123")
+- Mock only at true system boundaries (if ever)
diff --git a/docs/adr/011-context-based-state.md b/docs/adr/011-context-based-state.md
new file mode 100644
index 0000000..e38b018
--- /dev/null
+++ b/docs/adr/011-context-based-state.md
@@ -0,0 +1,101 @@
+# ADR-011: Context-Based Request State
+
+**Status:** Accepted
+
+## Context
+
+Middleware needs to share data through the request lifecycle:
+
+- Authentication middleware sets user info
+- Validation middleware sets parsed/validated input
+- Request ID middleware sets correlation ID
+- Handlers read data set by earlier middleware
+
+Options:
+
+**Mutate request object:**
+
+- Simple but loses type safety
+- Risk of property collisions
+- Difficult to track what's set where
+
+**Dependency injection:**
+
+- Type-safe but complex setup
+- Requires container configuration
+- Over-engineering for simple cases
+
+**Context object with get/set:**
+
+- Explicit storage and retrieval
+- Can be type-safe with generics
+- Clear ownership of data
+
+## Decision
+
+Use `TabiContext` with `set(key, value)` / `get(key)` for request-scoped
+state.
+
+```typescript
+// Generic context storage
+app.use(async (c, next) => {
+ c.set("requestTime", Date.now());
+ await next();
+});
+
+app.get("/", (c) => {
+ const time = c.get("requestTime");
+ return c.json({ time });
+});
+```
+
+For validated input, provide type-safe accessor pattern:
+
+```typescript
+const { validate, valid } = validator({
+ json: z.object({ name: z.string() }),
+ query: z.object({ page: z.coerce.number() }),
+});
+
+app.post("/users", validate, (c) => {
+ const { json, query } = valid(c); // Fully typed
+ // json.name is string, query.page is number
+});
+```
+
+## Consequences
+
+### Benefits
+
+- Simple API for ad-hoc state sharing
+- No global state - scoped to request
+- Validator pattern provides full type safety
+- UUID-based internal keys prevent collisions
+- Explicit data flow (set before get)
+
+### Drawbacks
+
+- Generic `get()` requires explicit type annotation
+- Key management is application responsibility
+- No compile-time guarantee that key was set
+- Validator setup adds complexity for type safety
+
+### Patterns
+
+```typescript
+// Constants for keys prevent typos
+const USER_KEY = "auth:user";
+c.set(USER_KEY, user);
+const user = c.get(USER_KEY);
+
+// Middleware can check if value exists
+if (c.get("auth:user")) {
+ // Authenticated
+}
+```
+
+### Type Safety Spectrum
+
+1. **Weakest:** `c.get("key")` - returns `unknown`
+2. **Medium:** `c.get("key")` - typed but not verified
+3. **Strongest:** `valid(c).json` - compile-time type from schema
diff --git a/docs/adr/012-composable-security-middleware.md b/docs/adr/012-composable-security-middleware.md
new file mode 100644
index 0000000..7caa456
--- /dev/null
+++ b/docs/adr/012-composable-security-middleware.md
@@ -0,0 +1,95 @@
+# ADR-012: Composable Security Middleware
+
+**Status:** Accepted
+
+## Context
+
+Security features can be packaged as:
+
+**Monolithic security layer:**
+
+- Single "security" middleware with all features
+- One configuration object
+- All-or-nothing approach
+- Simpler to enable "secure by default"
+
+**Composable security middleware:**
+
+- Each feature is independent middleware
+- Applications compose what they need
+- More configuration, more control
+- Explicit security posture
+
+## Decision
+
+Each security feature is a separate, independently usable middleware.
+
+```typescript
+import { securityHeaders } from "@tabirun/app/security-headers";
+import { csrf } from "@tabirun/app/csrf";
+import { cors } from "@tabirun/app/cors";
+import { rateLimit } from "@tabirun/app/rate-limit";
+import { bodySize } from "@tabirun/app/body-size";
+
+const app = new TabiApp();
+
+// Compose security middleware explicitly
+app.use(securityHeaders({ referrerPolicy: "strict-origin" }));
+app.use(csrf());
+app.use(cors({ origin: "https://example.com" }));
+app.use(rateLimit({ max: 100, windowMs: 60_000 }));
+app.use(bodySize({ maxBytes: 1024 * 1024 })); // 1MB
+```
+
+## Consequences
+
+### Benefits
+
+- Applications include only needed protections
+- Each middleware has clear, testable responsibility
+- Configuration is explicit per feature
+- Can compose in custom order
+- Easier to understand individual security measures
+- Can disable specific protections when needed (e.g., CSRF for API routes)
+
+### Drawbacks
+
+- More setup for comprehensive security
+- Easy to forget a middleware (no "secure by default")
+- Must understand each security measure
+- Documentation must show recommended composition
+
+### Security Modules
+
+| Module | Purpose |
+| ------------------ | ------------------------------------- |
+| `security-headers` | CSP, HSTS, X-Frame-Options, etc. |
+| `csrf` | Cross-Site Request Forgery protection |
+| `cors` | Cross-Origin Resource Sharing |
+| `csp` | Content Security Policy (detailed) |
+| `rate-limit` | Request rate limiting |
+| `body-size` | Request body size limits |
+
+### Recommended Composition
+
+For typical web applications:
+
+```typescript
+// Apply to all routes
+app.use(securityHeaders());
+app.use(bodySize());
+app.use(rateLimit());
+
+// Apply to browser-facing routes
+app.use("/app/*", csrf());
+
+// Apply to API routes
+app.use("/api/*", cors({ origin: ALLOWED_ORIGINS }));
+```
+
+### Documentation Requirements
+
+- Clear explanation of what each middleware protects against
+- Recommended defaults for common scenarios
+- Examples showing composition patterns
+- Warnings about common misconfigurations
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 0000000..5312e6c
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,63 @@
+# Architecture Decision Records
+
+This directory contains Architecture Decision Records (ADRs) for Tabi.
+
+## What is an ADR?
+
+An ADR captures an architecturally significant decision along with its context
+and consequences. ADRs are immutable once accepted; superseding decisions create
+new ADRs that reference the original.
+
+## ADR Status
+
+- **Proposed** - Under discussion
+- **Accepted** - Decision made and in effect
+- **Deprecated** - No longer applies but kept for historical context
+- **Superseded** - Replaced by a newer ADR (link to replacement)
+
+## Index
+
+| ADR | Title | Status |
+| -------------------------------------------- | -------------------------------------------------- | -------- |
+| [001](001-deno-runtime.md) | Use Deno as the Single Runtime | Accepted |
+| [002](002-minimal-philosophy.md) | Minimal Framework Philosophy | Accepted |
+| [003](003-monorepo-composable-modules.md) | Monorepo with Independently Composable Modules | Accepted |
+| [004](004-standard-schema-validation.md) | Standard Schema for Validation Library Agnosticism | Accepted |
+| [005](005-linear-router-default.md) | Linear Router as Default Implementation | Accepted |
+| [006](006-lazy-response-materialization.md) | Lazy Response Materialization | Accepted |
+| [007](007-memoized-request-body.md) | Memoized Request Body Parsing | Accepted |
+| [008](008-csrf-fetch-metadata.md) | CSRF Protection via Fetch Metadata Headers | Accepted |
+| [009](009-signed-cookies-hmac.md) | Signed Cookies with HMAC-SHA256 | Accepted |
+| [010](010-test-against-running-server.md) | Test Against Running Server | Accepted |
+| [011](011-context-based-state.md) | Context-Based Request State | Accepted |
+| [012](012-composable-security-middleware.md) | Composable Security Middleware | Accepted |
+
+## Creating a New ADR
+
+1. Copy the template below
+2. Use the next sequential number
+3. Write in past tense for context, present tense for decision
+4. Submit PR for review
+
+### Template
+
+```markdown
+# ADR-NNN: Title
+
+**Status:** Proposed | Accepted | Deprecated | Superseded by
+[ADR-NNN](NNN-title.md)
+
+**Date:** YYYY-MM-DD
+
+## Context
+
+What is the issue that we're seeing that is motivating this decision or change?
+
+## Decision
+
+What is the change that we're proposing and/or doing?
+
+## Consequences
+
+What becomes easier or more difficult to do because of this change?
+```