syntaxai/tdd.md · commit d407ee7

Docs: worked-example CRUD profile at /sama/v2/example-crud

Most readers landing on /sama/v2 see only tdd.md's own profile as a
worked example — that's a spec-of-one shape. This adds /sama/v2/
example-crud: a concrete e-commerce orders API profile (orders-api),
its sama.profile.toml inline, an ASCII directory tree (a_/b1_/b2_/
c1_/c2_/c3_/d_ prefixes — lex-sort matches layer order), and per-
layer paragraphs with code signatures (not full bodies).

Ends with a "common mistakes" table that maps eight realistic agent
errors to the §4 check that catches each one. Cross-link added on
/sama/v2 §2.3.

No new logic, no test changes; just a markdown page + handler + route
literal before /sama/v2 in the dispatch table.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-23 14:24:18 +01:00
parent
f88ab8d
commit
d407ee75b032cc435dbb923e38d48966650c0465

4 files changed · +277 −0

added content/sama/v2-example-crud.md +256 −0
@@ -0,0 +1,256 @@
1+# Worked example — an HTTP CRUD service under SAMA v2
2+
3+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.
4+
5+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.
6+
7+## The profile
8+
9+A single `sama.profile.toml` at the repo root, machine-ingestible by the verifier:
10+
11+```toml
12+sama_version = "2.0"
13+profile = "orders-api"
14+
15+# Layer 0 — Pure. Domain types, validation rules as data, pure transforms.
16+# No I/O, no clock, no framework.
17+[layers.0]
18+prefixes = ["a_"]
19+
20+# Layer 1 — Core. Business decisions and orchestration. No DB, no HTTP,
21+# no clock. Calls into Layer 2 only through dependency-injected interfaces
22+# the Pure layer declares.
23+# - b1_ policy: pure decisions over domain state ("canCancelOrder")
24+# - b2_ service: orchestrators that compose policy + delegate to adapters
25+# (b2 may import b1; b1 must never import b2)
26+[layers.1]
27+sublayers = [
28+ { name = "policy", prefix = "b1_" },
29+ { name = "service", prefix = "b2_" },
30+]
31+
32+# Layer 2 — Adapter. The boundary. External input is PARSED here (never
33+# cast). DB, network, filesystem, framework bindings.
34+# - c1_ repository: DB-backed CRUD (one repo per aggregate)
35+# - c2_ gateway: outbound HTTP / SMTP / SMS to third parties
36+# - c3_ controller: inbound HTTP request parsing (JSON.parse, new URL,
37+# schema validation). NOT to be confused with Layer 3 route handlers.
38+# c3 turns bytes into typed structures; d_ wires those structures to
39+# a service call.
40+[layers.2]
41+sublayers = [
42+ { name = "repository", prefix = "c1_" },
43+ { name = "gateway", prefix = "c2_" },
44+ { name = "controller", prefix = "c3_" },
45+]
46+
47+# Layer 3 — Entry. Route handlers + server bootstrap. Composes c3_
48+# controllers + b2_ services. Owns no business logic.
49+[layers.3]
50+prefixes = ["d_"]
51+```
52+
53+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.
54+
55+## The directory
56+
57+```
58+src/
59+├── a_order.ts # Layer 0 — Order, OrderLine, OrderStatus types
60+├── a_customer.ts # Layer 0 — Customer, Address types
61+├── a_payment.ts # Layer 0 — Payment, Money, Currency types
62+├── a_pricing.ts # Layer 0 — pure pricing math, tax rules
63+├── a_ports.ts # Layer 0 — adapter interface declarations
64+│ # (OrderRepository, PaymentGateway, ...)
65+├── b1_order_policy.ts # Layer 1 — canCancelOrder, isEligibleForRefund
66+├── b1_payment_policy.ts # Layer 1 — paymentRetryStrategy, refundWindow
67+├── b2_order_service.ts # Layer 1 — createOrder, fulfillOrder, cancelOrder
68+├── b2_payment_service.ts # Layer 1 — chargeOrder, refundOrder
69+├── c1_order_repo.ts # Layer 2 — implements OrderRepository against bun:sqlite
70+├── c1_customer_repo.ts # Layer 2 — implements CustomerRepository
71+├── c2_stripe_gateway.ts # Layer 2 — implements PaymentGateway via Stripe REST
72+├── c2_email_gateway.ts # Layer 2 — implements EmailGateway via Resend
73+├── c3_order_controller.ts # Layer 2 — parseCreateOrderRequest, parseListQuery
74+├── c3_payment_controller.ts # Layer 2 — parseChargeRequest, parseRefundRequest
75+├── d_orders_handlers.ts # Layer 3 — POST /orders, GET /orders/:id, ...
76+├── d_payments_handlers.ts # Layer 3 — POST /payments, POST /refunds
77+└── d_server.ts # Layer 3 — Bun.serve / Express / Hono bootstrap
78+
79++ sibling .test.ts for every b*_ and c*_ file (Modeled-tests check §4.3)
80+```
81+
82+Twenty-odd source files plus sibling tests. Small enough to navigate; covers every layer the spec names.
83+
84+## Layer 0 — Pure
85+
86+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.
87+
88+```ts
89+// a_order.ts
90+export type OrderStatus = "pending" | "paid" | "fulfilled" | "cancelled";
91+
92+export interface OrderLine {
93+ productId: string;
94+ quantity: number;
95+ unitPriceCents: number;
96+}
97+
98+export interface Order {
99+ id: string;
100+ customerId: string;
101+ lines: ReadonlyArray<OrderLine>;
102+ status: OrderStatus;
103+ createdAt: string; // ISO 8601; produced by Layer 1+
104+}
105+
106+// a_ports.ts — the interfaces Layer 1 calls through. Implementations
107+// live in Layer 2; declaring them in Layer 0 lets b2_*_service.ts
108+// import only types (no upward edge to c1_*).
109+export interface OrderRepository {
110+ save(order: Order): Promise<void>;
111+ findById(id: string): Promise<Order | null>;
112+ listByCustomer(customerId: string): Promise<Order[]>;
113+}
114+
115+export interface PaymentGateway {
116+ charge(orderId: string, amountCents: number, currency: string): Promise<{ chargeId: string } | { error: string }>;
117+}
118+```
119+
120+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.
121+
122+## Layer 1 — Core
123+
124+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).
125+
126+```ts
127+// b1_order_policy.ts — pure functions, no I/O, no clock
128+import type { Order } from "./a_order.ts";
129+
130+export const canCancelOrder = (order: Order, nowIso: string): boolean => {
131+ if (order.status !== "pending" && order.status !== "paid") return false;
132+ const ageMs = Date.parse(nowIso) - Date.parse(order.createdAt);
133+ return ageMs < 24 * 60 * 60 * 1000; // 24-hour cancellation window
134+};
135+
136+// b2_order_service.ts — orchestrator. Takes ports at construction.
137+import type { Order, OrderRepository, PaymentGateway } from "./a_ports.ts";
138+import { canCancelOrder } from "./b1_order_policy.ts";
139+
140+export interface OrderServiceDeps {
141+ orders: OrderRepository;
142+ payments: PaymentGateway;
143+ now: () => string; // clock as a port — pure functions stay pure
144+}
145+
146+export const createOrder = async (deps: OrderServiceDeps, input: CreateOrderInput): Promise<Order> => {
147+ // ... validates, computes totals via pure a_pricing, calls deps.orders.save
148+};
149+
150+export const cancelOrder = async (deps: OrderServiceDeps, orderId: string): Promise<void> => {
151+ const order = await deps.orders.findById(orderId);
152+ if (!order) throw new Error("order not found");
153+ if (!canCancelOrder(order, deps.now())) throw new Error("outside cancellation window");
154+ await deps.orders.save({ ...order, status: "cancelled" });
155+};
156+```
157+
158+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).
159+
160+## Layer 2 — Adapter
161+
162+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.
163+
164+```ts
165+// c1_order_repo.ts — implements a_ports.OrderRepository
166+import { Database } from "bun:sqlite";
167+import type { Order, OrderRepository } from "./a_ports.ts";
168+
169+export const createOrderRepository = (db: Database): OrderRepository => ({
170+ save: async (order) => {
171+ db.prepare("INSERT OR REPLACE INTO orders (id, customer_id, status, ...) VALUES (?, ?, ?, ...)")
172+ .run(order.id, order.customerId, order.status);
173+ },
174+ findById: async (id) => {
175+ const row = db.prepare("SELECT * FROM orders WHERE id = ?").get(id);
176+ return row ? rowToOrder(row) : null;
177+ },
178+ // ...
179+});
180+
181+// c3_order_controller.ts — parsing only. The §4.4 Modeled-boundary
182+// check FORBIDS JSON.parse / new URL outside this layer.
183+import type { CreateOrderInput } from "./a_order.ts";
184+
185+export const parseCreateOrderRequest = (body: string): { ok: true; value: CreateOrderInput } | { ok: false; error: string } => {
186+ let parsed: unknown;
187+ try {
188+ parsed = JSON.parse(body); // ← parse only here
189+ } catch (e) {
190+ return { ok: false, error: "invalid JSON" };
191+ }
192+ // ... schema validation, returns typed CreateOrderInput
193+};
194+```
195+
196+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.
197+
198+## Layer 3 — Entry
199+
200+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.
201+
202+```ts
203+// d_orders_handlers.ts
204+import { parseCreateOrderRequest } from "./c3_order_controller.ts";
205+import { createOrder } from "./b2_order_service.ts";
206+
207+export const postOrdersHandler = (deps: OrderServiceDeps) => async (req: Request): Promise<Response> => {
208+ const body = await req.text();
209+ const parsed = parseCreateOrderRequest(body); // bytes → typed
210+ if (!parsed.ok) return new Response(parsed.error, { status: 400 });
211+ const order = await createOrder(deps, parsed.value); // pure orchestration call
212+ return Response.json(order, { status: 201 });
213+};
214+
215+// d_server.ts
216+import { Database } from "bun:sqlite";
217+import { createOrderRepository } from "./c1_order_repo.ts";
218+import { createStripeGateway } from "./c2_stripe_gateway.ts";
219+import { postOrdersHandler } from "./d_orders_handlers.ts";
220+
221+const db = new Database("orders.db");
222+const deps = {
223+ orders: createOrderRepository(db),
224+ payments: createStripeGateway(process.env.STRIPE_KEY!),
225+ now: () => new Date().toISOString(),
226+};
227+Bun.serve({
228+ port: 3000,
229+ routes: { "POST /orders": postOrdersHandler(deps) },
230+});
231+```
232+
233+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.
234+
235+## Common mistakes — and the check that catches them
236+
237+| What an agent might do | Which v2 check catches it |
238+|---|---|
239+| Put `JSON.parse(req.body)` inside `d_orders_handlers.ts` | **#4 Modeled (boundary)** — boundary parsing must live in Layer 2 (here: `c3_*_controller.ts`) |
240+| 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) |
241+| 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` |
242+| 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`) |
243+| Create `b2_payment_service.ts` without a sibling `.test.ts` | **#3 Modeled (tests)** — Layer 1 behaviour files require sibling tests |
244+| 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 |
245+| Let `d_orders_handlers.ts` grow to 850 LOC | **#5 Atomic** — over the 700-line cap; split per route group |
246+| Add a `src/utils/json.ts` (no `a_`/`b_`/`c_`/`d_` prefix) | **#2 Architecture** — unprefixed file does not map to any canonical layer |
247+
248+Every column 2 entry is a deterministic check the verifier runs — the agent's harness cannot talk it out of the verdict.
249+
250+## See also
251+
252+- [/sama/v2](/sama/v2) — the canonical spec this profile instantiates
253+- [/sama/v2/verify](/sama/v2/verify) — the live verifier running against this site's own (different) profile
254+- [/sama/v2#5-operational--core-metrics-definitions](/sama/v2#5-operational--core-metrics-definitions) — the §5 metrics this profile would emit
255+
256+[← /sama/v2](/sama/v2) · [← /sama](/sama)
modified content/sama/v2.md +2 −0
@@ -97,6 +97,8 @@ prefixes = ["e3_"]
9797
9898 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.
9999
100+→ **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.
101+
100102 ---
101103
102104 ## 3. Layer assignment & the consistency check
modified src/d21_app.ts +3 −0
@@ -33,6 +33,7 @@ import {
3333 samaCliResponse,
3434 samaSkillHandler,
3535 samaV2Handler,
36+ samaV2ExampleCrudHandler,
3637 samaV2VerifyHandler,
3738 samaVerifyHandler,
3839 samaLandingHandler,
@@ -365,6 +366,8 @@ ${rows}
365366
366367 "/sama/v2/verify": samaV2VerifyHandler,
367368
369+ "/sama/v2/example-crud": samaV2ExampleCrudHandler,
370+
368371 "/sama/verify": samaVerifyHandler,
369372
370373 "/sama": samaLandingHandler,
modified src/d21_handlers_sama.ts +16 −0
@@ -185,6 +185,22 @@ export const samaV2Handler = async (): Promise<Response> => {
185185 return htmlResponse(html);
186186 };
187187
188+// -------- /sama/v2/example-crud (worked-example profile for an HTTP CRUD service) --------
189+
190+export const samaV2ExampleCrudHandler = async (): Promise<Response> => {
191+ const md = await Bun.file("./content/sama/v2-example-crud.md").text();
192+ const html = await renderDocsPage({
193+ title: "SAMA v2 — worked example: HTTP CRUD service — tdd.md",
194+ description:
195+ "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.",
196+ bodyMarkdown: md,
197+ ogPath: "https://tdd.md/sama/v2/example-crud",
198+ active: "sama",
199+ pathForDocs: "/sama/v2/example-crud",
200+ });
201+ return htmlResponse(html);
202+};
203+
188204 // -------- /sama/verify (form + report + dogfood short-circuit) --------
189205
190206 const VERIFY_FORM_MD = `# SAMA verify