From 71202c3be64564bef251cf2cdae6945f08778cbd Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Wed, 17 Jun 2026 08:48:38 -0500 Subject: [PATCH] M9-S8: seller-obligation e2e + M9 close housekeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the M9 Seller Console milestone (the slice plan's close, renumbered from S7 to S8 after the cross-queue race fix took S7). - Seller-perspective Playwright e2e (client/e2e/tests/seller-obligation.spec.ts): a seeded listing sells via the dev Buy-It-Now trigger, the seller console surfaces the post-sale obligation, the seller provides tracking through the console UI, and the obligation auto-confirms to Fulfilled. Identity bridge = seed-then-inject the seeded sellerId into the console session (no backend change). Two consecutive green runs against the live Aspire stack. - AppHost sets Obligations__DemoMode=true so the post-sale lifecycle runs live in demo seconds (the documented conference-demo posture; production defaults to false). The dev host previously ran production timers (days), making the lifecycle un-demoable and the e2e terminal unreachable. - Docs: bounded-contexts.md (three SPAs + shared + e2e), STATUS.md regenerated to v0.7 (M9 complete, 328 backend / 189 Vitest / 2 e2e), milestone doc closed + §7 renumbered + §1 ticked, narrative 006 e2e-coverage row. - CI frontend coverage verified (seller+shared already covered since M9-S1). - Pre-M10 skills audit: frontend-slice-discipline extended (all SPAs + e2e; host runtime config is part of the lived surface); signalr refresh + FakeHubConnection extraction recorded as M10 carry-forwards. - M9-S8 slice retro + M9 milestone retrospective. Backend 328/328 green; SPA Vitest 189 green; e2e member type-checks clean. --- .../skills/frontend-slice-discipline/SKILL.md | 10 +- client/e2e/README.md | 46 +++- client/e2e/tests/seller-obligation.spec.ts | 222 +++++++++++++++ docs/STATUS.md | 254 ++++++++---------- docs/milestones/M9-seller-console.md | 33 +-- ...06-seller-fulfills-post-sale-obligation.md | 1 + .../M9-S8-end-to-end-housekeeping.md | 104 +++++++ ...8-end-to-end-housekeeping-retrospective.md | 246 +++++++++++++++++ docs/retrospectives/M9-retrospective.md | 202 ++++++++++++++ docs/vision/bounded-contexts.md | 2 +- src/CritterBids.AppHost/Program.cs | 8 + 11 files changed, 962 insertions(+), 166 deletions(-) create mode 100644 client/e2e/tests/seller-obligation.spec.ts create mode 100644 docs/prompts/implementations/M9-S8-end-to-end-housekeeping.md create mode 100644 docs/retrospectives/M9-S8-end-to-end-housekeeping-retrospective.md create mode 100644 docs/retrospectives/M9-retrospective.md diff --git a/.claude/skills/frontend-slice-discipline/SKILL.md b/.claude/skills/frontend-slice-discipline/SKILL.md index e15a459..172427f 100644 --- a/.claude/skills/frontend-slice-discipline/SKILL.md +++ b/.claude/skills/frontend-slice-discipline/SKILL.md @@ -22,7 +22,8 @@ description: >- ## When to apply -- Starting a new frontend slice or feature in `client/bidder/` or `client/ops/`. +- Starting a new frontend slice or feature in any `client/` SPA (`bidder/`, `ops/`, `seller/`) + or the `client/e2e/` Playwright harness. - Mid-slice, when a spec (milestone table, narrative, prompt) names something you can't find in the backend. - Closing a slice: deciding what "verified" means before the retro claims it. @@ -42,6 +43,13 @@ types the slice consumes. Every M8 slice that skipped ahead would have shipped a (M8-S4). The view renders the subset that exists. - The prompt said `accessTokenFactory` puts the token on the negotiate; the installed client's source said Bearer header (M8-S5). Package source in `node_modules` counts as lived backend. +- The host's **runtime configuration** is part of the lived surface too, not just the code. The + M9-S8 seller-obligation e2e depends on the post-sale lifecycle auto-confirming in seconds — + but `ObligationsOptions.DemoMode` defaults to false (production timers are *days*) and nothing + in the dev/Aspire run enabled it. The fast lifecycle the demo posture assumes only exists once + the AppHost sets `Obligations__DemoMode=true`. Before a live smoke that rides a timer, confirm + the host is actually configured for the timing you expect — an env-var/appsettings gap is as + real as a missing endpoint. ## Rule 2 — Render the lived subset; gaps become carry-forwards diff --git a/client/e2e/README.md b/client/e2e/README.md index 7ecb0d1..5bfbaa3 100644 --- a/client/e2e/README.md +++ b/client/e2e/README.md @@ -1,12 +1,20 @@ # @critterbids/e2e — end-to-end tests -Playwright multi-context e2e tests (ADR 013) that drive the real SPAs against the **live -Aspire-orchestrated stack**. The headline test is the narrative-001 bid war: two anonymous -bidders in isolated browser contexts fight over one Flash listing through outbid, extended -bidding, and gavel-fall. +Playwright e2e tests (ADR 013) that drive the real SPAs against the **live Aspire-orchestrated +stack**. Two tests live here: + +- **`bid-war.spec.ts`** (narrative 001, bidder vantage) — two anonymous bidders in isolated + browser contexts fight over one Flash listing through outbid, extended bidding, and gavel-fall. +- **`seller-obligation.spec.ts`** (narrative 006, seller vantage) — a seller's listing sells, the + **seller console** surfaces the post-sale obligation, the seller provides tracking through the + console UI, and the obligation auto-confirms to "Completed". This test drives the seller SPA on + `:5175` and forces the sale with the dev Buy-It-Now trigger (the auction mechanics are the + bid-war test's job; the obligation lifecycle is this one's). See "Seller-obligation specifics" + below for the two backend facts it depends on. These tests are **not run in CI** (recorded M8-S7 deferral — they need Postgres, RabbitMQ, the -API host, and the bidder dev server all running). They are a local, pre-merge verification tool. +API host, and the SPA dev servers all running; the e2e member is type-checked in CI but not +executed). They are a local, pre-merge verification tool. ## Prerequisites @@ -37,14 +45,36 @@ From `client/`: npm run e2e ``` -Or from this directory: `npm test`. A single run takes **3–4 minutes of wall-clock time** — the -bid-war test rides a real 2-minute Flash auction plus its 15-second extended-bidding extension -and settlement; that is the point, not a defect. +Or from this directory: `npm test`. Run a single spec with +`npx playwright test seller-obligation` (or `bid-war`). + +Timings: the **bid-war** test takes **3–4 minutes** — it rides a real 2-minute Flash auction plus +its 15-second extended-bidding extension and settlement; that is the point, not a defect. The +**seller-obligation** test takes **under a minute** — it forces the sale with Buy-It-Now (no +auction wait) and rides only the demo-mode obligation timers (a ~10s ship-by window and a ~10s +post-tracking auto-confirm window). Each run seeds its own listing with a unique title, so repeated runs against the same database do not interfere with each other. Earlier runs' listings remain in the dev database; that is harmless. +## Seller-obligation specifics + +The seller-obligation test depends on two facts about the **live host**, both satisfied by the +Aspire-orchestrated run: + +1. **Demo-mode obligation timers.** The Obligations post-sale timers are days in production and + seconds in demo mode (`ObligationsOptions.DemoMode`). The AppHost sets + `Obligations__DemoMode=true` for the orchestrated run, so the full lifecycle + (tracking → shipped → auto-confirmed → fulfilled) completes live. Without it the auto-confirm + is 3 days out and the test's "Completed" assertion can never pass. +2. **Seed-then-inject identity bridge.** The seller console mints its own anonymous session, but + opening a listing for bidding is a staff/bus-only operator step. `POST /api/dev/seed-flash` + already creates a *registered seller* and drives the listing to Open; the test injects that + `sellerId` into the console's session storage (`critterbids.seller.participantId` + + `critterbids.seller.isRegisteredSeller`) via `context.addInitScript` so the console adopts the + seeded identity. No backend change, no operator UI in the seller console. + ## Conventions - Assertions go through the UI (what a bidder sees) — never into hub internals. A push is a diff --git a/client/e2e/tests/seller-obligation.spec.ts b/client/e2e/tests/seller-obligation.spec.ts new file mode 100644 index 0000000..6486c73 --- /dev/null +++ b/client/e2e/tests/seller-obligation.spec.ts @@ -0,0 +1,222 @@ +import { + test, + expect, + type APIRequestContext, + type Browser, + type Page, +} from "@playwright/test"; + +// The narrative-006 obligation-fulfillment spine (Moments 1–4), automated from the SELLER's +// vantage — the M9 counterpart to the bid-war's bidder-vantage test. One seller's listing sells, +// the seller console surfaces the resulting post-sale obligation, the seller provides tracking +// THROUGH THE CONSOLE UI (the react-hook-form dialog from M9-S6), and the obligation auto-confirms +// to the "Completed" terminal. Assertions go through what the SELLER sees (the rendered, re-queried +// ObligationStatusView), never into hub internals (ADR 026 applied to test assertions). +// +// Two pieces of lived-backend reality shape this test (read before it was written): +// • IDENTITY BRIDGE (M9-S8 OQ-1, resolved seed-then-inject). The seller console mints its own +// anonymous session and the operator create-session/attach/start step is staff/bus-only, so the +// console can't both publish AND open a listing on its own. POST /api/dev/seed-flash already +// drives a server-minted *registered seller* through publish→attach→start to Open and returns +// that sellerId; we inject it into the console's session storage so the console adopts the +// seeded seller identity. No backend change — the console renders that seller's obligations via +// GET /api/obligations/status?sellerId=. The sale itself is forced with the dev Buy-It-Now +// trigger (a single deterministic purchase) rather than riding a wall-clock auction — the +// auction mechanics are already the bid-war test's job; the obligation lifecycle is this one's. +// • DEMO TIMERS (M9-S8). The Obligations post-sale timers are days in production and seconds in +// demo mode; the AppHost sets Obligations__DemoMode=true for the orchestrated demo run, so the +// ship-by deadline (10s) and the post-tracking auto-confirm window (10s) elapse live inside this +// test. A consequence we assert around: the obligation may ESCALATE (Overdue) before we provide +// tracking — the "Provide Tracking" affordance survives escalation (narrative 007's recovery +// door), so we gate on the affordance, not on a not-yet-escalated status. +// +// Bid-war harness conventions carried verbatim: +// • the seller console's BiddingHub connection is asserted BEFORE any obligation action (a dead +// connection and a missing push are otherwise indistinguishable); +// • the listing is seeded with a per-run unique title, so a stale row from an earlier run can +// never satisfy an assertion; +// • the contexts are torn down at the end. + +/** Per-run unique seed title (bid-war lesson — greppable acceptance criterion). */ +const uniqueTitle = `E2E Seller Obligation ${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +// The seed's Buy-It-Now default is $100, so the sale's hammer price — and the obligation card's +// headline — is deterministic. +const BUY_IT_NOW = 100; + +// The seller console runs on its own origin (:5175, base /seller/); /api and /hub ride its Vite +// proxy exactly as the bidder app's do. The `request` fixture stays on the bidder origin (:5173, +// the config baseURL) — both proxy to the same API host, so API calls are origin-agnostic. +const SELLER_URL = process.env.CRITTERBIDS_SELLER_URL ?? "http://localhost:5175"; + +// The seller console keys its session off these (seller-specific, distinct from the bidder app's +// `critterbids.participantId`). Pre-seeding both makes SessionProvider boot straight to an +// established, registered-seller session with no session POST and no register-seller call. +const SELLER_PID_KEY = "critterbids.seller.participantId"; +const SELLER_REGISTERED_KEY = "critterbids.seller.isRegisteredSeller"; + +interface SeededListing { + listingId: string; + sellerId: string; +} + +async function seedFlashListing(request: APIRequestContext): Promise { + // The seed drives the listing all the way to Open, polling cross-BC read models between stages — + // give it a generous timeout. We keep the default 5-minute Flash duration: the sale is forced via + // Buy-It-Now, so the auction never has to close on its own. + const response = await request.post("/api/dev/seed-flash", { + data: { title: uniqueTitle }, + timeout: 90_000, + }); + expect(response.ok(), `seed-flash failed: ${response.status()}`).toBe(true); + const body = (await response.json()) as { listingId: string; sellerId: string }; + expect(body.listingId, "seed must return a listingId").toBeTruthy(); + expect(body.sellerId, "seed must return the seeded sellerId").toBeTruthy(); + return { listingId: body.listingId, sellerId: body.sellerId }; +} + +/** Mint a fresh anonymous participant to play the winning buyer. Returns its id. */ +async function mintBuyer(request: APIRequestContext): Promise { + const response = await request.post("/api/participants/session", { + data: {}, + headers: { "Content-Type": "application/json" }, + }); + expect(response.ok(), `session start failed: ${response.status()}`).toBe(true); + // CreationResponse: id is the last Location segment, with the body's `value` as a fallback. + const location = response.headers()["location"]; + const fromLocation = location?.split("/").filter(Boolean).pop(); + if (fromLocation) return fromLocation; + const body = (await response.json()) as { value?: string }; + expect(body.value, "session response must carry a participant id").toBeTruthy(); + return body.value!; +} + +/** The seeded seller's obligations as the read model currently holds them. */ +async function fetchObligations( + request: APIRequestContext, + sellerId: string, +): Promise> { + const response = await request.get(`/api/obligations/status?sellerId=${sellerId}`); + expect(response.ok(), `obligations query failed: ${response.status()}`).toBe(true); + return (await response.json()) as Array<{ id: string; status: string; hammerPrice: number }>; +} + +/** + * Poll the obligations read model until `predicate` holds, returning the matching obligation. The + * read model is fed asynchronously over RabbitMQ (settlement → obligation start; then the demo-mode + * timers), so this is a readiness gate, not the assertion — the assertion is what the seller SEES + * after we reload the console (the bid-war's scheduledCloseAt-gate pattern). + */ +async function waitForObligation( + request: APIRequestContext, + sellerId: string, + predicate: (o: { status: string }) => boolean, + timeoutMs: number, +): Promise<{ id: string; status: string; hammerPrice: number }> { + const deadline = Date.now() + timeoutMs; + let last: Array<{ id: string; status: string; hammerPrice: number }> = []; + while (Date.now() < deadline) { + last = await fetchObligations(request, sellerId); + const match = last.find(predicate); + if (match) return match; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error( + `obligation predicate not met within ${timeoutMs}ms; last read model state: ${JSON.stringify(last)}`, + ); +} + +/** + * Open the seller console AS the seeded seller: a fresh context whose session storage is pre-seeded + * with the seeded sellerId (and the registered-seller flag) before any app script runs, so the + * console adopts that identity instead of minting a new anonymous one. Gate on the BiddingHub + * connection before any obligation action (the bid-war lesson). + */ +async function openSellerConsole(browser: Browser, sellerId: string): Promise { + const context = await browser.newContext(); + await context.addInitScript( + ([pidKey, registeredKey, pid]) => { + sessionStorage.setItem(pidKey, pid); + sessionStorage.setItem(registeredKey, "true"); + }, + [SELLER_PID_KEY, SELLER_REGISTERED_KEY, sellerId] as const, + ); + + const page = await context.newPage(); + await page.goto(`${SELLER_URL}/seller/obligations`); + + // Hub connection asserted before any obligation traffic. "Connected" is case-exact and not a + // substring of "Disconnected"/"Reconnecting", so toContainText cannot false-positive. + await expect(page.getByTitle("BiddingHub connection")).toContainText("Connected"); + + return page; +} + +test("a seller fulfills a post-sale obligation through the console", async ({ browser, request }) => { + // --- Reachability gate: a clear message beats ECONNREFUSED noise ----------------------- + const probe = await request.get("/api/listings").catch(() => null); + if (probe === null || !probe.ok()) { + throw new Error( + "The CritterBids stack is not reachable on http://localhost:5173. " + + "Start it first: dotnet run --project src/CritterBids.AppHost --launch-profile http " + + "(the seller dev server on :5175 must be up too — it launches as an Aspire child).", + ); + } + + // --- Seed one Flash listing for this run (server-minted registered seller, unique title) --- + const { listingId, sellerId } = await seedFlashListing(request); + + // --- Open the seller console AS the seeded seller; assert it starts with no obligations ----- + const sellerPage = await openSellerConsole(browser, sellerId); + await expect(sellerPage.getByText("No post-sale obligations yet.")).toBeVisible(); + + // --- Force the sale: a fresh buyer takes the listing at Buy-It-Now ($100) ------------------- + // BuyNow has no public HTTP surface (buyer-side BIN UI is a future milestone), so we drive it + // through the dev trigger the same way the seed drives the bus-only seller/operator commands. + const buyerId = await mintBuyer(request); + const buyNow = await request.post("/api/dev/buy-now", { + data: { listingId, buyerId }, + }); + expect(buyNow.ok(), `buy-now failed: ${buyNow.status()}`).toBe(true); + + // --- Settlement → obligation: wait for the post-sale obligation to materialize -------------- + // It starts AwaitingShipment; under demo timers it may already have escalated (Overdue) by the + // time we observe it — either is an actionable, tracking-providable state. + const pending = await waitForObligation( + request, + sellerId, + (o) => o.status === "AwaitingShipment" || o.status === "Escalated", + 60_000, + ); + expect(pending.hammerPrice).toBe(BUY_IT_NOW); + + // --- Moment 1–2: the seller SEES the obligation in the console (re-query on reload) --------- + // The console's obligations query cached the empty result above; reload to re-fetch now that the + // read model carries the row (the burst-final push race, M8-S7 Finding 2, makes a reload more + // deterministic than waiting on the creation push for the FIRST observation). + await sellerPage.reload(); + await expect(sellerPage.getByText(`$${BUY_IT_NOW}.00 sale`)).toBeVisible(); + const provideTracking = sellerPage.getByRole("button", { name: "Provide Tracking" }); + await expect(provideTracking).toBeVisible(); + + // --- Moment 3: the seller provides tracking THROUGH THE CONSOLE (react-hook-form dialog) ----- + await provideTracking.click(); + const dialog = sellerPage.getByRole("dialog", { name: "Provide tracking information" }); + await expect(dialog).toBeVisible(); + await dialog.getByLabel("Tracking Number").fill("1Z999AA10123456784"); + await dialog.getByRole("button", { name: "Submit Tracking" }).click(); + // The dialog closes on a successful mutation. + await expect(dialog).toBeHidden(); + + // --- Moment 4: delivery auto-confirms; the seller SEES "Completed" -------------------------- + // Post-tracking the obligation goes Shipped, then auto-confirms after the demo AutoConfirmWindow + // (10s) to Fulfilled. Gate on the read model reaching Fulfilled, then reload and assert the + // terminal through the UI — the seller's window shows "Completed." + await waitForObligation(request, sellerId, (o) => o.status === "Fulfilled", 40_000); + await sellerPage.reload(); + await expect(sellerPage.getByText("Completed.")).toBeVisible(); + // `exact` pins the status badge; without it this also matches the "Fulfilled {timestamp}" line. + await expect(sellerPage.getByText("Fulfilled", { exact: true })).toBeVisible(); + + await sellerPage.context().close(); +}); diff --git a/docs/STATUS.md b/docs/STATUS.md index 1abcc0d..f31ba88 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # CritterBids — Project Status Snapshot -**As of:** 2026-06-12 · `main` @ `7b31147` + the M8-S7 close PR (regenerated inside that PR; **M8 complete**) -**Derived from:** [`retrospectives/M8-S7-end-to-end-housekeeping-retrospective.md`](./retrospectives/M8-S7-end-to-end-housekeeping-retrospective.md) + the M8 milestone retrospective (latest session close), [`milestones/M8-frontend-spas.md`](./milestones/M8-frontend-spas.md) (v0.6, ✅ Complete), [`decisions/README.md`](./decisions/README.md), [`research/README.md`](./research/README.md), `.github/workflows/ci.yml` +**As of:** 2026-06-17 · `main` @ `78443c1` + the M9-S8 close PR (regenerated inside that PR; **M9 complete**) +**Derived from:** [`retrospectives/M9-S8-end-to-end-housekeeping-retrospective.md`](./retrospectives/M9-S8-end-to-end-housekeeping-retrospective.md) + the M9 milestone retrospective (latest session close), [`milestones/M9-seller-console.md`](./milestones/M9-seller-console.md) (✅ Complete), [`decisions/README.md`](./decisions/README.md), `.github/workflows/ci.yml` > **This document is a derived snapshot, not a source of truth.** The canonical session-close > state is always the **most recent retrospective** in `docs/retrospectives/`. If this snapshot @@ -17,33 +17,31 @@ ## 1. Where Are We? -**M8 (React frontend SPAs) is complete** — and with it the full MVP arc: the backend (M1–M7, -all 8 BCs) plus both frontend SPAs and the end-to-end proof. The bidder app renders narrative -001's full journey (anonymous session → catalog → live bid war → extended bidding → gavel → -settlement confirmation); the ops dashboard renders the six operator boards over a **complete, -push-only** ops feed (the S6b 22-value vocabulary, polling stopgap deleted, topology invariant -in CI); and M8-S7 closed the milestone with the **Playwright two-bidder bid-war e2e** -(`client/e2e/`, two isolated browser contexts against the live Aspire stack — the ADR 013 -multi-context use case), the CI frontend job extended to the whole workspace, and the doc -surface refreshed to shipped reality. - -- **Build state at close:** 0 errors / 0 warnings; **307 backend tests green** (full - `dotnet test CritterBids.slnx`); **72 frontend Vitest** (`@critterbids/ops` 47, - `@critterbids/bidder` 25); **1 Playwright e2e** (local-only, two consecutive green runs - recorded in the S7 retro). -- **Engine baseline:** Wolverine **6.8.0** (Marten via `WolverineFx.Marten`) / .NET 10 / - ASP.NET Core 10.0.9 / Aspire **13.4.3**. A single `dotnet run --project - src/CritterBids.AppHost` starts Postgres, RabbitMQ, the API host, and **both SPA dev servers** - (bidder `:5173`, ops `:5174`) as Aspire children (commit `7b31147`). -- **Frontend:** `client/` is a three-member npm-workspaces monorepo (ADR 025): `bidder/`, - `ops/`, and `e2e/` (the M8-S7 Playwright harness). `shared/` stays a planned member — - extraction **deferred to M9** (S7 decision: the apps duplicate the pattern, not the bytes; - the seller console is the third consumer that reveals the real shared subset). -- **Notable S7 finding (escalated, not fixed):** the bidder app's extended-bidding *banner* is - structurally unreachable — the Listings BC has no `ExtendedBiddingTriggered` handler, so - `CatalogListingView.ScheduledCloseAt` never advances after `BiddingOpened`. The saga-side - extension itself works (e2e-proven: the listing outlives its original close). Backend - carry-forward; M8 had no sanctioned exception left. +**M9 (Seller Console) is complete** — the third React SPA ships, and with it the platform's third +perspective on the same engine. The seller console (`client/seller/`, anonymous `BiddingHub`, base +`/seller/`) renders the seller-perspective journeys: registration + listing management (narratives +004), live auction observation (narrative 005), and post-sale obligation fulfillment (narratives +006/007). M9 also realized the `client/shared/` extraction ADR 025 had deferred to the third +consumer, and absorbed the first housekeeping backend slices from M8's deferred ledger. + +- **Build state at close:** 0 errors / 0 net-new warnings (the `NU1903` MessagePack advisory and + the two `CS0108` saga-`Version` hides are the held baseline); **328 backend tests green** (full + `dotnet test CritterBids.slnx`); **189 frontend Vitest** (`@critterbids/seller` 117, + `@critterbids/ops` 47, `@critterbids/bidder` 25); **2 Playwright e2e** (local-only by recorded + decision — the M8 bid-war + the new M9-S8 seller-obligation test, each run twice green against + the live Aspire stack). +- **Engine baseline:** Wolverine **6.8.0** (Marten via `WolverineFx.Marten`) / .NET 10 / Aspire + **13.4.3** — carried from M8 close; M9 added no engine bump. A single `dotnet run --project + src/CritterBids.AppHost` starts Postgres, RabbitMQ, the API host, and **all three SPA dev + servers** (bidder `:5173`, ops `:5174`, seller `:5175`) as Aspire children. The AppHost now also + sets `Obligations__DemoMode=true` (M9-S8) so the post-sale lifecycle runs live in demo seconds. +- **Frontend:** `client/` is a **five-member** npm-workspaces monorepo (ADR 025): `shared/` + (`@critterbids/shared` — the SignalR provider/hook/cache-bridge factory, shared Zod wire schemas, + and the Tailwind theme, extracted at M9-S1), `bidder/`, `ops/`, `seller/`, and `e2e/` (Playwright). +- **Backend surface:** all seller-facing flows now have public HTTP endpoints (the M9-S2/S3 + precursor slices); the M8-flagged bus-only seller commands are gone. The Listings + `ExtendedBiddingTriggered` handler shipped (M9-S3), re-arming the extended-bidding banner; and the + `CatalogListingView` cross-queue create race is fixed (M9-S7, `Insert`-on-create + retry). ### Milestone ladder @@ -57,46 +55,48 @@ surface refreshed to shipped reality. | M5 | Settlement BC (payment saga, financial event stream) | ✅ Complete | | M6 | Obligations BC (post-sale saga) + Relay BC (SignalR push) | ✅ Complete | | M7 | Operations BC (operator read models + staff auth) | ✅ Complete (281 tests at close) | -| **M8** | **React frontend SPAs (bidder + ops dashboard) + e2e** | ✅ **Complete** (307 backend / 72 Vitest / 1 e2e at close) | -| M9 | Seller console (working name) | 🔭 Next — **not yet scoped**; needs its milestone-scoping session first | +| M8 | React frontend SPAs (bidder + ops dashboard) + e2e | ✅ Complete (307 backend / 72 Vitest / 1 e2e at close) | +| **M9** | **Seller console (third SPA) + `client/shared/` + housekeeping** | ✅ **Complete** (328 backend / 189 Vitest / 2 e2e at close) | +| M10 | (working name) — **not yet scoped** | 🔭 Next — needs its milestone-scoping session | -### M8 slice ledger (final) +### M9 slice ledger (final) | Slice | Scope | Status | |---|---|---| -| M8-S1 | Foundation: ADR 013 accepted, ADR 025 (monorepo layout), `client/bidder/` BiddingHub proof | ✅ Done | -| M8-S2 | Bidder SPA shell + catalog + anonymous session | ✅ Done (PR #80) | -| M8-S3a | Backend precursor: `POST /api/auctions/bids` over the existing PlaceBid DCB command | ✅ Done (PR #84) | -| M8-S3b | Bidder live bidding + ADR 026 (SignalR integration pattern) | ✅ Done (PR #86) | -| *(interlude)* | Bug #2 root cause + fix (dispatcher bridge, ADR 027 authored, 409 middleware, skills) | ✅ Done (PRs #88–#92) | -| M8-S3c | ADR 027 implementation: per-BC sticky queue bindings, exactly-once consumption | ✅ Done (PR #93) | -| M8-S4 | Bidder settlement outcome (confirmed-charge view) | ✅ Done (PR #94) | -| M8-S5 | Ops SPA shell + staff auth gate + `OperationsHub` credential dance | ✅ Done (PR #95) | -| M8-S6 | Ops dashboard views (six boards, ADR 026 cache bridge, Title join) | ✅ Done (PR #98) | -| M8-S6b | Relay ops-feed completion (8 events, topology invariant, stopgap deleted) + dispute-resolution control | ✅ Done (PR #100) | -| M8-S7 | Playwright two-bidder bid-war e2e + full-workspace CI frontend job + doc refresh + retros | ✅ Done (this PR) | +| (scoping) | M9 milestone-scoping doc | ✅ Done (PR #103) | +| M9-S1 | Foundation: `client/shared/` extraction + seller SPA scaffold (`:5175`) | ✅ Done (PR #104) | +| M9-S2 | Backend precursor: seller listing endpoints (submit, update-draft, my-listings query) | ✅ Done (PR #105) | +| M9-S3 | Backend precursor: seller query endpoints (obligation status, settlement summary) + Listings `ExtendedBiddingTriggered` handler + cache-bridge hardening (deferred) | ✅ Done (PR #106) | +| M9-S4a | Seller registration + my-listings dashboard | ✅ Done (PR #107) | +| M9-S4b | Seller listing management write operations (edit-draft, submit, withdraw) | ✅ Done (PR #108) | +| M9-S5 | Seller live auction observation (`BiddingHub`) | ✅ Done (PR #109) | +| M9-S6 | Seller obligation fulfillment (status view + provide-tracking form) | ✅ Done (PR #110) | +| M9-S7 | Listings cross-queue race fix (`CatalogListingView` `Insert`-on-create + retry) | ✅ Done (PR #112) | +| M9-S8 | End-to-end + housekeeping (close): seller-obligation Playwright e2e + doc refresh + skills audit + M9 retro | ✅ Done (this PR) | + +> Numbering note: the close was planned as "M9-S7"; the cross-queue race fix took S7 mid-milestone, +> so the close renumbered to S8. (PR #111 — a `react-hook-form` `shouldUnregister` fix + the CI +> frontend-matrix restructure — landed between S6 and S7 outside the slice plan.) --- ## 2. What's Up Next? -### Immediate: scope M9 (seller console) +### Immediate: scope M10 -M8's milestone retrospective defers the seller-perspective journeys (narratives 004/005/006 — -publish, watch-close, fulfill-obligation) outward to a working-name **M9 seller console**. -**No `docs/milestones/M9-*.md` exists yet** — per the milestone-doc precondition gate, the next -step is a milestone-scoping session that authors it; an M9-S1 implementation prompt would -hard-gate on its absence. Inputs: the M8 milestone retro's "what M8 defers outward", narratives -004/005/006, and the `client/shared/` extraction decision (M9 is its recorded trigger). +M9 closes the seller console and the third demo perspective. **No `docs/milestones/M10-*.md` exists +yet** — per the milestone-doc precondition gate, the next step is a milestone-scoping session that +authors it; an M10-S1 implementation prompt would hard-gate on its absence. Candidate inputs: the +M9 milestone retrospective's "what M9 defers outward", the deferred ledger below, and the M9-S8 +pre-M10 skills-audit findings (recorded in the M9-S8 retro). Satellite work running alongside (not session-gated): - **Wolverine upstream fix** — agent work order at [`research/wolverine-upstream-saga-sticky-separation-handoff.md`](./research/wolverine-upstream-saga-sticky-separation-handoff.md) - (launch in `C:\Code\JasperFx\wolverine`); includes the `FirstOrDefault` sticky-sibling - starvation finding and the `describe-routing --explain` NRE. Verify against the 6.8.0 line - before filing — the handoff predates two engine bumps. -- **ai-skills PR #71** (private repo) — with Jeremy for review. + (the `FirstOrDefault` sticky-sibling starvation finding + the `describe-routing --explain` NRE). + Re-verify against the current engine line before filing. +- **ai-skills PR** (private repo) — with Jeremy for review. --- @@ -106,45 +106,39 @@ Satellite work running alongside (not session-gated): | Item | Deferred at | Target | |---|---|---| -| **Listings `ExtendedBiddingTriggered` handler** (CatalogListingView.ScheduledCloseAt never advances → the bidder extended-bidding banner and the "Extended" status are unreachable; narrative 001 Moment 6's Listings-side upsert is still forward-spec) | M8-S7 e2e finding | Backend slice (first M9-adjacent housekeeping candidate) | -| **`client/shared/` extraction** (Zod wire schemas, SignalR provider pattern) | M8-S7 decision | M9 — the seller console is the third consumer | -| **Playwright e2e in CI** (needs the full Aspire stack in Actions — its own infrastructure piece) | M8-S7 decision (comment at the `frontend` CI job) | Re-evaluate when CI infra work is on the table | -| **Push-refetch vs projection race** (a hub push can trigger the re-query before the projection applies; the UI converges only at the next push — observed once in the S7 e2e's first run on the close-move; benign for bids, structural for the last event of a burst) | M8-S7 e2e finding | Frontend hardening candidate (e.g. delayed re-invalidate in the cache bridge) — pairs with the row above | -| Sold → Settled banner transition distinguished in e2e (the bid-war test accepts either terminal text; the Settled-specific "It's yours!" beat is not separately asserted) | M8-S4 → S7 | Future e2e extension | -| `IRevisioned` + `ConcurrencyException` retry policies for `SettlementSaga` / `PostSaleCoordinationSaga` | M8-S3c retro | Follow-up slice/chore | -| `SettlementCompleted`/`PaymentFailed` double-publish (saga appends AND returns via `OutgoingMessages` → 2 envelopes per queue) | M8-S3c retro | Follow-up — pick one canonical publish path | -| Deterministic regression test for the saga lost-update race | M8-S3c retro | Future testing session | -| Dev-only StrictMode SignalR console artifact (one benign negotiation-stop log per page load) | M8-S3c browser smoke | Cosmetic; whenever convenient | -| `remainingCredit` display on the bidder settlement-outcome view (lived notification omits it) | M8-S4 retro | Requires backend change (credit read endpoint or notification enrichment) | -| Bidder display-name header (no anonymous read endpoint surfaces the generated name) | M8-S2 retro | Backend read path, future slice | -| `LiveActivity` non-`bidPlaced` dedupe identity is `kind+occurredAt+text` (lossy but benign for a transient ticker) | Bug #2 verification pass | Revisit if a non-bid feed class gains volume | -| Transient push-fed ops *feed* surface would need bounded seen-set dedupe (at-least-once duplicates observed live) | M8-S6b retro | If/when a ticker-style ops surface ships | -| `OperationsHub` staff-group targeting (currently `Clients.All`) | M7-S1 fork #4 / ADR-024 item 6 | Post-MVP Relay edit | -| `marten-projections` skill: non-monotone state-machine guard section | M7-S4 | Future skill-update session | -| `wolverine-http-auth` skill codifying default-scheme trap / hub-path credential patterns | M7-S6 gap | Future skill-authoring session | +| **Cache-bridge burst-final hardening** (a hub push can trigger the re-query before the projection applies; the UI converges only at the next push — structural for the last event of a burst) | M8-S7 → evaluated + deferred M9-S3 | Bake the delayed re-invalidate into the `@critterbids/shared` cache-bridge surface when/if that is extracted (cache bridges stayed app-local at M9-S1) | +| **`CatalogListingView` update-update race** (two handlers both load an *existing* row and `Store`; last-writer-wins on a contended field — never the observed bug, fields largely monotone) | M9-S7 (OQ-3) | Backend chore; convert merge writes to `UpdateRevision` only if it surfaces | +| **Operations BC analogous multi-handler read-model race** (`DisputeQueueView`, `SellerPerformanceView` share the multi-handler upsert pattern the Listings race fix addressed) | M9-S7 | Separate evaluation; pairs with the row above | +| **`FakeHubConnection` shared test-util extraction** (three copies across the three SPAs) | M9-S6 → skills review | Extract to `@critterbids/shared` test utilities (M10 housekeeping) | +| **Seller settlement-summary UI** (the `GET /api/settlement/summaries?sellerId=` endpoint shipped at M9-S3b; no seller-console surface renders it yet) | M9 | Future seller-console slice (was a cross-reference surface, not a core exit criterion) | +| **Playwright e2e in CI** (needs the full Aspire stack in Actions; the `e2e` member is type-checked in CI, not executed) | M8-S7 decision; reaffirmed M9-S8 | Re-evaluate when CI infra work is on the table | +| `IRevisioned` + `ConcurrencyException` retry policies for `SettlementSaga` / `PostSaleCoordinationSaga` | M8-S3c | Follow-up slice/chore | +| `SettlementCompleted`/`PaymentFailed` double-publish (saga appends AND returns via `OutgoingMessages`) | M8-S3c | Follow-up — pick one canonical publish path | +| `remainingCredit` on the bidder settlement-outcome view; bidder display-name header | M8-S4 / S2 | Backend read paths, future slices | +| `OperationsHub` staff-group targeting (currently `Clients.All`) | M7-S1 / ADR-024 item 6 | Post-MVP Relay edit | | Marten event-type alias / upgrade pass for prior namespace promotions | M5 | First-real-deploy retrospective | ### Decided, not deferred (recorded dispositions) | Item | Disposition | |---|---| -| Mash-bidding UX (each click places a real +$1 bid via prefill auto-advance) | **Working as intended** (Erik, 2026-06-09). | -| M8-S3a "DCB retry policy" deferral | **Resolved differently:** bus path had `AuctionsConcurrencyRetryPolicies`; HTTP commit-time conflicts map to 409 via `ConcurrencyConflictMiddleware`. | -| Ops-feed completion vs polling stopgap | **Sanctioned and shipped at M8-S6b** (two independent evaluations; `docs/research/ops-feed-completion-evaluation-comparison.md`). | -| `BidPlacedOperations`/`ListingSoldOperations` suffixed wire values | **Stay as-is** — renaming wire values is churn (S6b). | +| **Obligations `DemoMode` on the live host** | **Enabled at M9-S8** (`Obligations__DemoMode=true` in the AppHost) — the documented conference-demo posture; production binds `DemoMode=false` by default. Makes the full post-sale lifecycle run live (and the M9-S8 e2e's "Completed" terminal reachable). | +| **Seller-console e2e identity bridge** | **Seed-then-inject** (M9-S8): the dev `seed-flash` endpoint creates a registered seller + open listing; the e2e injects that `sellerId` into the console's session storage. No backend change; the operator session-start step stays out of the seller console (milestone §3 non-goal). | +| `client/shared/` extraction (M8-D1 deferral) | **Realized at M9-S1** — factory `createSignalRProvider()` + shared Zod schemas + Tailwind theme; consumed by all three SPAs. | +| Listings `ExtendedBiddingTriggered` handler (M8-S7 finding) | **Shipped at M9-S3** — `CatalogListingView.ScheduledCloseAt` now advances on extension. | +| Mash-bidding UX; M8-S3a "DCB retry policy"; ops-feed completion; suffixed wire values | Recorded in M8 (unchanged). | ### Post-MVP (explicitly out of scope) | Item | Source | |---|---| -| `Refund` settlement-reversal / compensation mechanics on `DisputeResolved`; `Refund`/`Closed` dispute controls | M6/M7 non-goals (re-affirmed S6b/S7) | -| Buyer "report a problem" form; notification-history expansion for ops-feed publications | M8-S6/S6b retros | -| Real carrier-tracking webhook (`ProvideTracking` is an in-process stub) | M6 non-goals | -| Email / SMS / push delivery (Relay is SignalR-only; stubs log) | M6 non-goals | -| Per-user staff identity, roles, external IdP (swap behind unchanged `StaffOnly` policy) | ADR-024 revisit trigger | -| `DemoResetInitiated` cascade (MVP demo reset = Docker volume removal) | M7 non-goals | -| Operations runbook / SRE docs (P-001) · Demo-script runbook (P-002) | `decisions/PARKED.md` | -| Transport swap demo (RabbitMQ → Azure Service Bus) | ADR 002 | +| Seller-side dispute UI (viewing/responding to an open dispute) | M9 non-goal | +| Timed-listing seller-console path (Flash is the demo story) | M9 non-goal | +| `Refund` settlement-reversal / compensation; `Refund`/`Closed` dispute controls | M6/M7 non-goals | +| Real carrier-tracking webhook (`ProvideTracking` is an in-process stub); carrier field on the tracking form | M6 non-goals / narrative 006 | +| Email / SMS / push delivery (Relay is SignalR-only) | M6 non-goals | +| Per-user staff / seller identity, roles, external IdP | ADR-024 revisit trigger | +| Production static-file serving for the SPAs (Aspire-served in dev) | M8/M9 non-goals | --- @@ -153,33 +147,27 @@ Satellite work running alongside (not session-gated): | # | Risk | Severity | Notes / Mitigation | |---|---|---|---| | 1 | **Two sagas still have unenforced optimistic concurrency** (`SettlementSaga`, `PostSaleCoordinationSaga`). | Medium | Follow-up ships `IRevisioned` + matching retry policies together. | -| 2 | **Wolverine upstream defects unfixed** (Separated single-saga fan-out suppression; `FirstOrDefault` sticky-sibling starvation) — CritterBids is insulated by ADR 027, but new saga continue-handlers on shared events remain a sharp edge. Engine has since moved 6.5.1 → 6.8.0; the handoff's repro claims need re-verification before filing. | Medium | `wolverine-sagas` skill rule + ADR 027 bindings + audit discipline; upstream handoff ready. | -| 2b | **New-consumer drift under sticky bindings** (consumer without a binding, or route without a consumer). | Low-Medium | Convention in the S3c retro + handler docstrings; topology test now guards the Relay-ops side (S6b). | -| 3 | **Push-refetch can lose the race to the projection** for the last event of a burst — the UI shows stale data until the next push (S7 e2e observed it once on the close-move; the boards' re-query idempotency absorbs it everywhere else today). | Low | Recorded with a frontend-hardening candidate (delayed re-invalidate); becomes Medium if a no-further-push surface starts depending on burst-final events. | -| 4 | **Occasional Testcontainers flakes** in Auctions and Settlement suites. Pass on rerun. | Low | Inherent startup variance. | -| 5 | **Doc nit:** the M7-S4 retro header says `**Date:** 2025-02-14` (should be ~2026-05-31). | Trivial | Fix in a docs PR. | +| 2 | **Wolverine upstream defects unfixed** (Separated single-saga fan-out suppression; `FirstOrDefault` sticky-sibling starvation) — CritterBids is insulated by ADR 027, but new saga continue-handlers on shared events remain a sharp edge. | Medium | `wolverine-sagas` skill rule + ADR 027 bindings + audit discipline; upstream handoff ready (re-verify against the current engine line). | +| 3 | **Read-model multi-handler races beyond Listings** — the Operations BC shares the multi-handler upsert pattern the M9-S7 fix hardened; an `Insert`-on-create audit there is open. | Low-Medium | M9-S7 carry-forward; the Listings fix is the template. | +| 4 | **Push-refetch can lose the race to the projection** for the last event of a burst — the UI shows stale data until the next push. | Low | Recorded with a frontend-hardening candidate (delayed re-invalidate in the shared cache bridge); the seller e2e sidesteps it with a read-model readiness gate + reload. | +| 5 | **Occasional Testcontainers flakes** — a full-suite run spins up ~10 Postgres containers near-simultaneously; one BC occasionally loses the startup race. Pass on isolated re-run. | Low | Inherent startup variance; re-run the single project before treating a single full-suite failure as a regression. | --- ## 5. Key Numbers (at this snapshot) -- **Tests:** **307 backend** passing — Auctions 77, Api 46, Relay 45, Operations 38, Selling 36, - Settlement 25, Listings 20, Obligations 13, Participants 6, Contracts 1 — plus **72 frontend - Vitest** (`@critterbids/ops` 47, `@critterbids/bidder` 25) and **1 Playwright e2e** - (local-only by recorded decision) -- **CI:** backend matrix (8 BC suites + Api) + unit tests + a `frontend` job covering **both** - SPA builds + Vitest suites + the e2e type-check; single `CI` aggregator as the required check -- **Ops feed:** 22 push eventTypes, all cache-bridge-mapped; `OperationsFeedTopologyTests` - enforces Operations-consumed ⇒ ops-pushed; polling stopgap deleted (S6b) -- **Sticky bindings (ADR 027):** per-BC sticky consumption across 6 BCs; 23 RabbitMQ listeners; - exactly-once consumption per queue (S3c) — S6b added 2 publish routes to existing queues -- **ADRs:** 27 authored (next unreserved: **028**); 013/025/026/027 accepted in M8 -- **Workspaces:** 8 backend BCs in `src/`; 3 npm workspace members in `client/` - (`bidder`, `ops`, `e2e`); `shared/` planned (M9) -- **PRs this M8 stretch:** #80, #83–#86, #88–#100 + the Aspire SPA-orchestration commit - (`7b31147`) + the S7 close PR — all merged or in flight -- **Satellite:** JasperFx/ai-skills#71 open (private); Wolverine upstream handoff ready - (re-verify against 6.8.0) +- **Tests:** **328 backend** passing (full `dotnet test CritterBids.slnx`) — plus **189 frontend + Vitest** (`@critterbids/seller` 117, `@critterbids/ops` 47, `@critterbids/bidder` 25) and **2 + Playwright e2e** (bid-war + seller-obligation; local-only by recorded decision) +- **CI:** backend matrix (8 BC suites + Api + Contracts) + a `frontend` matrix job covering + `seller`/`bidder`/`ops` (build + Vitest) and `shared`/`e2e` (type-check); single `CI` aggregator + as the required check. The frontend matrix has covered `seller`+`shared` since M9-S1; PR #111 + restructured it into its current build-test/type-check form. +- **Frontend:** five npm-workspace members (`shared`, `bidder`, `ops`, `seller`, `e2e`); three SPAs + Aspire-orchestrated dev children (bidder `:5173`, ops `:5174`, seller `:5175`) +- **Seller-facing HTTP surface:** complete — submit / update-draft / withdraw / my-listings query / + obligation-status query / settlement-summary query / provide-tracking, all public endpoints +- **ADRs:** 27 authored (next unreserved: **028**); M9 added none — it operated under ADR 013/025/026 - **OpenSpec adoption:** Obligations ✅ adopted · Relay ❌ declined · Operations ❌ declined --- @@ -190,10 +178,9 @@ Satellite work running alongside (not session-gated): |---|---| | What are the conventions / hard rules? | `CLAUDE.md` (root) | | What happened in the last session? | Newest file in `docs/retrospectives/` (canonical state) | -| What did M8 ship, end to end? | The M8 milestone retrospective in `docs/retrospectives/` | +| What did M9 ship, end to end? | The M9 milestone retrospective in `docs/retrospectives/` | | How do I run the e2e? | `client/e2e/README.md` (requires the live Aspire stack) | -| What happened with Bug #2? | `docs/research/jasperfx-escalation-bidplaced-cross-bc-delivery.md` + `docs/research/README.md` | -| A message was produced but a consumer never saw it? | `docs/skills/message-flow-diagnosis/SKILL.md` | +| How do I work a frontend slice? | `.claude/skills/frontend-slice-discipline/SKILL.md` | | Why is the architecture this way? | `docs/decisions/README.md` (ADR index) | | What was deliberately deferred? | `docs/decisions/PARKED.md` + §3 of this file | | How do I implement pattern X? | `docs/skills/README.md` (skill index) | @@ -205,36 +192,23 @@ Satellite work running alongside (not session-gated): ## Document History -- **v0.6** (2026-06-12): Regenerated at M8 close (M8-S7). M8 flipped to ✅ Complete across the - ladder and slice tables (S5 #95, S6 #98, S6b #100, S7 rows added); headline rewritten to the - milestone-close posture (both SPAs shipped, ops feed complete and push-only, bid-war e2e green - twice, full-workspace frontend CI). Engine baseline 6.8.0 / Aspire 13.4.3 with both SPAs as - Aspire children. Deferred ledger re-derived: added the Listings `ExtendedBiddingTriggered` - handler gap (S7 e2e finding), the `client/shared/` → M9 decision, the Playwright-in-CI - deferral, and the push-refetch race; S4's live-smoke item resolved into the e2e with the - Sold-vs-Settled distinction carried. "What's next" pivots to the M9 scoping session - (milestone-doc precondition gate). Key numbers: 307 backend / 72 Vitest / 1 e2e. -- **v0.5** (2026-06-10): Regenerated at M8-S4 merge. Bidder app narrative arc complete (S1–S4 - done); slice table flips S3c and S4 to done, adds the M8-S7 row; "What's next" pivots to - M8-S5 (ops dashboard shell); deferred ledger gains `remainingCredit` display gap and S4 live - smoke test; frontend test count updated (17 → 25); PR ledger updated (S3c #93, S4 #94 merged). -- **v0.4** (2026-06-09): Regenerated at M8-S3c close (ADR 027 implementation). Slice table flips - S3c to done; headline records exactly-once delivery, the two new self-consumption queues, the - first live BIN/withdrawal verification, and the two masked races found-and-fixed (saga - `IRevisioned` enforcement, order-tolerant catalog settle). Deferred ledger: two items closed, - four added (Settlement/PostSale `IRevisioned` pairing, settlement double-publish, race - regression test, StrictMode console artifact). Risks re-derived: N-copies risk retired, - saga-concurrency gap and sticky-drift convention added; upstream handoff extended with the - `FirstOrDefault` sticky-sibling starvation finding. -- **v0.3** (2026-06-09): Regenerated after the M8 Bug #2 fix-and-follow-ups stretch (PRs #88–#91 + - the follow-ups branch). M8 ladder/slice tables added (S1–S3b done, S3c queued); Bug #2 outcome, - ADR 027, the 409 concurrency middleware, research-folder consolidation, and the satellite - JasperFx work items recorded; risks and key numbers re-derived (298 backend tests; engine - baseline corrected to Wolverine 6.5.1 / Marten 9.6.0). -- **v0.2** (2026-06-03): Regenerated at M7 close (all 7 slices complete, 281 tests). Derived - from the M7-S7 retro, M7 milestone retro, M7 milestone doc, ADR index, PARKED.md, and the - CI workflow. Milestone ladder, risks, key numbers, and deferred items fully updated. -- **v0.1** (2026-05-31): Authored at the Copilot → Claude Code tooling transition, immediately - after M7-S4 merged (PR #70). Derived from the M7-S4 retro, M6 milestone retro, M7 milestone - doc, ADR index, PARKED.md, and the CI workflow. First snapshot of its kind; regeneration rule - recorded in the header. +- **v0.7** (2026-06-17): Regenerated at M9 close (M9-S8). M9 flipped to ✅ Complete across the + ladder and a final M9 slice ledger added (scoping #103 → S8). Headline rewritten to the + seller-console-close posture (three SPAs, five-member `client/` workspace, `client/shared/` + realized at S1, all seller-facing endpoints public, the `ExtendedBiddingTriggered` handler and + the `CatalogListingView` create-race fix shipped). Key numbers: 328 backend / 189 Vitest / 2 e2e. + Deferred ledger re-derived: closed the `client/shared/` extraction and the `ExtendedBiddingTriggered` + handler (both shipped in M9); added the `CatalogListingView` update-update race (OQ-3), the + Operations multi-handler read-model audit, the `FakeHubConnection` shared extraction, and the + seller settlement-summary UI gap; recorded the `Obligations DemoMode` host-config decision and the + seed-then-inject e2e identity bridge. "What's next" pivots to the M10 scoping session + (milestone-doc precondition gate). +- **v0.6** (2026-06-12): Regenerated at M8 close (M8-S7). M8 ✅ Complete; both SPAs shipped, ops + feed complete and push-only, bid-war e2e green twice, full-workspace frontend CI. Engine baseline + 6.8.0 / Aspire 13.4.3. Deferred ledger added the Listings `ExtendedBiddingTriggered` gap, the + `client/shared/` → M9 decision, the Playwright-in-CI deferral, and the push-refetch race. +- **v0.5** (2026-06-10): Regenerated at M8-S4 merge (bidder narrative arc complete). +- **v0.4** (2026-06-09): Regenerated at M8-S3c close (ADR 027 implementation). +- **v0.3** (2026-06-09): Regenerated after the M8 Bug #2 fix-and-follow-ups stretch. +- **v0.2** (2026-06-03): Regenerated at M7 close (281 tests). +- **v0.1** (2026-05-31): Authored at the Copilot → Claude Code tooling transition. diff --git a/docs/milestones/M9-seller-console.md b/docs/milestones/M9-seller-console.md index 1f76f1c..143ee7c 100644 --- a/docs/milestones/M9-seller-console.md +++ b/docs/milestones/M9-seller-console.md @@ -1,6 +1,6 @@ # M9 — Seller Console -**Status:** Planned +**Status:** ✅ Complete (M9-S8 close, 2026-06-17) **Scope:** The third React SPA — a seller-facing console that renders the seller-perspective journeys narratives 004–007 describe: listing management, live auction observation, and post-sale obligation fulfillment. M9 also triggers the `client/shared/` extraction that ADR 025 deferred to the third consumer, and carries the first housekeeping backend slices from M8's deferred ledger. **Companion docs:** [`../narratives/004-seller-publishes-and-withdraws-listing.md`](../narratives/004-seller-publishes-and-withdraws-listing.md) · [`../narratives/005-seller-watches-flash-auction-close.md`](../narratives/005-seller-watches-flash-auction-close.md) · [`../narratives/006-seller-fulfills-post-sale-obligation.md`](../narratives/006-seller-fulfills-post-sale-obligation.md) · [`../narratives/007-seller-recovers-missed-shipping-deadline.md`](../narratives/007-seller-recovers-missed-shipping-deadline.md) · [`../decisions/025-spa-monorepo-layout.md`](../decisions/025-spa-monorepo-layout.md) (§`client/shared/`) · [`../decisions/013-frontend-core-stack.md`](../decisions/013-frontend-core-stack.md) · [`../decisions/026-signalr-integration-pattern.md`](../decisions/026-signalr-integration-pattern.md) · `CLAUDE.md` §Frontend · [`../retrospectives/M8-retrospective.md`](../retrospectives/M8-retrospective.md) (§"What M9 Should Know") · [`../skills/frontend-slice-discipline/SKILL.md`](../skills/frontend-slice-discipline/SKILL.md) @@ -18,19 +18,19 @@ The backend is largely shipped — the Selling, Auctions, Obligations, and Relay ### Exit criteria -- [ ] **Seller SPA** exists at `client/seller/` as an npm-workspace member, rendering the seller-perspective journeys: listing management (narratives 004 Moments 1–5), live auction observation (narrative 005 Moments 1–4), and obligation fulfillment (narrative 006 Moments 1–4) -- [ ] **`client/shared/` extracted** as the fourth npm-workspace member per ADR 025, consumed by all three SPAs (bidder, ops, seller); the shared surface includes at minimum the SignalR provider/hook/cache-bridge pattern and the Zod wire schemas both apps already duplicate -- [ ] **Seller-side HTTP surface complete** — every seller action the console drives has a public HTTP endpoint (no bus-only commands remain for seller-facing flows); the backend precursor slices are sanctioned and recorded as in M8's precedent -- [ ] **Live auction observation** — the seller console connects to `BiddingHub` (anonymous, same as the bidder app) and shows real-time bid activity, reserve crossing, extended-bidding status, and gavel-fall for the seller's own listings -- [ ] **Obligation management** — the seller console surfaces the `ObligationStatusView` for the seller's own listings and drives the `ProvideTracking` command (endpoint already exists at `POST /api/obligations/tracking`) -- [ ] **Listings `ExtendedBiddingTriggered` handler shipped** — `CatalogListingView.ScheduledCloseAt` advances on extension, re-arming the extended-bidding banner the M8-S7 e2e found unreachable (first M8 housekeeping carry-forward) -- [ ] **Cache-bridge burst-final hardening evaluated** — the push-refetch race for the last event of a burst (M8-S7 Finding 2) is either fixed (delayed re-invalidate in the cache bridge) or explicitly deferred with rationale -- [ ] Clean-checkout `npm install` + `npm run build` on all four workspace members; TypeScript strict; no .NET breakage -- [ ] Existing .NET baseline unchanged beyond sanctioned backend exceptions — 0 errors / 0 warnings held; 307 backend tests preserved or grown -- [ ] Playwright e2e extended with at least one seller-perspective test (reusing the M8-S7 harness conventions) -- [ ] CI `frontend` job covers the seller app (build + Vitest) -- [ ] `CLAUDE.md` §Frontend updated (seller app, `client/shared/`, workspace member count) -- [ ] All slice retros + M9 retrospective doc written +- [x] **Seller SPA** exists at `client/seller/` as an npm-workspace member, rendering the seller-perspective journeys: listing management (narratives 004 Moments 1–5, M9-S4a/S4b), live auction observation (narrative 005 Moments 1–4, M9-S5), and obligation fulfillment (narrative 006 Moments 1–4, M9-S6) +- [x] **`client/shared/` extracted** as a fifth npm-workspace member per ADR 025 (M9-S1), consumed by all three SPAs (bidder, ops, seller); the shared surface includes the SignalR provider/hook/cache-bridge pattern, the shared Zod wire schemas, and the Tailwind theme — **annotation:** ADR 025 counted it as the "fourth" member; with the `e2e` member already landed in M8, `shared` is the fifth, leaving a five-member workspace +- [x] **Seller-side HTTP surface complete** — every seller action the console drives has a public HTTP endpoint (M9-S2/S3 backend precursor slices, sanctioned and recorded per M8's precedent); no bus-only commands remain for seller-facing flows +- [x] **Live auction observation** — the seller console connects to `BiddingHub` (anonymous, same as the bidder app, M9-S5) and shows real-time bid activity, reserve crossing, extended-bidding status, and gavel-fall for the seller's own listings +- [x] **Obligation management** — the seller console surfaces the `ObligationStatusView` for the seller's own listings and drives the `ProvideTracking` command (M9-S6); the full lifecycle is now e2e-verified (M9-S8) +- [x] **Listings `ExtendedBiddingTriggered` handler shipped** (M9-S3) — `CatalogListingView.ScheduledCloseAt` advances on extension, re-arming the extended-bidding banner the M8-S7 e2e found unreachable (first M8 housekeeping carry-forward) +- [x] **Cache-bridge burst-final hardening evaluated** (M9-S3) — see the M9-S3 retro for the disposition +- [x] Clean-checkout `npm install` + `npm run build` on all workspace members; TypeScript strict; no .NET breakage +- [x] Existing .NET baseline unchanged beyond sanctioned backend exceptions — 0 errors / 0 net-new warnings held; **annotation:** baseline grew 307 → 328 backend tests across M9's sanctioned backend slices (S2/S3/S7), not broken +- [x] Playwright e2e extended with a seller-perspective test (M9-S8 — `client/e2e/tests/seller-obligation.spec.ts`, two consecutive green runs against the live Aspire stack; reuses the M8-S7 harness conventions) +- [x] CI `frontend` job covers the seller app (build + Vitest) — **annotation:** landed earlier than this slice (PR #111 restructured the `frontend` matrix to cover `seller` build-test + `shared`/`e2e` type-check); M9-S8 verified it rather than adding it +- [x] `CLAUDE.md` §Frontend updated (seller app, `client/shared/`, workspace member count) — kept current across M9 slices; verified at M9-S8 close +- [x] All slice retros + M9 retrospective doc written (M9-S8) --- @@ -226,7 +226,7 @@ All backend conventions from `CLAUDE.md` apply to the precursor slices: `sealed ## 7. Slice Breakdown -M9 is planned as seven slices. The first two are backend and infrastructure precursors; the remaining five build the seller SPA and close the milestone. Like M8, this is a scope ceiling refined in per-slice prompts — open questions are resolved in the slices that need them, not pre-decided here. +M9 ran as eight slices. The first two are backend and infrastructure precursors; the next four build the seller SPA; a backend race-fix slice (S7) landed before the close; and S8 closes the milestone. Like M8, this was a scope ceiling refined in per-slice prompts — open questions resolved in the slices that needed them, not pre-decided here. (The slice plan originally numbered the close "S7"; the `CatalogListingView` cross-queue race fix took S7 mid-milestone, so the close renumbered to S8.) | Slice | Title | Scope | |---|---|---| @@ -236,7 +236,8 @@ M9 is planned as seven slices. The first two are backend and infrastructure prec | M9-S4 | Seller SPA — registration + listing management | Seller app shell + layout; seller registration flow (the one-click `RegisterAsSeller` from the session); listing dashboard ("my listings" view consuming the S2 query endpoint); create-draft form (`react-hook-form` + Zod validation against listing-time fields); edit-draft; submit-for-publication; withdraw. Narratives 004 Moments 1–5. | | M9-S5 | Seller SPA — live auction observation | Live auction view from the seller's vantage: `BiddingHub` connection (via `@critterbids/shared` SignalR provider); real-time bid feed on the seller's own listings; reserve-crossing indicator (the seller's confidential reserve is known client-side from the draft fields); extended-bidding status; gavel-fall. Narrative 005 Moments 1–4. | | M9-S6 | Seller SPA — obligation fulfillment | Obligation tracker: post-sale status view (`ObligationStatusView` for the seller's listings); ship-by deadline countdown; reminder banner; provide-tracking form (driving the existing `POST /api/obligations/tracking`); delivery-confirmed / fulfilled terminal state. Narratives 006 Moments 1–4 + narrative 007's escalation-recovery UX (the "Overdue — under review" → "Shipped" recovery). | -| M9-S7 | End-to-end + housekeeping | Playwright seller-perspective e2e (extend the M8-S7 harness — a seller publishes, the listing sells, the seller provides tracking, the obligation fulfills); CI `frontend` job extended to seller + shared; `CLAUDE.md` / `bounded-contexts.md` / `STATUS.md` updates; test-baseline update; M9 retrospective. | +| M9-S7 | Listings cross-queue race fix | Backend bug fix landed mid-milestone (PR #112): the `CatalogListingView` last-writer-wins create race between the `listings-selling-events` and `listings-auctions-events` queue handlers, resolved with `Insert`-on-create across all twelve write methods + a `DocumentAlreadyExistsException` retry policy. No frontend; not in the original slice plan. See `docs/retrospectives/M9-S7-listings-cross-queue-race-fix-retrospective.md`. | +| M9-S8 | End-to-end + housekeeping (close) | Playwright seller-perspective e2e (extend the M8-S7 harness — a seller's listing sells, the seller console provides tracking, the obligation auto-confirms to Fulfilled; `client/e2e/tests/seller-obligation.spec.ts`); CI `frontend` coverage verified (already complete per PR #111); `bounded-contexts.md` / `STATUS.md` / milestone-doc refresh; pre-M10 skills audit; M9 milestone retrospective. Sanctioned dev-host config: `Obligations__DemoMode=true` in the AppHost so the post-sale lifecycle runs live (the conference-demo posture). | ### Open questions for per-slice resolution diff --git a/docs/narratives/006-seller-fulfills-post-sale-obligation.md b/docs/narratives/006-seller-fulfills-post-sale-obligation.md index f664dd0..f688b93 100644 --- a/docs/narratives/006-seller-fulfills-post-sale-obligation.md +++ b/docs/narratives/006-seller-fulfills-post-sale-obligation.md @@ -184,3 +184,4 @@ The five W005 decisions are the design record; this narrative dramatises four of - **2026-05-28** — `M6 Obligations BC opening (W005 design sequence)`: Initial authoring as ADR 017 step 4, following the W005 Event Modeling workshop. Four Moments dramatising the post-sale coordination happy path (saga start, single reminder, seller provides tracking with escalation-timer cancellation, clock-triggered auto-confirm to `ObligationFulfilled` + `MarkCompleted()`). Forward-spec against the unshipped Obligations BC; audit surface is W005 + `005-scenarios.md`. Cross-narrative continuity with narratives 002 ($49.50 payout, `SettlementCompleted` payload) and 005 ($55.00 hammer, keyboard identity, GreyOwl12 as seller). Escalation/recovery (W005 5.5/5.6) and disputes (5.7/5.8) deferred to successor narratives. - **2026-05-29** — `M6-S3 saga happy path + cancellable reminder chain`: Moments 3–4 are now code. The seller-provides-tracking step (Moment 3) lands as the `ProvideTracking` command + `[WolverinePost("/api/obligations/tracking")]` endpoint, whose saga handler cancels the pending reminder + escalation timers (via `IMessageStore.ScheduledMessages.CancelAsync`, not a bus method — see the M6-S3 retro skill correction), records `TrackingInfoProvided`, and schedules the auto-confirm timer. The self-closing fulfillment (Moment 4) lands as the `ConfirmDelivery` handler emitting `DeliveryConfirmed` → `ObligationFulfilled` and calling `MarkCompleted()`. The lifecycle is surfaced by the new `ObligationStatusView` single-stream Inline projection. **One drift flagged:** the dramatised tracking entry implies a carrier alongside the tracking number, but the frozen `TrackingInfoProvided` contract (M6-S1) carries only `TrackingNumber`; the carrier is an additive contract change (ADR 005) deferred beyond M6-S3 — the narrative's carrier mention is aspirational until then. Escalation/recovery and the dispute sub-workflow remain S4. - **2026-05-30** — `M6-S7 saga start proven as post-sale fan-out sibling`: Moment 1 (saga start on `SettlementCompleted`) is now **end-to-end integration-covered as one half of the post-sale fan-out** — no new behaviour, coverage only. A composed real-Kestrel + Marten host (`PostSaleFanOutTestFixture` in Relay.Tests, running Obligations **and** Relay together under `MultipleHandlerBehavior.Separated` + `MessageIdentity.IdAndDestination`) asserts that a single `SettlementCompleted` publish drives **two independent sibling consumers**: the Obligations `SettlementCompletedHandler` starting `PostSaleCoordinationSaga` (asserted via the `ObligationStatusView` reaching `AwaitingShipment`, with the view / saga / event-stream all sharing the one deterministic UUID-v5 `ObligationId`) **and** the Relay `bidder:{WinnerId}` `BiddingHub` push (narrative 001 Moment 8) — siblings off one event, not a chain. This closes the post-sale join from settlement into Obligations end-to-end. The deterministic `ObligationId` derivation itself stays unit-covered in Obligations.Tests. Escalation/recovery (5.5/5.6) and disputes (5.7/5.8) remain successor-narrative scope. +- **2026-06-17** — `M9-S8 seller-vantage e2e coverage`: Moments 1–4 are now validated **end-to-end from the seller console** by an automated Playwright test (`client/e2e/tests/seller-obligation.spec.ts`), no new behaviour — the seller-perspective complement to the M9-S6 component tests + HTTP smoke. Against the live Aspire stack, a seeded listing sells (forced via the dev Buy-It-Now trigger), the seller console (adopting the seeded seller identity via session-storage injection) surfaces the obligation (Moment 1, with the demo-mode reminder/ship-by timers running live), the seller provides tracking through the console's `react-hook-form` dialog (Moment 3), and the obligation auto-confirms to `Fulfilled` / "Completed" (Moment 4) within the demo `AutoConfirmWindow`. Narrative 007's escalation-recovery property is exercised incidentally — under demo timers the obligation may escalate before tracking is provided, and the "Provide Tracking" affordance survives the `Escalated` state, so the flow still completes. The lifecycle running live depends on the AppHost enabling `Obligations__DemoMode=true` (M9-S8). The aspirational `Carrier` field remains deferred (frozen `TrackingInfoProvided` contract carries `TrackingNumber` only). diff --git a/docs/prompts/implementations/M9-S8-end-to-end-housekeeping.md b/docs/prompts/implementations/M9-S8-end-to-end-housekeeping.md new file mode 100644 index 0000000..4669829 --- /dev/null +++ b/docs/prompts/implementations/M9-S8-end-to-end-housekeeping.md @@ -0,0 +1,104 @@ +# M9-S8: End-to-End + Housekeeping (M9 close) + +**Milestone:** M9 ([Seller Console](../../milestones/M9-seller-console.md)) — slice plan §7 (the milestone close; labelled "M9-S7" in the doc, renumbered to **S8** because the race fix took S7) +**Slice:** S8 of M9 — the final slice; lands on a clean `main` after the M9-S7 cross-queue race fix (PR #112, `78443c1`) +**Narrative:** `docs/narratives/006-seller-fulfills-post-sale-obligation.md` (the obligation-fulfillment spine the seller e2e dramatizes; Moments 1–4, with narrative 007's escalation-recovery as the adjacent UX). The housekeeping half anchors to the milestone doc, not a narrative. +**Agent:** Claude Code +**Estimated scope:** one PR; a seller-perspective Playwright e2e in `client/e2e/` + a CI-coverage verification + doc refreshes (`bounded-contexts.md`, `STATUS.md`, milestone doc) + a pre-M10 skills audit + the M9-S8 slice retro **and** the M9 milestone retrospective. **No backend domain change. No `CritterBids.Contracts` change.** + +--- + +## Baseline this slice inherits + +- All functional M9 slices are merged: S1 (`client/shared/` extraction + seller scaffold), S2/S3 (seller listing + query endpoints + the Listings `ExtendedBiddingTriggered` handler + cache-bridge evaluation), S4a/S4b (registration + listing management), S5 (live auction observation), S6 (obligation fulfillment), and S7 (the `CatalogListingView` cross-queue create-race fix — `Insert`-on-create + `DocumentAlreadyExistsException` retry). +- **Backend: 328 tests green** at the M9-S7 close (full `dotnet test CritterBids.slnx`); 0 errors / 0 warnings net-new (the `NU1903` MessagePack advisory in `CritterBids.AppHost` is a pre-existing baseline, not net-new). +- **Frontend:** four-app `client/` workspace (`bidder`, `ops`, `seller`, `shared`) plus the `e2e` member. Seller SPA at **117 Vitest tests** (M9-S6 close); bidder 25, ops 47. The seller dev server is the fourth Aspire child (`:5175`, base `/seller/`). +- **CI is already at full frontend coverage.** PR #111 (`7ba744f`) restructured the `frontend` job into a matrix that *already* builds+tests `bidder`/`ops`/`seller` and type-checks `shared`/`e2e`. **This slice does not add CI coverage — it verifies the matrix is correct and leaves it alone unless the seller e2e changes the `e2e` member's needs.** (Read the lived `ci.yml` before assuming the handoff's "extend CI" item is open work — it is not.) +- **`CLAUDE.md` §Frontend is already current** (three SPAs + `shared` + `e2e`, member counts correct — refreshed in earlier M9 slices). The stale doc surfaces are `docs/vision/bounded-contexts.md` (still describes *two* SPAs) and `docs/STATUS.md` (frozen at M8 close, v0.6). +- The M8-S7 bid-war e2e (`client/e2e/tests/bid-war.spec.ts`) is the harness this slice extends — its conventions (live Aspire stack, hub-asserted-before-traffic, per-run unique seed title, assert-through-the-UI, system-browser fallback) are the precedent. +- **The full obligation-lifecycle e2e was explicitly deferred to this slice** by the M9-S6 retro: "seller publishes → operator starts session → bidder bids → settlement → obligation → seller provides tracking → fulfilled." + +## Goal + +Close M9. Ship the milestone's last owed artifact — a **seller-perspective Playwright e2e** that drives the seller console through the obligation-fulfillment spine against the live Aspire stack (a seller's listing reaches sold, the seller console provides tracking through its own UI, and the obligation reaches `Fulfilled`) — verify the CI `frontend` matrix already covers the seller and shared members, refresh the documentation surface that still describes CritterBids as a two-SPA M8 system (`bounded-contexts.md`, `STATUS.md`) and correct the milestone doc's own slice table for the S7→S8 renumber, run the pre-M10 skills audit, and write both the M9-S8 slice retro and the M9 milestone retrospective with the exit criteria checked off honestly. + +## Context to load + +| File | Purpose | +|---|---| +| `docs/milestones/M9-seller-console.md` | **Authoritative for scope.** §1 exit criteria (the checklist this slice closes), §7 slice table (the row to renumber S7→S8 and the close-slice scope), §3 non-goals (operator session management is *not* a seller-console surface). | +| `docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md` | The deferred full-lifecycle e2e (§"What remains"), the seller obligation UI surfaces the e2e asserts (`/obligations`, provide-tracking dialog, "Completed" terminal), and the `BidderGroupNotification` obligation push channel. | +| `docs/retrospectives/M9-S7-listings-cross-queue-race-fix-retrospective.md` + `docs/retrospectives/M8-retrospective.md` | The just-merged backend state (`Insert`-on-create + retry); and the **shape** for the M9 milestone retro (exit-criteria walk, session-by-session table, test-count table, key-learnings, deferred ledger). | +| `.claude/skills/frontend-slice-discipline/SKILL.md` + `.claude/skills/signalr/SKILL.md` | Working rules (live-host verification, browser-fallback playbook, assert-through-UI) — and the two are the **subjects** of the pre-M10 skills audit. | +| `client/e2e/` (`tests/bid-war.spec.ts`, `playwright.config.ts`, `README.md`) | The harness to extend: conventions, the seed-then-drive pattern, the run story to amend. | +| `src/CritterBids.Api/Dev/DemoSeedEndpoint.cs` | The dev-only seed (`POST /api/dev/seed-flash`) the e2e leans on — it drives a server-minted **registered seller** through publish→attach→start to `Open` and returns `sellerId`/`listingId`/`sessionId`. The identity-bridge open question turns on it. | +| `docs/vision/bounded-contexts.md` (§ frontend/SPA, ~line 206) + `docs/STATUS.md` + `CLAUDE.md` §Frontend | The refresh targets — read for what is stale, not for scope. (CLAUDE.md is already current; verify rather than rewrite.) | + +## In scope + +**The e2e (the slice's headline):** + +1. **A seller-perspective test in `client/e2e/`.** Extend the harness with one new spec that exercises the obligation-fulfillment spine *from the seller console's vantage* (`:5175`, base `/seller/`): a seller's listing reaches **sold**, the seller console surfaces the resulting obligation, the seller **provides tracking through the console UI** (the `react-hook-form` dialog from M9-S6), and the obligation reaches the **`Fulfilled` ("Completed")** terminal — asserted through what the seller *sees*, never into hub internals (ADR 026 applied to assertions). Carry the bid-war conventions verbatim: assert the seller console's `BiddingHub` connection before any traffic, seed with a per-run unique title, tear down after. +2. **Reach "sold" with the existing seed + a single winning bid.** The listing reaching `Open` is the existing `POST /api/dev/seed-flash` pipeline; the winning bid is a supporting action (place it however reads cleanest and stays timing-stable — the bidder is not the subject under test). Use a short Flash duration so the close + settlement + obligation chain completes inside the Playwright timeout. +3. **A documented run story.** Amend `client/e2e/README.md` so a contributor can run the new seller e2e from a clean checkout: prerequisites (the seller dev server must be up at `:5175`), invocation, and the expected wall-clock duration. The seed/identity-bridge technique the test uses is recorded there or in the retro. + +**CI (verify, do not extend):** + +4. **Confirm the `frontend` matrix already covers seller + shared.** Read `ci.yml`; confirm `seller` is `build-test`, `shared` is `typecheck`, `e2e` is `typecheck`, and the `CI` aggregator contract is unchanged. Record in the retro that the coverage was already complete (PR #111) — **no matrix edit** unless the new e2e spec breaks the `e2e` type-check, in which case the fix is to keep the member type-clean, not to add a Playwright-in-CI run (M8-D2 stands; the e2e is local-only). + +**Housekeeping (doc refresh):** + +5. **`docs/vision/bounded-contexts.md`** — the frontend/SPA framing (~line 206) still says two SPAs (`bidder`, `ops`). Update it to the lived three-SPA + `shared` + `e2e` workspace (seller console = the seller's window on the same engine; `client/shared/` = the frontend analogue of `CritterBids.Contracts`). +6. **`docs/STATUS.md`** — regenerate to the M9-close posture per the file's own regeneration rule: milestone ladder (M9 → ✅ Complete), the M9 slice ledger, test counts (328 backend / seller+bidder+ops Vitest / Playwright e2e count), the deferred/risk ledgers re-derived from the M9 retros, and the "As of" line bumped to this PR's commit. +7. **`docs/milestones/M9-seller-console.md`** — flip status to ✅ Complete; **correct §7's slice table** so S7 is the cross-queue race fix and S8 is this close (the table currently shows the close as S7); tick the §1 exit-criteria checkboxes with honest annotations (e.g. the "307 backend tests" baseline is now 328; CI coverage landed earlier than this slice; `client/shared/` extracted at S1). +8. **`CLAUDE.md` §Frontend** — verify it is current (three SPAs, `shared`, `e2e`, member counts). Edit only what is actually stale; record "verified current" in the retro if no edit is needed. + +**Pre-M10 skills audit (per the `m9-skills-review` carry-forward):** + +9. **Audit the frontend skill surface before M10 scoping** and record the findings (in the M9 retro or a short note the retro points at): does `.claude/skills/frontend-slice-discipline` need M9 lessons (seller as a third consumer; the seed-then-inject e2e identity bridge)? Is `.claude/skills/signalr` bloated or does it need the seller `BidderGroupNotification` obligation-channel rule? Are the M9 patterns worth capturing — the `FakeHubConnection` triple-copy → `@critterbids/shared` test-util extraction candidate, the actor-form-identity-from-context pattern, the shared-schema extraction the seller revealed? Each finding lands as either an applied skill edit **in this PR** or a recorded carry-forward to M10; do not author a brand-new skill file speculatively (that is a milestone-level decision). + +**Close:** the **M9-S8 slice retro** (per `docs/retrospectives/README.md`) **and** the **M9 milestone retrospective** (the ladder's final deliverable; M8-retro shape) — the latter walking the milestone against its §1 exit criteria, recording the per-slice ledger, the test-count growth (M8's 307 → M9's 328), the key cross-slice learnings, the new ADRs/decisions if any, and what M9 defers outward to M10. + +## Explicitly out of scope + +- **Any backend domain change.** M9's sanctioned backend exceptions (S2/S3 endpoints, the S3/S7 housekeeping) are spent. If the e2e exposes a domain defect, stop and escalate — do not fix it inside this slice. (A *dev-only* harness affordance for the operator session-start bridge is the one possible exception — see Open question 1 — and only if `Insert`-on-`DemoSeedEndpoint`-style seed-then-inject proves insufficient; it is recorded and sanctioned, never a silent `.cs` touch, and adds no domain event/contract.) +- **Operator session management in the seller console.** `CreateSession`/`AttachListingToSession`/`StartSession` are staff/`StaffOnly` commands (milestone §3 non-goal). The e2e reaches `Open` via the dev seed or a dev bridge — never by surfacing operator controls in the seller SPA. +- **Playwright-in-CI infrastructure** beyond the recorded verification (item 4) — M8-D2 stands; no bespoke Aspire-in-Actions plumbing. +- **New seller SPA surfaces or journeys.** No settlement-summary view, no dispute UI, no Timed-listing path — all standing M9 non-goals. The e2e renders the already-shipped surfaces. +- **`CritterBids.Contracts` changes**, new domain events, new saga transitions, new BC modules. +- **Authoring a new skill file from scratch** — the audit applies edits or records carry-forwards; a net-new skill is an M10-scoping decision. + +## Conventions to pin or follow + +- The seller e2e follows the M8-S7 harness conventions promoted to automation: hub connection asserted before any traffic, per-run unique seed title, assert through the rendered re-query result (a push is a signal, never authoritative data — ADR 026), full teardown. +- E2e dependencies live only in the `client/e2e/` workspace member; neither app's production build or type-check sees them (ADR 025 dependency hygiene). +- Frontend slice discipline (`.claude/skills/frontend-slice-discipline`): read the lived backend (the seed pipeline, the obligation read model, the seller session/registration code) before writing the test; render/assert the lived subset; close with the live smoke the e2e *is*. +- Retro discipline (`docs/retrospectives/README.md`): item codes mirror this prompt; verbatim error messages for any failure; negative assertions where they prove the work; the mandatory `## Spec delta — landed?` paragraph. + +## Spec delta + +- `docs/narratives/006-seller-fulfills-post-sale-obligation.md` gains a Document History row: the obligation-fulfillment spine (Moments 1–4) is now validated end-to-end by an automated seller-perspective Playwright test, not only by the M9-S6 component tests + HTTP smoke. +- `docs/milestones/M9-seller-console.md` §1 exit criteria are checked off and the milestone closes; its §7 slice table is corrected for the S7 (race fix) / S8 (close) renumber; the **M9 milestone retrospective** becomes the canonical record of what M9 shipped and what it defers to M10. +- No ADR and no canonical-spec (workshop/contract/event) amendment: this slice renders and verifies existing surfaces. Any skill-file edit from the audit is recorded in the retro, not a spec delta. + +## Acceptance criteria + +- [ ] A seller-perspective Playwright spec exists in `client/e2e/` and passes locally against the Aspire-orchestrated stack: a seeded listing reaches sold, the seller console surfaces the obligation, tracking is provided through the console's `react-hook-form` dialog, and the obligation reaches the `Fulfilled` / "Completed" terminal — the run recorded in the retro. +- [ ] The spec asserts the seller console's `BiddingHub` connection before the first action and seeds with a per-run unique title (both greppable in the test source). +- [ ] `client/e2e/README.md` documents the new seller e2e's prerequisites, invocation, and expected duration; the seed/identity-bridge technique is written down (README or retro). +- [ ] The CI `frontend` matrix is confirmed to already cover `seller` (build-test) and `shared` (typecheck); the retro records that coverage predates this slice (PR #111); the `e2e` member still type-checks clean; the `CI` aggregator contract is unchanged. +- [ ] `docs/vision/bounded-contexts.md` describes the lived three-SPA + `shared` + `e2e` frontend (no longer two SPAs). +- [ ] `docs/STATUS.md` regenerated to the M9-close posture (M9 ✅ Complete, slice ledger, 328 backend test count, deferred/risk ledgers re-derived, "As of" bumped). +- [ ] `docs/milestones/M9-seller-console.md` status is ✅ Complete, §7's slice table reflects S7=race fix / S8=close, and §1 exit-criteria boxes are ticked with honest annotations. +- [ ] `CLAUDE.md` §Frontend verified current (edited only if stale; "verified" noted in the retro otherwise). +- [ ] The pre-M10 skills audit is recorded, each finding either applied as a skill edit in this PR or carried forward to M10 with rationale. +- [ ] `dotnet build` + `dotnet test CritterBids.slnx` green (328+, untouched); all four SPA Vitest suites green; all SPA builds exit 0; the `e2e` member type-checks clean and does not enter any app's production type-check. +- [ ] `docs/retrospectives/M9-S8-end-to-end-housekeeping-retrospective.md` authored **and** the M9 milestone retrospective authored. +- [ ] Branched off `main`; one PR; no commit to `main`; no `Co-Authored-By` trailer. + +## Open questions + +1. **The e2e identity bridge (the central design question — flag, do not guess past the lean).** The seller console mints its own anonymous `ParticipantId`, but the operator `create-session/attach/start` step that opens a listing is staff/bus-only. **Lean: seed-then-inject** — `POST /api/dev/seed-flash` already creates a *registered seller* and drives the listing to `Open`, returning that `sellerId`; inject it into the seller console's session (`sessionStorage`) so the console adopts the seeded seller identity and renders that seller's listing + obligation. This needs **no backend change**. If the console's registered-seller gate does not recognize an externally-registered participant (read the seller session/registration code to confirm), the fallbacks, in order: (a) drive the console's own one-click register-seller for the injected id if it is idempotent; (b) escalate a *dev-only* harness affordance (an operator-bridge seed that starts a session for a console-created listing) — sanctioned and recorded per frontend-slice-discipline Rule 2, adding no domain capability. Resolve by reading the lived code; record the choice in the retro. +2. **How much of the live-observation beat (narrative 005) to assert from the seller's vantage.** The seller seeing the bid arrive / gavel fall on their own listing enriches the test but adds timing surface already covered by the bid-war e2e. Lean: make the obligation spine the criterion; include the seller-side live-observation assertions only if timing-stable, else record them as covered-by-bid-war. Do not ship a flaky test to tick a box (the M8-S7 extended-bidding-beat precedent). +3. **Backend defects surfaced by the e2e** are escalations, not fixes — M9 has no remaining sanctioned domain exception. +4. **Skills-audit scope creep.** If a finding implies a net-new skill file or a cross-cutting restructure, record it as an M10-scoping carry-forward rather than authoring it here (Rule 4 / item 9). diff --git a/docs/retrospectives/M9-S8-end-to-end-housekeeping-retrospective.md b/docs/retrospectives/M9-S8-end-to-end-housekeeping-retrospective.md new file mode 100644 index 0000000..69adadf --- /dev/null +++ b/docs/retrospectives/M9-S8-end-to-end-housekeeping-retrospective.md @@ -0,0 +1,246 @@ +# M9-S8: End-to-End + Housekeeping (M9 close) - Retrospective + +**Date:** 2026-06-17 +**Milestone:** M9 - Seller Console +**Slice:** S8 - end-to-end + housekeeping (the milestone close) +**Agent:** Claude Code +**Prompt:** `docs/prompts/implementations/M9-S8-end-to-end-housekeeping.md` + +## Baseline + +- Clean `main` at `78443c1` (M9-S7 cross-queue race fix merged, PR #112). +- .NET build: 0 errors; warnings are the held baseline (`NU1903` MessagePack advisory + two + `CS0108` saga-`Version` hides). Backend tests: **328** green. +- Frontend: five-member `client/` workspace (`shared`, `bidder`, `ops`, `seller`, `e2e`). Vitest: + seller 117, ops 47, bidder 25 (189 total). Playwright e2e: 1 (M8 bid-war). +- **CI `frontend` matrix already covered `seller` (build-test) + `shared`/`e2e` (type-check)** — + coverage landed at M9-S1 (#104), restructured into the current matrix at PR #111. The handoff's + "extend CI to seller+shared" was already-done work. +- `CLAUDE.md` §Frontend already current (three SPAs + `shared` + `e2e`); `bounded-contexts.md` + (still "two SPAs") and `STATUS.md` (v0.6, M8 close) were the stale doc surfaces. + +## Items completed + +| Item | Description | +|------|-------------| +| S8-1 | Seller-perspective Playwright e2e — `client/e2e/tests/seller-obligation.spec.ts` | +| S8-2 | Seed-then-inject identity bridge + Buy-It-Now deterministic sale (OQ-1 resolved) | +| S8-3 | `Obligations__DemoMode=true` in the AppHost (sanctioned dev-host config) | +| S8-4 | `client/e2e/README.md` — seller-obligation run story + the two backend facts it relies on | +| S8-5 | CI `frontend` coverage verified (already complete; no matrix edit) | +| S8-6 | Doc refresh — `bounded-contexts.md`, `STATUS.md` (v0.7), milestone doc (status + §7 renumber + §1 ticks); `CLAUDE.md` verified current | +| S8-7 | Pre-M10 skills audit (`frontend-slice-discipline` edited; three carry-forwards recorded) | +| S8-8 | M9-S8 slice retro (this file) + the M9 milestone retrospective | + +## S8-1 / S8-2: The seller-obligation e2e and its identity bridge + +**The test.** One spec drives the obligation-fulfillment spine (narrative 006) from the *seller's* +vantage against the live Aspire stack: a seeded listing sells, the seller console surfaces the +post-sale obligation, the seller provides tracking through the console's `react-hook-form` dialog, +and the obligation auto-confirms to "Completed". Assertions go through what the seller *sees* (the +re-queried `ObligationStatusView`), with read-model reads only as readiness gates — the same +assert-through-the-UI discipline the bid-war test established. + +**Why seed-then-inject (OQ-1 resolved).** The seller console mints its own anonymous session, and +the operator `create-session/attach/start` step that opens a listing is staff/bus-only — so the +console cannot both publish *and* open a listing on its own (milestone §3 keeps operator session +management out of the seller console). Reading the lived code resolved this cleanly: + +- `POST /api/dev/seed-flash` already drives a server-minted **registered seller** through + publish→attach→start to `Open`, returning that `sellerId`. +- The seller console's `SessionProvider` keys off `sessionStorage["critterbids.seller.participantId"]` + + `["critterbids.seller.isRegisteredSeller"]` and boots straight to an *established*, + *registered-seller* session when both are pre-set (no session POST, no register call). +- So the test injects the seeded `sellerId` into those two keys via `context.addInitScript` before + any app script runs; the console adopts the seeded identity and renders that seller's obligations + via `GET /api/obligations/status?sellerId=`. **No backend change.** + +**Why Buy-It-Now for the sale.** The sale is forced with the dev `POST /api/dev/buy-now` trigger (a +single deterministic purchase at the seed's $100 BIN) rather than riding a wall-clock auction close +— the auction mechanics are already the bid-war test's job; this test's subject is the obligation +lifecycle. This keeps the test under a minute instead of riding a 2-minute Flash auction. + +**Why a read-model readiness gate, not a push wait.** The obligation's first appearance and its +`Fulfilled` flip are gated on a poll of `GET /api/obligations/status?sellerId=` (then a reload), +not on the SignalR push. The M8-S7 burst-final race (a push-triggered re-query can lose to a +sibling-queue projection, and the *last* event of a burst has no later push to self-heal) makes the +push unreliable precisely for the terminal event — so the test reloads after the read model +confirms the state, asserting the rendered result through the UI. + +## S8-3: `Obligations__DemoMode` — sanctioned dev-host config + +**The finding (lived-surface, Rule 1).** The seller-obligation flow's back half (provide tracking → +`Fulfilled`) depends on the post-sale auto-confirm timer firing. Reading the lived host config: +`ObligationsOptions.DemoMode` defaults to **false** (production durations — ship-by 5 days, +auto-confirm 3 days *after* tracking), and **nothing** in the dev/Aspire run enabled it +(`Program.cs` binds the section and never flips it under `IsDevelopment()`; there is no +`appsettings.json`; `DemoMode=true` existed only in test fixtures). The bid-war e2e never touched +obligations, so no prior test surfaced this. The conference-demo posture the `ObligationsOptions` +docstring describes ("the full lifecycle must run live within a demo session (seconds)") was, in +practice, un-reachable on the live stack. + +**Resolution (user-sanctioned).** Add `.WithEnvironment("Obligations__DemoMode", "true")` to the +API project in the AppHost — scoped to the orchestrated demo run, mirroring the existing +`ASPNETCORE_ENVIRONMENT` injection. Demo durations are reminder +5s, ship-by +10s, auto-confirm ++10s, so the full lifecycle completes in ~15–20s. Production binds `DemoMode=false` by default (no +appsettings override). This was escalated as a scope-expanding decision (it touches host config +beyond the "no backend change" envelope) and confirmed before proceeding; it also closes a latent +demo gap — the obligation lifecycle was previously un-demoable live. + +**A consequence the test asserts around.** With demo timers, the obligation can **escalate** +(Overdue) before the test provides tracking. The "Provide Tracking" affordance survives escalation +(narrative 007's recovery door — `ACTIONABLE_STATUSES` includes `Escalated`), so the test gates on +the affordance, not on a not-yet-escalated status. + +## S8-1: the live run, and the one fix + +Two consecutive green runs against the live Aspire stack (17.6s, then 13.3s) after a single fix. +The first run reached the terminal correctly — the `"Completed."` assertion passed, proving the +full lifecycle ran live including the demo-mode auto-confirm — but the *next* line, a redundant +status-badge assertion, hit a Playwright strict-mode violation: + +``` +Error: strict mode violation: getByText('Fulfilled') resolved to 2 elements: + 1) Fulfilled + 2)

Fulfilled Jun 17, 2026, 8:34 AM

+``` + +The Fulfilled card renders both a "Fulfilled" status badge and a "Fulfilled {timestamp}" line; +`getByText("Fulfilled", { exact: true })` pins the badge. The *product* behaved correctly through +every beat — the only defect was test-assertion specificity (the live tier finding a test bug, not +a product bug). + +## S8-5 / S8-6: CI verify + doc refresh + +- **CI:** verified the `frontend` matrix covers `seller` (build-test), `shared` (typecheck), and + `e2e` (typecheck); the `CI` aggregator contract is unchanged. **No matrix edit** — coverage has + existed since M9-S1, restructured at PR #111. The new e2e spec type-checks clean and stays + execution-excluded in CI (M8-D2 holds). +- **`bounded-contexts.md`:** the frontend framing now describes three SPAs + `shared` + `e2e` + (was two SPAs). +- **`STATUS.md`:** regenerated to v0.7 (M9 ✅ Complete; final M9 slice ledger; 328/189/2 numbers; + deferred ledger re-derived — closed the `client/shared/` and `ExtendedBiddingTriggered` items, + added the OQ-3 update-update race / Operations audit / `FakeHubConnection` extraction / seller + settlement-summary UI; recorded the DemoMode + seed-then-inject decisions). +- **Milestone doc:** status → ✅ Complete; §7 slice table corrected (S7 = race fix, **S8** = close); + §1 exit-criteria boxes ticked with honest annotations (the 307→328 test growth; CI coverage + landed earlier than this slice; `shared` is the fifth member, not the "fourth" ADR 025 counted). +- **`CLAUDE.md` §Frontend:** verified current (three SPAs + `shared` + `e2e`, member counts correct + against `client/package.json`); **no edit needed**. + +## S8-7: Pre-M10 skills audit + +Per the `m9-skills-review` carry-forward. Audited `.claude/skills/frontend-slice-discipline`, +`.claude/skills/signalr`, and the relevant global skills (react-hook-form, e2e-testing/playwright). + +| Finding | Disposition | +|---|---| +| `frontend-slice-discipline` "When to apply" named only `bidder`/`ops`; M9 added `seller` + the `e2e` harness | **Applied in-PR** — extended to all SPAs + the e2e harness | +| Rule 1 ("read the lived backend") had no case for *host runtime config* being part of the lived surface | **Applied in-PR** — added the M9-S8 `DemoMode` lesson (verify the host's timer config before a live smoke that rides a timer) | +| `signalr` skill is scoped to "bidder and ops"; M9 added the seller as a third consumer, the shared `createSignalRProvider()` factory (M9-S1) that reshaped the provider the skill describes, and the `BidderGroupNotification` obligation-push channel (M9-S6) | **Carry-forward (M10)** — a focused signalr-skill refresh; folding the factory + seller is a substantive rewrite, out of scope for a close slice (prompt OQ-4) | +| `FakeHubConnection` has three copies across the SPAs | **Carry-forward (M10)** — extract to `@critterbids/shared` test utilities | +| The actor-form-identity-from-context pattern (M9-S6) recurs across the seller's three `react-hook-form` forms | **Carry-forward** — candidate for a CritterBids-local seller-form note if M10 grows seller forms; not a global-skill gap | + +No net-new skill file authored (an M10-scoping decision per OQ-4). + +## Test results + +| Phase | Suite | Result | +|-------|-------|--------| +| e2e — first live run | `seller-obligation.spec.ts` | flow green to "Completed"; failed on redundant `Fulfilled` badge selector (strict-mode) | +| e2e — after `{ exact: true }` fix | `seller-obligation.spec.ts` | **pass (17.6s)** | +| e2e — second consecutive run | `seller-obligation.spec.ts` | **pass (13.3s)** | +| e2e member type-check | `@critterbids/e2e` | **clean** (`tsc --noEmit`) | +| Backend regression | full `dotnet test CritterBids.slnx` | **328/328** (unchanged — no domain code touched) | + +## Build state at session close + +- `.cs` files changed: **0 domain** — only `src/CritterBids.AppHost/Program.cs` (one + `.WithEnvironment` line, dev-host config). +- `CritterBids.Contracts` changes: **0**. +- New e2e specs: **1** (`seller-obligation.spec.ts`); Playwright e2e total 1 → **2**. +- Errors: **0**; warnings: held baseline (no net-new). +- Doc/skill files changed: `bounded-contexts.md`, `STATUS.md`, `M9-seller-console.md`, + `frontend-slice-discipline/SKILL.md`, `client/e2e/README.md`, the M9-S8 prompt, this retro, the + M9 milestone retro. + +## Key learnings + +1. **Host runtime config is part of the lived surface.** The most consequential finding this slice + was not in any `src/` type — it was that `Obligations:DemoMode` was off in the dev host, making + the demo posture's "seconds" lifecycle actually "days". A live smoke that rides a timer must + first confirm the host is configured for that timing. (Folded into `frontend-slice-discipline`.) +2. **Seed-then-inject is the clean bridge for a console whose identity is its own session.** When a + journey needs an actor whose listing was opened by a staff-only step, reuse the dev seed's + server-minted identity and inject it into the console's session storage — no backend change, no + leaking operator controls into the wrong SPA. +3. **Gate terminal-event assertions on the read model, then reload — don't trust the burst-final + push.** The M8-S7 burst-final race is exactly the case where the last event's push can be lost; + an e2e that asserts a terminal state should read the model for readiness and reload, keeping the + assertion on the rendered UI. +4. **The live tier finds test bugs too.** Every prior milestone's live smoke caught a *product* + defect; here the product was correct end-to-end and the live run caught an over-broad test + selector. The tier still earned its place — a green build + type-check shipped an ambiguous + `getByText` that only a real DOM exposed. +5. **"Verify, don't assume" applies to the handoff itself.** Two of the five handoff items (CI + extension, `CLAUDE.md` refresh) were already done; reading `ci.yml` and `client/package.json` + first turned them into verifications, not rework. + +## Findings against narrative + +**Narrative 006** (`docs/narratives/006-seller-fulfills-post-sale-obligation.md`): the +obligation-fulfillment spine (Moments 1–4) is now validated end-to-end by an automated +seller-perspective Playwright test, complementing the M9-S6 component tests + HTTP smoke. The test +implements the narrative as drafted — no drift. **Narrative 007**'s escalation-recovery property +(the "Provide Tracking" affordance survives `Escalated`) is exercised incidentally: under demo +timers the obligation may escalate before tracking is provided, and the test still completes +through the recovery door. Routed `narrative-update`: a Document History row noting the e2e +coverage (added in this PR). + +## Spec delta - landed? + +The prompt declared: narrative 006 gains a Document History row for the automated seller e2e; the +milestone doc §1 exit criteria are ticked and §7 renumbered (S7 race fix / S8 close); the M9 +milestone retrospective becomes the canonical record; no ADR or canonical-spec amendment. +**Landed as written.** Narrative 006's Document History gained the e2e-coverage row; the milestone +doc closed with the renumber and honest annotations; the M9 milestone retro is authored. The one +divergence from the prompt's *expectation* (not its declared delta): the prompt anticipated "no +backend change", and this slice took one **sanctioned dev-host config** change +(`Obligations__DemoMode=true`) — escalated and user-confirmed mid-session, recorded here and in +STATUS §3 (Decided, not deferred). No domain event, contract, or ADR changed. + +## Verification checklist + +- [x] A seller-perspective Playwright spec exists in `client/e2e/` and passes locally against the + live Aspire stack (seeded listing sells → console surfaces the obligation → tracking provided + through the dialog → `Fulfilled`/"Completed"); two consecutive green runs recorded +- [x] The spec asserts the seller console's `BiddingHub` connection before any obligation action + and seeds with a per-run unique title (both greppable in the source) +- [x] `client/e2e/README.md` documents the seller e2e's prerequisites, invocation, duration, and + the two backend facts (DemoMode, seed-then-inject) it relies on +- [x] CI `frontend` matrix confirmed to already cover `seller` (build-test) + `shared` (typecheck); + retro records coverage predates this slice; `e2e` type-checks clean; aggregator unchanged +- [x] `docs/vision/bounded-contexts.md` describes three SPAs + `shared` + `e2e` +- [x] `docs/STATUS.md` regenerated to the M9-close posture (v0.7) +- [x] `docs/milestones/M9-seller-console.md` status ✅ Complete; §7 reflects S7=race fix / S8=close; + §1 boxes ticked with annotations +- [x] `CLAUDE.md` §Frontend verified current (no edit needed; "verified" recorded) +- [x] Pre-M10 skills audit recorded; two edits applied in-PR, three carry-forwards to M10 +- [x] `dotnet test CritterBids.slnx` green (328, untouched); e2e member type-checks clean; e2e not + in any app's production type-check +- [x] M9-S8 slice retro authored **and** the M9 milestone retrospective authored +- [x] Branched off `main`; one PR; no commit to `main`; no `Co-Authored-By` trailer + +## What remains / next session should verify + +- **M10 scoping session** — no `docs/milestones/M10-*.md` exists; the precondition gate applies. + Inputs: the M9 milestone retro's "what M9 defers outward", the STATUS §3 deferred ledger, and + this slice's skills-audit carry-forwards. +- **signalr-skill refresh** (carry-forward) — fold in the seller consumer, the shared + `createSignalRProvider` factory, and the `BidderGroupNotification` obligation channel. +- **`FakeHubConnection` shared extraction** (carry-forward) — three copies → `@critterbids/shared`. +- **Seller settlement-summary UI** — the `GET /api/settlement/summaries?sellerId=` endpoint shipped + at M9-S3b; no seller-console surface renders it yet. +- **Cache-bridge burst-final hardening** — still deferred (the seller e2e sidesteps it with a + readiness-gate + reload); bake the delayed re-invalidate into the shared cache bridge if/when the + cache-bridge surface is extracted. diff --git a/docs/retrospectives/M9-retrospective.md b/docs/retrospectives/M9-retrospective.md new file mode 100644 index 0000000..6c7c554 --- /dev/null +++ b/docs/retrospectives/M9-retrospective.md @@ -0,0 +1,202 @@ +# M9 — Seller Console — Milestone Retrospective + +**Date:** 2026-06-17 +**Milestone:** M9 — Seller Console +**Sessions:** S1 → S8, plus the M9-scoping session and one out-of-plan interlude (PR #111) — 10 PR-producing sessions +**Agents:** Claude Code throughout + +--- + +## Exit Criteria Status + +Walk of each criterion from `docs/milestones/M9-seller-console.md` §1 (ticked there with the same annotations): + +| Exit criterion | Status | +|---|---| +| Seller SPA at `client/seller/` rendering the seller journeys (listing management, live auction observation, obligation fulfillment) | ✅ S1 scaffold → S4a/S4b (narrative 004) → S5 (narrative 005) → S6 (narratives 006/007) | +| `client/shared/` extracted, consumed by all three SPAs | ✅ S1 — **annotation:** ADR 025 counted it as the "fourth" member; with `e2e` already landed in M8, `shared` is the **fifth**, leaving a five-member workspace. The extracted surface is the `createSignalRProvider()` factory, shared Zod wire schemas, and the Tailwind theme | +| Seller-side HTTP surface complete (no bus-only seller commands) | ✅ S2/S3 backend precursors — all five gaps from the §2 audit closed (submit, update-draft, my-listings query, obligation-status query, settlement-summary query) | +| Live auction observation on the seller's own listings via `BiddingHub` | ✅ S5 | +| Obligation management — `ObligationStatusView` + `ProvideTracking` | ✅ S6; full lifecycle e2e-verified at S8 | +| Listings `ExtendedBiddingTriggered` handler shipped | ✅ S3 — `CatalogListingView.ScheduledCloseAt` advances on extension (the M8-S7 carry-forward) | +| Cache-bridge burst-final hardening evaluated | ✅ S3 — evaluated, **deferred** with rationale (the fix belongs in the shared cache-bridge surface, which stayed app-local) | +| Clean-checkout `npm install` + `npm run build`, TS strict, no .NET breakage | ✅ — all five workspace members | +| .NET baseline unchanged beyond sanctioned exceptions — 0 errors / 0 net-new warnings | ✅ with the honest annotation: **328 at close** — grown from 307 by the M9-S2/S3/S7 backend slices, not broken; net-new warnings: **0** (held `NU1903` + `CS0108` baseline) | +| Playwright e2e extended with a seller-perspective test | ✅ S8 — `client/e2e/tests/seller-obligation.spec.ts`, two consecutive green runs against the live Aspire stack | +| CI `frontend` job covers the seller app | ✅ — landed at S1, restructured into the current build-test/typecheck matrix at PR #111; verified at S8 | +| `CLAUDE.md` §Frontend updated | ✅ S1, kept current across M9; verified at S8 | +| All slice retros + this milestone retrospective | ✅ — eight session retros + this document | + +All criteria honored; the annotations above are recorded divergences, not silent edits. + +--- + +## Session-by-Session Summary + +| Session | Scope | Outcome | Notable deviations | +|---|---|---|---| +| Scoping (PR #103) | M9 milestone-scoping doc (cleared the precondition gate) | ✅ | Eight-slice ceiling planned (later renumbered) | +| S1 (PR #104) | `client/shared/` extraction (factory pattern) + seller SPA scaffold (`:5175`) | ✅ | `shared` is the fifth member, not the "fourth"; `vite-env.d.ts` needed for TS6 CSS side-effect imports | +| S2 (PR #105) | **Backend precursor #1:** seller listing endpoints (submit, update-draft, my-listings query) + `SellerListingSummary` projection | ✅ | OQ-2 resolved — a Selling-side document projection for "my listings" | +| S3 (PR #106) | **Backend precursor #2:** obligation-status + settlement-summary query endpoints; Listings `ExtendedBiddingTriggered` handler; cache-bridge hardening evaluated + deferred | ✅ | Two `SettlementCompleted` handlers coexist on one sticky queue under Separated dispatch | +| S4a (PR #107) | Seller registration + my-listings dashboard | ✅ | One-click `RegisterAsSeller` from the session; 409-tolerant | +| S4b (PR #108) | Seller listing management write ops (edit-draft, submit, withdraw) | ✅ | `shouldUnregister` form bug surfaced (fixed at #111) | +| *(interlude)* (PR #111) | `react-hook-form` `shouldUnregister` fix + CI frontend-matrix restructure | ✅ | Out of the slice plan; folded the matrix into build-test/typecheck per app | +| S5 (PR #109) | Seller live auction observation (`BiddingHub`) | ✅ | Reserve-crossing indicator from client-side draft fields | +| S6 (PR #110) | Seller obligation fulfillment (status view + provide-tracking form) | ✅ | `BidderGroupNotification` is the seller's obligation-push channel; seller Vitest → 117 | +| S7 (PR #112) | `CatalogListingView` cross-queue create race fix | ✅ | OQ-1 live spike **refuted** the planned `UseNumericRevisions`; fix is `Insert`-on-create + `DocumentAlreadyExistsException` retry. Took the "S7" number, renumbering the close to S8 | +| S8 (this PR) | Seller-obligation Playwright e2e + doc refresh + skills audit + retros | ✅ | Found `Obligations:DemoMode` off in the dev host → sanctioned `Obligations__DemoMode=true` AppHost config; seed-then-inject identity bridge | + +The milestone ran eight slices plus the scoping session and the #111 interlude — the close +renumbered from the planned "S7" to S8 after the race fix took S7. + +--- + +## Test Count at M9 Close + +| Project | Count | Δ from M8 close | M9 contributions | +|---|---|---|---| +| `CritterBids.Auctions.Tests` | 77 | — | — | +| `CritterBids.Api.Tests` | 46 | — | — | +| `CritterBids.Selling.Tests` | 45 | +9 | S2 submit/update-draft endpoints + `SellerListingSummary` | +| `CritterBids.Relay.Tests` | 45 | — | — | +| `CritterBids.Operations.Tests` | 38 | — | — | +| `CritterBids.Settlement.Tests` | 30 | +5 | S3 `SellerSettlementSummary` projection + query | +| `CritterBids.Listings.Tests` | 24 | +4 | S3 `ExtendedBiddingTriggered` handler (+2); S7 race fix (+2) | +| `CritterBids.Obligations.Tests` | 16 | +3 | S3 seller obligation-status query endpoint | +| `CritterBids.Participants.Tests` | 6 | — | — | +| `CritterBids.Contracts.Tests` | 1 | — | — | +| **Backend total** | **328** | **+21** | all from the M9-S2/S3/S7 backend slices | +| **Frontend (Vitest)** | **189** | +117 | seller 117 (S1→S6), ops 47, bidder 25 — the seller app is M9's frontend contribution | +| **E2e (Playwright)** | **2** | +1 | S8 seller-obligation (local-only by recorded decision), joining the M8 bid-war | + +--- + +## Key Decisions Made in M9 + +| Identifier | Decision | +|---|---| +| M9-D1 (S1) | **`client/shared/` extraction shape.** A `createSignalRProvider()` factory (per-app Context + typed hooks via configuration props), shared Zod wire schemas, and the Tailwind theme CSS. Message parsers and cache-bridge implementations stay app-local — they differ in substance, not configuration. Realizes ADR 025's planned member; operates under ADR 026 unchanged. | +| M9-D2 (S2) | **"My listings" query shape** (OQ-2): a lightweight Selling-side `SellerListingSummary` document projection, not a query over the aggregate stream. | +| M9-D3 (S7) | **`CatalogListingView` cross-queue race fix mechanism** (OQ-1): `Insert`-on-create across all twelve write methods + a `DocumentAlreadyExistsException` retry policy. A live spike **refuted** the planned `UseNumericRevisions` (a plain `Store` under numeric revisions still last-writer-wins, no exception) — numeric revisions only enforce on a revision-checked write path, which a read-model handler's `Store` does not have. | +| M9-D4 (S8) | **`Obligations:DemoMode` enabled on the live demo host** (`Obligations__DemoMode=true` in the AppHost). The documented conference-demo posture (lifecycle in seconds, not days) was un-reachable on the live stack; enabling it makes the full post-sale lifecycle run live and the seller-obligation e2e's terminal reachable. Production binds `DemoMode=false` by default. | +| M9-D5 (S8) | **Seller-console e2e identity bridge = seed-then-inject.** The dev seed creates a registered seller + open listing; the e2e injects that `sellerId` into the console's session storage. No backend change; operator session management stays out of the seller console (milestone §3 non-goal). | + +--- + +## Key Learnings — Cross-Milestone Patterns + +### 1. The third consumer is what makes a shared extraction honest + +M8 deferred `client/shared/` precisely because two apps "duplicate the pattern, not the bytes." +The seller console — the third consumer — revealed the real shared subset at M9-S1: the SignalR +provider/hook/cache-bridge *factory*, the Zod wire schemas, and the theme. What stayed app-local +(parsers, cache bridges, group management) confirmed the boundary by *not* fitting. Extracting +against three real consumers, not two plus speculation, is what kept the boundary right. + +### 2. Backend precursors as their own slices kept "frontend milestone" true again + +Like M8, M9 was a frontend milestone with a budgeted backend pressure valve. The seller-facing +HTTP surface was bus-only at M8 close; M9-S2/S3 exposed it over HTTP as sanctioned, recorded +precursor slices *before* the frontend slices that needed it — six endpoints/projections, zero +new domain capability. The discipline (`frontend-slice-discipline` Rule 2) held: every gap took +"render the subset + carry-forward" or "escalate a sanctioned slice", never a silent `.cs` touch +inside a UI slice. + +### 3. A lean is a hypothesis; the spike is the truth + +The M9-S7 prompt's lean was `UseNumericRevisions`; a live Postgres spike refuted it (a plain +`Store` under numeric revisions still commits last-writer-wins). The fix became `Insert`-on-create ++ a PK-collision retry. The lesson generalizes the M8 "specs describe intent; only the lived +surface binds" rule to *mechanism*: when a concurrency fix's correctness depends on framework +semantics, prove the semantics against a live store before committing the design. + +### 4. The lived surface includes the host's runtime configuration + +M9-S8's decisive finding wasn't in any `src/` type — it was that `Obligations:DemoMode` was off in +the dev host, so the "demo posture" lifecycle ran in days, not seconds. A live smoke that rides a +timer has to confirm the host is configured for the timing it assumes. Encoded into +`frontend-slice-discipline` Rule 1: env-var/appsettings gaps are as real as missing endpoints. + +### 5. The verification ladder's top rung catches its own class — including test bugs + +M8 proved unit → live-smoke → e2e each catches a class the rung below cannot. M9-S8 added a +wrinkle: with the *product* correct end-to-end (the hard part — the demo-mode auto-confirm timer +firing live), the e2e's first run caught an over-broad `getByText("Fulfilled")` that a real DOM +exposed and a green build + type-check did not. The top rung still earned its place. + +### 6. Read-model concurrency is a recurring shape, not a one-off + +The M9-S7 `CatalogListingView` cross-queue race is the same multi-handler-upsert pattern the +Operations BC uses (`DisputeQueueView`, `SellerPerformanceView`). The fix (`Insert`-on-create + PK +retry) is now a template, and the Operations audit is an open carry-forward. When a race is found +in one read model fed by multiple sticky queues, assume its siblings share the shape. + +--- + +## ADR Candidate Review + +| Finding | ADR warranted? | Rationale | +|---|---|---| +| `client/shared/` extraction shape (factory) | **No** | ADR 025 already records the planned member; ADR 026 records the SignalR pattern. S1 realizes both; the factory is an implementation of the recorded pattern, documented in the S1 retro | +| `CatalogListingView` race fix (`Insert`-on-create + retry) | **No** | A read-model concurrency fix within the established Wolverine-retry idiom; documented in the M9-S7 retro with the refuted-lean reasoning | +| `Obligations:DemoMode` on the demo host | **No** | A dev-host config decision (the documented demo posture), not a rejected architecture; recorded in the M9-S8 retro + STATUS §3 | +| Seed-then-inject e2e identity bridge | **No** | A test-harness technique; recorded in the M9-S8 retro + `client/e2e/README.md` | + +**Next unreserved ADR number: 028** (unchanged — M9 authored none). + +--- + +## Technical Debt and Deferred Items (what M9 defers outward) + +| Item | Deferred in | Target | +|---|---|---| +| **signalr-skill refresh** (fold in the seller consumer, the `createSignalRProvider` factory, the `BidderGroupNotification` obligation channel) | S8 skills audit | M10 | +| **`FakeHubConnection` shared test-util extraction** (three copies across the SPAs) | S6 / S8 skills audit | M10 housekeeping | +| **`CatalogListingView` update-update race** (residual after the S7 create-race fix) | S7 (OQ-3) | Backend chore (convert merges to `UpdateRevision` only if it surfaces) | +| **Operations BC multi-handler read-model audit** (same shape as the Listings race) | S7 | Backend evaluation | +| **Seller settlement-summary UI** (the `GET /api/settlement/summaries?sellerId=` endpoint shipped at S3b; no console surface renders it) | M9 | Future seller-console slice | +| **Cache-bridge burst-final hardening** (delayed re-invalidate) | S3 evaluation | Bake into `@critterbids/shared` if/when the cache bridge is extracted there | +| **Playwright e2e in CI** | M8-S7 decision, reaffirmed S8 | Re-evaluate with CI infra work | +| `IRevisioned` + retry for `SettlementSaga`/`PostSaleCoordinationSaga`; settlement double-publish | M8-S3c | Backend follow-ups | +| Seller-side dispute UI; Timed-listing seller path; carrier field on the tracking form | M9 non-goals | Post-MVP | +| Full ledger | — | `docs/STATUS.md` §3 (v0.7) | + +--- + +## Key Numbers at M9 Close + +- **Tests:** 328 backend (+21 from M8's 307) · 189 frontend Vitest (seller 117 / ops 47 / bidder 25) · 2 Playwright e2e — all green +- **Sessions:** 10 PR-producing (scoping + S1–S8 + the #111 interlude); the close renumbered S7→S8 after the race fix took S7 +- **New ADRs:** 0 (M9 operated under ADR 013/025/026/027); next unreserved **028** +- **Frontend surface:** 3 SPAs + `shared` + `e2e` in a five-member npm workspace; three SPA dev servers as Aspire children (bidder `:5173`, ops `:5174`, seller `:5175`) +- **Seller-facing HTTP surface:** complete — submit / update-draft / withdraw / my-listings / obligation-status / settlement-summary / provide-tracking, all public +- **Backend changes:** the M9-S2/S3 endpoint precursors + the S7 race fix + the S8 `DemoMode` host-config; unsanctioned domain changes: **0** +- **Engine at close:** Wolverine 6.8.0 / .NET 10 / Aspire 13.4.3 (carried from M8; no M9 bump) +- **Build:** 0 errors, 0 net-new warnings throughout + +--- + +## What M10 Should Know + +**At M9 close all three demo perspectives are whole:** a single `dotnet run --project +src/CritterBids.AppHost` starts the full stack and all three SPAs; a bidder can bid and win, an +operator can watch the engine, and a **seller** can publish, watch their auction, and fulfill the +post-sale obligation — the last now machine-verified by the seller-obligation e2e (and the +obligation lifecycle runs live in demo seconds, since the AppHost enables `DemoMode`). + +**For M10 (working name):** + +- **Scope the milestone first.** No `docs/milestones/M10-*.md` exists; an M10-S1 prompt hard-gates + on it. Inputs: this retro's deferred table, the STATUS §3 ledger, and the M9-S8 skills-audit + carry-forwards. +- **Skills housekeeping is queued.** The signalr-skill refresh (seller + factory + obligation + channel) and the `FakeHubConnection` shared extraction are recorded; fold them in before or early + in M10. +- **Read-model concurrency audit.** The Operations BC shares the multi-handler read-model shape the + M9-S7 fix hardened — an `Insert`-on-create audit there is the open analogue. +- **Two sagas still lack enforced optimistic concurrency** (`SettlementSaga`, + `PostSaleCoordinationSaga`) — the `IRevisioned` + retry follow-up is still open. +- **Carry the verification ladder + the demo-host posture awareness:** lived backend (including + host runtime config) first, live smoke per slice, extend the e2e — and remember the e2e is + local-only, the `e2e` member type-checked but not executed in CI. diff --git a/docs/vision/bounded-contexts.md b/docs/vision/bounded-contexts.md index 780cf96..b2f1d8e 100644 --- a/docs/vision/bounded-contexts.md +++ b/docs/vision/bounded-contexts.md @@ -203,7 +203,7 @@ Draft → Submitted → Approved → Published - The Operations dashboard is the "look at the engine running" moment in a conference demo. It should be legible from a projector — real-time, high-contrast, showing saga state and message activity alongside business state. - The demo reset mechanism lives here. In MVP, resetting between conference sessions is handled by Docker volume removal. A more graceful `DemoResetInitiated` command that cascades through BCs is a post-MVP milestone. -- The ops dashboard and participant-facing app are separate React SPAs pointing at the same API host — lived since M8: `client/bidder/` (public, anonymous, `BiddingHub`) and `client/ops/` (staff-gated, `OperationsHub`), an npm-workspaces monorepo per ADR 025, both launched as Aspire children in dev. Two browser windows on the same projector tells the audience the story without narration. +- The platform's perspectives are separate React SPAs pointing at the same API host — three by M9 close: `client/bidder/` (public, anonymous, `BiddingHub`), `client/ops/` (staff-gated, `OperationsHub`), and `client/seller/` (public, anonymous, `BiddingHub` — the seller's window on listing management, live auction observation, and post-sale obligation fulfillment, added in M9). They live in an npm-workspaces monorepo per ADR 025 alongside `client/shared/` (`@critterbids/shared` — the frontend analogue of `CritterBids.Contracts`: the SignalR provider/hook/cache-bridge pattern, shared Zod wire schemas, and the Tailwind theme, extracted at M9-S1 once the third consumer made the real shared subset visible) and `client/e2e/` (the Playwright harness). All three SPA dev servers launch as Aspire children in dev (bidder `:5173`, ops `:5174`, seller `:5175`). Multiple browser windows on the same projector tell the audience the story without narration. **Storage:** PostgreSQL via Marten (ADR 011 — All-Marten Pivot). Originally designed for SQL Server / Polecat for BI tooling and live reporting; the BI rationale is preserved as a stretch goal in ADR 003. diff --git a/src/CritterBids.AppHost/Program.cs b/src/CritterBids.AppHost/Program.cs index 93a1d82..58eca4b 100644 --- a/src/CritterBids.AppHost/Program.cs +++ b/src/CritterBids.AppHost/Program.cs @@ -28,6 +28,14 @@ // defeats any `app.Environment.IsDevelopment()` guards (e.g. the OpenAPI/SwaggerUI map). var api = builder.AddProject("critterbids-api") .WithEnvironment("ASPNETCORE_ENVIRONMENT", builder.Environment.EnvironmentName) + // Collapse the Obligations post-sale timers (reminder / ship-by deadline / auto-confirm) + // to demo-second durations for the orchestrated demo run. ObligationsOptions.DemoMode is + // the runtime flag the BC was built around (W001-6): production durations are days, demo + // durations are seconds, so the full post-sale lifecycle — provide-tracking → shipped → + // auto-confirmed → fulfilled — actually completes inside a conference demo session (and + // inside the M9-S8 seller-obligation Playwright e2e). Scoped to the Aspire-orchestrated + // demo host only; production binds DemoMode=false by default (no appsettings override). + .WithEnvironment("Obligations__DemoMode", "true") .WithReference(postgres) .WithReference(rabbitMq) .WaitFor(postgres)