d407ee75b032cc435dbb923e38d48966650c0465 diff --git a/content/sama/v2-example-crud.md b/content/sama/v2-example-crud.md new file mode 100644 index 0000000000000000000000000000000000000000..1feae1ae180e3eff8b6a2034d700c93d26658eda --- /dev/null +++ b/content/sama/v2-example-crud.md @@ -0,0 +1,256 @@ +# Worked example — an HTTP CRUD service under SAMA v2 + +The [/sama/v2](/sama/v2) spec is profile-agnostic by design. This page is one concrete instantiation of it: a small e-commerce orders API. The same four canonical layers and the same import law govern; only the **profile** changes — the sublayer split inside Layers 1 and 2 reflects what HTTP-CRUD services actually need. + +Use this as a reading anchor, not a copy-paste skeleton. A SAMA v2-conformant CRUD service has many valid shapes; this page commits to one and explains every decision. + +## The profile + +A single `sama.profile.toml` at the repo root, machine-ingestible by the verifier: + +```toml +sama_version = "2.0" +profile = "orders-api" + +# Layer 0 — Pure. Domain types, validation rules as data, pure transforms. +# No I/O, no clock, no framework. +[layers.0] +prefixes = ["a_"] + +# Layer 1 — Core. Business decisions and orchestration. No DB, no HTTP, +# no clock. Calls into Layer 2 only through dependency-injected interfaces +# the Pure layer declares. +# - b1_ policy: pure decisions over domain state ("canCancelOrder") +# - b2_ service: orchestrators that compose policy + delegate to adapters +# (b2 may import b1; b1 must never import b2) +[layers.1] +sublayers = [ + { name = "policy", prefix = "b1_" }, + { name = "service", prefix = "b2_" }, +] + +# Layer 2 — Adapter. The boundary. External input is PARSED here (never +# cast). DB, network, filesystem, framework bindings. +# - c1_ repository: DB-backed CRUD (one repo per aggregate) +# - c2_ gateway: outbound HTTP / SMTP / SMS to third parties +# - c3_ controller: inbound HTTP request parsing (JSON.parse, new URL, +# schema validation). NOT to be confused with Layer 3 route handlers. +# c3 turns bytes into typed structures; d_ wires those structures to +# a service call. +[layers.2] +sublayers = [ + { name = "repository", prefix = "c1_" }, + { name = "gateway", prefix = "c2_" }, + { name = "controller", prefix = "c3_" }, +] + +# Layer 3 — Entry. Route handlers + server bootstrap. Composes c3_ +# controllers + b2_ services. Owns no business logic. +[layers.3] +prefixes = ["d_"] +``` + +Lex check: `a_ < b1_ < b2_ < c1_ < c2_ < c3_ < d_`. Layer order: `0 < 1 < 1 < 2 < 2 < 2 < 3`. The Sorted check (§4.1) is satisfied for free — `ls src/` reads top-to-bottom in dependency order. + +## The directory + +``` +src/ +├── a_order.ts # Layer 0 — Order, OrderLine, OrderStatus types +├── a_customer.ts # Layer 0 — Customer, Address types +├── a_payment.ts # Layer 0 — Payment, Money, Currency types +├── a_pricing.ts # Layer 0 — pure pricing math, tax rules +├── a_ports.ts # Layer 0 — adapter interface declarations +│ # (OrderRepository, PaymentGateway, ...) +├── b1_order_policy.ts # Layer 1 — canCancelOrder, isEligibleForRefund +├── b1_payment_policy.ts # Layer 1 — paymentRetryStrategy, refundWindow +├── b2_order_service.ts # Layer 1 — createOrder, fulfillOrder, cancelOrder +├── b2_payment_service.ts # Layer 1 — chargeOrder, refundOrder +├── c1_order_repo.ts # Layer 2 — implements OrderRepository against bun:sqlite +├── c1_customer_repo.ts # Layer 2 — implements CustomerRepository +├── c2_stripe_gateway.ts # Layer 2 — implements PaymentGateway via Stripe REST +├── c2_email_gateway.ts # Layer 2 — implements EmailGateway via Resend +├── c3_order_controller.ts # Layer 2 — parseCreateOrderRequest, parseListQuery +├── c3_payment_controller.ts # Layer 2 — parseChargeRequest, parseRefundRequest +├── d_orders_handlers.ts # Layer 3 — POST /orders, GET /orders/:id, ... +├── d_payments_handlers.ts # Layer 3 — POST /payments, POST /refunds +└── d_server.ts # Layer 3 — Bun.serve / Express / Hono bootstrap + ++ sibling .test.ts for every b*_ and c*_ file (Modeled-tests check §4.3) +``` + +Twenty-odd source files plus sibling tests. Small enough to navigate; covers every layer the spec names. + +## Layer 0 — Pure + +Domain types, validation rules as data, pure transforms. No I/O. No clock. No `new Date()` inside a Layer 0 function (use a clock value passed in from Layer 1+). No framework imports — `node:fs`, `express`, `bun:sqlite`, `Bun.serve` all live higher. + +```ts +// a_order.ts +export type OrderStatus = "pending" | "paid" | "fulfilled" | "cancelled"; + +export interface OrderLine { + productId: string; + quantity: number; + unitPriceCents: number; +} + +export interface Order { + id: string; + customerId: string; + lines: ReadonlyArray; + status: OrderStatus; + createdAt: string; // ISO 8601; produced by Layer 1+ +} + +// a_ports.ts — the interfaces Layer 1 calls through. Implementations +// live in Layer 2; declaring them in Layer 0 lets b2_*_service.ts +// import only types (no upward edge to c1_*). +export interface OrderRepository { + save(order: Order): Promise; + findById(id: string): Promise; + listByCustomer(customerId: string): Promise; +} + +export interface PaymentGateway { + charge(orderId: string, amountCents: number, currency: string): Promise<{ chargeId: string } | { error: string }>; +} +``` + +The crucial Layer 0 move: **adapter interfaces declared here, not in Layer 2**. A `b2_order_service.ts` that orchestrates `OrderRepository` doesn't import from `c1_order_repo.ts` — it imports the *interface* from `a_ports.ts` and is handed a concrete instance at construction. This is what keeps the Law check green: Layer 1 → Layer 0 only. + +## Layer 1 — Core + +Business logic and orchestration. Two sublayers — `b1_` policy (pure decisions) and `b2_` service (composes policy + delegates to interfaces). `b2` may import `b1`; `b1` must never import `b2` (the profile's sublayer order enforces this via §2.2). + +```ts +// b1_order_policy.ts — pure functions, no I/O, no clock +import type { Order } from "./a_order.ts"; + +export const canCancelOrder = (order: Order, nowIso: string): boolean => { + if (order.status !== "pending" && order.status !== "paid") return false; + const ageMs = Date.parse(nowIso) - Date.parse(order.createdAt); + return ageMs < 24 * 60 * 60 * 1000; // 24-hour cancellation window +}; + +// b2_order_service.ts — orchestrator. Takes ports at construction. +import type { Order, OrderRepository, PaymentGateway } from "./a_ports.ts"; +import { canCancelOrder } from "./b1_order_policy.ts"; + +export interface OrderServiceDeps { + orders: OrderRepository; + payments: PaymentGateway; + now: () => string; // clock as a port — pure functions stay pure +} + +export const createOrder = async (deps: OrderServiceDeps, input: CreateOrderInput): Promise => { + // ... validates, computes totals via pure a_pricing, calls deps.orders.save +}; + +export const cancelOrder = async (deps: OrderServiceDeps, orderId: string): Promise => { + const order = await deps.orders.findById(orderId); + if (!order) throw new Error("order not found"); + if (!canCancelOrder(order, deps.now())) throw new Error("outside cancellation window"); + await deps.orders.save({ ...order, status: "cancelled" }); +}; +``` + +Every Layer 1 file has a sibling `.test.ts` (Modeled-tests check §4.3). The policy tests are pure unit tests over fixture orders; the service tests use in-memory fake implementations of the ports (not mocks — concrete in-memory `OrderRepository` and `PaymentGateway` that the test constructs). + +## Layer 2 — Adapter + +The boundary. Three sublayers — `c1_` repository (DB), `c2_` gateway (outbound HTTP), `c3_` controller (inbound HTTP parsing). Within Layer 2, controllers may import repositories + gateways through their interfaces. + +```ts +// c1_order_repo.ts — implements a_ports.OrderRepository +import { Database } from "bun:sqlite"; +import type { Order, OrderRepository } from "./a_ports.ts"; + +export const createOrderRepository = (db: Database): OrderRepository => ({ + save: async (order) => { + db.prepare("INSERT OR REPLACE INTO orders (id, customer_id, status, ...) VALUES (?, ?, ?, ...)") + .run(order.id, order.customerId, order.status); + }, + findById: async (id) => { + const row = db.prepare("SELECT * FROM orders WHERE id = ?").get(id); + return row ? rowToOrder(row) : null; + }, + // ... +}); + +// c3_order_controller.ts — parsing only. The §4.4 Modeled-boundary +// check FORBIDS JSON.parse / new URL outside this layer. +import type { CreateOrderInput } from "./a_order.ts"; + +export const parseCreateOrderRequest = (body: string): { ok: true; value: CreateOrderInput } | { ok: false; error: string } => { + let parsed: unknown; + try { + parsed = JSON.parse(body); // ← parse only here + } catch (e) { + return { ok: false, error: "invalid JSON" }; + } + // ... schema validation, returns typed CreateOrderInput +}; +``` + +Layer 2 imports from Layer 0 and Layer 1 (e.g. a repository helper might call a `b1_` policy to enrich rows before returning). Never from Layer 3. + +## Layer 3 — Entry + +Route handlers + server bootstrap. Owns zero business logic — every handler is a thin composition: parse request via `c3_*_controller`, invoke `b2_*_service`, serialize the result. + +```ts +// d_orders_handlers.ts +import { parseCreateOrderRequest } from "./c3_order_controller.ts"; +import { createOrder } from "./b2_order_service.ts"; + +export const postOrdersHandler = (deps: OrderServiceDeps) => async (req: Request): Promise => { + const body = await req.text(); + const parsed = parseCreateOrderRequest(body); // bytes → typed + if (!parsed.ok) return new Response(parsed.error, { status: 400 }); + const order = await createOrder(deps, parsed.value); // pure orchestration call + return Response.json(order, { status: 201 }); +}; + +// d_server.ts +import { Database } from "bun:sqlite"; +import { createOrderRepository } from "./c1_order_repo.ts"; +import { createStripeGateway } from "./c2_stripe_gateway.ts"; +import { postOrdersHandler } from "./d_orders_handlers.ts"; + +const db = new Database("orders.db"); +const deps = { + orders: createOrderRepository(db), + payments: createStripeGateway(process.env.STRIPE_KEY!), + now: () => new Date().toISOString(), +}; +Bun.serve({ + port: 3000, + routes: { "POST /orders": postOrdersHandler(deps) }, +}); +``` + +The Entry layer is where adapters get wired into services. Test files don't normally sit beside `d_*` — the Modeled-tests rule (§4.3) only requires sibling tests for Layer 1 and Layer 2. Layer 3 is exercised by end-to-end integration tests, not unit tests. + +## Common mistakes — and the check that catches them + +| What an agent might do | Which v2 check catches it | +|---|---| +| Put `JSON.parse(req.body)` inside `d_orders_handlers.ts` | **#4 Modeled (boundary)** — boundary parsing must live in Layer 2 (here: `c3_*_controller.ts`) | +| Import `c1_order_repo.ts` from `b2_order_service.ts` | **#6 Law (§1.2)** — Layer 1 → Layer 2 is upward (use the `a_ports.ts` interface instead) | +| Import `b1_order_policy.ts` from `b2_order_service.ts` | ✓ Legal — same layer, `b2` is later in the sublayer order so it may import `b1` | +| Import `b2_order_service.ts` from `b1_order_policy.ts` | **#6 Law (§1.2)** — same layer but reversed sublayer order (`b1` may not import `b2`) | +| Create `b2_payment_service.ts` without a sibling `.test.ts` | **#3 Modeled (tests)** — Layer 1 behaviour files require sibling tests | +| Let `a_pricing.ts` import `b1_order_policy.ts` | **#7 Consistency (§3)** — file's `a_` prefix claims Pure but imports reach Layer 1; the prefix lies | +| Let `d_orders_handlers.ts` grow to 850 LOC | **#5 Atomic** — over the 700-line cap; split per route group | +| Add a `src/utils/json.ts` (no `a_`/`b_`/`c_`/`d_` prefix) | **#2 Architecture** — unprefixed file does not map to any canonical layer | + +Every column 2 entry is a deterministic check the verifier runs — the agent's harness cannot talk it out of the verdict. + +## See also + +- [/sama/v2](/sama/v2) — the canonical spec this profile instantiates +- [/sama/v2/verify](/sama/v2/verify) — the live verifier running against this site's own (different) profile +- [/sama/v2#5-operational--core-metrics-definitions](/sama/v2#5-operational--core-metrics-definitions) — the §5 metrics this profile would emit + +[← /sama/v2](/sama/v2) · [← /sama](/sama) diff --git a/content/sama/v2.md b/content/sama/v2.md index ec2009eb8e9859ba75cba627789d36eeba2d1946..5b1072366464d1a645c996395dc353443802619f 100644 --- a/content/sama/v2.md +++ b/content/sama/v2.md @@ -97,6 +97,8 @@ prefixes = ["e3_"] A `cli` profile would leave `[layers.2]` minimal and subdivide `[layers.3]` into `arg-parser → dispatch`. A `frontend` profile would subdivide Layer 1 into `store` vs `view-logic`. Same law, different dialect. +→ **Worked example:** [a CRUD HTTP service under v2](/sama/v2/example-crud) — full profile, directory tree, per-layer code signatures, and the common mistakes each §4 check catches. + --- ## 3. Layer assignment & the consistency check diff --git a/src/d21_app.ts b/src/d21_app.ts index 682f7473e0fbd87c619d7d9940f6dce51f271ce5..d693684d7bac6f7e7289aef02a9ec7fc7b307896 100644 --- a/src/d21_app.ts +++ b/src/d21_app.ts @@ -33,6 +33,7 @@ import { samaCliResponse, samaSkillHandler, samaV2Handler, + samaV2ExampleCrudHandler, samaV2VerifyHandler, samaVerifyHandler, samaLandingHandler, @@ -365,6 +366,8 @@ ${rows} "/sama/v2/verify": samaV2VerifyHandler, + "/sama/v2/example-crud": samaV2ExampleCrudHandler, + "/sama/verify": samaVerifyHandler, "/sama": samaLandingHandler, diff --git a/src/d21_handlers_sama.ts b/src/d21_handlers_sama.ts index 6ea652fcc5aafc07784044f7b033e8bffdbfbdb4..0238e692c59a631f0744bb491121ada4f1213df6 100644 --- a/src/d21_handlers_sama.ts +++ b/src/d21_handlers_sama.ts @@ -185,6 +185,22 @@ export const samaV2Handler = async (): Promise => { return htmlResponse(html); }; +// -------- /sama/v2/example-crud (worked-example profile for an HTTP CRUD service) -------- + +export const samaV2ExampleCrudHandler = async (): Promise => { + const md = await Bun.file("./content/sama/v2-example-crud.md").text(); + const html = await renderDocsPage({ + title: "SAMA v2 — worked example: HTTP CRUD service — tdd.md", + description: + "One concrete instantiation of the SAMA v2 spec: an e-commerce orders API. Profile declaration, directory layout, per-layer code signatures, and the common mistakes each §4 check catches.", + bodyMarkdown: md, + ogPath: "https://tdd.md/sama/v2/example-crud", + active: "sama", + pathForDocs: "/sama/v2/example-crud", + }); + return htmlResponse(html); +}; + // -------- /sama/verify (form + report + dogfood short-circuit) -------- const VERIFY_FORM_MD = `# SAMA verify