syntaxai/tdd.md · commit c49532a

Docs: WordPress worked-example at /sama/v2/example-wordpress

Pairs with /sama/v2/example-crud. Same four canonical layers, same
import law, different profile sublayer split — chosen to reflect what
WordPress plugin code actually does.

The example domain is an event-registration plugin (events-plugin):
visitors register, pay via Stripe, get confirmation email, admins
manage attendees from /wp-admin. Touches every layer:

- Layer 0 (a_*): Event/Registration/Attendee types, pure pricing math,
  port interfaces. No WP function calls AT ALL (no $wpdb, no get_option,
  no current_time — the Clock port replaces current_time()).
- Layer 1 (b1_/b2_): CapacityPolicy + RegistrationPolicy as pure
  functions; RegistrationService composes policy and delegates to
  ports declared in a_ports.php.
- Layer 2 (c1_/c2_/c3_): $wpdb repositories, Stripe gateway via
  wp_remote_post, wp_mail wrapper, REST/admin/shortcode controllers
  that parse $_POST, json_decode WP_REST_Request bodies, and shortcode
  atts.
- Layer 3 (d_*): WP hook registrations (rest_api_init, init), shortcode
  callbacks, admin pages — every callback is parse-via-c3 → invoke-b2
  → serialize, no business logic inline.

The page is honest about scope: the live verifier scans TS only;
PHP-aware conformance checking is a separate piece of work. The spec
itself is language-agnostic so the architectural shape carries — this
documents the shape, not the mechanical check.

Cross-links added both directions (crud ↔ wordpress) and on /sama/v2
§2.3 alongside the existing CRUD link.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-23 14:39:15 +01:00
parent
c144030
commit
c49532a3a7f1881c1c4365f2e98939bbf7220962

5 files changed · +334 −1

modified content/sama/v2-example-crud.md +1 −0
@@ -250,6 +250,7 @@ Every column 2 entry is a deterministic check the verifier runs — the agent's
250250 ## See also
251251
252252 - [/sama/v2](/sama/v2) — the canonical spec this profile instantiates
253+- [/sama/v2/example-wordpress](/sama/v2/example-wordpress) — the same shape against WordPress + PHP
253254 - [/sama/v2/verify](/sama/v2/verify) — the live verifier running against this site's own (different) profile
254255 - [/sama/v2#5-operational--core-metrics-definitions](/sama/v2#5-operational--core-metrics-definitions) — the §5 metrics this profile would emit
255256
added content/sama/v2-example-wordpress.md +313 −0
@@ -0,0 +1,313 @@
1+# Worked example — a WordPress plugin under SAMA v2
2+
3+This is the same shape as [/sama/v2/example-crud](/sama/v2/example-crud) but in PHP, against WordPress idioms — `add_action`/`add_filter` hooks, `$wpdb`, REST routes via `register_rest_route`, shortcodes, custom post types. The four canonical layers and the import law are unchanged; only the **profile** sublayer split changes to reflect what WordPress code actually does.
4+
5+The example domain is an **event-registration plugin** (`events-plugin`): visitors register for events, pay via Stripe, get a confirmation email, admins manage attendees from `/wp-admin`.
6+
7+> **Note on the verifier:** the live verifier at [/sama/v2/verify](/sama/v2/verify) currently scans TypeScript only (regex on `from "./...ts"` imports). The SAMA v2 spec is language-agnostic — the import law applies equally to PHP `use` statements and PSR-4 autoloaded classes — but a PHP-aware verifier is a separate piece of work. This page documents the **architectural shape**; the mechanical conformance check for a PHP repo would need its own implementation against the same §4 rules.
8+
9+## The profile
10+
11+```toml
12+sama_version = "2.0"
13+profile = "wordpress-plugin"
14+
15+# Layer 0 — Pure. Domain types, pricing math, validation rules. No WP
16+# globals ($wpdb, $post, $_POST), no calls to WP functions, no I/O,
17+# no clock. PHP itself only.
18+[layers.0]
19+prefixes = ["a_"]
20+
21+# Layer 1 — Core. Business decisions and orchestration. May not touch
22+# WP globals or call WP functions. Takes adapters at construction
23+# through the interfaces declared in a_ports.php.
24+# - b1_ policy: pure decisions (canRegister, isEligibleForRefund)
25+# - b2_ service: orchestrators that compose policy + delegate
26+[layers.1]
27+sublayers = [
28+ { name = "policy", prefix = "b1_" },
29+ { name = "service", prefix = "b2_" },
30+]
31+
32+# Layer 2 — Adapter. The WordPress boundary. THIS is where $wpdb,
33+# wp_mail, get_option, $_POST, REST args, and HTTP-to-Stripe live.
34+# - c1_ repository: $wpdb queries (one per custom post type / table)
35+# - c2_ gateway: outbound HTTP (Stripe) + wp_mail() wrappers
36+# - c3_ controller: REST/admin/shortcode INPUT parsing — turns
37+# $_POST, $_GET, json_decode(WP_REST_Request body), and admin
38+# form posts into typed values
39+[layers.2]
40+sublayers = [
41+ { name = "repository", prefix = "c1_" },
42+ { name = "gateway", prefix = "c2_" },
43+ { name = "controller", prefix = "c3_" },
44+]
45+
46+# Layer 3 — Entry. WordPress hook registrations + REST route
47+# registrations + shortcode callbacks + admin pages. Owns no business
48+# logic; every callback is parse-via-c3 → invoke-b2 → serialize.
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`. ✓
54+
55+## The directory
56+
57+```
58+events-plugin/
59+├── events-plugin.php # WP-required root file (4 lines: header
60+│ # comment + require __DIR__.'/src/d_main.php')
61+├── src/
62+│ ├── a_event.php # Layer 0 — Event, EventStatus types
63+│ ├── a_registration.php # Layer 0 — Registration, RegistrationStatus
64+│ ├── a_attendee.php # Layer 0 — Attendee, Email types
65+│ ├── a_pricing.php # Layer 0 — pure pricing + tax math
66+│ ├── a_ports.php # Layer 0 — interfaces:
67+│ │ # EventRepository, RegistrationRepository,
68+│ │ # PaymentGateway, EmailGateway, Clock
69+│ ├── b1_registration_policy.php # Layer 1 — canRegister, isEligibleForRefund
70+│ ├── b1_capacity_policy.php # Layer 1 — availableSeats, isWaitlistEligible
71+│ ├── b2_registration_service.php # Layer 1 — register, cancel, refund (compose)
72+│ ├── b2_payment_service.php # Layer 1 — chargeRegistration, refundRegistration
73+│ ├── c1_event_repo.php # Layer 2 — $wpdb-backed EventRepository
74+│ ├── c1_registration_repo.php # Layer 2 — $wpdb-backed RegistrationRepository
75+│ ├── c2_stripe_gateway.php # Layer 2 — outbound HTTP to Stripe via wp_remote_post
76+│ ├── c2_email_gateway.php # Layer 2 — wp_mail() wrapper
77+│ ├── c3_rest_controller.php # Layer 2 — parses WP_REST_Request bodies
78+│ ├── c3_admin_controller.php # Layer 2 — parses admin form $_POST
79+│ ├── c3_shortcode_controller.php # Layer 2 — parses shortcode atts
80+│ ├── d_main.php # Layer 3 — boots autoloader, wires deps, fires
81+│ │ # all add_action() registrations below
82+│ ├── d_rest_routes.php # Layer 3 — register_rest_route() calls
83+│ ├── d_admin_pages.php # Layer 3 — add_menu_page() + form handlers
84+│ ├── d_shortcodes.php # Layer 3 — add_shortcode() callbacks
85+│ └── d_cron_hooks.php # Layer 3 — wp_schedule_event() + cron callbacks
86+
87++ sibling *.test.php for every b*_ and c*_ file (Modeled-tests §4.3)
88+```
89+
90+The root-level `events-plugin.php` is WP's required header file — it has the plugin metadata WordPress scans for, and a single `require` line. Everything else lives under `src/` and follows the prefix scheme.
91+
92+## Layer 0 — Pure
93+
94+Domain types as plain PHP classes, validation rules as data, pure transforms. **No WP function calls whatsoever**: no `$wpdb`, no `$_POST`, no `get_option`, no `wp_mail`, no `current_time`, no `$post`. PHP standard library only.
95+
96+```php
97+// a_event.php
98+final class Event {
99+ public function __construct(
100+ public readonly string $id,
101+ public readonly string $title,
102+ public readonly string $startsAtIso, // ISO 8601 string; produced by Layer 1+
103+ public readonly int $capacity,
104+ public readonly int $priceCents,
105+ ) {}
106+}
107+
108+// a_ports.php — the interfaces Layer 1 calls THROUGH. Concrete
109+// implementations live in Layer 2; declaring the interface here lets
110+// b2_registration_service.php import only the type, never the c1_ class.
111+interface EventRepository {
112+ public function findById(string $id): ?Event;
113+ public function save(Event $event): void;
114+ public function listUpcoming(string $nowIso): array;
115+}
116+
117+interface PaymentGateway {
118+ public function charge(string $registrationId, int $amountCents): PaymentResult;
119+}
120+
121+interface Clock {
122+ public function nowIso(): string;
123+}
124+```
125+
126+The Clock port matters because `time()`, `current_time()`, `new DateTime()` are all forms of I/O the spec keeps out of Layer 0/1. Higher layers inject a `Clock` instance whose Layer 2 implementation calls WP's `current_time('mysql')`. Tests construct a `FixedClock` that returns whatever ISO string the test wants.
127+
128+## Layer 1 — Core
129+
130+Business logic. Two sublayers — `b1_` policy (pure functions over domain state) and `b2_` service (orchestrators that compose policy + call through ports).
131+
132+```php
133+// b1_capacity_policy.php — pure
134+final class CapacityPolicy {
135+ public static function availableSeats(Event $event, int $confirmedCount): int {
136+ return max(0, $event->capacity - $confirmedCount);
137+ }
138+
139+ public static function isWaitlistEligible(Event $event, int $confirmedCount): bool {
140+ return $confirmedCount >= $event->capacity
141+ && $confirmedCount < $event->capacity + 10; // 10-seat waitlist
142+ }
143+}
144+
145+// b2_registration_service.php — orchestrator
146+final class RegistrationService {
147+ public function __construct(
148+ private EventRepository $events,
149+ private RegistrationRepository $registrations,
150+ private PaymentGateway $payments,
151+ private EmailGateway $emails,
152+ private Clock $clock,
153+ ) {}
154+
155+ public function register(RegisterInput $input): Registration {
156+ $event = $this->events->findById($input->eventId)
157+ ?? throw new \RuntimeException('event not found');
158+ $confirmed = $this->registrations->countConfirmed($event->id);
159+ if (CapacityPolicy::availableSeats($event, $confirmed) <= 0) {
160+ throw new \RuntimeException('event sold out');
161+ }
162+ $registration = new Registration(/* ... */);
163+ $charge = $this->payments->charge($registration->id, $event->priceCents);
164+ // ... persist + email confirmation ...
165+ return $registration;
166+ }
167+}
168+```
169+
170+Notice what `RegistrationService` does **not** import: any `c1_`/`c2_`/`c3_`/`d_` file. It takes ports at construction. The DI happens in Layer 3.
171+
172+## Layer 2 — Adapter
173+
174+The WordPress boundary. Three sublayers:
175+
176+```php
177+// c1_event_repo.php — implements a_ports.EventRepository against $wpdb
178+final class WpdbEventRepository implements EventRepository {
179+ public function __construct(private \wpdb $db) {}
180+
181+ public function findById(string $id): ?Event {
182+ $row = $this->db->get_row(
183+ $this->db->prepare("SELECT * FROM {$this->db->prefix}events WHERE id = %s", $id)
184+ );
185+ return $row ? $this->rowToEvent($row) : null;
186+ }
187+
188+ public function save(Event $event): void {
189+ $this->db->replace("{$this->db->prefix}events", [
190+ 'id' => $event->id,
191+ 'title' => $event->title,
192+ 'starts_at' => $event->startsAtIso,
193+ 'capacity' => $event->capacity,
194+ 'price_cents' => $event->priceCents,
195+ ]);
196+ }
197+}
198+
199+// c2_stripe_gateway.php — outbound HTTP via wp_remote_post
200+final class StripePaymentGateway implements PaymentGateway {
201+ public function __construct(private string $apiKey) {}
202+
203+ public function charge(string $registrationId, int $amountCents): PaymentResult {
204+ $res = wp_remote_post('https://api.stripe.com/v1/charges', [
205+ 'headers' => ['Authorization' => "Bearer {$this->apiKey}"],
206+ 'body' => ['amount' => $amountCents, 'currency' => 'eur', 'metadata[registration_id]' => $registrationId],
207+ ]);
208+ // ... parse $res, return PaymentResult ...
209+ }
210+}
211+
212+// c3_rest_controller.php — parses incoming WP_REST_Request bodies.
213+// THIS is the only sublayer allowed to call json_decode on external
214+// input, read $_POST, or inspect raw request bodies.
215+final class RegistrationRestController {
216+ public function parseRegisterRequest(\WP_REST_Request $req): RegisterInputOrError {
217+ $body = $req->get_json_params(); // WP's parser; the boundary is here
218+ if (!is_array($body)) return RegisterInputOrError::error('invalid JSON');
219+ if (empty($body['event_id']) || !is_string($body['event_id'])) {
220+ return RegisterInputOrError::error('missing event_id');
221+ }
222+ // ... validate every field, return typed RegisterInput ...
223+ }
224+}
225+```
226+
227+Within Layer 2, controllers may import gateways + repositories (controller is the outermost sublayer in the adapter layer). Repositories may not import controllers — they don't know they're serving an HTTP request.
228+
229+## Layer 3 — Entry
230+
231+WordPress hook registrations, REST routes, shortcodes, admin pages. **Owns zero business logic.** Every callback is the same three-step composition: parse via `c3_*` → call `b2_*_service` → return.
232+
233+```php
234+// d_main.php — bootstraps the plugin. Constructs concrete adapters,
235+// wires them into services, exposes the services to the hook files.
236+global $events_plugin_services;
237+
238+$events_plugin_services = (object)[
239+ 'events' => new WpdbEventRepository($GLOBALS['wpdb']),
240+ 'registrations' => new WpdbRegistrationRepository($GLOBALS['wpdb']),
241+ 'payments' => new StripePaymentGateway(get_option('events_plugin_stripe_key')),
242+ 'emails' => new WpMailEmailGateway(),
243+ 'clock' => new WpClock(),
244+];
245+
246+require __DIR__ . '/d_rest_routes.php';
247+require __DIR__ . '/d_admin_pages.php';
248+require __DIR__ . '/d_shortcodes.php';
249+require __DIR__ . '/d_cron_hooks.php';
250+
251+// d_rest_routes.php
252+add_action('rest_api_init', function () {
253+ global $events_plugin_services;
254+ register_rest_route('events-plugin/v1', '/register', [
255+ 'methods' => 'POST',
256+ 'callback' => function (\WP_REST_Request $req) {
257+ $controller = new RegistrationRestController();
258+ $parsed = $controller->parseRegisterRequest($req); // c3
259+ if ($parsed->isError()) return new \WP_Error('bad_request', $parsed->error(), ['status' => 400]);
260+ $service = new RegistrationService(
261+ $GLOBALS['events_plugin_services']->events,
262+ $GLOBALS['events_plugin_services']->registrations,
263+ $GLOBALS['events_plugin_services']->payments,
264+ $GLOBALS['events_plugin_services']->emails,
265+ $GLOBALS['events_plugin_services']->clock,
266+ );
267+ $registration = $service->register($parsed->value()); // b2
268+ return rest_ensure_response(['id' => $registration->id]);
269+ },
270+ 'permission_callback' => '__return_true',
271+ ]);
272+});
273+
274+// d_shortcodes.php
275+add_shortcode('event_register_button', function ($atts) {
276+ global $events_plugin_services;
277+ $atts = (new RegistrationShortcodeController())->parseAtts($atts); // c3
278+ $event = $events_plugin_services->events->findById($atts->eventId); // direct c1 OK (read path)
279+ return render_register_button($event);
280+});
281+```
282+
283+Hook registrations live in Layer 3 because they're the *wiring* — they tell WordPress "when X happens, call this callback." The callback body composes c3 + b2; it does not implement business rules inline.
284+
285+## Common mistakes — and the check that catches them
286+
287+| What a WordPress dev might do | Which v2 check catches it |
288+|---|---|
289+| Call `$wpdb->get_results(...)` inside `b2_registration_service.php` | **#6 Law (§1.2)** — Layer 1 → Layer 2 is upward (use the `EventRepository` port instead) |
290+| Use `$_POST['event_id']` inside `b1_registration_policy.php` | **#6 Law** + **#4 Modeled-boundary** — superglobals are Layer 2 boundary input, accessible only in `c3_*` controllers |
291+| Put `wp_mail(...)` directly inside a `d_*` hook callback (skipping the `c2_email_gateway`) | **Not a v2 violation** — Layer 3 may use Layer 2 directly. But it's a *design smell*: business outcomes (sending a confirmation) belong in `b2_*_service`, which the hook should call. The verifier won't catch this; a code reviewer might. |
292+| Register `add_action('init', ...)` inside `b2_registration_service.php` | **#6 Law** — `add_action` is a Layer 2 framework binding (WP function call); Layer 1 must not depend on it. Hooks live in `d_*` only. |
293+| Call `current_time('mysql')` in `a_pricing.php` | **#6 Law** + **#7 Consistency** — `a_` claims Pure but the file imports a WP function (Layer 2). Use a `Clock` port instead. |
294+| Add a top-level `src/helpers.php` with no prefix | **#2 Architecture** — unprefixed file maps to no canonical layer |
295+| Let `c1_registration_repo.php` grow to 800 LOC of SQL | **#5 Atomic** — over the 700-line cap; split (e.g. into `c1_registration_repo.php` + `c1_registration_repo_queries.php`) |
296+| `json_decode($req->get_body())` inside a `d_*` REST callback | **#4 Modeled-boundary** — JSON parsing is a Layer 2 controller concern; move it into `c3_rest_controller.php` |
297+| Same-layer reverse: `b1_*` policy importing a `b2_*` service | **#6 Law (§2.2)** — `b2` is later in the sublayer order; `b1` may not import `b2` |
298+
299+## WordPress idioms that fit cleanly under v2
300+
301+- **Custom Post Types** — register them in Layer 3 (`d_main.php` calls `register_post_type` during the `init` hook). Their schemas (allowed statuses, taxonomy structure) live in Layer 0 as plain data.
302+- **The `$wpdb` global** — only `c1_*` repositories may touch it. Pass `$GLOBALS['wpdb']` into the repository constructor in `d_main.php`; never reach for the global from anywhere else.
303+- **Hook priorities & filter chains** — they live in `d_*` only. If your `b2_*_service.php` needs configurable behavior, that's a port (interface in `a_ports.php`) passed at construction, not an `apply_filters()` call from inside business logic.
304+- **REST permission callbacks** — small enough to inline in `d_rest_routes.php`. Anything beyond an auth check (e.g. "may this user cancel this registration?") becomes a `b1_*` policy function.
305+- **Transients / object cache** — Layer 2 adapter. The cache is I/O, even if it looks like a hash table.
306+
307+## See also
308+
309+- [/sama/v2](/sama/v2) — the canonical spec this profile instantiates
310+- [/sama/v2/example-crud](/sama/v2/example-crud) — the same shape for a Bun/TypeScript HTTP service
311+- [/sama/v2/verify](/sama/v2/verify) — the live TS-only verifier (PHP support is a separate piece of work)
312+
313+[← /sama/v2](/sama/v2) · [← /sama](/sama)
modified content/sama/v2.md +1 −1
@@ -97,7 +97,7 @@ 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.
100+→ **Worked examples:** [a CRUD HTTP service under v2](/sama/v2/example-crud) (TypeScript) · [a WordPress plugin under v2](/sama/v2/example-wordpress) (PHP) — both ship full profiles, directory trees, per-layer code signatures, and the common mistakes each §4 check catches.
101101
102102 ---
103103
modified src/d21_app.ts +3 −0
@@ -34,6 +34,7 @@ import {
3434 samaSkillHandler,
3535 samaV2Handler,
3636 samaV2ExampleCrudHandler,
37+ samaV2ExampleWordpressHandler,
3738 samaV2VerifyHandler,
3839 samaVerifyHandler,
3940 samaLandingHandler,
@@ -368,6 +369,8 @@ ${rows}
368369
369370 "/sama/v2/example-crud": samaV2ExampleCrudHandler,
370371
372+ "/sama/v2/example-wordpress": samaV2ExampleWordpressHandler,
373+
371374 "/sama/verify": samaVerifyHandler,
372375
373376 "/sama": samaLandingHandler,
modified src/d21_handlers_sama.ts +16 −0
@@ -201,6 +201,22 @@ export const samaV2ExampleCrudHandler = async (): Promise<Response> => {
201201 return htmlResponse(html);
202202 };
203203
204+// -------- /sama/v2/example-wordpress (worked-example profile for a WP plugin) --------
205+
206+export const samaV2ExampleWordpressHandler = async (): Promise<Response> => {
207+ const md = await Bun.file("./content/sama/v2-example-wordpress.md").text();
208+ const html = await renderDocsPage({
209+ title: "SAMA v2 — worked example: WordPress plugin — tdd.md",
210+ description:
211+ "The SAMA v2 spec applied to a WordPress plugin: $wpdb in Layer 2 only, hooks in Layer 3, no WP function calls in Layer 0/1. Profile, directory layout, per-layer PHP signatures, and the common WordPress mistakes each §4 check catches.",
212+ bodyMarkdown: md,
213+ ogPath: "https://tdd.md/sama/v2/example-wordpress",
214+ active: "sama",
215+ pathForDocs: "/sama/v2/example-wordpress",
216+ });
217+ return htmlResponse(html);
218+};
219+
204220 // -------- /sama/verify (form + report + dogfood short-circuit) --------
205221
206222 const VERIFY_FORM_MD = `# SAMA verify