From 774d3e74fdc0ad9b4a8fd4f422089f458f2b4683 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 03:28:19 +0000 Subject: [PATCH 1/9] docs(plan): plan the guestbook moderation lifecycle (area A) Units U1-U6 for the visitor-only Visible/Flagged/Hidden lifecycle on the systemfsoftware/xstate fork. U1 is the fork spike that R3 depends on; if it fails, A stops with no fallback to upstream. --- ...eat-guestbook-moderation-lifecycle-plan.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md diff --git a/docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md b/docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md new file mode 100644 index 0000000..4358fe2 --- /dev/null +++ b/docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md @@ -0,0 +1,264 @@ +--- +title: Guestbook Moderation Lifecycle - Plan +type: feat +date: 2026-10-09 +origin: docs/brainstorms/2026-10-08-0340-feat-starter-state-of-the-art-plan.md +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +execution: code +--- + +# Guestbook Moderation Lifecycle - Plan + +## Goal Capsule + +- **Objective:** Any visitor can flag and vouch for a guestbook entry. Two flags with no vouch between them hide it. The lifecycle runs as a pure transition on a machine from our XState fork, over a state name stored in D1, behind effect/rpc. The repo gains a stateful decision that agents can copy. +- **Scope:** Area A of the origin document (R1-R12, F1, AE1-AE4). Area C has its own plan (`docs/plans/2026-10-08-0705-feat-gates-bind-and-inline-suppression-plan.md`). +- **Authority:** The origin document's R-IDs win on behavior. This plan's KTDs win on mechanism within those R-IDs. The conductor reviews this plan before U2 starts. +- **Stop conditions:** If U1 shows the fork can't do what R3 needs, A stops and goes back to Ryan. There is no fallback to upstream xstate or npm (origin Q1). If U1 shows the house lint refuses every way a decision can reach the fork, A stops and goes back to the conductor (KTD3). +- **Execution profile:** A `gh stack` on `main` with two layers (see Sequencing), plain pushes only. No local Stryker; mutation runs only at the release gate. + +## Product Contract + +### Summary + +The guestbook gets a three-state lifecycle (`Visible`, `Flagged`, `Hidden`) driven by two visitor events (`Flag`, `Vouch`). Each request reads the stored state name, decodes it, decides with the fork's pure `transition`, and writes only if the row still holds the state it read. The public list leaves out `Hidden` entries. A journey against `pnpm dev` walks one entry from signing to hidden. + +### Problem Frame + +See the origin document's Problem Frame and Key Decisions "A pure transition over a stored state name" and "When a decision uses a machine". The starter has a one-shot decision (signing) and no worked stateful one. + +### Requirements + +The origin document owns the requirement text. This plan carries R1-R12 unchanged: + +- R1 states, events and the legal-move table (3 legal pairs, 3 illegal, `Hidden` final). +- R2 the fork as a `file:.sfs-deps` tarball, covered by `check:sfs-sources`. +- R3 the per-request sandwich; R4 illegal-event refusal; R5 compare-on-state write; R6 typed decode error. +- R7 the public `list` returns `Visible` and `Flagged` only. +- R8 the six property-test laws; R9 mutation 100 with no `stryker.mutate` change and no directives. +- R10 the journey; R11 no privileged actor; R12 the README removal procedure. + +### Acceptance Examples + +From the origin document: AE1 (R4), AE2 (R5), AE3 (R6), AE4 (R7, R10). + +### Outcomes that must not count + +From the origin document, area A, all eight bullets. The ones this plan's mechanisms touch most: a machine that exists only in tests; a refusal delivered as a throw, defect or 500; persisting anything beyond the state name; a machine with actions, `after` or actors; transition tests whose expected pairs come from the machine; mutation 100 reached by narrowing; a journey that passes on a `page.route()` stub or a pre-seeded row; any role, login, token or allow-list on `Flag` or `Vouch`. + +### Scope Boundaries + +- Signing stays as it is. Its handler is not migrated to the cell shape KTD5 uses for moderation. +- No moderator UI, audit trail or un-hide. `Hidden` is final (R1). +- The Effect 4.0.2 bump and deployed traces stay deferred (origin "Deferred for later"). + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **`Hidden` is an atomic state with no outgoing transitions, not `type: 'final'`.** A `type: 'final'` target makes `Flagged` → `Hidden` return one `@xstate.terminate` effect (fork `packages/xstate/src/transitionActions.ts:1029-1053`; `test/final.test.ts:1619-1621`). That breaks R8's "every transition returns an empty action list". With no outgoing transitions, every event on `Hidden` is unhandled, so `Hidden` is final in R1's sense, and R8's "Hidden absorbs every event" law is what proves it. U1 confirms both readings. Governs R1, R8. +- KTD2. **The decode guards `resolveState`.** The fork's `resolveState` throws a raw `Error` on an unknown state name (`src/stateUtils.ts:908-910`; `test/rehydration.test.ts:144-147`), and a decision may not throw (CONST-P1). So the decision's command carries the state as the three-name literal schema, and a stored `Banished` never reaches the machine (R6). Governs R3, R6. +- KTD3. **The decision reaches the fork only through the module-private machine constant.** The dmmf `make-body-purity` rule reports a reference to any imported binding outside `effect` and a relative `*.schema.ts` as `unsealedImportReference` (`oxlint-plugin-dmmf-workflow/dist/index.mjs:1154-1185`, `:1637`). A non-exported `const` declared in the same file passes (`:1541-1560`), and `workflow-file-export-topology` ignores it (`:211-330`). So `decide` calls `lifecycle.resolveState(...)` and `lifecycle.transition(...)` on that constant, and never references the free functions `transition` or `isUnhandled`. The refusal predicate is `isUnhandled`'s own definition (same snapshot object, no effects; `src/transition.ts:118-123`), written as one exhaustive `Match` with no unreachable arm. Aliasing an import into a module constant to get past the rule (`const unhandled = isUnhandled`) is banned, because it hides an unsealed import from the gate that grades this file (CONST-E9). If U1 finds that the rule refuses the method form too, the remedy is to propose sealing the fork's core in the dmmf purity rule to systemfsoftware. That rule is a judgment surface the conductor owns, so A waits for it. Governs R3, R9. +- KTD4. **The fork arrives the way `stryker-js-effect` does.** A new flake input `systemfsoftware-xstate` (`github:systemfsoftware/xstate`, locked at `84e602e`). Its `workspace-tarballs` joins the `sfs-deps` copy loop (`flake.nix:57`) and its `index.json` joins the `jq -s add` merge (`flake.nix:60`). That copies the fork's six public tarballs into `.sfs-deps`. Only `xstate-6.0.0-alpha.64.tgz` is referenced (fork `nix/from-source/pack.mjs:17-18`). One `catalog:` line and its `overrides:` mirror in `pnpm-workspace.yaml`, and `"@systemfsoftware/xstate": "catalog:"` in `apps/site` `dependencies`. The core package has no `dependencies` or `peerDependencies`, ships ESM with no `node:` imports, and so bundles into the Worker. `check:sfs-sources` covers it unedited, because its filter keys on the scope (`scripts/check-sfs-sources.ts:4,19-22`). **No `follows` on `systemfsoftware` or `pnpm-release-management`:** the fork's lockfile integrity and its fetch `hash` (fork `flake.nix:175`) are tied to its own pins, so redirecting them would break its tarball build. `nixpkgs.follows` is kept only if U2's build still passes with it. Governs R2. +- KTD5. **Moderation is an effect-cell-types `Sandwich` cell, so the types carry the order.** `Sandwich.named('guestbook.moderate')`: `read` loads the stored state name for the entry, plus the event. The library's `decode` runs the command schema, so a stored `Banished` goes to the `CommandRejected` handler (R6). Then `decide` runs, and the `write` handlers cover every outcome tag. Deleting a handler, or adding one for an impossible tag, stops the file compiling (effect-cell-types 12.0.0 `README.md:73-141`, "Quick start" and "How a run works"). That is CONST-B6's phase chain, from a package the site already depends on. Nothing in the repo uses `Sandwich` yet, so U4 starts by proving that a cell's `run` fits an `RpcGroup` handler. The existing sign handler predates this and stays out of scope. Governs R3, R4, R6. +- KTD6. **The write compares on the stored state, in SQL.** `UPDATE guestbook_entries SET state = ?to WHERE id = ?id AND state = ?from`. Zero changed rows becomes the typed conflict refusal (R5). The read is a plain `SELECT state … WHERE id = ?`. A missing row becomes a typed not-found refusal. Store unavailability stays a defect, as `list` and `sign` handle it today (`guestbook-handlers.ts:11-17`). Governs R5. +- KTD7. **The migration adds a state column, with no `CHECK`.** `0002_add_guestbook_entry_state.sql`: `ALTER TABLE guestbook_entries ADD COLUMN state TEXT NOT NULL DEFAULT 'Visible'`. Existing rows and new signatures start `Visible` (R1). The column has no `CHECK`, because R6 puts enforcement in the decode, and AE3 needs a row holding `Banished`. Alchemy applies migrations under `alchemy dev` as well as on deploy, so `pnpm dev` picks it up. Governs R1, R6. +- KTD8. **`list` filters with `state IN ('Visible', 'Flagged')`, not `state != 'Hidden'`.** A corrupt row then drops out of the public list, so a bad row can't break `list` with a decode defect. Each listed entry carries its state so the page can show `Flagged`. Governs R7. + +### High-Level Technical Design + +_Directional. It shows shape, not code._ + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Visible: sign + Visible --> Flagged: Flag + Flagged --> Visible: Vouch + Flagged --> Hidden: Flag +``` + +`Hidden` has no outgoing edges (KTD1). The three pairs not drawn (`Visible`/Vouch, `Hidden`/Flag, `Hidden`/Vouch) are unhandled, and the decision refuses them. + +```mermaid +sequenceDiagram + participant P as Page + participant H as moderate RPC (cell) + participant S as GuestbookStore (D1) + participant D as moderateGuestbookEntry (pure) + P->>H: { id, event } + H->>S: read state name for id + S-->>H: raw name | EntryNotFound + H->>H: decode with command schema (library) + alt name not one of three + H-->>P: StoredStateInvalid + else decoded + H->>D: { state, event } + D-->>H: EntryMoved { from, to } | IllegalTransition { state, event } + alt EntryMoved + H->>S: UPDATE … WHERE id AND state = from + S-->>H: 1 row | 0 rows + H-->>P: new state | TransitionConflict + else IllegalTransition + H-->>P: IllegalTransition (no write) + end + end + P->>P: re-fetch list +``` + +### Sequencing + +U1 first, alone. If it passes, layer 1 is U2 + U3 (the dependency and the decision, inert until wired), plus the README's xstate removal lines. Layer 2, stacked on layer 1, is U4 + U5 + U6 (migration, store, RPC, page, journey, and the rest of R12's README steps). Layer 1 changes `flake.nix` and `flake.lock`, which are judgment surfaces on the origin's R24 list, and its PR body declares them. + +### Risks & Dependencies + +- **The fork is alpha and has no other consumer.** U1 is there to catch a behavior gap before any code lands. +- **The fork's flake brings its own input tree.** That includes `release-tools` with its own nixpkgs, so the first `nix develop` after U2 evaluates more. If a missing `.drv` shows up, clear `~/.cache/nix/eval-cache-v*`. Never touch `/nix`. +- **AE2 can't be forced over RPC.** No request can make two handlers read before either writes. A race test passes whenever the race doesn't happen, so it might stay green with the compare removed. U5 admits it only if sabotage turns it red (see Test Admission). + +### Test Admission + +Every proposed test was run through the layer gate (default refuse; e2e is for what only the live Worker can show). + +- **Admitted: one decision property file** (U3), colocated as `*.property.test.ts`. It carries R8's six laws and AE3's refusal, as hand-written refusals at the command schema. +- **Admitted: one moderation journey** (U5, R10 merged with AE4). This is a seam-only observation: live Worker RPC plus D1 persistence across a fresh page load. +- **Admitted: one representative failure** (U5, AE1). A refusal crosses the RPC boundary as a typed error and not a 500, and the entry is unchanged. The rest of the refusal taxonomy stays in U3. +- **Conditional: the AE2 race** (U5). It is admitted only if U5's sabotage probe shows it goes red with `AND state = ?from` removed, in each of 3 runs. Otherwise R5 is carried by review of KTD6's SQL, and the layer-2 PR says so. +- **Refused: an e2e scenario that stages a `Banished` row.** Staging needs SQL access to Alchemy's local storage, which is an implementation detail of the dev server. R6 is carried without it: + - the command schema refuses `Banished` (a U3 hand-written refusal); + - the cell routes every decode failure to `CommandRejected`, and the compiler requires that handler (KTD5); + - that handler maps it to `StoredStateInvalid` and has no store call, so no write can happen. +- **Refused: schema codec-law files** for `EntryState` and `LifecycleEvent`. They are literal schemas with identity encoding, so a law would test Effect Schema rather than domain code, and U3's laws already decode every state the fold reaches. +- **Refused: unit tests for the store, handler or page.** These are shell, with one real D1 and no fake (CONST-T8). + +### Challenge Record + +A destructive review (Edge-First lens) tested three assumptions in the first draft: + +1. The decision can reach the fork through the machine constant's methods under the purity rule. Kept as KTD3, with U1 item 6 as its falsifier and a stop condition. +2. The e2e process can stage and observe AE1-AE3 by writing to the local D1 file. Broken: it depends on Alchemy's local storage layout and on sandbox file access, and nothing proves either. Replaced by the admissions above. +3. Five new e2e scenarios are worth their upkeep. Broken: R10 and AE4 observe the same seam, and AE3's refusal sits below it. Collapsed to two scenarios plus the conditional race. + +The radical alternative was an in-process D1 fake with no e2e. It was rejected: it needs a fake with no contract test against real D1, or a new package (Miniflare), and the brief forbids new packages. + +### Assumptions + +Agent bets the conductor has not confirmed: + +- An unknown entry id gets a typed `EntryNotFound` refusal. R6 forbids a 500 for bad stored data, and this extends the same treatment to a bad id. +- The moderation RPC is one method, `moderate`, with payload `{ id, event }`, not one method per event. One decision serves both events. +- The page shows `Flagged` next to a flagged entry and gives each listed entry `Flag` and `Vouch` buttons. R10's journey needs a visible handle for each step. +- A successful `moderate` returns the entry's new state. + +### Open Questions + +- **Resolve before U6: R12's grep matches the planning docs.** `git grep -nI xstate -- . ':!*.lock'` already exits 0 on `main` because of `docs/brainstorms/2026-10-08-0340-…` (checked 2026-10-09), and this plan adds another match. Either the predicate excludes `docs/` (recommended: these docs are records, not wiring), or the removal procedure deletes those docs. That is the conductor's call, because it changes R12's text. +- **Decide before layer 2 merges: is review enough proof for R5?** If U5's probe shows the race test can't catch a missing compare, R5 rests on review of KTD6's SQL. A deterministic test would need a test-only delay hook in the Worker, or an in-process D1 (Miniflare, a new package). The brief rules out the package, and the hook puts test code in production. Recommended: accept review, declared in the layer-2 PR. + +## Implementation Units + +- U1. **Spike: does the fork do what R3 needs?** + - **Goal:** Settle, on fork main `84e602e`, that `resolveState` on a decoded state name, then `transition` and `isUnhandled`, behave as R3 needs. Also settle the two mechanisms R3 and R9 rest on: KTD1's finality and KTD3's lint fit. + - **Requirements:** R1, R3, R4, R8, R9. **Decisions:** KTD1, KTD2, KTD3. + - **Files:** None committed. Work in `.cache/xstate-spike/` (gitignored). Build the tarball with `nix build github:systemfsoftware/xstate/84e602e#workspace-tarballs`, then run a scratch ESM script and a scratch `.ts` file type-checked against the extracted package. The lint probe is a throwaway `apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts`, deleted afterwards. oxlint's AST rules don't resolve the import, so the probe needs no install. + - **Approach:** Build the R1 machine (no context, actions, `after` or actors; `Hidden` atomic with no `on`) and check: + 1. `resolveState({ value })` for each of the three names gives a snapshot with that value and status `active`. + 2. For all six (state, event) pairs, both the free `transition(machine, s, e)` and the method `machine.transition(s, e)` behave as follows. The 3 legal pairs return a new snapshot with R1's target and an empty effect list. The 3 illegal pairs return the same snapshot object and an empty list. `isUnhandled` is true for exactly the illegal three. + 3. A `type: 'final'` `Hidden` makes `Flagged`/Flag return `@xstate.terminate`, which confirms KTD1's reason. + 4. `resolveState` on `Banished` throws, which confirms KTD2. + 5. Types: `snapshot.value` comes out as the three-name union, so `EntryMoved.to` needs no cast. Also record the effect element type for this machine. + 6. Lint: a scratch decision written per KTD3 passes `make-body-purity`, complexity 1 and `workflow-match-exhaustive`, and its refusal agrees with `isUnhandled` on all six pairs. A scratch reference to the free `isUnhandled` inside `decide` is reported, which confirms KTD3's reading. + 7. Pick how R8's "empty action list" law is observed from the public decision (the machine is module-private, CONST-T8). It must add no unreachable branch, because a NoCoverage mutant fails `break: 100`. Candidates: the outcome carries the transition's effect count as data and the law pins it at zero; or a type-level `never` on the effect list from item 5, plus a law over the outcome. + - **Verification:** The PR body for layer 1 records each item's command and output. Stop and go back to Ryan if item 1, 2 or 4 shows behavior R3 can't be built on. Stop and go back to the conductor if item 6 shows that every form reaching the fork from `decide` is refused (KTD3). The throwaway workflow file is gone before layer 1 is committed. + +- U2. **The fork as a starter dependency.** + - **Goal:** `@systemfsoftware/xstate` resolves from `.sfs-deps` in the devshell, in CI and in the Worker bundle. Nothing resolves from npm. + - **Requirements:** R2. **Decisions:** KTD4. + - **Files:** `flake.nix` (input, `outputs` arguments, copy loop, jq merge), `flake.lock`, `pnpm-workspace.yaml` (catalog line and overrides mirror), `apps/site/package.json`, `pnpm-lock.yaml` (from `pnpm install`, never hand-edited). + - **Approach:** Mirror the `stryker-js-effect` input. Start with no `follows`, then try `nixpkgs.follows` and keep it only if the build passes. + - **Test expectation:** none. This is dependency wiring, and `check:sfs-sources` plus the U3 tests that import the package are its proof. + - **Verification:** `nix develop` builds and `.sfs-deps/xstate-6.0.0-alpha.64.tgz` exists. `pnpm install --frozen-lockfile` and `pnpm check:sfs-sources` pass, and the lockfile key for the fork starts with `file:`. + +- U3. **The lifecycle decision and its properties.** + - **Goal:** A pure decision `moderateGuestbookEntry({ state, event })` returns `EntryMoved { from, to }` or `IllegalTransition { state, event }` by running the fork's machine. + - **Requirements:** R1, R3, R4, R6, R8, R9; AE3 (refusal half). **Decisions:** KTD1, KTD2, KTD3; U1 item 7. + - **Files:** `apps/site/src/features/guestbook/guestbook.schema.ts` (`EntryState` and `LifecycleEvent` literal schemas), `apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts` (new), `apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts` (new). + - **Approach:** + - The command class `ModerateGuestbookEntry` carries the instrumentation brand map (state, event). + - `EntryMoved` is a family-branded tagged class. `IllegalTransition` is a tagged error whose message names the state and the event. + - The machine is a module-private `const` in the same file (origin Key Decision "The machine lives inside the existing decision shape"). + - `Workflow.make` with one success class and one error satisfies the exclusive-outcome law (effect-cell-types README, "Decisions"). + - **Execution note:** Write the property file first, against the hand-written table, and watch it fail before the decision exists. + - **Test scenarios:** One `it.prop` file. Event sequences are generated from `LifecycleEvent`, and each sequence is folded from `Visible` through the decision: a success moves to `to`, a refusal stays put. The oracle is a 3×2 table typed into the test (origin Key Decision "The oracle for the transition table is written by hand"). Each law must refute a constant impostor of the subject (`@systemfsoftware/vitest` VacuousProperty gate). + - Every state reached decodes as `EntryState`. + - A refusal returns `IllegalTransition` with the input state and event, and the fold's state is unchanged. + - From `Hidden`, both events are refused. + - For each of the six pairs, the decision succeeds exactly when the table has the pair, with the table's target. + - Each legal pair is taken by some sequence from `Visible`. This is an existential claim, so it is proved by witnesses: `[Flag]`, `[Flag, Vouch]` and `[Flag, Flag]`. They sit beside the generated laws, never in place of them. + - The empty-action-list law, in the form U1 item 7 picked. + - AE3, hand-written refusal (CONST-T10): the command schema refuses `{ state: 'Banished', event: 'Flag' }`, and also an empty string and a lowercase `visible`. + - **Verification:** `pnpm --filter @endgame/site test` and `lint` pass with no directive, and `pnpm typecheck` passes. The file matches `stryker.mutate` unchanged (`apps/site/package.json:51-54`). Sabotage (CONST-T10): swap one target in the machine, and at least one law goes red; then revert. + +- U4. **Migration, store, RPC and handler.** + - **Goal:** `moderate` runs F1 end to end against D1, with every refusal typed on the RPC error channel. + - **Requirements:** R3, R4, R5, R6, R7, R11. **Decisions:** KTD5, KTD6, KTD7, KTD8. + - **Files:** `apps/site/src/features/guestbook/migrations/0002_add_guestbook_entry_state.sql` (new), `apps/site/src/features/guestbook/guestbook.schema.ts` (`state` on `GuestbookEntry`; `TransitionConflict`, `StoredStateInvalid`, `EntryNotFound`), `apps/site/src/features/guestbook/guestbook-store.ts` (`stateOf`, `move`, filtered `latest`, `state` in `sign`'s `RETURNING`), `apps/site/src/features/guestbook/guestbook-rpcs.ts` (`moderate`), `apps/site/src/features/guestbook/guestbook-handlers.ts`. + - **Approach:** + - First, a throwaway cell over the U3 decision must typecheck as the `moderate` handler, with `run`'s error channel matching the RPC error schema. If it doesn't, stop and rework KTD5 before writing the store methods. + - The cell's `read` decodes the row with a schema, never a cast. Its `write` handlers: + - `EntryMoved` → `move` (conflict on zero rows). + - `IllegalTransition` → fail with it. + - `CommandRejected` → fail with `StoredStateInvalid`. + - The RPC error schema is the union of the four refusals. + - The handler checks no identity, role or token (R11). + - **Test expectation:** No new unit test. The store and handler are shell, with one real D1 implementation and no fake (CONST-T8). U5's scenarios prove them, and the typecheck proves the cell covers every outcome tag. + - **Verification:** `pnpm typecheck` and `pnpm lint` pass. Under `pnpm dev`, the migration applies on start (`.journeys/dev.log`). + +- U5. **Page, journey and the representative failure.** + - **Goal:** A visitor flags and vouches from the page. R10's journey, AE4 and AE1 pass against `pnpm dev`. AE2 passes if its race test is admitted. + - **Requirements:** R4, R5, R7, R10, R11; AE1, AE2 (conditional), AE4. + - **Files:** `apps/site/src/features/guestbook/guestbook-page.tsx`, `apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts` (new), `apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts` (flag, vouch and entry-state helpers). + - **Approach:** + - Each entry in `ol[aria-label=Entries]` gets `Flag` and `Vouch` buttons and shows its state when `Flagged`. Each button's accessible name names its entry (for example "Flag Ada's entry"). Both buttons are disabled while a request is in flight, the way the sign button uses `ready` today. Otherwise a double-click would send two Flags and hide the entry with one gesture. + - The page catches all four refusal tags into the `role=alert` notice and re-fetches after each action. + - Every step is a real click, so a real RPC. No `page.route()` stub and no seeded row. + - **Test scenarios** (Gherkin, `effect-gherkin-spec`, a unique message per entry per run): + - **Journey (R10 + AE4).** Sign entries A, B and C. Walk A through R10's steps, one click each: Flag (shows `Flagged`), Vouch (shown without the label), Flag, Flag. Flag B once and leave C alone. A fresh browser context lists B as `Flagged` and C, and doesn't list A. + - **Representative failure (AE1).** Vouch on a fresh `Visible` entry shows the refusal naming `Visible` and `Vouch`. The entry's listed guest, message and state are identical before and after. Those are every column except `id`, which is the lookup key. + - **Race (AE2), conditional.** Before writing it, run a throwaway probe: on fresh `Flagged` entries, send Vouch and Flag concurrently from two contexts, K times, with `AND state = ?from` removed from `move`. Admit the scenario only if each of 3 probe runs ends with some pair where both succeed and the final state isn't `Flagged`. The admitted scenario asserts that every pair ends as one of: + - one success and one conflict; + - Flag succeeds, then Vouch is refused as illegal (final `Hidden`); + - both succeed and the final state is `Flagged` (Vouch, then Flag). + - **Verification:** + - `pnpm journeys` passes. + - Sabotage (CONST-T10): make `latest` return `Hidden` rows, and the journey goes red; map `IllegalTransition` to a defect, and AE1 goes red. Then revert both. + - The layer-2 PR records the AE2 probe output and whether the race test was admitted. + +- U6. **README removal procedure.** + - **Goal:** Removing the guestbook is still one procedure, and it takes the lifecycle with it. + - **Requirements:** R12. + - **Files:** `README.md` (section 4, `:117-128`). + - **Approach:** Add these steps: + - Remove both `@systemfsoftware/xstate` lines from `pnpm-workspace.yaml` and the `apps/site` dependency. + - Remove the `systemfsoftware-xstate` flake input and its place in the `sfs-deps` copy loop and jq merge. + - Run `pnpm install`. + - Extend the D1 note: also delete the `0002_add_guestbook_entry_state.sql` row from `__alchemy_migrations`. + - Layer 1 adds the dependency lines; layer 2 adds the migration line. + - **Test expectation:** none. This is documentation, proved by running it. + - **Verification:** In a scratch clone of the layer-2 head, follow the README steps literally. `pnpm install` succeeds. `git grep -nI xstate` exits 1 under the pathspec the Open Question settles. + +## Verification Contract + +- Before each layer's PR: `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm check:ci`, and for layer 2 `pnpm journeys`. Large output goes to `.cache/`, and only the tail is read. +- R9's score is read from the release gate's mutation job after layer 1 merges to `main`. Never run Stryker locally. The score counts killed mutants over the declared set, and CompileError mutants sit outside it (origin, "Outcomes that must not count"). +- Never `--no-verify`, never force-push. + +## Definition of Done + +- U1's seven items are recorded in the layer-1 PR, with no stop condition hit. +- R1-R12 hold. U3's property file, U5's scenarios, `check:sfs-sources` and the release gate's 100 on the moderation decision show them, and Test Admission names what carries R5 and R6. +- None of the "Outcomes that must not count" for area A is present. +- Layer 1 declares its judgment surfaces (`flake.nix`, `flake.lock`). +- The R12 grep question is settled by the conductor and U6 passes under that answer. +- No spike file, throwaway workflow or sabotage edit is left behind. From 7740df9c03f03677542574def52ac049a65226d1 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 03:31:28 +0000 Subject: [PATCH 2/9] docs(plan): record conductor ruling A-3 on the lifecycle plan --- ...eat-guestbook-moderation-lifecycle-plan.md | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md b/docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md index 4358fe2..e85eedb 100644 --- a/docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md +++ b/docs/plans/2026-10-09-0313-feat-guestbook-moderation-lifecycle-plan.md @@ -14,9 +14,9 @@ execution: code - **Objective:** Any visitor can flag and vouch for a guestbook entry. Two flags with no vouch between them hide it. The lifecycle runs as a pure transition on a machine from our XState fork, over a state name stored in D1, behind effect/rpc. The repo gains a stateful decision that agents can copy. - **Scope:** Area A of the origin document (R1-R12, F1, AE1-AE4). Area C has its own plan (`docs/plans/2026-10-08-0705-feat-gates-bind-and-inline-suppression-plan.md`). -- **Authority:** The origin document's R-IDs win on behavior. This plan's KTDs win on mechanism within those R-IDs. The conductor reviews this plan before U2 starts. -- **Stop conditions:** If U1 shows the fork can't do what R3 needs, A stops and goes back to Ryan. There is no fallback to upstream xstate or npm (origin Q1). If U1 shows the house lint refuses every way a decision can reach the fork, A stops and goes back to the conductor (KTD3). -- **Execution profile:** A `gh stack` on `main` with two layers (see Sequencing), plain pushes only. No local Stryker; mutation runs only at the release gate. +- **Authority:** The origin document's R-IDs win on behavior. This plan's KTDs win on mechanism within those R-IDs. The conductor approved this plan in ruling A-3 (2026-10-09). See "Decided (conductor ruling A-3)". +- **Stop conditions:** If U1 shows the fork can't do what R3 needs, A stops and goes back to the conductor with the commands and their output. There is no fallback to upstream xstate or npm (origin Q1). If U1 shows the house lint refuses every way a decision can reach the fork, A stops and goes back to the conductor (KTD3). +- **Execution profile:** A `gh stack` on `main` with two layers (see Sequencing). This plan file is the first commit of layer 1. Plain pushes only. No local Stryker; mutation runs only at the release gate. ## Product Contract @@ -142,19 +142,15 @@ A destructive review (Edge-First lens) tested three assumptions in the first dra The radical alternative was an in-process D1 fake with no e2e. It was rejected: it needs a fake with no contract test against real D1, or a new package (Miniflare), and the brief forbids new packages. -### Assumptions +### Decided (conductor ruling A-3, 2026-10-09) -Agent bets the conductor has not confirmed: - -- An unknown entry id gets a typed `EntryNotFound` refusal. R6 forbids a 500 for bad stored data, and this extends the same treatment to a bad id. -- The moderation RPC is one method, `moderate`, with payload `{ id, event }`, not one method per event. One decision serves both events. -- The page shows `Flagged` next to a flagged entry and gives each listed entry `Flag` and `Vouch` buttons. R10's journey needs a visible handle for each step. -- A successful `moderate` returns the entry's new state. - -### Open Questions - -- **Resolve before U6: R12's grep matches the planning docs.** `git grep -nI xstate -- . ':!*.lock'` already exits 0 on `main` because of `docs/brainstorms/2026-10-08-0340-…` (checked 2026-10-09), and this plan adds another match. Either the predicate excludes `docs/` (recommended: these docs are records, not wiring), or the removal procedure deletes those docs. That is the conductor's call, because it changes R12's text. -- **Decide before layer 2 merges: is review enough proof for R5?** If U5's probe shows the race test can't catch a missing compare, R5 rests on review of KTD6's SQL. A deterministic test would need a test-only delay hook in the Worker, or an in-process D1 (Miniflare, a new package). The brief rules out the package, and the hook puts test code in production. Recommended: accept review, declared in the layer-2 PR. +- **Assumptions accepted:** + - A typed `EntryNotFound` for an unknown id. + - One `moderate` RPC with `{ id, event }`. + - `Flag` and `Vouch` buttons and a `Flagged` marker on the page. + - A successful `moderate` returns the new state. +- **R12's grep excludes `docs/`.** The predicate is that `git grep -nI xstate -- . ':!*.lock' ':!docs/'` exits 1 after removal. The docs are records, not wiring. +- **R5's proof.** If U5's sabotage probe can't turn the race test red in 3 of 3 runs, R5 rests on review of KTD6's SQL, and the layer-2 PR says so. There is no test-only delay hook and no Miniflare. ## Implementation Units @@ -170,7 +166,7 @@ Agent bets the conductor has not confirmed: 5. Types: `snapshot.value` comes out as the three-name union, so `EntryMoved.to` needs no cast. Also record the effect element type for this machine. 6. Lint: a scratch decision written per KTD3 passes `make-body-purity`, complexity 1 and `workflow-match-exhaustive`, and its refusal agrees with `isUnhandled` on all six pairs. A scratch reference to the free `isUnhandled` inside `decide` is reported, which confirms KTD3's reading. 7. Pick how R8's "empty action list" law is observed from the public decision (the machine is module-private, CONST-T8). It must add no unreachable branch, because a NoCoverage mutant fails `break: 100`. Candidates: the outcome carries the transition's effect count as data and the law pins it at zero; or a type-level `never` on the effect list from item 5, plus a law over the outcome. - - **Verification:** The PR body for layer 1 records each item's command and output. Stop and go back to Ryan if item 1, 2 or 4 shows behavior R3 can't be built on. Stop and go back to the conductor if item 6 shows that every form reaching the fork from `decide` is refused (KTD3). The throwaway workflow file is gone before layer 1 is committed. + - **Verification:** The PR body for layer 1 records each item's command and output. Stop and go back to the conductor if item 1, 2 or 4 shows behavior R3 can't be built on, or if item 6 shows that every form reaching the fork from `decide` is refused (KTD3). The throwaway workflow file is gone before layer 1 is committed. - U2. **The fork as a starter dependency.** - **Goal:** `@systemfsoftware/xstate` resolves from `.sfs-deps` in the devshell, in CI and in the Worker bundle. Nothing resolves from npm. @@ -246,7 +242,7 @@ Agent bets the conductor has not confirmed: - Extend the D1 note: also delete the `0002_add_guestbook_entry_state.sql` row from `__alchemy_migrations`. - Layer 1 adds the dependency lines; layer 2 adds the migration line. - **Test expectation:** none. This is documentation, proved by running it. - - **Verification:** In a scratch clone of the layer-2 head, follow the README steps literally. `pnpm install` succeeds. `git grep -nI xstate` exits 1 under the pathspec the Open Question settles. + - **Verification:** In a scratch clone of the layer-2 head, follow the README steps literally. `pnpm install` succeeds, and `git grep -nI xstate -- . ':!*.lock' ':!docs/'` exits 1. ## Verification Contract @@ -260,5 +256,5 @@ Agent bets the conductor has not confirmed: - R1-R12 hold. U3's property file, U5's scenarios, `check:sfs-sources` and the release gate's 100 on the moderation decision show them, and Test Admission names what carries R5 and R6. - None of the "Outcomes that must not count" for area A is present. - Layer 1 declares its judgment surfaces (`flake.nix`, `flake.lock`). -- The R12 grep question is settled by the conductor and U6 passes under that answer. +- U6 passes under ruling A-3's grep predicate. - No spike file, throwaway workflow or sabotage edit is left behind. From 4c0e3c3ad4c5a44c0bed3ba18d25b015d59f0c65 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 04:09:16 +0000 Subject: [PATCH 3/9] build(site): take @systemfsoftware/xstate from our fork's flake Adds the systemfsoftware-xstate input (locked at 84e602e) to sfs-deps and the catalog, so the package resolves as a file: tarball and never from npm. The README's removal steps now take the dependency and the flake input out too. flake.nix and flake.lock are judgment surfaces. --- README.md | 4 + apps/site/package.json | 1 + flake.lock | 435 ++++++++++++++++++++++++++++++++++++++++- flake.nix | 10 +- pnpm-lock.yaml | 10 + pnpm-workspace.yaml | 2 + 6 files changed, 458 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c70243e..7d889c2 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,10 @@ To remove it: 4. `apps/site/src/api/site-rpcs.ts`: drop the `GuestbookRpcs` import and make `SiteRpcs` just `HealthRpcs`. 5. `apps/site/src/api/site-rpc-server.ts`: drop the `GuestbookHandlers` import and its `Layer.provide(GuestbookHandlers)` line. 6. `apps/site/alchemy.run.ts`: drop the `migrations` option from `Cloudflare.D1.Database('Database', …)`. +7. `pnpm-workspace.yaml`: drop the `@systemfsoftware/xstate` line under `catalog:` and its mirror under `overrides:`; `apps/site/package.json`: drop the `@systemfsoftware/xstate` dependency. +8. `flake.nix`: drop the `systemfsoftware-xstate` input, its name in the `outputs` arguments, and its two places in the `sfs-deps` derivation (the copy loop and the `jq -s add` merge); then run `nix flake lock`. +9. Run `pnpm install`. +10. Delete this README's guestbook text, from "The site ships one example feature" through the D1 note below. Removal leaves a deployed D1 as it is: the `guestbook_entries` table and its `0001_create_guestbook_entries.sql` row in `__alchemy_migrations` stay; drop them from the D1 console in the Cloudflare dashboard with `DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name = '0001_create_guestbook_entries.sql';`. diff --git a/apps/site/package.json b/apps/site/package.json index 20c997a..4997338 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -3,6 +3,7 @@ "description": "The one Worker: the starter's TanStack Start site", "dependencies": { "@systemfsoftware/effect-cell-types": "catalog:", + "@systemfsoftware/xstate": "catalog:", "@tanstack/react-router": "catalog:", "@tanstack/react-start": "catalog:", "effect": "catalog:", diff --git a/flake.lock b/flake.lock index eb94493..c0226ff 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,22 @@ { "nodes": { + "are-the-types-wrong-effect": { + "flake": false, + "locked": { + "lastModified": 1789533049, + "narHash": "sha256-UsnhLvTURM27VB3T/gxc6Oql/av0FYYI6nLv5UBKusw=", + "owner": "systemfsoftware", + "repo": "are-the-types-wrong-effect", + "rev": "d43a8020f7dc82a32de410823c6da5298c53a3b6", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "are-the-types-wrong-effect", + "rev": "d43a8020f7dc82a32de410823c6da5298c53a3b6", + "type": "github" + } + }, "comment-checker": { "inputs": { "nixpkgs": [ @@ -21,6 +38,75 @@ "type": "github" } }, + "comment-checker_2": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "nixpkgs" + ], + "rust-overlay": "rust-overlay_3" + }, + "locked": { + "lastModified": 1790884787, + "narHash": "sha256-XZz0bdSY5zjlRDme0dfC98oYcAf3WOpMCueoU39iXjY=", + "owner": "systemfsoftware", + "repo": "comment-checker", + "rev": "3bfedd853c1d6dca666e65e71e9f8a31d47db336", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "comment-checker", + "type": "github" + } + }, + "comment-checker_3": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "release-tools", + "nixpkgs" + ], + "rust-overlay": "rust-overlay_4" + }, + "locked": { + "lastModified": 1790884787, + "narHash": "sha256-XZz0bdSY5zjlRDme0dfC98oYcAf3WOpMCueoU39iXjY=", + "owner": "systemfsoftware", + "repo": "comment-checker", + "rev": "3bfedd853c1d6dca666e65e71e9f8a31d47db336", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "comment-checker", + "type": "github" + } + }, + "comment-checker_4": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "systemfsoftware", + "pnpm-release-management", + "nixpkgs" + ], + "rust-overlay": "rust-overlay_5" + }, + "locked": { + "lastModified": 1790884787, + "narHash": "sha256-XZz0bdSY5zjlRDme0dfC98oYcAf3WOpMCueoU39iXjY=", + "owner": "systemfsoftware", + "repo": "comment-checker", + "rev": "3bfedd853c1d6dca666e65e71e9f8a31d47db336", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "comment-checker", + "type": "github" + } + }, "importPnpmLock": { "inputs": { "nixpkgs": [ @@ -42,6 +128,53 @@ "type": "github" } }, + "importPnpmLock_2": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "nixpkgs" + ], + "systems": "systems_2" + }, + "locked": { + "lastModified": 1790096829, + "narHash": "sha256-mnjMXjY/fo5OQH/iYNPijIqo0jAb/z7EhHtfXLlzOrs=", + "owner": "Scrumplex", + "repo": "importPnpmLock.nix", + "rev": "ca18d47b0e98a4b51404f69ce5ab51efb6ebc151", + "type": "github" + }, + "original": { + "owner": "Scrumplex", + "repo": "importPnpmLock.nix", + "type": "github" + } + }, + "importPnpmLock_3": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "systemfsoftware", + "pnpm-release-management", + "nixpkgs" + ], + "systems": "systems_3" + }, + "locked": { + "lastModified": 1790096829, + "narHash": "sha256-mnjMXjY/fo5OQH/iYNPijIqo0jAb/z7EhHtfXLlzOrs=", + "owner": "Scrumplex", + "repo": "importPnpmLock.nix", + "rev": "ca18d47b0e98a4b51404f69ce5ab51efb6ebc151", + "type": "github" + }, + "original": { + "owner": "Scrumplex", + "repo": "importPnpmLock.nix", + "rev": "ca18d47b0e98a4b51404f69ce5ab51efb6ebc151", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1791191277, @@ -58,6 +191,22 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1791191277, + "narHash": "sha256-i8KuaINOYLvB2oQikGvtRmdZRxATRxsI9Yad2pp7dEA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "aa48d347080940b8a2b8d2f48228674e280a3514", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "pnpm-release-management": { "inputs": { "comment-checker": [ @@ -85,6 +234,77 @@ "type": "github" } }, + "pnpm-release-management_2": { + "inputs": { + "comment-checker": [ + "systemfsoftware-xstate", + "comment-checker" + ], + "nixpkgs": [ + "systemfsoftware-xstate", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791289049, + "narHash": "sha256-EaB7hYyD684/oCH9VgDiHcJw2vAuLf8InlZGFPxN2hE=", + "owner": "systemfsoftware", + "repo": "pnpm-release-management", + "rev": "8f1984418fef130956a3d1f50dc471fd1984d2ec", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "pnpm-release-management", + "rev": "8f1984418fef130956a3d1f50dc471fd1984d2ec", + "type": "github" + } + }, + "pnpm-release-management_3": { + "inputs": { + "comment-checker": "comment-checker_4", + "importPnpmLock": "importPnpmLock_3", + "nixpkgs": [ + "systemfsoftware-xstate", + "systemfsoftware", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791325964, + "narHash": "sha256-csqhLDOiIfTNnDDIkxHTYyTPnk6Zs/cPwnOIbsDeRrw=", + "owner": "systemfsoftware", + "repo": "pnpm-release-management", + "rev": "5eb4c5d5a607d2f9470a21237b2316a858ae1776", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "pnpm-release-management", + "rev": "5eb4c5d5a607d2f9470a21237b2316a858ae1776", + "type": "github" + } + }, + "release-tools": { + "inputs": { + "comment-checker": "comment-checker_3", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1791338691, + "narHash": "sha256-08KabPJqTo7qKVyxH0H8EM+utl24QKOTAhqGaKjItlI=", + "owner": "systemfsoftware", + "repo": "pnpm-release-management", + "rev": "c10c1c47121110b6fc5217251c07e6f7f3eb812d", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "pnpm-release-management", + "rev": "c10c1c47121110b6fc5217251c07e6f7f3eb812d", + "type": "github" + } + }, "root": { "inputs": { "comment-checker": "comment-checker", @@ -92,7 +312,8 @@ "nixpkgs": "nixpkgs", "pnpm-release-management": "pnpm-release-management", "stryker-js-effect": "stryker-js-effect", - "systemfsoftware": "systemfsoftware" + "systemfsoftware": "systemfsoftware", + "systemfsoftware-xstate": "systemfsoftware-xstate" } }, "rust-overlay": { @@ -137,6 +358,97 @@ "type": "github" } }, + "rust-overlay_3": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "comment-checker", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1790756160, + "narHash": "sha256-NPs4nNLxCpho3Ctj8O7t/bzmX0zR8r0zndKghAFEz5U=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "ed3a19fd0439ed618ec5fe1e12f0ba69a8be38b5", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "rust-overlay_4": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "release-tools", + "comment-checker", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1790756160, + "narHash": "sha256-NPs4nNLxCpho3Ctj8O7t/bzmX0zR8r0zndKghAFEz5U=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "ed3a19fd0439ed618ec5fe1e12f0ba69a8be38b5", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "rust-overlay_5": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "systemfsoftware", + "pnpm-release-management", + "comment-checker", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1790756160, + "narHash": "sha256-NPs4nNLxCpho3Ctj8O7t/bzmX0zR8r0zndKghAFEz5U=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "ed3a19fd0439ed618ec5fe1e12f0ba69a8be38b5", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "rust-overlay_6": { + "inputs": { + "nixpkgs": [ + "systemfsoftware-xstate", + "systemfsoftware", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1790928989, + "narHash": "sha256-5r3MN/Rx7N9tLapCBmUq6/zCc3C9J7gNif1HybD1fJQ=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "368fee9beaab04ca6fe7af28db63caa9badb22fa", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, "stryker-js-effect": { "inputs": { "comment-checker": [ @@ -170,6 +482,23 @@ "type": "github" } }, + "stryker-js-effect_2": { + "flake": false, + "locked": { + "lastModified": 1791330724, + "narHash": "sha256-e7AS4Mmt3qsW5UvUzXZYhR1qJkrklZkZl2ir707nJAU=", + "owner": "systemfsoftware", + "repo": "stryker-js-effect", + "rev": "3db0c428530803e6b425a834a64b2764fd6ee41b", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "stryker-js-effect", + "rev": "3db0c428530803e6b425a834a64b2764fd6ee41b", + "type": "github" + } + }, "systemfsoftware": { "inputs": { "comment-checker": [ @@ -198,6 +527,80 @@ "type": "github" } }, + "systemfsoftware-effect-cell-types-7": { + "flake": false, + "locked": { + "lastModified": 1789152418, + "narHash": "sha256-j6RoO74XnpW0zJX6CKdp6eW88WkptcqXWPFEXZsiELY=", + "owner": "systemfsoftware", + "repo": "systemfsoftware", + "rev": "4cf0f77016f1827fc54ed00144cc85e00eb729a1", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "systemfsoftware", + "rev": "4cf0f77016f1827fc54ed00144cc85e00eb729a1", + "type": "github" + } + }, + "systemfsoftware-xstate": { + "inputs": { + "are-the-types-wrong-effect": "are-the-types-wrong-effect", + "comment-checker": "comment-checker_2", + "importPnpmLock": "importPnpmLock_2", + "nixpkgs": [ + "nixpkgs" + ], + "pnpm-release-management": "pnpm-release-management_2", + "release-tools": "release-tools", + "stryker-js-effect": "stryker-js-effect_2", + "systemfsoftware": "systemfsoftware_2", + "systemfsoftware-effect-cell-types-7": "systemfsoftware-effect-cell-types-7" + }, + "locked": { + "lastModified": 1791513925, + "narHash": "sha256-Us5JhH7g3hHjTufGedIuM5rpnzr+HHjfEGM7vJTPkpQ=", + "owner": "systemfsoftware", + "repo": "xstate", + "rev": "84e602e77a562be7ac3c293f7b39eed23e503d69", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "ref": "main", + "repo": "xstate", + "type": "github" + } + }, + "systemfsoftware_2": { + "inputs": { + "comment-checker": [ + "systemfsoftware-xstate", + "comment-checker" + ], + "nixpkgs": [ + "systemfsoftware-xstate", + "nixpkgs" + ], + "pnpm-release-management": "pnpm-release-management_3", + "rust-overlay": "rust-overlay_6" + }, + "locked": { + "lastModified": 1791330870, + "narHash": "sha256-2L8GdzF71zucIf/qS5fdSg0daiItvOrP1/j7Er2hicI=", + "owner": "systemfsoftware", + "repo": "systemfsoftware", + "rev": "217c80d5d52c17d03d4aeb83b580ef97ed986c85", + "type": "github" + }, + "original": { + "owner": "systemfsoftware", + "repo": "systemfsoftware", + "rev": "217c80d5d52c17d03d4aeb83b580ef97ed986c85", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -212,6 +615,36 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 0a2d5be..1f41bdf 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,10 @@ inputs.pnpm-release-management.follows = "pnpm-release-management"; inputs.systemfsoftware.follows = "systemfsoftware"; }; + systemfsoftware-xstate = { + url = "github:systemfsoftware/xstate/main"; + inputs.nixpkgs.follows = "nixpkgs"; + }; # The pnpm store is hashless: each tarball's lockfile integrity is its fetch hash, so a lockfile change needs no hash edit. importPnpmLock = { url = "github:Scrumplex/importPnpmLock.nix"; @@ -36,7 +40,7 @@ }; }; - outputs = { self, nixpkgs, comment-checker, pnpm-release-management, systemfsoftware, stryker-js-effect, importPnpmLock }: + outputs = { self, nixpkgs, comment-checker, pnpm-release-management, systemfsoftware, stryker-js-effect, systemfsoftware-xstate, importPnpmLock }: let systems = [ "x86_64-linux" "aarch64-linux" ]; forEachSystem = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); @@ -54,10 +58,10 @@ }; sfs-deps = pkgs.runCommand "sfs-deps" { nativeBuildInputs = [ pkgs.jq ]; } '' mkdir $out - for dir in ${systemfsoftware.packages.${system}.workspace-tarballs} ${stryker-js-effect.packages.${system}.workspace-tarballs}; do + for dir in ${systemfsoftware.packages.${system}.workspace-tarballs} ${stryker-js-effect.packages.${system}.workspace-tarballs} ${systemfsoftware-xstate.packages.${system}.workspace-tarballs}; do cp "$dir"/*.tgz $out/ done - jq -s add ${systemfsoftware.packages.${system}.workspace-tarballs}/index.json ${stryker-js-effect.packages.${system}.workspace-tarballs}/index.json > $out/index.json + jq -s add ${systemfsoftware.packages.${system}.workspace-tarballs}/index.json ${stryker-js-effect.packages.${system}.workspace-tarballs}/index.json ${systemfsoftware-xstate.packages.${system}.workspace-tarballs}/index.json > $out/index.json ''; pnpm-store = pnpm-release-management.lib.mkPnpmConsumerStore { inherit pkgs; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5493d67..954465c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,7 @@ overrides: '@systemfsoftware/stryker-js-vm-runner': file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz '@systemfsoftware/tsconfig': file:.sfs-deps/tsconfig-2.0.1.tgz '@systemfsoftware/vitest': file:.sfs-deps/vitest-2.0.0.tgz + '@systemfsoftware/xstate': file:.sfs-deps/xstate-6.0.0-alpha.64.tgz workerd: 1.20261005.1 importers: @@ -352,6 +353,9 @@ importers: '@systemfsoftware/effect-cell-types': specifier: file:../../.sfs-deps/effect-cell-types-12.0.0.tgz version: file:.sfs-deps/effect-cell-types-12.0.0.tgz(effect@4.0.1) + '@systemfsoftware/xstate': + specifier: file:../../.sfs-deps/xstate-6.0.0-alpha.64.tgz + version: file:.sfs-deps/xstate-6.0.0-alpha.64.tgz '@tanstack/react-router': specifier: 'catalog:' version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -2848,6 +2852,10 @@ packages: effect: ^4 vitest: ^5 + '@systemfsoftware/xstate@file:.sfs-deps/xstate-6.0.0-alpha.64.tgz': + resolution: {integrity: sha512-NsN1kippHN4OJ5BR2wijgo3Qn04PTL1L5wwwoWUFgDGTmpZLoaq1RQuuXV4UjGTTDAJpTUew+6U+Lfq6YwUd4Q==, tarball: file:.sfs-deps/xstate-6.0.0-alpha.64.tgz} + version: 6.0.0-alpha.64 + '@tanstack/history@1.162.4': resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==} engines: {node: '>=20.19'} @@ -6371,6 +6379,8 @@ snapshots: - redis - utf-8-validate + '@systemfsoftware/xstate@file:.sfs-deps/xstate-6.0.0-alpha.64.tgz': {} + '@tanstack/history@1.162.4': {} '@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 91359ed..c9ed1f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,6 +51,7 @@ catalog: "@systemfsoftware/stryker-js-vm-runner": file:.sfs-deps/stryker-js-vitest-runner-8.1.1.tgz "@systemfsoftware/tsconfig": file:.sfs-deps/tsconfig-2.0.1.tgz "@systemfsoftware/vitest": file:.sfs-deps/vitest-2.0.0.tgz + "@systemfsoftware/xstate": file:.sfs-deps/xstate-6.0.0-alpha.64.tgz "@tanstack/react-router": 1.170.41 "@tanstack/react-start": 1.168.60 "@tanstack/router-cli": 1.167.40 @@ -116,6 +117,7 @@ overrides: "@systemfsoftware/stryker-js-vm-runner": "catalog:" "@systemfsoftware/tsconfig": "catalog:" "@systemfsoftware/vitest": "catalog:" + "@systemfsoftware/xstate": "catalog:" workerd: 1.20261005.1 packages: - apps/* From 203d0006021c433e42f87069d772992eb300e3e8 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 04:09:18 +0000 Subject: [PATCH 4/9] feat(site): decide guestbook moderation on the fork's machine moderateGuestbookEntry resolves the entry's decoded state on a module-private machine, applies its transition, and refuses an unhandled event with IllegalTransition. Seven property laws carry R8 and AE3's refusal. --- ...-guestbook-entry.workflow.property.test.ts | 138 ++++++++++++++++++ .../features/guestbook/guestbook.schema.ts | 6 + .../moderate-guestbook-entry.workflow.ts | 99 +++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts create mode 100644 apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts diff --git a/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts b/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts new file mode 100644 index 0000000..c16be97 --- /dev/null +++ b/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts @@ -0,0 +1,138 @@ +import { describe, it } from '@systemfsoftware/vitest' +import * as Result from 'effect/Result' +import * as S from 'effect/Schema' + +import { EntryState, LifecycleEvent } from '../guestbook.schema.ts' +import { + type EntryMoved, + type IllegalTransition, + ModerateGuestbookEntry, + moderateGuestbookEntry, +} from '../moderate-guestbook-entry.workflow.ts' + +type Moderated = Result.Result +type Moderate = (command: ModerateGuestbookEntry) => Moderated +type Move = readonly [EntryState, LifecycleEvent, EntryState] + +const STATE_NAMES: ReadonlyArray = ['Visible', 'Flagged', 'Hidden'] + +const LEGAL_MOVES: ReadonlyArray = [ + ['Visible', 'Flag', 'Flagged'], + ['Flagged', 'Vouch', 'Visible'], + ['Flagged', 'Flag', 'Hidden'], +] + +const targetOf = (state: EntryState, event: LifecycleEvent): EntryState | undefined => + LEGAL_MOVES.find(([from, on]) => from === state && on === event)?.[2] + +interface Step { + readonly before: EntryState + readonly event: LifecycleEvent + readonly outcome: Moderated + readonly after: EntryState +} + +const stateAfter = (before: EntryState, outcome: Moderated): EntryState => + Result.match(outcome, { onSuccess: (moved) => moved.to, onFailure: () => before }) + +const walk = (moderate: Moderate, events: ReadonlyArray): ReadonlyArray => + events.reduce>((steps, event) => { + const before = steps.at(-1)?.after ?? 'Visible' + const outcome = moderate(new ModerateGuestbookEntry({ state: before, event })) + return [...steps, { before, event, outcome, after: stateAfter(before, outcome) }] + }, []) + +const refusalKeepsState = ({ before, event, outcome, after }: Step): boolean => + Result.match(outcome, { + onSuccess: () => true, + onFailure: (refused) => + refused.state === before && refused.event === event && after === before && + refused.message.includes(before) && refused.message.includes(event), + }) + +const movesOf = (steps: ReadonlyArray): ReadonlyArray => + steps.flatMap(({ before, event, outcome }) => + Result.match(outcome, { + onSuccess: (moved): ReadonlyArray => [[before, event, moved.to]], + onFailure: (): ReadonlyArray => [], + }) + ) + +const tableMovesOf = (events: ReadonlyArray): ReadonlyArray => + events.reduce<{ readonly state: EntryState; readonly moves: ReadonlyArray }>( + ({ state, moves }, event) => { + const to = targetOf(state, event) + return to === undefined ? { state, moves } : { state: to, moves: [...moves, [state, event, to]] } + }, + { state: 'Visible', moves: [] }, + ).moves + +const takes = ([from, on, to]: Move) => (events: ReadonlyArray): boolean => + tableMovesOf(events).some(([state, event, target]) => state === from && event === on && target === to) + +const decodeCommand = (input: { readonly state: string; readonly event: LifecycleEvent }) => + S.decodeUnknownResult(ModerateGuestbookEntry)(input) + +describe('moderateGuestbookEntry — a visitor event moves an entry along R1, or is refused', () => { + it.prop( + '∀e_ReachedState_∈EntryState', + { of: { events: S.Array(LifecycleEvent) }, subject: moderateGuestbookEntry }, + (subject, { events }) => walk(subject, events).every(({ after }) => S.is(EntryState)(after)), + ) + + it.prop( + '∀e_RefusedEvent_=UnchangedState', + { of: { events: S.Array(LifecycleEvent) }, subject: moderateGuestbookEntry }, + (subject, { events }) => walk(subject, events).every(refusalKeepsState), + ) + + it.prop( + '∀e_HiddenEvent_⊥Move', + { of: { event: LifecycleEvent }, subject: moderateGuestbookEntry }, + (subject, { event }) => + Result.match(subject(new ModerateGuestbookEntry({ state: 'Hidden', event })), { + onSuccess: () => false, + onFailure: (refused) => refused.state === 'Hidden' && refused.event === event, + }), + ) + + it.prop( + '∀p_LegalPair_≡HandWrittenTable', + { of: { state: EntryState, event: LifecycleEvent }, subject: moderateGuestbookEntry }, + (subject, { state, event }) => + Result.match(subject(new ModerateGuestbookEntry({ state, event })), { + onSuccess: (moved) => moved.from === state && moved.to === targetOf(state, event), + onFailure: () => targetOf(state, event) === undefined, + }), + ) + + it.prop( + '∀p_MoveActions_=Empty', + { of: { state: EntryState, event: LifecycleEvent }, subject: moderateGuestbookEntry }, + (subject, { state, event }) => + Result.match(subject(new ModerateGuestbookEntry({ state, event })), { + onSuccess: (moved) => moved.actions === 0, + onFailure: () => true, + }), + ) + + it.prop( + '∀e_WalkMoves_≡HandWrittenTableWalk', + { + of: [S.Array(LifecycleEvent)], + subject: moderateGuestbookEntry, + cover: { + visibleFlag: [takes(['Visible', 'Flag', 'Flagged']), 0.1], + flaggedVouch: [takes(['Flagged', 'Vouch', 'Visible']), 0.1], + flaggedFlag: [takes(['Flagged', 'Flag', 'Hidden']), 0.1], + }, + }, + (subject, [events]) => JSON.stringify(movesOf(walk(subject, events))) === JSON.stringify(tableMovesOf(events)), + ) + + it.prop( + '∀s_CommandStateDecode_≡ThreeNameMembership', + { of: { state: S.Union([EntryState, S.String]), event: LifecycleEvent }, subject: decodeCommand }, + (decode, { state, event }) => Result.isSuccess(decode({ state, event })) === STATE_NAMES.includes(state), + ) +}) diff --git a/apps/site/src/features/guestbook/guestbook.schema.ts b/apps/site/src/features/guestbook/guestbook.schema.ts index 20ea0ba..1628ed3 100644 --- a/apps/site/src/features/guestbook/guestbook.schema.ts +++ b/apps/site/src/features/guestbook/guestbook.schema.ts @@ -12,6 +12,12 @@ export const EntryId = S.Int.pipe( ) export type EntryId = S.Schema.Type +export const EntryState = S.Literals(['Visible', 'Flagged', 'Hidden']) +export type EntryState = S.Schema.Type + +export const LifecycleEvent = S.Literals(['Flag', 'Vouch']) +export type LifecycleEvent = S.Schema.Type + export const GuestbookEntry = S.Struct({ id: EntryId, guest: GuestName, message: GuestMessage }) export type GuestbookEntry = S.Schema.Type diff --git a/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts b/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts new file mode 100644 index 0000000..1a65f23 --- /dev/null +++ b/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts @@ -0,0 +1,99 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import { createMachine, type StateMachine } from '@systemfsoftware/xstate' +import * as Match from 'effect/Match' +import * as Result from 'effect/Result' +import * as S from 'effect/Schema' + +import { EntryState, LifecycleEvent } from './guestbook.schema.ts' + +const ModerateGuestbookEntryTypeId: unique symbol = Symbol() + +export class ModerateGuestbookEntry extends S.Class('ModerateGuestbookEntry')({ + state: EntryState, + event: LifecycleEvent, +}) { + static readonly [Workflow.InstrumentationBrand]: Record = {} +} + +export class EntryMoved extends S.TaggedClass()('EntryMoved', { + from: EntryState, + to: EntryState, + actions: S.Int, +}) { + readonly [ModerateGuestbookEntryTypeId] = ModerateGuestbookEntryTypeId +} + +export class IllegalTransition extends S.TaggedError()('IllegalTransition', { + state: EntryState, + event: LifecycleEvent, +}) { + override get message(): string { + return `An entry that is ${this.state} can't take ${this.event}.` + } +} + +const machine = createMachine({ + initial: 'Visible', + states: { + Visible: { on: { Flag: { target: 'Flagged' } } }, + Flagged: { on: { Vouch: { target: 'Visible' }, Flag: { target: 'Hidden' } } }, + Hidden: {}, + }, +}) + +type MachineWidenedToStateMachine = typeof machine extends StateMachine< + infer Context, + infer Event, + infer Children, + infer Value, + infer Tag, + infer Input, + infer Output, + infer Emitted, + infer Meta, + infer Config, + infer Actions, + infer Actors, + infer Guards, + infer Delays, + infer InternalEvent, + infer TransitionMeta +> ? StateMachine< + Context, + Event, + Children, + Value, + Tag, + Input, + Output, + Emitted, + Meta, + Config, + Actions, + Actors, + Guards, + Delays, + InternalEvent, + TransitionMeta + > + : never + +const lifecycle: MachineWidenedToStateMachine = machine + +export const moderateGuestbookEntry = Workflow.make({ + command: ModerateGuestbookEntry, + decision: EntryMoved, + error: IllegalTransition, + decide: (command) => { + const snapshot = lifecycle.resolveState({ value: command.state }) + const [next, actions] = lifecycle.transition(snapshot, { type: command.event }) + return Match.value(next === snapshot).pipe( + Match.when(true, () => Result.fail(new IllegalTransition({ state: command.state, event: command.event }))), + Match.when( + false, + () => Result.succeed(new EntryMoved({ from: command.state, to: next.value, actions: actions.length })), + ), + Match.exhaustive, + ) + }, +}) From 3612204e2ac40ef5e9e375656f66489541ae8a25 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 04:27:42 +0000 Subject: [PATCH 5/9] refactor(site): type the lifecycle machine through provide, not a 35-line infer alias createMachine's result intersects StateMachine with identity members, and the fork types transition's effects as ExecutableActionObjectFromLogic (StateMachine.ts:701), so the method returns any on it. provide({}) is declared to return the plain StateMachine, which restores the types. Delete it once the fork types that return from its own parameters (conductor ruling A-4). --- .../moderate-guestbook-entry.workflow.ts | 45 ++----------------- 1 file changed, 3 insertions(+), 42 deletions(-) diff --git a/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts b/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts index 1a65f23..2948caf 100644 --- a/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts +++ b/apps/site/src/features/guestbook/moderate-guestbook-entry.workflow.ts @@ -1,5 +1,5 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' -import { createMachine, type StateMachine } from '@systemfsoftware/xstate' +import { createMachine } from '@systemfsoftware/xstate' import * as Match from 'effect/Match' import * as Result from 'effect/Result' import * as S from 'effect/Schema' @@ -32,53 +32,14 @@ export class IllegalTransition extends S.TaggedError()('Illeg } } -const machine = createMachine({ +const lifecycle = createMachine({ initial: 'Visible', states: { Visible: { on: { Flag: { target: 'Flagged' } } }, Flagged: { on: { Vouch: { target: 'Visible' }, Flag: { target: 'Hidden' } } }, Hidden: {}, }, -}) - -type MachineWidenedToStateMachine = typeof machine extends StateMachine< - infer Context, - infer Event, - infer Children, - infer Value, - infer Tag, - infer Input, - infer Output, - infer Emitted, - infer Meta, - infer Config, - infer Actions, - infer Actors, - infer Guards, - infer Delays, - infer InternalEvent, - infer TransitionMeta -> ? StateMachine< - Context, - Event, - Children, - Value, - Tag, - Input, - Output, - Emitted, - Meta, - Config, - Actions, - Actors, - Guards, - Delays, - InternalEvent, - TransitionMeta - > - : never - -const lifecycle: MachineWidenedToStateMachine = machine +}).provide({}) export const moderateGuestbookEntry = Workflow.make({ command: ModerateGuestbookEntry, From a09171017b80f920158f2359e4f3862c35e4e058 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 05:16:42 +0000 Subject: [PATCH 6/9] feat(site): flag and vouch for guestbook entries through the lifecycle Migration 0002 adds the state column. The store reads an entry's state and writes a move only if the row still holds the state it read. The moderate RPC runs the lifecycle decision in a Sandwich cell and types all four refusals. list omits Hidden entries. The page gets Flag and Vouch buttons. The journeys cover R10, AE1 and the AE2 race. The README removal steps cover migration 0002, and the check-sfs-sources message stops naming flakes. --- README.md | 4 +- .../__fixtures__/guestbook.fixture.ts | 86 +++++++++++++- .../guestbook-moderation.integration.test.ts | 111 ++++++++++++++++++ ...-guestbook-entry.workflow.property.test.ts | 2 +- .../features/guestbook/guestbook-handlers.ts | 20 ++++ .../src/features/guestbook/guestbook-page.tsx | 39 +++++- .../src/features/guestbook/guestbook-rpcs.ts | 17 ++- .../src/features/guestbook/guestbook-store.ts | 39 +++++- .../features/guestbook/guestbook.schema.ts | 33 +++++- .../0002_add_guestbook_entry_state.sql | 1 + .../moderate-guestbook-entry.workflow.ts | 4 +- scripts/check-sfs-sources.ts | 4 +- 12 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts create mode 100644 apps/site/src/features/guestbook/migrations/0002_add_guestbook_entry_state.sql diff --git a/README.md b/README.md index 7d889c2..d1e5941 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ pnpm dev # the whole app at http://localhost:1337, Alchemy's local emulati pnpm journeys # the end-to-end journeys in a real browser against pnpm dev ``` -The site ships one example feature, a guestbook at `/guestbook`: a pure decision (`sign-guestbook.workflow.ts`, built with `Workflow.make`) trims a name and a message and refuses them with typed errors; the RPC procedures `sign` and `list` write and read entries in D1, with the decision's tagged refusals as `sign`'s error schema; and the page shows the entries or the refusal. Everything it owns lives in [`apps/site/src/features/guestbook`](apps/site/src/features/guestbook) and [`apps/site-e2e/tests/features/guestbook`](apps/site-e2e/tests/features/guestbook). +The site ships one example feature, a guestbook at `/guestbook`: a pure decision (`sign-guestbook.workflow.ts`, built with `Workflow.make`) trims a name and a message and refuses them with typed errors; the RPC procedures `sign` and `list` write and read entries in D1, with the decision's tagged refusals as `sign`'s error schema; and the page shows the entries or the refusal. Any visitor can flag or vouch for an entry: a second decision (`moderate-guestbook-entry.workflow.ts`) applies the event to the entry's stored state (`Visible`, `Flagged` or `Hidden`) with the pure `transition` of a machine from our XState fork, and refuses an event the state can't take. The `moderate` procedure writes the new state only if the row still holds the state it read, and `list` never returns a `Hidden` entry. Everything it owns lives in [`apps/site/src/features/guestbook`](apps/site/src/features/guestbook) and [`apps/site-e2e/tests/features/guestbook`](apps/site-e2e/tests/features/guestbook). To remove it: @@ -129,7 +129,7 @@ To remove it: 9. Run `pnpm install`. 10. Delete this README's guestbook text, from "The site ships one example feature" through the D1 note below. -Removal leaves a deployed D1 as it is: the `guestbook_entries` table and its `0001_create_guestbook_entries.sql` row in `__alchemy_migrations` stay; drop them from the D1 console in the Cloudflare dashboard with `DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name = '0001_create_guestbook_entries.sql';`. +Removal leaves a deployed D1 as it is: the `guestbook_entries` table and its `0001_create_guestbook_entries.sql` and `0002_add_guestbook_entry_state.sql` rows in `__alchemy_migrations` stay; drop them from the D1 console in the Cloudflare dashboard with `DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name IN ('0001_create_guestbook_entries.sql', '0002_add_guestbook_entry_state.sql');`. ### 5. Make the Gates Block Merges diff --git a/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts b/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts index ff29415..b21219f 100644 --- a/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts +++ b/apps/site-e2e/tests/features/guestbook/__fixtures__/guestbook.fixture.ts @@ -25,12 +25,42 @@ export interface PageVisit { readonly notice: string } +export interface Moderation { + readonly message: string + readonly event: 'Flag' | 'Vouch' +} + const signedOrRefused = (message: string): boolean => document.querySelector('[role=alert]')?.textContent !== '' || (message !== '' && document.querySelector('ol')?.textContent.includes(message) === true) const submitEnabled = (): boolean => document.querySelector('button[type=submit]')?.hasAttribute('disabled') === false +const entryLines = (page: Page): Promise> => + page.locator('ol li').evaluateAll((items) => + items.map((item) => Array.from(item.querySelectorAll(':scope > span'), (part) => part.textContent).join(' ')) + ) + +const rpcAnswered = (page: Page, procedure: string) => + page.waitForResponse((response) => + response.url().endsWith('/api/rpc') && response.request().postData()?.includes(`"tag":"${procedure}"`) === true + ) + +const clickToModerate = async (page: Page, moderation: Moderation): Promise => { + const answered = Promise.all([rpcAnswered(page, 'moderate'), rpcAnswered(page, 'list')]) + const button = moderation.event === 'Flag' ? /^Flag / : /^Vouch for / + await page.getByRole('listitem').filter({ hasText: moderation.message }).getByRole('button', { name: button }).click() + await answered + await page.waitForFunction(submitEnabled) +} + +const visitOf = (page: Page) => + Effect.gen(function*() { + const entries = yield* Effect.promise(() => entryLines(page)) + const notice = yield* Effect.promise(() => page.getByRole('alert').textContent()) + return { entries, notice: notice ?? '' } satisfies PageVisit + }) + const sign = (page: Page, origin: string, entry: Entry) => Effect.gen(function*() { yield* Effect.promise(() => page.goto(`${origin}/guestbook`)) @@ -39,12 +69,20 @@ const sign = (page: Page, origin: string, entry: Entry) => yield* Effect.promise(() => page.getByLabel('Message').fill(entry.message)) yield* Effect.promise(() => page.getByRole('button', { name: 'Sign the guestbook' }).click()) yield* Effect.promise(() => page.waitForFunction(signedOrRefused, entry.message.trim())) - const entries = yield* Effect.promise(() => page.locator('ol li').allTextContents()) - const notice = yield* Effect.promise(() => page.getByRole('alert').textContent()) - return { entries, notice: notice ?? '' } satisfies PageVisit + return yield* visitOf(page) }) -const inFreshContext = (use: (page: Page, origin: string) => Effect.Effect): Effect.Effect => +const moderate = (page: Page, origin: string, moderation: Moderation) => + Effect.gen(function*() { + yield* Effect.promise(() => page.goto(`${origin}/guestbook`)) + yield* Effect.promise(() => page.waitForFunction(submitEnabled)) + yield* Effect.promise(() => clickToModerate(page, moderation)) + return yield* visitOf(page) + }) + +const inFreshContext = ( + use: (page: Page, origin: string) => Effect.Effect, +): Effect.Effect => Effect.gen(function*() { const origin = yield* siteUrl const browser = yield* Chromium @@ -62,6 +100,44 @@ export const listedOnPage: Effect.Effect, never, Chromium> Effect.gen(function*() { yield* Effect.promise(() => page.goto(`${origin}/guestbook`)) yield* Effect.promise(() => page.waitForFunction(submitEnabled)) - return yield* Effect.promise(() => page.locator('ol li').allTextContents()) + return yield* Effect.promise(() => entryLines(page)) }) ) + +export const moderateOnPage = (moderation: Moderation): Effect.Effect => + inFreshContext((page, origin) => moderate(page, origin, moderation)) + +const opened = async (page: Page, origin: string): Promise => { + await page.goto(`${origin}/guestbook`) + await page.waitForFunction(submitEnabled) +} + +const answerOf = (notice: string | null): string => + notice === '' ? 'ok' : notice?.includes("can't take") === true + ? 'illegal' + : notice?.includes('changed after it was read') === true + ? 'conflict' + : `${notice}` + +const stateOf = (listed: ReadonlyArray, message: string): string => { + const line = listed.find((entry) => entry.includes(message)) + return line === undefined ? 'Hidden' : line.endsWith(' Flagged') ? 'Flagged' : 'Visible' +} + +export const raceOnPage = (message: string): Effect.Effect => + inFreshContext((vouchPage, origin) => + inFreshContext((flagPage) => + Effect.promise(async () => { + await Promise.all([opened(vouchPage, origin), opened(flagPage, origin)]) + await Promise.all([ + clickToModerate(vouchPage, { message, event: 'Vouch' }), + clickToModerate(flagPage, { message, event: 'Flag' }), + ]) + const [vouch, flag] = await Promise.all([ + vouchPage.getByRole('alert').textContent(), + flagPage.getByRole('alert').textContent(), + ]) + return `${answerOf(vouch)}/${answerOf(flag)}` + }) + ) + ).pipe(Effect.flatMap((answers) => Effect.map(listedOnPage, (listed) => `${answers}/${stateOf(listed, message)}`))) diff --git a/apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts b/apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts new file mode 100644 index 0000000..e07a7d9 --- /dev/null +++ b/apps/site-e2e/tests/features/guestbook/guestbook-moderation.integration.test.ts @@ -0,0 +1,111 @@ +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Effect } from 'effect' + +import { ChromiumLive } from '../../__fixtures__/browser.fixture' +import { listedOnPage, moderateOnPage, raceOnPage, signOnPage, uniqueMessage } from './__fixtures__/guestbook.fixture' + +const Feature = makeFeature({ it }) + +const CHROMIUM_TIMEOUT_MS = 180_000 + +const RACES = 6 + +const SERIAL_OUTCOMES = ['ok/conflict/Visible', 'conflict/ok/Hidden', 'illegal/ok/Hidden', 'ok/ok/Flagged'] + +const signed = (name: string, suffix: string) => + Effect.flatMap(uniqueMessage, (base) => { + const message = `${base} ${suffix}` + return Effect.as(signOnPage({ name, message }), message) + }) + +const flagged = (suffix: string) => + Effect.flatMap(signed('Ray', suffix), (message) => Effect.as(moderateOnPage({ message, event: 'Flag' }), message)) + +Feature('Flagging and vouching for guestbook entries from the page', { timeout: CHROMIUM_TIMEOUT_MS }) + .withLayer(ChromiumLive) + .live('a real Chromium drives the page the running Worker serves, over its local D1 database') + .body(({ scenario }) => { + scenario( + 'Two flags with no vouch between them hide an entry; one flag leaves it listed as Flagged', + Gherkin.Do.pipe( + Given('Ada, Ben and Cy have each signed the guestbook')( + 'messages', + () => Effect.all({ ada: signed('Ada', 'first'), ben: signed('Ben', 'second'), cy: signed('Cy', 'third') }), + ), + When("a visitor flags Ada's entry")( + 'flagged', + (s) => moderateOnPage({ message: s.messages.ada, event: 'Flag' }), + ), + Then("Ada's entry is listed as Flagged")((s, expect) => + expect(s.flagged).toEqual({ notice: '', entries: expect.arrayContaining([`Ada ${s.messages.ada} Flagged`]) }) + ), + When('another visitor vouches for it')( + 'vouched', + (s) => moderateOnPage({ message: s.messages.ada, event: 'Vouch' }), + ), + Then("Ada's entry is listed without the Flagged label")((s, expect) => + expect(s.vouched).toEqual({ notice: '', entries: expect.arrayContaining([`Ada ${s.messages.ada}`]) }) + ), + When('a visitor flags it again')( + 'reflagged', + (s) => moderateOnPage({ message: s.messages.ada, event: 'Flag' }), + ), + Then('it is Flagged once more')((s, expect) => + expect(s.reflagged.entries).toContain(`Ada ${s.messages.ada} Flagged`) + ), + When('a visitor flags it a second time with no vouch between')( + 'hidden', + (s) => moderateOnPage({ message: s.messages.ada, event: 'Flag' }), + ), + When("a visitor flags Ben's entry once")( + 'benFlagged', + (s) => moderateOnPage({ message: s.messages.ben, event: 'Flag' }), + ), + When('another visitor opens the guestbook in their own browser')('listed', () => listedOnPage), + Then("Ada's entry is gone, Ben's is Flagged and Cy's is listed as signed")((s, expect) => + expect({ + ada: s.listed.filter((line) => line.includes(s.messages.ada)), + ben: s.listed.filter((line) => line.includes(s.messages.ben)), + cy: s.listed.filter((line) => line.includes(s.messages.cy)), + }).toEqual({ ada: [], ben: [`Ben ${s.messages.ben} Flagged`], cy: [`Cy ${s.messages.cy}`] }) + ), + ), + ) + + scenario( + 'A vouch for an entry nobody flagged is refused and changes nothing', + Gherkin.Do.pipe( + Given('Dee has signed the guestbook')('message', () => signed('Dee', 'unflagged')), + Given('the entry as any visitor sees it')('before', () => listedOnPage), + When("a visitor vouches for Dee's entry")( + 'visit', + (s) => moderateOnPage({ message: s.message, event: 'Vouch' }), + ), + Then('the page names the state and the event, and the entry is what it was')((s, expect) => + expect({ notice: s.visit.notice, entry: s.visit.entries.filter((line) => line.includes(s.message)) }).toEqual( + { + notice: "An entry that is Visible can't take Vouch.", + entry: s.before.filter((line) => line.includes(s.message)), + }, + ) + ), + ), + ) + + scenario( + 'A vouch and a flag sent at the same moment end as one of them happening first', + Gherkin.Do.pipe( + Given('several entries that one visitor has flagged')( + 'messages', + () => Effect.forEach(Array.from({ length: RACES }, (_, race) => `race ${race}`), flagged), + ), + When('for each entry, one visitor vouches while another flags it')( + 'outcomes', + (s) => Effect.forEach(s.messages, raceOnPage), + ), + Then('each answer pair and final state is one some serial order of the two would give')((s, expect) => + expect(s.outcomes.filter((outcome) => !SERIAL_OUTCOMES.includes(outcome))).toEqual([]) + ), + ), + ) + }) diff --git a/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts b/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts index c16be97..d5062c7 100644 --- a/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts +++ b/apps/site/src/features/guestbook/__tests__/moderate-guestbook-entry.workflow.property.test.ts @@ -71,7 +71,7 @@ const takes = ([from, on, to]: Move) => (events: ReadonlyArray): tableMovesOf(events).some(([state, event, target]) => state === from && event === on && target === to) const decodeCommand = (input: { readonly state: string; readonly event: LifecycleEvent }) => - S.decodeUnknownResult(ModerateGuestbookEntry)(input) + S.decodeResult(ModerateGuestbookEntry)(input) describe('moderateGuestbookEntry — a visitor event moves an entry along R1, or is refused', () => { it.prop( diff --git a/apps/site/src/features/guestbook/guestbook-handlers.ts b/apps/site/src/features/guestbook/guestbook-handlers.ts index a8eb961..2188f4f 100644 --- a/apps/site/src/features/guestbook/guestbook-handlers.ts +++ b/apps/site/src/features/guestbook/guestbook-handlers.ts @@ -1,10 +1,25 @@ +import { Sandwich } from '@systemfsoftware/effect-cell-types' import * as Effect from 'effect/Effect' import * as Layer from 'effect/Layer' import { GuestbookRpcs } from './guestbook-rpcs' import { GuestbookStore, GuestbookStoreLive } from './guestbook-store' +import { type ModerationRequest, StoredStateInvalid } from './guestbook.schema' +import { IllegalTransition, moderateGuestbookEntry } from './moderate-guestbook-entry.workflow' import { signGuestbook } from './sign-guestbook.workflow' +const moderateEntry = Sandwich.named('guestbook.moderate')((request: ModerationRequest) => + Effect.flatMap(GuestbookStore, (store) => store.stateOf(request.id)).pipe( + Effect.map((state) => ({ id: request.id, state, event: request.event })), + ) +) + .decide(moderateGuestbookEntry) + .write({ + EntryMoved: ({ from, to }, { id }) => Effect.flatMap(GuestbookStore, (store) => store.move(id, from, to)), + IllegalTransition: ({ state, event }) => Effect.fail(new IllegalTransition({ state, event })), + CommandRejected: ({ issue }) => Effect.fail(new StoredStateInvalid({ detail: issue })), + }) + export const GuestbookHandlers = GuestbookRpcs.toLayer( Effect.gen(function*() { const store = yield* GuestbookStore @@ -15,6 +30,11 @@ export const GuestbookHandlers = GuestbookRpcs.toLayer( Effect.flatMap((entry) => Effect.orDie(store.sign(entry))), ), list: () => Effect.orDie(store.latest), + moderate: (request) => + moderateEntry.run(request).pipe( + Effect.catchTags({ GuestbookUnavailable: Effect.die, GuestbookRowInvalid: Effect.die }), + Effect.provideService(GuestbookStore, store), + ), } }), ).pipe(Layer.provide(GuestbookStoreLive)) diff --git a/apps/site/src/features/guestbook/guestbook-page.tsx b/apps/site/src/features/guestbook/guestbook-page.tsx index 4a01ae5..fcb3538 100644 --- a/apps/site/src/features/guestbook/guestbook-page.tsx +++ b/apps/site/src/features/guestbook/guestbook-page.tsx @@ -2,7 +2,7 @@ import * as Effect from 'effect/Effect' import { type SubmitEvent, useEffect, useState } from 'react' import { siteClient, SiteClientProtocol } from '../../api/site-rpc-client' -import type { GuestbookEntries } from './guestbook.schema' +import type { GuestbookEntries, GuestbookEntry, LifecycleEvent, ModerationRequest } from './guestbook.schema' import { SignGuestbook } from './sign-guestbook.workflow' interface GuestbookView { @@ -35,9 +35,23 @@ const signEntry = (command: SignGuestbook): Effect.Effect => Effect.provide(SiteClientProtocol), ) +const moderateEntry = (request: ModerationRequest): Effect.Effect => + Effect.scoped(Effect.flatMap(siteClient, (client) => client.moderate(request))).pipe( + Effect.as(''), + Effect.catchTags({ + IllegalTransition: (refusal) => Effect.succeed(refusal.message), + StoredStateInvalid: (refusal) => Effect.succeed(refusal.message), + TransitionConflict: (refusal) => Effect.succeed(refusal.message), + EntryNotFound: (refusal) => Effect.succeed(refusal.message), + }), + Effect.catchCause(() => Effect.succeed(UNAVAILABLE)), + Effect.provide(SiteClientProtocol), + ) + export function GuestbookPage() { const [view, setView] = useState(loading) const [ready, setReady] = useState(false) + const [busy, setBusy] = useState(false) const [name, setName] = useState('') const [message, setMessage] = useState('') @@ -60,6 +74,16 @@ export function GuestbookPage() { ) } + const moderate = (entry: GuestbookEntry, event: LifecycleEvent) => { + setBusy(true) + void Effect.runPromise(moderateEntry({ id: entry.id, event })).then((notice) => + Effect.runPromise(latestEntries).then((loaded) => { + setView({ entries: loaded.entries, notice: notice === '' ? loaded.notice : notice }) + setBusy(false) + }) + ) + } + return (

Guestbook

@@ -70,13 +94,22 @@ export function GuestbookPage() {