setTrackingOpen(false)}
+ />
+ )}
+ >
+ );
+}
+
+function ObligationStatusDetail({
+ obligation,
+}: {
+ obligation: ObligationStatusView;
+}) {
+ switch (obligation.status) {
+ case "AwaitingShipment":
+ return (
+
+
+ Ship your item by{" "}
+
+ {formatRelativeDeadline(obligation.shipByDeadline)}
+
+
+ {obligation.reminderSentAt && (
+
+ Reminder sent — ship before your deadline.
+
+ )}
+
+ );
+
+ case "Escalated":
+ return (
+
+
+ Overdue — your deadline passed; this sale is under review.
+
+ {obligation.escalatedAt && (
+
+ Escalated {formatTimestamp(obligation.escalatedAt)}
+
+ )}
+
+ );
+
+ case "Shipped":
+ return (
+
+
+ Shipped — tracking #{obligation.trackingNumber}; delivery
+ confirmation pending.
+
+ {obligation.trackingProvidedAt && (
+
+ Shipped {formatTimestamp(obligation.trackingProvidedAt)}
+
+ )}
+
+ );
+
+ case "Fulfilled":
+ return (
+
+
Completed.
+ {obligation.fulfilledAt && (
+
+ Fulfilled {formatTimestamp(obligation.fulfilledAt)}
+
+ )}
+
+ );
+
+ case "Disputed":
+ return (
+
+ {obligation.disputeResolution ? (
+
+ Dispute resolved ({obligation.disputeResolution}).
+
+ ) : (
+
+ Dispute open ({obligation.disputeReason}).
+
+ )}
+ {obligation.disputeOpenedAt && (
+
+ Opened {formatTimestamp(obligation.disputeOpenedAt)}
+
+ )}
+ {obligation.disputeResolvedAt && (
+
+ Resolved {formatTimestamp(obligation.disputeResolvedAt)}
+
+ )}
+
+ );
+
+ default:
+ return null;
+ }
+}
+
+export function useActionableObligationCount(): number {
+ const { participantId } = useSession();
+ const { data } = useSellerObligations(participantId ?? "");
+ if (!data) return 0;
+ return data.filter((o) => ACTIONABLE_STATUSES.has(o.status)).length;
+}
+
+function ObligationsSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+ -
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/seller/src/obligations/ProvideTrackingDialog.test.tsx b/client/seller/src/obligations/ProvideTrackingDialog.test.tsx
new file mode 100644
index 0000000..eb40aa0
--- /dev/null
+++ b/client/seller/src/obligations/ProvideTrackingDialog.test.tsx
@@ -0,0 +1,195 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+
+import { ProvideTrackingDialog } from "@/obligations/ProvideTrackingDialog";
+import type { ObligationStatusView } from "@/obligations/schema";
+import { SessionProvider } from "@/session/SessionContext";
+
+const OBLIGATION: ObligationStatusView = {
+ id: "0192f1a0-1111-7000-8000-000000000001",
+ listingId: "0192f1a0-2222-7000-8000-000000000002",
+ winnerId: "0192f1a0-3333-7000-8000-000000000003",
+ sellerId: "seller-test-id",
+ hammerPrice: 55.0,
+ status: "AwaitingShipment",
+ shipByDeadline: "2026-06-14T12:00:00Z",
+ trackingNumber: null,
+ reminderSentAt: null,
+ trackingProvidedAt: null,
+ fulfilledAt: null,
+ escalatedAt: null,
+ disputeId: null,
+ disputeReason: null,
+ disputeOpenedAt: null,
+ disputeResolution: null,
+ disputeResolvedAt: null,
+};
+
+function renderDialog(onClose = vi.fn()): {
+ onClose: ReturnType;
+ fetchMock: ReturnType;
+} {
+ sessionStorage.setItem("critterbids.seller.participantId", "seller-test-id");
+ sessionStorage.setItem("critterbids.seller.isRegisteredSeller", "true");
+
+ const fetchMock = vi.fn(async () => ({
+ ok: true,
+ status: 202,
+ headers: new Headers(),
+ json: async () => null,
+ text: async () => "",
+ }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const rootRoute = createRootRoute({ component: Outlet });
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/",
+ component: () => (
+
+ ),
+ });
+ const routeTree = rootRoute.addChildren([homeRoute]);
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ return { onClose, fetchMock };
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("ProvideTrackingDialog", () => {
+ it("renders the dialog with tracking number input", async () => {
+ renderDialog();
+
+ expect(
+ await screen.findByRole("dialog", { name: /provide tracking/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Tracking Number")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Submit Tracking" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Cancel" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the obligation context (hammer price)", async () => {
+ renderDialog();
+
+ expect(await screen.findByText(/\$55\.00 sale/)).toBeInTheDocument();
+ });
+
+ it("validates that tracking number is required", async () => {
+ renderDialog();
+ const user = userEvent.setup();
+
+ await user.click(
+ await screen.findByRole("button", { name: "Submit Tracking" }),
+ );
+
+ expect(
+ await screen.findByText(/tracking number is required/i),
+ ).toBeInTheDocument();
+ });
+
+ it("submits tracking number to the endpoint", async () => {
+ const { fetchMock } = renderDialog();
+ const user = userEvent.setup();
+
+ await user.type(
+ await screen.findByLabelText("Tracking Number"),
+ "1Z999AA10123456784",
+ );
+ await user.click(screen.getByRole("button", { name: "Submit Tracking" }));
+
+ await vi.waitFor(() => {
+ const trackingCall = fetchMock.mock.calls.find(
+ (c) =>
+ typeof c[0] === "string" &&
+ c[0].includes("/api/obligations/tracking"),
+ );
+ expect(trackingCall).toBeDefined();
+ const body = JSON.parse(
+ (trackingCall![1] as { body: string }).body,
+ ) as { obligationId: string; trackingNumber: string };
+ expect(body.obligationId).toBe(OBLIGATION.id);
+ expect(body.trackingNumber).toBe("1Z999AA10123456784");
+ });
+ });
+
+ it("calls onClose on successful submit", async () => {
+ const onClose = vi.fn();
+ renderDialog(onClose);
+ const user = userEvent.setup();
+
+ await user.type(
+ await screen.findByLabelText("Tracking Number"),
+ "TRACK-XYZ",
+ );
+ await user.click(screen.getByRole("button", { name: "Submit Tracking" }));
+
+ await vi.waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("calls onClose when Cancel is clicked", async () => {
+ const onClose = vi.fn();
+ renderDialog(onClose);
+ const user = userEvent.setup();
+
+ await user.click(
+ await screen.findByRole("button", { name: "Cancel" }),
+ );
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("shows error on mutation failure", async () => {
+ const { fetchMock } = renderDialog();
+ fetchMock.mockResolvedValue({
+ ok: false,
+ status: 400,
+ headers: new Headers(),
+ json: async () => null,
+ text: async () => "Invalid tracking number",
+ });
+ const user = userEvent.setup();
+
+ await user.type(
+ await screen.findByLabelText("Tracking Number"),
+ "BAD-TRACK",
+ );
+ await user.click(screen.getByRole("button", { name: "Submit Tracking" }));
+
+ expect(
+ await screen.findByText("Invalid tracking number"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/client/seller/src/obligations/ProvideTrackingDialog.tsx b/client/seller/src/obligations/ProvideTrackingDialog.tsx
new file mode 100644
index 0000000..a1f1b5d
--- /dev/null
+++ b/client/seller/src/obligations/ProvideTrackingDialog.tsx
@@ -0,0 +1,110 @@
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import { useSession } from "@/session/SessionContext";
+import { useProvideTracking } from "@/obligations/mutations";
+import {
+ trackingFormSchema,
+ type TrackingFormValues,
+} from "@/obligations/formSchemas";
+import type { ObligationStatusView } from "@/obligations/schema";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { formatUsd } from "@/lib/format";
+
+const resolver = zodResolver(trackingFormSchema);
+
+export function ProvideTrackingDialog({
+ obligation,
+ onClose,
+}: {
+ obligation: ObligationStatusView;
+ onClose: () => void;
+}) {
+ const { participantId } = useSession();
+ const mutation = useProvideTracking(participantId ?? "");
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver,
+ defaultValues: { trackingNumber: "" },
+ });
+
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [onClose]);
+
+ const onSubmit = handleSubmit((values) => {
+ mutation.mutate(
+ { obligationId: obligation.id, values },
+ { onSuccess: onClose },
+ );
+ });
+
+ return (
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ role="dialog"
+ aria-modal="true"
+ aria-label="Provide tracking information"
+ >
+
+
Provide Tracking
+
+ {formatUsd(obligation.hammerPrice)} sale — obligation{" "}
+ {obligation.id.slice(0, 8)}…
+
+
+
+
+ );
+}
diff --git a/client/seller/src/obligations/formSchemas.ts b/client/seller/src/obligations/formSchemas.ts
new file mode 100644
index 0000000..e7d20ef
--- /dev/null
+++ b/client/seller/src/obligations/formSchemas.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const trackingFormSchema = z.object({
+ trackingNumber: z.string().min(1, "Tracking number is required"),
+});
+
+export type TrackingFormValues = z.infer;
diff --git a/client/seller/src/obligations/mutations.ts b/client/seller/src/obligations/mutations.ts
new file mode 100644
index 0000000..e17e5c8
--- /dev/null
+++ b/client/seller/src/obligations/mutations.ts
@@ -0,0 +1,40 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import type { TrackingFormValues } from "@/obligations/formSchemas";
+
+export function useProvideTracking(sellerId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ obligationId,
+ values,
+ }: {
+ obligationId: string;
+ values: TrackingFormValues;
+ }) => {
+ const response = await fetch("/api/obligations/tracking", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({
+ obligationId,
+ trackingNumber: values.trackingNumber,
+ }),
+ });
+ if (!response.ok) {
+ const text = await response.text().catch(() => "");
+ throw new Error(
+ text || `Provide tracking failed with ${response.status}.`,
+ );
+ }
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: ["sellerObligations", sellerId],
+ });
+ },
+ });
+}
diff --git a/client/seller/src/obligations/queries.ts b/client/seller/src/obligations/queries.ts
new file mode 100644
index 0000000..5835193
--- /dev/null
+++ b/client/seller/src/obligations/queries.ts
@@ -0,0 +1,29 @@
+import { queryOptions, useQuery } from "@tanstack/react-query";
+
+import {
+ obligationStatusListSchema,
+ type ObligationStatusView,
+} from "@/obligations/schema";
+
+export function sellerObligationsQueryOptions(sellerId: string) {
+ return queryOptions({
+ queryKey: ["sellerObligations", sellerId],
+ queryFn: async (): Promise => {
+ const response = await fetch(
+ `/api/obligations/status?sellerId=${encodeURIComponent(sellerId)}`,
+ { headers: { Accept: "application/json" } },
+ );
+ if (!response.ok) {
+ throw new Error(
+ `Obligations request failed with ${response.status}.`,
+ );
+ }
+ return obligationStatusListSchema.parse(await response.json());
+ },
+ enabled: sellerId.length > 0,
+ });
+}
+
+export function useSellerObligations(sellerId: string) {
+ return useQuery(sellerObligationsQueryOptions(sellerId));
+}
diff --git a/client/seller/src/obligations/schema.test.ts b/client/seller/src/obligations/schema.test.ts
new file mode 100644
index 0000000..7cdca9f
--- /dev/null
+++ b/client/seller/src/obligations/schema.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ obligationStatusViewSchema,
+ obligationStatusListSchema,
+} from "@/obligations/schema";
+
+const VALID_OBLIGATION = {
+ id: "00000000-0000-0000-0000-000000000001",
+ listingId: "00000000-0000-0000-0000-000000000002",
+ winnerId: "00000000-0000-0000-0000-000000000003",
+ sellerId: "00000000-0000-0000-0000-000000000004",
+ hammerPrice: 55.0,
+ status: "AwaitingShipment",
+ shipByDeadline: "2026-06-14T12:00:00Z",
+ trackingNumber: null,
+ reminderSentAt: null,
+ trackingProvidedAt: null,
+ fulfilledAt: null,
+ escalatedAt: null,
+ disputeId: null,
+ disputeReason: null,
+ disputeOpenedAt: null,
+ disputeResolution: null,
+ disputeResolvedAt: null,
+};
+
+describe("obligationStatusViewSchema", () => {
+ it("parses a valid AwaitingShipment obligation", () => {
+ const result = obligationStatusViewSchema.safeParse(VALID_OBLIGATION);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.status).toBe("AwaitingShipment");
+ expect(result.data.hammerPrice).toBe(55.0);
+ }
+ });
+
+ it("parses a Shipped obligation with tracking", () => {
+ const shipped = {
+ ...VALID_OBLIGATION,
+ status: "Shipped",
+ trackingNumber: "1Z999AA10123456784",
+ trackingProvidedAt: "2026-06-13T10:00:00Z",
+ };
+ const result = obligationStatusViewSchema.safeParse(shipped);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.trackingNumber).toBe("1Z999AA10123456784");
+ }
+ });
+
+ it("parses a Fulfilled obligation", () => {
+ const fulfilled = {
+ ...VALID_OBLIGATION,
+ status: "Fulfilled",
+ trackingNumber: "TRACK123",
+ trackingProvidedAt: "2026-06-13T10:00:00Z",
+ fulfilledAt: "2026-06-13T12:00:00Z",
+ };
+ const result = obligationStatusViewSchema.safeParse(fulfilled);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.status).toBe("Fulfilled");
+ expect(result.data.fulfilledAt).toBe("2026-06-13T12:00:00Z");
+ }
+ });
+
+ it("parses an Escalated obligation", () => {
+ const escalated = {
+ ...VALID_OBLIGATION,
+ status: "Escalated",
+ escalatedAt: "2026-06-14T12:00:00Z",
+ };
+ const result = obligationStatusViewSchema.safeParse(escalated);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.status).toBe("Escalated");
+ }
+ });
+
+ it("parses a Disputed obligation", () => {
+ const disputed = {
+ ...VALID_OBLIGATION,
+ status: "Disputed",
+ disputeId: "00000000-0000-0000-0000-000000000099",
+ disputeReason: "NonDelivery",
+ disputeOpenedAt: "2026-06-14T14:00:00Z",
+ };
+ const result = obligationStatusViewSchema.safeParse(disputed);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.disputeReason).toBe("NonDelivery");
+ }
+ });
+
+ it("rejects an unknown status value", () => {
+ const invalid = { ...VALID_OBLIGATION, status: "Unknown" };
+ const result = obligationStatusViewSchema.safeParse(invalid);
+ expect(result.success).toBe(false);
+ });
+
+ it("rejects missing required fields", () => {
+ const result = obligationStatusViewSchema.safeParse({ id: "abc" });
+ expect(result.success).toBe(false);
+ });
+});
+
+describe("obligationStatusListSchema", () => {
+ it("parses an array of obligations", () => {
+ const result = obligationStatusListSchema.safeParse([
+ VALID_OBLIGATION,
+ { ...VALID_OBLIGATION, id: "00000000-0000-0000-0000-000000000099", status: "Fulfilled" },
+ ]);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).toHaveLength(2);
+ }
+ });
+
+ it("parses an empty array", () => {
+ const result = obligationStatusListSchema.safeParse([]);
+ expect(result.success).toBe(true);
+ });
+});
diff --git a/client/seller/src/obligations/schema.ts b/client/seller/src/obligations/schema.ts
new file mode 100644
index 0000000..9d8731c
--- /dev/null
+++ b/client/seller/src/obligations/schema.ts
@@ -0,0 +1,34 @@
+import { z } from "zod";
+
+export const obligationStatusEnum = z.enum([
+ "AwaitingShipment",
+ "Shipped",
+ "Escalated",
+ "Fulfilled",
+ "Disputed",
+]);
+export type ObligationStatus = z.infer;
+
+export const obligationStatusViewSchema = z.object({
+ id: z.string(),
+ listingId: z.string(),
+ winnerId: z.string(),
+ sellerId: z.string(),
+ hammerPrice: z.number(),
+ status: obligationStatusEnum,
+ shipByDeadline: z.string(),
+ trackingNumber: z.string().nullable(),
+ reminderSentAt: z.string().nullable(),
+ trackingProvidedAt: z.string().nullable(),
+ fulfilledAt: z.string().nullable(),
+ escalatedAt: z.string().nullable(),
+ disputeId: z.string().nullable(),
+ disputeReason: z.string().nullable(),
+ disputeOpenedAt: z.string().nullable(),
+ disputeResolution: z.string().nullable(),
+ disputeResolvedAt: z.string().nullable(),
+});
+
+export type ObligationStatusView = z.infer;
+
+export const obligationStatusListSchema = z.array(obligationStatusViewSchema);
diff --git a/client/seller/src/router.tsx b/client/seller/src/router.tsx
index 5494e5a..abf933f 100644
--- a/client/seller/src/router.tsx
+++ b/client/seller/src/router.tsx
@@ -11,6 +11,7 @@ import { HomePage } from "@/pages/HomePage";
import { ListingsPage } from "@/listings/ListingsPage";
import { CreateListingPage } from "@/listings/CreateListingPage";
import { ListingDetailPage } from "@/listings/ListingDetailPage";
+import { ObligationsPage } from "@/obligations/ObligationsPage";
const rootRoute = createRootRoute({
component: AppShell,
@@ -44,11 +45,18 @@ const listingDetailRoute = createRoute({
}),
});
+const obligationsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/obligations",
+ component: ObligationsPage,
+});
+
const routeTree = rootRoute.addChildren([
homeRoute,
listingsRoute,
createListingRoute,
listingDetailRoute,
+ obligationsRoute,
]);
export const router = createRouter({
diff --git a/client/seller/src/signalr/cacheBridge.test.ts b/client/seller/src/signalr/cacheBridge.test.ts
index 663ee1e..06fe9a9 100644
--- a/client/seller/src/signalr/cacheBridge.test.ts
+++ b/client/seller/src/signalr/cacheBridge.test.ts
@@ -86,4 +86,64 @@ describe("applyHubMessage", () => {
queryKey: ["sellerListings"],
});
});
+
+ it("invalidates obligations on ObligationFulfilled bidderEvent", () => {
+ const message: HubMessage = {
+ kind: "bidderEvent",
+ bidderId: "seller-1",
+ listingId: "listing-abc",
+ eventType: "ObligationFulfilled",
+ payload: "Obligation fulfilled.",
+ occurredAt: "2026-06-13T14:00:00Z",
+ };
+
+ applyHubMessage(queryClient, message);
+
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ["sellerObligations"],
+ });
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ["listing", "listing-abc"],
+ });
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ["sellerListings"],
+ });
+ });
+
+ it("invalidates obligations on TrackingInfoProvided bidderEvent", () => {
+ const message: HubMessage = {
+ kind: "bidderEvent",
+ bidderId: "seller-1",
+ listingId: null,
+ eventType: "TrackingInfoProvided",
+ payload: "Tracking provided: TRACK123.",
+ occurredAt: "2026-06-13T10:00:00Z",
+ };
+
+ applyHubMessage(queryClient, message);
+
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ["sellerObligations"],
+ });
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ["sellerListings"],
+ });
+ });
+
+ it("does not invalidate obligations on non-obligation bidderEvent", () => {
+ const message: HubMessage = {
+ kind: "bidderEvent",
+ bidderId: "seller-1",
+ listingId: "listing-abc",
+ eventType: "ProxyBidExhausted",
+ payload: "Proxy exhausted.",
+ occurredAt: "2026-06-13T10:00:00Z",
+ };
+
+ applyHubMessage(queryClient, message);
+
+ expect(queryClient.invalidateQueries).not.toHaveBeenCalledWith({
+ queryKey: ["sellerObligations"],
+ });
+ });
});
diff --git a/client/seller/src/signalr/cacheBridge.ts b/client/seller/src/signalr/cacheBridge.ts
index 1ddf913..9e72a4e 100644
--- a/client/seller/src/signalr/cacheBridge.ts
+++ b/client/seller/src/signalr/cacheBridge.ts
@@ -2,9 +2,10 @@ import type { QueryClient } from "@tanstack/react-query";
import { listingIdOf, type HubMessage } from "@/signalr/messages";
-// ADR 026 cache bridge for the seller app. A hub push is a "something changed, refetch" signal,
-// never authoritative data. Invalidate the listing detail query so the CatalogListingView
-// re-fetches, and invalidate the seller listings so the dashboard reflects status changes.
+const OBLIGATION_EVENT_TYPES = new Set([
+ "TrackingInfoProvided",
+ "ObligationFulfilled",
+]);
export function applyHubMessage(
queryClient: QueryClient,
@@ -12,6 +13,15 @@ export function applyHubMessage(
): void {
const listingId = listingIdOf(message);
- void queryClient.invalidateQueries({ queryKey: ["listing", listingId] });
+ if (listingId) {
+ void queryClient.invalidateQueries({ queryKey: ["listing", listingId] });
+ }
void queryClient.invalidateQueries({ queryKey: ["sellerListings"] });
+
+ if (
+ message.kind === "bidderEvent" &&
+ OBLIGATION_EVENT_TYPES.has(message.eventType)
+ ) {
+ void queryClient.invalidateQueries({ queryKey: ["sellerObligations"] });
+ }
}
diff --git a/client/seller/src/signalr/messages.test.ts b/client/seller/src/signalr/messages.test.ts
index d52a2b3..9861144 100644
--- a/client/seller/src/signalr/messages.test.ts
+++ b/client/seller/src/signalr/messages.test.ts
@@ -52,21 +52,46 @@ describe("parseHubMessage", () => {
expect(parseHubMessage(42)).toBeNull();
});
- it("returns null for BidderGroupNotification (seller ignores these)", () => {
+ it("parses BidderGroupNotification as bidderEvent", () => {
const payload = {
bidderId: "bidder-1",
listingId: "abc-123",
- eventType: "ProxyBidExhausted",
- payload: "Proxy exhausted at max 100.",
+ eventType: "ObligationFulfilled",
+ payload: "Obligation fulfilled.",
occurredAt: "2026-06-13T12:00:00Z",
};
- // BidderGroupNotification has a bidderId field which makes it match the
- // listingGroupSchema too — but only because the schemas are loose. The seller
- // receives these as listingEvent, which is harmless (logged-and-ignored in the
- // activity feed if the listingId doesn't match). This test documents the behavior.
const result = parseHubMessage(payload);
- expect(result).not.toBeNull();
+
+ expect(result).toEqual({
+ kind: "bidderEvent",
+ bidderId: "bidder-1",
+ listingId: "abc-123",
+ eventType: "ObligationFulfilled",
+ payload: "Obligation fulfilled.",
+ occurredAt: "2026-06-13T12:00:00Z",
+ });
+ });
+
+ it("parses BidderGroupNotification with null listingId", () => {
+ const payload = {
+ bidderId: "bidder-1",
+ listingId: null,
+ eventType: "TrackingInfoProvided",
+ payload: "Tracking provided: TRACK123.",
+ occurredAt: "2026-06-13T10:00:00Z",
+ };
+
+ const result = parseHubMessage(payload);
+
+ expect(result).toEqual({
+ kind: "bidderEvent",
+ bidderId: "bidder-1",
+ listingId: null,
+ eventType: "TrackingInfoProvided",
+ payload: "Tracking provided: TRACK123.",
+ occurredAt: "2026-06-13T10:00:00Z",
+ });
});
it("discriminates bidPlaced before listingEvent (bidId is the distinguisher)", () => {
@@ -87,7 +112,7 @@ describe("parseHubMessage", () => {
});
describe("listingIdOf", () => {
- it("returns the listing id from any message kind", () => {
+ it("returns the listing id from auction message kinds", () => {
expect(
listingIdOf({
kind: "bidPlaced",
@@ -121,4 +146,30 @@ describe("listingIdOf", () => {
}),
).toBe("l-3");
});
+
+ it("returns null for bidderEvent with no listing", () => {
+ expect(
+ listingIdOf({
+ kind: "bidderEvent",
+ bidderId: "b-1",
+ listingId: null,
+ eventType: "ObligationFulfilled",
+ payload: "Fulfilled.",
+ occurredAt: "",
+ }),
+ ).toBeNull();
+ });
+
+ it("returns the listing id from bidderEvent when present", () => {
+ expect(
+ listingIdOf({
+ kind: "bidderEvent",
+ bidderId: "b-1",
+ listingId: "l-4",
+ eventType: "TrackingInfoProvided",
+ payload: "Tracking provided.",
+ occurredAt: "",
+ }),
+ ).toBe("l-4");
+ });
});
diff --git a/client/seller/src/signalr/messages.ts b/client/seller/src/signalr/messages.ts
index 790016b..451e588 100644
--- a/client/seller/src/signalr/messages.ts
+++ b/client/seller/src/signalr/messages.ts
@@ -1,13 +1,8 @@
import { z } from "zod";
// Zod wire-boundary schemas for the BiddingHub push surface the seller consumes. The seller
-// joins listing:{listingId} groups and receives the same auction-lifecycle notifications as the
-// bidder — BidPlacedNotification, ListingSoldNotification, and the eventType-tagged
-// ListingGroupNotification family. The seller does NOT receive bidder-targeted pushes
-// (BidderGroupNotification, SettlementCompletedNotification).
-//
-// Structural discrimination: no uniform wire discriminator (M8-S3b finding). Parse
-// most-specific-first and assign a client-side `kind` discriminator.
+// joins listing:{listingId} groups for auction-lifecycle notifications and bidder:{participantId}
+// for obligation-lifecycle pushes. Parse most-specific-first; assign a client-side `kind`.
const bidPlacedSchema = z.object({
listingId: z.string(),
@@ -26,6 +21,14 @@ const listingSoldSchema = z.object({
soldAt: z.string(),
});
+const bidderGroupSchema = z.object({
+ bidderId: z.string(),
+ listingId: z.string().nullish(),
+ eventType: z.string(),
+ payload: z.string(),
+ occurredAt: z.string(),
+});
+
const listingGroupSchema = z.object({
listingId: z.string(),
eventType: z.string(),
@@ -51,6 +54,14 @@ export type HubMessage =
bidCount: number;
soldAt: string;
}
+ | {
+ kind: "bidderEvent";
+ bidderId: string;
+ listingId: string | null;
+ eventType: string;
+ payload: string;
+ occurredAt: string;
+ }
| {
kind: "listingEvent";
listingId: string;
@@ -70,6 +81,18 @@ export function parseHubMessage(payload: unknown): HubMessage | null {
return { kind: "listingSold", ...listingSold.data };
}
+ const bidderEvent = bidderGroupSchema.safeParse(payload);
+ if (bidderEvent.success) {
+ return {
+ kind: "bidderEvent",
+ bidderId: bidderEvent.data.bidderId,
+ listingId: bidderEvent.data.listingId ?? null,
+ eventType: bidderEvent.data.eventType,
+ payload: bidderEvent.data.payload,
+ occurredAt: bidderEvent.data.occurredAt,
+ };
+ }
+
const listingEvent = listingGroupSchema.safeParse(payload);
if (listingEvent.success) {
return { kind: "listingEvent", ...listingEvent.data };
@@ -78,6 +101,6 @@ export function parseHubMessage(payload: unknown): HubMessage | null {
return null;
}
-export function listingIdOf(message: HubMessage): string {
+export function listingIdOf(message: HubMessage): string | null {
return message.listingId;
}
diff --git a/docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md b/docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md
new file mode 100644
index 0000000..d62419b
--- /dev/null
+++ b/docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md
@@ -0,0 +1,167 @@
+# M9-S6: Seller SPA — Obligation Fulfillment
+
+**Milestone:** M9 ([Seller Console](../../milestones/M9-seller-console.md))
+**Slice:** S6 of M9 (fourth frontend-heavy seller slice — post-sale obligation tracking + provide-tracking form)
+**Narrative:** `docs/narratives/006-seller-fulfills-post-sale-obligation.md` (Moments 1–4 — obligation begins, reminder nudges, seller ships, auto-confirm fulfills) + `docs/narratives/007-seller-recovers-missed-shipping-deadline.md` (Moments 1–3 — deadline escalates, late tracking recovers, auto-confirm fulfills)
+**Agent:** @PSA
+**Estimated scope:** one PR, ~20–25 files (obligation queries, provide-tracking form, obligation status page, message parsing extension, tests, retro)
+
+---
+
+## Preconditions
+
+This prompt assumes M9-S5 shipped (PR #109, `0fdfcd6`). The seller SPA has: registration, listing management (create/edit/submit), dashboard, listing detail page with live auction observation (SignalR-wired, reserve indicator, terminal outcomes). The seller's `parseHubMessage` parses `BidPlacedNotification`, `ListingSoldNotification`, and `ListingGroupNotification` — it does NOT parse `BidderGroupNotification`. The seller joins `listing:{listingId}` groups for live auction observation and `bidder:{participantId}` for session-level pushes, but obligation-lifecycle pushes (`ObligationFulfilled`, `TrackingInfoProvided`) arrive as `BidderGroupNotification` on `bidder:{sellerId}` and are currently dropped (return `null`).
+
+## Goal
+
+Build the seller's obligation fulfillment surface: a new `/obligations` route showing the seller's post-sale obligations (from `GET /api/obligations/status?sellerId=X`), a provide-tracking form (driving `POST /api/obligations/tracking`), ship-by deadline countdown, status-driven UI (awaiting shipment → shipped → fulfilled; escalated → shipped → fulfilled), and live updates via BidderGroupNotification parsing for obligation events. This is the seller's **first actor surface** since S4b — unlike S5's observer-protagonist, here GreyOwl12 submits the tracking form (narrative 006 Moment 3).
+
+## Context to load
+
+| File | Purpose |
+|---|---|
+| `docs/milestones/M9-seller-console.md` | Authoritative for scope. §7 S6 row. |
+| `CLAUDE.md` | Routing layer, global conventions, §Frontend. |
+| `.claude/skills/frontend-slice-discipline/SKILL.md` | **Required** — verification ladder, live-smoke rules. |
+| `docs/narratives/006-seller-fulfills-post-sale-obligation.md` | Happy-path obligation lifecycle (Moments 1–4). |
+| `docs/narratives/007-seller-recovers-missed-shipping-deadline.md` | Escalation → late-tracking recovery (Moments 1–3). |
+| `client/seller/src/` | The S5 foundation to build on. |
+| `client/seller/src/listings/CreateListingPage.tsx` | `react-hook-form` + Zod + `zodResolver` precedent for the provide-tracking form. |
+| `client/seller/src/listings/mutations.ts` | TanStack Mutation + cache invalidation pattern. |
+| `client/seller/src/listings/formSchemas.ts` | Zod form schema precedent. |
+| `client/seller/src/signalr/messages.ts` | Current message parser (needs BidderGroupNotification extension). |
+| `client/seller/src/signalr/cacheBridge.ts` | Current cache bridge (needs obligation query invalidation). |
+| `client/bidder/src/signalr/messages.ts` | Bidder's `bidderGroupSchema` and `bidderEvent` kind as precedent. |
+| `src/CritterBids.Obligations/GetSellerObligationsEndpoint.cs` | `GET /api/obligations/status?sellerId=X` — the query endpoint. |
+| `src/CritterBids.Obligations/ProvideTrackingEndpoint.cs` | `POST /api/obligations/tracking` — the command endpoint. |
+| `src/CritterBids.Obligations/ObligationStatusView.cs` | Read-model shape: `Id`, `ListingId`, `SellerId`, `WinnerId`, `HammerPrice`, `Status`, `ShipByDeadline`, `TrackingNumber`, `ReminderSentAt`, `TrackingProvidedAt`, `FulfilledAt`, `EscalatedAt`, `DisputeId`, `DisputeReason`, `DisputeOpenedAt`, `DisputeResolution`, `DisputeResolvedAt`. |
+| `src/CritterBids.Obligations/ObligationStatus.cs` | Enum: `AwaitingShipment`, `Shipped`, `Escalated`, `Fulfilled`, `Disputed`. |
+| `src/CritterBids.Obligations/ObligationCommands.cs` | `ProvideTracking(ObligationId, TrackingNumber)` — the command shape. |
+| `src/CritterBids.Relay/Handlers/ObligationsRelayHandlers.cs` | Relay push routing: `TrackingInfoProvided` → `bidder:{WinnerId ?? SellerId}`; `ObligationFulfilled` → `bidder:{WinnerId}` + `bidder:{SellerId}`; both as `BidderGroupNotification`. |
+
+## In scope
+
+### S6-1: Obligation status Zod schema + query hook
+
+Add a Zod schema for the `ObligationStatusView` response shape. The backend returns an array of obligation records, each with: `id`, `listingId`, `sellerId`, `winnerId`, `hammerPrice`, `status` (string enum), `shipByDeadline`, `trackingNumber`, `reminderSentAt`, `trackingProvidedAt`, `fulfilledAt`, `escalatedAt`, `disputeId`, `disputeReason`, `disputeOpenedAt`, `disputeResolution`, `disputeResolvedAt`. All timestamp fields are nullable ISO strings; `status` is one of `AwaitingShipment | Shipped | Escalated | Fulfilled | Disputed`.
+
+Add a `useSellerObligations(sellerId)` query hook backed by `GET /api/obligations/status?sellerId=X`. Query key: `["sellerObligations", sellerId]`.
+
+### S6-2: Provide-tracking mutation
+
+Add a `useProvideTracking(sellerId)` mutation that POSTs to `/api/obligations/tracking` with `{ obligationId, trackingNumber }`. On success, invalidate `["sellerObligations", sellerId]`. Follow the S4a/S4b mutation pattern (`useMutation` + `useQueryClient` + `invalidateQueries` on success).
+
+### S6-3: Provide-tracking form schema
+
+Add a Zod form schema for the tracking entry form. Two fields: `trackingNumber` (required, non-empty string). The `obligationId` comes from the obligation record, not the form. Follow the `formSchemas.ts` precedent: `z.object()` with `.superRefine()` for validation messages.
+
+### S6-4: BidderGroupNotification parsing
+
+Extend the seller's `parseHubMessage` in `messages.ts` to parse `BidderGroupNotification`. The wire shape is `{ bidderId, listingId?, eventType, payload, occurredAt }` (same schema the bidder uses as `bidderGroupSchema`). Add a `bidderEvent` kind to the seller's `HubMessage` union. The seller cares about obligation-lifecycle eventTypes: `TrackingInfoProvided`, `ObligationFulfilled`. Other bidder-group events are accepted by the parser (unknown eventTypes are not fatal) but only the obligation-related ones drive cache invalidation.
+
+Parse order: insert the bidderGroup parse before the listingGroup parse (same as bidder) so a `BidderGroupNotification` with `listingId` is not misidentified as a `ListingGroupNotification`.
+
+### S6-5: Cache bridge extension
+
+Extend `applyHubMessage` to invalidate `["sellerObligations"]` on `bidderEvent` messages whose `eventType` is `TrackingInfoProvided` or `ObligationFulfilled`. The existing listing/sellerListings invalidations continue unchanged for auction-lifecycle messages.
+
+### S6-6: Obligations page at `/obligations`
+
+A new route and page component. The seller navigates here from the app shell navigation. The page shows the seller's obligations in a list/grid. Each obligation card renders:
+
+**Status-driven display:**
+- **Awaiting Shipment** — "Ship your item by \." Ship-by deadline countdown (relative time: "in X hours" or "in X days"; past deadline: "Overdue"). Reminder banner if `reminderSentAt` is set: "Reminder sent — ship before your deadline." "Provide Tracking" button opening the tracking form.
+- **Escalated** — "Overdue — your deadline passed; this sale is under review." Still shows the "Provide Tracking" button (narrative 007: the door is still open for late tracking recovery).
+- **Shipped** — "Shipped — tracking #\; delivery confirmation pending." No action needed. Shows `trackingProvidedAt` timestamp.
+- **Fulfilled** — "Completed." Terminal state with green treatment. Shows `fulfilledAt` timestamp.
+- **Disputed** — "Dispute open (\)." Shows `disputeOpenedAt`. If resolved: "Dispute resolved (\)." No action for the seller in MVP disputes.
+
+**Common fields on every card:**
+- Listing reference: hammer price (`formatUsd`), obligation ID (abbreviated)
+- `ListingId` for cross-reference (link to `/listings/$id` if the listing detail page exists)
+
+### S6-7: Provide-tracking dialog/form
+
+A dialog or inline form rendered when the seller clicks "Provide Tracking" on an AwaitingShipment or Escalated obligation. Uses `react-hook-form` + `zodResolver` with the S6-3 form schema. Fields: tracking number (text input, required). On submit: call the `useProvideTracking` mutation with `{ obligationId, trackingNumber }`. On success: close the form, show success feedback, the cache bridge re-fetch shows the obligation as "Shipped". On error: show the error message inline.
+
+Follow the S4a `CreateListingPage` pattern: `useForm` + `zodResolver`, `register()`, `handleSubmit()`, inline error messages via `formState.errors`.
+
+### S6-8: App shell navigation
+
+Add an "Obligations" nav link to the app shell so the seller can navigate between "My Listings" and "Obligations" (and Home). The obligation link should show a visual indicator (e.g. badge count) if there are obligations in actionable states (AwaitingShipment or Escalated).
+
+### S6-9: Tests
+
+- **Schema tests:** Validate the obligation status Zod schema against known payload shapes.
+- **Mutation tests:** Validate `useProvideTracking` sends correct payload, invalidates correct query keys.
+- **Message parsing tests:** Validate the extended `parseHubMessage` parses `BidderGroupNotification` into `bidderEvent` kind; verify existing auction-lifecycle parsing is unchanged.
+- **Cache bridge tests:** Validate `applyHubMessage` invalidates `["sellerObligations"]` on obligation-related `bidderEvent` messages.
+- **Obligations page tests:** Render with mock obligation data; verify status-driven display for each obligation status (AwaitingShipment, Escalated, Shipped, Fulfilled, Disputed); verify deadline countdown; verify reminder banner; verify "Provide Tracking" button visibility.
+- **Provide-tracking form tests:** Render form; verify validation (empty tracking number rejected); verify submit calls mutation; verify success closes form.
+
+### S6-10: Live smoke
+
+- Run `dotnet run --project src/CritterBids.AppHost --launch-profile http`
+- Open the seller SPA at `http://localhost:5175/seller/`
+- Verify: obligations route loads; with no obligations, shows empty state
+- Verify: the "Obligations" nav link appears in the app shell
+- (Full obligation lifecycle requires a sold listing + settlement completion — record what's observable from the seller's entry point)
+- Record smoke findings in the retro
+
+### S6-11: Retrospective
+
+- `docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md`
+
+## Explicitly out of scope
+
+- **Backend changes.** Zero `.cs` touches. The obligation query endpoint (`GET /api/obligations/status?sellerId=X`), the provide-tracking endpoint (`POST /api/obligations/tracking`), and the Relay obligation push handlers all exist and are live. The `ObligationStatusView` projection is Inline and up-to-date.
+- **Dispute actions from the seller console.** The seller sees dispute status (read-only) but cannot open or resolve disputes — that's operator-side (ops dashboard, M8-S6b).
+- **Settlement summary view.** The milestone doc mentions settlement summary as a separate surface; the `ObligationStatusView` carries `HammerPrice` which is sufficient for the post-sale context. A dedicated settlement query is out of scope for S6.
+- **Dashboard obligation-status enrichment.** The my-listings dashboard (S4a/S4b) shows Selling BC lifecycle only. Enriching it with obligation status (e.g. "Awaiting Shipment" on a sold listing card) would require cross-BC data joining on the dashboard — out of scope; the obligations page is the dedicated surface.
+- **`@critterbids/shared` message extraction.** The seller's message parser now adds `bidderEvent`, increasing overlap with the bidder. Extraction remains deferred to M9-S7.
+- **Cache-bridge burst-final hardening.** Carry-forward from M8-S7; deferred to M9-S7.
+- **`docs/STATUS.md` regeneration.** Deferred to M9-S7.
+- **Email/push notification for obligation reminders.** Relay is SignalR-only at MVP.
+
+## Conventions to pin or follow
+
+- **Frontend-slice-discipline** — all rules apply. Rule 1: read backend shapes before writing client code (done — `ObligationStatusView`, `ProvideTracking`, Relay handlers all audited). Rule 3: live smoke.
+- **ADR 026 (SignalR integration pattern)** — push is a re-query signal; cache bridge invalidates queries; the obligation page re-queries on `ObligationFulfilled` push.
+- **`react-hook-form` + `zodResolver` pattern** — established in S4a/S4b for create-draft and edit-draft forms; the provide-tracking form follows the same pattern (smaller surface: one field).
+- **TanStack Mutation + cache invalidation** — `mutations.ts` pattern from S4a/S4b: `useMutation` + `useQueryClient` + `invalidateQueries` on success.
+- **shadcn/ui components are locally owned** — copy new components as needed (e.g. dialog if used for the tracking form).
+
+## Spec delta
+
+Per ADR 020: this slice has **significant spec consequence**. Narrative 006 Moments 1–4 (obligation begins, reminder nudges, seller ships, auto-confirm fulfills) gain concrete frontend implementations from the seller's vantage. Narrative 007 Moments 1–3 (deadline escalates, late tracking recovers, auto-confirm fulfills) gain UX surface: the Escalated status shows "Overdue — under review" with the tracking form still available. The milestone doc's exit criteria advance: "Obligation management — the seller console surfaces the `ObligationStatusView` for the seller's own listings and drives the `ProvideTracking` command."
+
+## Acceptance criteria
+
+- [ ] Obligation status Zod schema validates `ObligationStatusView` response shape
+- [ ] `useSellerObligations(sellerId)` query hook fetches from `GET /api/obligations/status?sellerId=X`
+- [ ] `useProvideTracking` mutation POSTs to `/api/obligations/tracking` with `{ obligationId, trackingNumber }`
+- [ ] Provide-tracking mutation invalidates `["sellerObligations", sellerId]` on success
+- [ ] Seller `parseHubMessage` extended to parse `BidderGroupNotification` into `bidderEvent` kind
+- [ ] Existing auction-lifecycle message parsing unchanged (bidPlaced, listingSold, listingEvent all still work)
+- [ ] Cache bridge invalidates `["sellerObligations"]` on obligation-related `bidderEvent` messages
+- [ ] Obligations page at `/obligations` renders obligation status per lifecycle state
+- [ ] Awaiting Shipment: deadline countdown + reminder banner + "Provide Tracking" button
+- [ ] Escalated: "Overdue" messaging + "Provide Tracking" button still available
+- [ ] Shipped: tracking number + timestamp + "delivery confirmation pending"
+- [ ] Fulfilled: "Completed" terminal state
+- [ ] Disputed: dispute reason + status (read-only)
+- [ ] Provide-tracking form uses `react-hook-form` + `zodResolver` with validation
+- [ ] App shell navigation includes "Obligations" link
+- [ ] `npm run build` succeeds for seller app (TypeScript strict, no errors)
+- [ ] Existing frontend baselines preserved: bidder 25 tests, ops 47 tests
+- [ ] Seller Vitest count grows from 84
+- [ ] Live smoke against Aspire stack recorded in retro
+- [ ] No backend changes (zero `.cs` files touched)
+- [ ] No commit to `main`; one PR off `main`; no `Co-Authored-By` trailer
+- [ ] `docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md` written
+
+## Open questions
+
+- **Obligations page layout.** Should obligations be a full-page list/grid at `/obligations`, or a section within an expanded listing detail page? Lean: dedicated `/obligations` route — the obligation lifecycle is distinct from the auction lifecycle; mixing them on the detail page overloads the seller's vantage. The dashboard links to `/listings/$id` for auctions and `/obligations` for post-sale tracking.
+- **Provide-tracking form placement.** Inline expansion on the obligation card, or a dialog/modal? Lean: dialog — follows the S4b `EditDraftDialog` precedent; keeps the obligation list stable while the form is open. If the form is simple enough (one field), inline expansion is also acceptable.
+- **Deadline countdown granularity.** Should the ship-by countdown show real-time seconds ticking, or a human-friendly relative format ("in 2 hours", "in 3 days")? Lean: human-friendly relative format with periodic refresh (the page already re-queries on cache invalidation; the countdown is informational, not real-time critical). In demo mode the deadline is seconds away, so "less than a minute" is the likely display.
diff --git a/docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md b/docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md
new file mode 100644
index 0000000..5c446f6
--- /dev/null
+++ b/docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md
@@ -0,0 +1,223 @@
+# M9-S6: Seller SPA — Obligation Fulfillment - Retrospective
+
+**Date:** 2026-06-13
+**Milestone:** M9 - Seller Console
+**Slice:** S6 - Obligation fulfillment (post-sale tracking + provide-tracking form)
+**Agent:** @PSA
+**Prompt:** `docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md`
+**Duration:** ~2h
+
+## Baseline
+
+- Clean main at `0fdfcd6` (M9-S5 shipped)
+- Seller SPA: 84 Vitest tests passing, build clean, TypeScript strict clean
+- Bidder SPA: 25 tests (baseline), ops SPA: 47 tests (baseline)
+- Seller `parseHubMessage` parsed `BidPlacedNotification`, `ListingSoldNotification`, `ListingGroupNotification` — no `BidderGroupNotification`
+- No obligation-related UI surface; no `/obligations` route
+- `GET /api/obligations/status?sellerId=X` and `POST /api/obligations/tracking` both existed and were live
+
+## Items completed
+
+| Item | Description |
+|------|-------------|
+| S6-1 | Obligation status Zod schema + `useSellerObligations(sellerId)` query hook |
+| S6-2 | `useProvideTracking` mutation with cache invalidation |
+| S6-3 | Tracking form Zod schema (`trackingFormSchema`) |
+| S6-4 | BidderGroupNotification parsing — `bidderEvent` kind added to seller message parser |
+| S6-5 | Cache bridge extended — `sellerObligations` invalidated on obligation-related bidder events |
+| S6-6 | Obligations page at `/obligations` with status-driven display per lifecycle state |
+| S6-7 | Provide-tracking dialog (`react-hook-form` + `zodResolver`) |
+| S6-8 | App shell navigation — "Obligations" link with actionable-count badge |
+| S6-9 | Tests — 33 new tests across 4 test files |
+| S6-10 | Live smoke (HTTP contract verified) |
+| S6-11 | Retrospective (this file) |
+
+## S6-1: Obligation status schema + query
+
+The `ObligationStatusView` Zod schema (`obligations/schema.ts`) mirrors the backend's read model exactly — 17 fields including the full dispute lifecycle. The `obligationStatusEnum` covers all five `ObligationStatus` values: `AwaitingShipment`, `Shipped`, `Escalated`, `Fulfilled`, `Disputed`.
+
+The `useSellerObligations(sellerId)` hook follows the established `sellerListingsQueryOptions` pattern from S4a: `queryOptions` + `fetchParsed`-style fetch + Zod parse at the boundary.
+
+Query key: `["sellerObligations", sellerId]` — distinct from `["sellerListings", sellerId]` so cache invalidation targets the right surface.
+
+## S6-2: Provide-tracking mutation
+
+`useProvideTracking(sellerId)` follows the S4a/S4b `useMutation` pattern exactly: POST to `/api/obligations/tracking` with `{ obligationId, trackingNumber }`, invalidate `["sellerObligations", sellerId]` on success. The mutation takes a composite argument `{ obligationId, values }` so the form values and obligation identity travel together.
+
+**Why `obligationId` is not in the form:** The backend command is `ProvideTracking(ObligationId, TrackingNumber)`. The `ObligationId` comes from the obligation record (the card the seller clicked on), not from user input. Putting it in the form schema would make it a hidden field — cleaner to pass it alongside the form values at mutation call time.
+
+## S6-3: Tracking form schema
+
+Minimal schema: `{ trackingNumber: z.string().min(1, "Tracking number is required") }`. No carrier field — the frozen `ProvideTracking` contract carries `TrackingNumber` only (narrative 006 notes carrier is aspirational until an additive contract change).
+
+## S6-4: BidderGroupNotification parsing
+
+The seller's `parseHubMessage` now parses four notification shapes (was three). The `bidderGroupSchema` mirrors the bidder's exactly: `{ bidderId, listingId?, eventType, payload, occurredAt }`. Parse order: bidPlaced → listingSold → **bidderGroup** → listingGroup (bidderGroup before listingGroup because a `BidderGroupNotification` with `listingId` also satisfies the `listingGroupSchema`'s looser requirements).
+
+**Why this matters:** The Relay obligation handlers push `ObligationFulfilled` and `TrackingInfoProvided` to `bidder:{sellerId}` as `BidderGroupNotification`. Without S6-4, these pushes were silently dropped (returned `null`). With S6-4, they flow through the cache bridge and trigger obligation query re-fetch — the seller's obligation page updates live when the auto-confirm fires.
+
+`listingIdOf` return type changed from `string` to `string | null` to accommodate `bidderEvent` messages where `listingId` may be null.
+
+## S6-5: Cache bridge extension
+
+`applyHubMessage` now checks for `bidderEvent` messages with obligation-related `eventType` values (`TrackingInfoProvided`, `ObligationFulfilled`) and invalidates `["sellerObligations"]`. The existing `["listing", id]` and `["sellerListings"]` invalidations continue for all message kinds (with a null guard on `listingId` for bidder events that may carry no listing).
+
+## S6-6: Obligations page
+
+The `/obligations` route renders the seller's obligations in a responsive grid. Each obligation card shows:
+
+- **Header:** hammer price + abbreviated obligation ID + status badge
+- **Status-driven detail:** renders different content per `ObligationStatus`:
+ - `AwaitingShipment`: deadline countdown (relative time) + reminder banner if set + "Provide Tracking" button
+ - `Escalated`: "Overdue — your deadline passed; this sale is under review." + still shows tracking button (narrative 007's recovery door)
+ - `Shipped`: tracking number + "delivery confirmation pending" + timestamp
+ - `Fulfilled`: "Completed." (green) + fulfilled timestamp
+ - `Disputed`: dispute reason (if open) or resolution (if resolved) + timestamps
+
+**Deadline countdown:** Uses a human-friendly relative format (`formatRelativeDeadline`): "in X hours", "in X days", "less than a minute", or "Overdue" if past. In demo mode the deadline is seconds away, so "less than a minute" is the likely display.
+
+**`useActionableObligationCount` hook:** Exported from the obligations page for the nav badge. Counts obligations in `AwaitingShipment` or `Escalated` status.
+
+## S6-7: Provide-tracking dialog
+
+Follows the S4b `EditDraftDialog` pattern exactly: fixed-position overlay, Escape-key dismiss, backdrop-click dismiss, `react-hook-form` + `zodResolver`, inline error messages, mutation-pending state on the submit button.
+
+One field: tracking number (text input, required). The obligation context (hammer price, abbreviated ID) is shown in the dialog header for context.
+
+## S6-8: App shell navigation
+
+Added "Obligations" nav link alongside "My Listings". The `ObligationsNavLink` component renders an actionable-count badge (destructive variant) when there are obligations in `AwaitingShipment` or `Escalated` status — the visual indicator the prompt specified.
+
+## S6-9: Tests
+
+| File | Tests | Coverage |
+|------|-------|----------|
+| `schema.test.ts` | 9 | All five obligation statuses + list schema + rejection of unknown status + missing fields + empty array |
+| `messages.test.ts` | +1 (now 9) | BidderGroupNotification with listingId + BidderGroupNotification with null listingId (replaced the "returns null" test) |
+| `cacheBridge.test.ts` | +3 (now 7) | ObligationFulfilled invalidates obligations, TrackingInfoProvided invalidates obligations, non-obligation bidderEvent does NOT invalidate obligations |
+| `ObligationsPage.test.tsx` | 11 | Empty state, AwaitingShipment (deadline + tracking button), reminder banner, Escalated (overdue + tracking button), Shipped (tracking number), Fulfilled (completed), Disputed (open + resolved), hammer price display, error state, overdue deadline text |
+| `ProvideTrackingDialog.test.tsx` | 7 | Dialog rendering, obligation context, validation (empty rejected), submit sends correct payload, onClose on success, onClose on cancel, error display on failure |
+| `listingIdOf tests` | +2 (now 5) | bidderEvent with null listing, bidderEvent with listing |
+
+All test files use the established seller test patterns: `vi.stubGlobal("fetch")` for API mocking, TanStack Router memory history for route testing, `SessionProvider` for session context.
+
+## Test results
+
+| Phase | Seller Tests | Bidder Tests | Ops Tests | Result |
+|-------|-------------|-------------|-----------|--------|
+| Baseline (S5 close) | 84 | 25 | 47 | Pass |
+| S6-1 + S6-3 (schema + form) | 93 (+9) | 25 | 47 | Pass |
+| S6-4 + S6-5 (messages + cache) | 99 (+6) | 25 | 47 | Pass |
+| S6-6 + S6-7 + S6-8 (UI) | 117 (+18) | 25 | 47 | Pass |
+| **Final** | **117** | **25** | **47** | **Pass** |
+
+Seller test count: 84 → 117 (+33 tests, +39%).
+
+## Build state at session close
+
+- **Errors:** 0 (all three SPAs)
+- **TypeScript strict:** Clean (`tsc --noEmit` passes for seller)
+- **Vite build:** Clean (seller dist generated, 6 precache entries)
+- **Backend:** Zero `.cs` files touched — `dotnet build` verified 0 errors / 2 warnings (existing)
+- **Seller routes:** 5 (home, listings, listings/new, listings/$id, **obligations**)
+- **Seller `HubMessage` union members:** 4 (was 3; added `bidderEvent`)
+- **Seller mutation hooks:** 4 (`useCreateDraft`, `useEditDraft`, `useSubmitListing`, **`useProvideTracking`**)
+- **`react-hook-form` + `zodResolver` usage:** 3 forms (CreateListingPage, EditDraftDialog, **ProvideTrackingDialog**)
+
+## Key learnings
+
+1. **BidderGroupNotification is the obligation-lifecycle push channel for the seller.** The Relay obligation handlers push to `bidder:{sellerId}` as `BidderGroupNotification`, not to `listing:{listingId}` groups. This means the seller's `bidder:{participantId}` group (which was already joined at connection time for session-level events) receives obligation pushes. The S5 parser dropped these (returned `null`); S6 activates them.
+
+2. **Actor forms are simpler when the identity comes from context, not the form.** The provide-tracking form has one user field (`trackingNumber`). The `obligationId` comes from the obligation record the seller clicked on. This is cleaner than the create-draft form (which derives `sellerId` from the session) because the obligation identity is fully determined before the form opens.
+
+3. **Escalated + tracking form available = narrative 007's recovery UX.** The non-terminal escalation's most important frontend property is that the "Provide Tracking" button remains visible. The seller sees "Overdue — under review" but can still act. This required no special logic — just including `Escalated` in the `ACTIONABLE_STATUSES` set alongside `AwaitingShipment`.
+
+4. **`listingIdOf` return type change propagates cleanly.** Changing from `string` to `string | null` to handle `bidderEvent` messages only affected `cacheBridge.ts` (null guard on the `["listing", id]` invalidation). TypeScript strict caught the change at compile time.
+
+5. **Test async rendering is essential with TanStack Router.** The `ProvideTrackingDialog` tests initially failed because the router's first render cycle produces an empty DOM. Using `findByRole` (async) instead of `getByRole` (synchronous) for the first assertion resolved this — same pattern the bidder app uses.
+
+## Findings against narratives
+
+**Narrative 006:** `docs/narratives/006-seller-fulfills-post-sale-obligation.md`
+
+- **Moments 1–4 implemented as drafted.** The seller sees obligation status (Moment 1: "Awaiting Shipment"), reminder banner (Moment 2: "Reminder sent"), provide-tracking form (Moment 3: the one actor beat), and fulfilled terminal (Moment 4: "Completed").
+- **No drift found.** The `ProvideTracking(ObligationId, TrackingNumber)` command shape matches the form exactly. The `ObligationStatusView` read model provides all fields the page needs.
+- **`document-as-intentional`:** The narrative mentions a "carrier" field alongside the tracking number (Moment 3: "carrier and tracking number"). The frozen `ProvideTracking` contract carries `TrackingNumber` only — no `Carrier` field. The narrative's carrier mention is aspirational (flagged in narrative 006's Document History); the form sends `trackingNumber` only, matching the lived contract.
+
+**Narrative 007:** `docs/narratives/007-seller-recovers-missed-shipping-deadline.md`
+
+- **Moments 1–3 UX implemented.** Escalated status shows "Overdue — your deadline passed; this sale is under review." (Moment 1). The tracking form remains available on escalated obligations (Moment 2: recovery door). Auto-confirm to fulfilled (Moment 3) shows "Completed" terminal.
+- **No drift found.** The narrative's defining property — non-terminal escalation with late-tracking recovery — maps directly to `ACTIONABLE_STATUSES.has("Escalated")`.
+
+## Spec delta — landed?
+
+Prompt declared significant spec consequence: narratives 006 Moments 1–4 and 007 Moments 1–3 gain concrete frontend implementations. **Landed as written.** The seller can now view obligation status, provide tracking, and observe obligation lifecycle through the UI. The milestone doc's exit criteria advance: "Obligation management — the seller console surfaces the `ObligationStatusView` for the seller's own listings and drives the `ProvideTracking` command." No narrative or workshop amendment was required.
+
+## Verification checklist
+
+- [x] Obligation status Zod schema validates `ObligationStatusView` response shape
+- [x] `useSellerObligations(sellerId)` query hook fetches from `GET /api/obligations/status?sellerId=X`
+- [x] `useProvideTracking` mutation POSTs to `/api/obligations/tracking` with `{ obligationId, trackingNumber }`
+- [x] Provide-tracking mutation invalidates `["sellerObligations", sellerId]` on success
+- [x] Seller `parseHubMessage` extended to parse `BidderGroupNotification` into `bidderEvent` kind
+- [x] Existing auction-lifecycle message parsing unchanged (bidPlaced, listingSold, listingEvent all still work)
+- [x] Cache bridge invalidates `["sellerObligations"]` on obligation-related `bidderEvent` messages
+- [x] Obligations page at `/obligations` renders obligation status per lifecycle state
+- [x] Awaiting Shipment: deadline countdown + reminder banner + "Provide Tracking" button
+- [x] Escalated: "Overdue" messaging + "Provide Tracking" button still available
+- [x] Shipped: tracking number + timestamp + "delivery confirmation pending"
+- [x] Fulfilled: "Completed" terminal state
+- [x] Disputed: dispute reason + status (read-only)
+- [x] Provide-tracking form uses `react-hook-form` + `zodResolver` with validation
+- [x] App shell navigation includes "Obligations" link
+- [x] `npm run build` succeeds for seller app (TypeScript strict, no errors)
+- [x] Existing frontend baselines preserved: bidder 25 tests, ops 47 tests
+- [x] Seller Vitest count grew from 84 to 117 (+33)
+- [x] Live smoke against Aspire stack recorded in retro
+- [x] No backend changes (zero `.cs` files touched)
+- [x] No commit to `main`; one PR off `main`; no `Co-Authored-By` trailer
+- [x] This retrospective written
+
+## Live smoke results
+
+**HTTP contract verification:**
+1. `GET /api/obligations/status?sellerId=` → 200, returns array (empty when no obligations exist) ✓
+2. `POST /api/obligations/tracking` with `{ obligationId: "", trackingNumber: "TEST123" }` → endpoint exists and accepts the payload shape ✓
+3. Seller SPA `/obligations` route loads and shows empty state ✓
+4. "Obligations" nav link visible in app shell when seller is registered ✓
+
+**Live-obligation lifecycle limitation (expected):** A full obligation lifecycle (AwaitingShipment → provide tracking → Shipped → Fulfilled) requires a sold listing + settlement completion + obligation saga start. This is an end-to-end flow requiring operator session start + bidder bids + settlement → obligation propagation. The smoke verified the seller's entry point (obligations page loads, endpoint responds, empty state renders). Full lifecycle verification deferred to M9-S7 Playwright e2e.
+
+## What remains / next session should verify
+
+- **M9-S7 (e2e + housekeeping):** Closing milestone slice. Playwright seller-perspective e2e, CI frontend job extension, CLAUDE.md updates, STATUS.md regeneration, skills audit, M9 retrospective.
+- **Full obligation lifecycle e2e:** A Playwright test that runs: seller publishes → operator starts session → bidder bids → settlement → obligation → seller provides tracking → fulfilled.
+- **Cache-bridge burst-final hardening:** Carry-forward from M8-S7. Evaluate in M9-S7.
+- **`@critterbids/shared` message extraction:** Seller and bidder parsers now overlap ~65% (three shared schemas, one seller-only `bidderEvent` addition). Evaluate extraction in M9-S7.
+- **`docs/STATUS.md` regeneration:** Deferred to M9-S7.
+- **`FakeHubConnection` shared extraction:** Three copies across three SPAs. Extract to `@critterbids/shared` test utilities (M9 skills review).
+
+## Files changed
+
+**New:**
+- `client/seller/src/obligations/schema.ts` — Obligation status Zod schema
+- `client/seller/src/obligations/queries.ts` — `useSellerObligations` query hook
+- `client/seller/src/obligations/mutations.ts` — `useProvideTracking` mutation
+- `client/seller/src/obligations/formSchemas.ts` — Tracking form Zod schema
+- `client/seller/src/obligations/ObligationsPage.tsx` — Obligations page + `useActionableObligationCount`
+- `client/seller/src/obligations/ProvideTrackingDialog.tsx` — Provide-tracking dialog
+- `client/seller/src/obligations/schema.test.ts` — 9 tests
+- `client/seller/src/obligations/ObligationsPage.test.tsx` — 11 tests
+- `client/seller/src/obligations/ProvideTrackingDialog.test.tsx` — 7 tests
+
+**Modified:**
+- `client/seller/src/signalr/messages.ts` — Added `bidderGroupSchema` + `bidderEvent` kind; `listingIdOf` returns `string | null`
+- `client/seller/src/signalr/cacheBridge.ts` — Obligation query invalidation on bidder events
+- `client/seller/src/router.tsx` — Added `obligationsRoute`
+- `client/seller/src/components/AppShell.tsx` — "Obligations" nav link with actionable-count badge
+- `client/seller/src/signalr/messages.test.ts` — Updated BidderGroupNotification tests + `listingIdOf` null tests
+- `client/seller/src/signalr/cacheBridge.test.ts` — Added 3 obligation cache invalidation tests
+
+**Docs:**
+- `docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md` — Session prompt
+- `docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md` — This file