diff --git a/Directory.Packages.props b/Directory.Packages.props
index 26c8128..3e1dc8b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,10 +4,10 @@
-
-
-
-
+
+
+
+
@@ -19,23 +19,25 @@
+ PINNED AT 6.16.0 to match the CritterWatch line installed below. CritterWatch 1.0.0-beta.1 is
+ compiled against WolverineFx(.Marten) 6.16.0; running a HIGHER WolverineFx than the version
+ CritterWatch targets throws a startup TypeLoadException in Wolverine.Marten's projection-distribution
+ internals (EventSubscriptionAgentFamily.TryRebuildRegisteredProjectionAsync). There is only one
+ Wolverine.Marten per process, so the console and the four services must share the exact version
+ CritterWatch was built against. Bump these ONLY in lockstep with a CritterWatch release
+ (CritterWatch + Wolverine.CritterWatch + WolverineFx.* move together, never apart) — diagnosed
+ 2026-06-24 (CW alpha.3 / 6.12.0), updated 2026-06-30 (CW beta.1 / 6.16.0); see the dependency-update
+ runbook. This supersedes the prior "held at 6.12.0, no 6.13.x" pin: beta.1 ships against GA Wolverine
+ 6.16.0, so Dependabot's stranded 6.13.x bumps (#94/#99) are now obsolete. -->
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+ bump CritterWatch has not yet taken. Re-verified unaddressed on 1.0.0-beta.1 (2026-06-30; was
+ 2.5.301 on alpha.3). Suppress until an upstream CritterWatch release moves to MessagePack 3.x. -->
diff --git a/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md b/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md
new file mode 100644
index 0000000..cf2effd
--- /dev/null
+++ b/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md
@@ -0,0 +1,54 @@
+---
+retrospective: 004
+kind: chore
+prompt: docs/prompts/chore/004-critterwatch-next-release-upgrade.md
+deliverable: Directory.Packages.props (CritterWatch + Wolverine.CritterWatch alpha.3 → beta.1; WolverineFx.* 6.12.0 → 6.16.0; coupling + MessagePack-CVE comment rewrites); src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs + Shopping/CartsAwaitingActivity.cs (stateless projections + read-shaped row records); src/CritterMart.Orders/Features/PlaceOrder.cs + AddToCart.cs (read-time deadline endpoints); src/CritterMart.Orders/Program.cs (generic projection registration + comments); tests/CritterMart.Orders.Tests/{OrdersAwaitingPaymentProjectionTests,CartsAwaitingActivityProjectionTests,PaymentTimeoutTests,CartAbandonmentTests}.cs; docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md (this file)
+date: 2026-06-30
+mode: solo maintenance; CritterWatch coupling per Directory.Packages.props + the dependency-update runbook; Marten-9.12 compat fix added when the bump surfaced a projection regression
+session-runner: Claude (Opus 4.8)
+---
+
+# Retrospective — Chore 004: CritterWatch next-release upgrade (→ beta.1 / Wolverine 6.16.0)
+
+## Outcome summary
+
+CritterWatch moved **`1.0.0-alpha.3` → `1.0.0-beta.1`** and, in lockstep, the whole Critter Stack line moved **WolverineFx.* `6.12.0` → `6.16.0`** (Marten `9.12.0` + JasperFx.Events `2.18.1` pulled transitively). beta.1's nuspec was read first-hand — it targets **GA WolverineFx 6.16.0**, not a prerelease alpha line, which is exactly the condition Erik set for taking the upgrade ("revisit when a GA-Wolverine CW ships"; alpha.4's prerelease-6.14 line was the reason alpha.4 was skipped). This **resolves the standing "DO NOT merge Wolverine 6.13.x" warning** the sanctioned way — past 6.13.x straight to 6.16.0, atomically with CritterWatch — so the stranded Dependabot 6.13.x bumps are obsolete. The MessagePack CVE (`GHSA-hv8m-jj95-wg3x`) is **still unresolved on beta.1** (transitive MessagePack `2.5.302` < the `3.0.214` fix), so the audit suppression stays, comment re-verified-dated.
+
+**The bump was not zero-code.** Marten 9.12 surfaced a projection regression: an inline `SingleStreamProjection` registered as an *instance* (`Projections.Add(new T(arg), …)`) is re-materialized by the runtime, so **constructor-injected state is dropped** — the two "awaiting-list" Bruun todo-list projections read their captured timeout back as `default(TimeSpan)`, so the view's visible `Deadline` silently became "now". Fixed by making both projections **stateless fact-recorders** (store `PlacedAt` / `LastActivityAt`) and moving the deadline policy to the **read side** (the GET endpoints inject the existing `PaymentDeadline` / `CartActivityDeadline` singletons and compute `Deadline = timestamp + duration`). Impact was cosmetic-only — the cancellation/abandonment decisions run off the scheduled messages and the Order/Cart streams, never this view.
+
+Verified three ways: full solution build (0 warnings / 0 errors); **149/149 tests green** (the two integration failures the bump introduced are fixed, nothing else regressed); and a **full live boot** on the real Aspire stack — all three services + the **CritterWatch console came up clean** (PID confirmed; the coupling crash is a startup `TypeLoadException`, so a healthy boot is the proof), and the refactored `GET /orders/awaiting-payment` returned a deadline ~7 min in the future (the `Orders__PaymentTimeout` knob), not "now".
+
+## What worked
+
+- **Read the nuspec, don't guess the floor.** `curl`-ing `wolverine.critterwatch/1.0.0-beta.1/*.nuspec` showed `WolverineFx 6.16.0` (and `WolverineFx.Marten/Http.Marten/SignalR 6.16.0`, `Marten 9.12.0`) directly — turning "is beta.1 safe to take?" from a judgment call into a fact (GA vs prerelease), which is the whole gating question.
+- **Empirical root-causing beat speculation.** The failure (`Deadline ≈ now` despite a 10-min config) had two plausible stories (TimeProvider-sourced `e.Timestamp` vs dropped timeout). The unit test passing (direct `new Projection(10min)`) while the integration test failed, plus a one-line hardcode probe (`Add(10min)` → green), isolated it to "Marten doesn't preserve the instance's ctor state" without needing the changelog (ctx7 was quota-blocked all session).
+- **The fix is a better design, not just a workaround.** Stateless projection + read-time policy is the more idiomatic CQRS split (the view stores facts; the endpoint applies config). The bump forced the cleaner shape.
+- **Live boot earned its keep.** Both the CW coupling (startup `TypeLoadException`) and the projection fix (a live deadline value) are runtime-only truths a green test summary wouldn't fully prove.
+
+## What was harder than expected
+
+- **The Marten 9.12 instance-projection-state change is undocumented-to-us and subtle.** It only manifests through the runtime projection path, so it slipped past the build and the unit tests — only the two integration tests that drive the projection through Marten caught it. Diagnosis took several iterations of arithmetic before the hardcode probe settled it.
+- **ctx7 monthly quota was exhausted** (as the prior session flagged). Fell back to the official Aspire/Marten release notes via WebFetch and to first-hand nuspec + empirical probes — no training-data guessing, per the docs rule.
+
+## Methodology refinements that emerged
+
+1. **A Critter Stack dependency upgrade needs BOTH a full `dotnet test` and a live boot — they catch different failure classes.** The CW coupling is a startup crash (live boot only); the Marten projection-state change is a runtime behavior shift (integration tests only). A build + unit tests would have shipped both bugs.
+2. **Prefer stateless projections; put per-projection config on the read side.** Constructor-injected projection state is fragile across Marten versions (it re-materializes projections). When a projection needs config, store the raw fact and apply the policy where DI lives (the endpoint), not in the projection instance.
+3. **A "version bump" chore can legitimately grow a code fix when the bump breaks behavior** — bundling the fix is correct (the PR can't go green without it); the retro records the divergence from the prompt's "Directory.Packages.props only" plan.
+
+## Outstanding items / next-session inputs
+
+1. **MessagePack CVE still suppressed** — beta.1 transitively pulls MessagePack `2.5.302` (< `3.0.214`). Re-check on the next CritterWatch release; drop the `` when it moves to MessagePack 3.x.
+2. **Consider reporting the Marten 9.12 instance-projection-state behavior upstream** to JasperFx (instance-registered inline projections losing constructor state). The CritterMart fix doesn't need it, but other Critter Stack apps using `Projections.Add(new T(arg), …)` would hit the same.
+3. **Dependabot cleanup:** #104 (critter-stack group), #108 (CritterWatch alpha.4), #110 (Wolverine.CritterWatch alpha.4) are superseded by this PR — closed. #105 (aspire group) is superseded by the separate Aspire PR #116. #106 (otel), #107 (test-stack), #109 (Swashbuckle) remain as separate concerns.
+4. **Post-talk:** the four AppHost demo knobs still want deleting after the talk (unchanged by this PR).
+
+## Spec-delta — landed?
+
+**None named, and none shipped** — this chore changes no domain behavior. The `Directory.Packages.props` coupling comment is the durable operational record of the new CW↔Wolverine pin. The read-time deadline refactor is an internal restructuring that **preserves the HTTP contract** (the awaiting-list responses still carry `Deadline`); no narrative, workshop, or OpenSpec capability changed, and the unchanged integration assertions (deadline in the future; row vanishes on terminal events) are the regression guard. The retro forward-confirms the named-none.
+
+## Process notes
+
+- One PR bundles `chore:` (the version coupling) and the Marten-9.12 compatibility fix it necessitated.
+- Branch: `chore/critterwatch-beta1`.
+- The upgrade crosses the previously-held 6.12.0 Wolverine pin to 6.16.0 — Erik's merge is the conscious blessing of that line move; the coupling comment + this retro record why it is now safe (CW beta.1 ships against GA 6.16.0).
diff --git a/src/CritterMart.AppHost/CritterMart.AppHost.csproj b/src/CritterMart.AppHost/CritterMart.AppHost.csproj
index 9def52f..c2973cd 100644
--- a/src/CritterMart.AppHost/CritterMart.AppHost.csproj
+++ b/src/CritterMart.AppHost/CritterMart.AppHost.csproj
@@ -1,6 +1,6 @@
-
+
Exe
diff --git a/src/CritterMart.AppHost/Program.cs b/src/CritterMart.AppHost/Program.cs
index 885d899..b8dd9c1 100644
--- a/src/CritterMart.AppHost/Program.cs
+++ b/src/CritterMart.AppHost/Program.cs
@@ -8,7 +8,7 @@
// Only Postgres + RabbitMQ are containers here — the three services and the seeder are dotnet
// processes and the storefront is a node process, so they never appear in Docker Desktop's
// container list. WithContainerRuntimeArgs passes the --label straight through to the runtime.
-// Mirrors CritterBids' AppHost (same Aspire 13.4.3).
+// Mirrors CritterBids' AppHost (both on the Aspire 13.4 line).
const string dockerProject = "crittermart";
// Shared PostgreSQL with one database; each service uses its own schema (ADR 002).
diff --git a/src/CritterMart.Orders/Features/AddToCart.cs b/src/CritterMart.Orders/Features/AddToCart.cs
index f77432f..c559177 100644
--- a/src/CritterMart.Orders/Features/AddToCart.cs
+++ b/src/CritterMart.Orders/Features/AddToCart.cs
@@ -131,11 +131,16 @@ public static async Task Get(string cartId, IQuerySession session)
// conditional deletes). A literal route segment, so it wins over /carts/{cartId} by ASP.NET
// Core route precedence.
[WolverineGet("/carts/awaiting-activity")]
- public static async Task GetAwaitingActivity(IQuerySession session)
+ public static async Task GetAwaitingActivity(IQuerySession session, CartActivityDeadline deadline)
{
+ // The view stores LastActivityAt; the visible Deadline is LastActivityAt + the configured window,
+ // applied here on read (the projection is stateless — see CartsAwaitingActivity remarks). Ordering
+ // by LastActivityAt equals ordering by Deadline since the window is constant across rows.
var rows = await session.Query()
- .OrderBy(x => x.Deadline)
+ .OrderBy(x => x.LastActivityAt)
.ToListAsync();
- return Results.Ok(rows);
+ var result = rows.Select(r =>
+ new CartAwaitingActivityRow(r.Id, r.CustomerId, r.LastActivityAt.Add(deadline.Duration)));
+ return Results.Ok(result);
}
}
diff --git a/src/CritterMart.Orders/Features/PlaceOrder.cs b/src/CritterMart.Orders/Features/PlaceOrder.cs
index e9fe068..7e330d5 100644
--- a/src/CritterMart.Orders/Features/PlaceOrder.cs
+++ b/src/CritterMart.Orders/Features/PlaceOrder.cs
@@ -117,11 +117,16 @@ public static async Task Get(string orderId, IQuerySession session)
// live set of orders the payment-timeout automation is watching. A literal route segment, so
// it wins over /orders/{orderId} by ASP.NET Core route precedence.
[WolverineGet("/orders/awaiting-payment")]
- public static async Task GetAwaitingPayment(IQuerySession session)
+ public static async Task GetAwaitingPayment(IQuerySession session, PaymentDeadline deadline)
{
+ // The view stores PlacedAt; the visible Deadline is PlacedAt + the configured timeout, applied
+ // here on read (the projection is stateless — see OrdersAwaitingPayment remarks). Ordering by
+ // PlacedAt equals ordering by Deadline since the timeout is constant across rows.
var rows = await session.Query()
- .OrderBy(x => x.Deadline)
+ .OrderBy(x => x.PlacedAt)
.ToListAsync();
- return Results.Ok(rows);
+ var result = rows.Select(r =>
+ new OrderAwaitingPaymentRow(r.Id, r.CustomerId, r.Total, r.PlacedAt.Add(deadline.Duration)));
+ return Results.Ok(result);
}
}
diff --git a/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs b/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs
index c07bbaa..0b60336 100644
--- a/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs
+++ b/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs
@@ -8,32 +8,40 @@ namespace CritterMart.Orders.Ordering;
// when OrderPlaced folds and DELETED when any terminal event folds — a Marten conditional delete.
// The timeout handler does NOT read this view (the Order stream is the single source of truth for
// the cancellation decision; design.md Decision 3) — this is the observable face of the automation.
+//
+// The view stores PlacedAt (a fact: the OrderPlaced append time); the visible Deadline is computed at
+// READ time in the endpoint as PlacedAt + the configured payment timeout. The deadline policy lives on
+// the read side, NOT in the projection, on purpose: under Marten 9.x an inline projection registered as
+// an instance is re-materialized by the runtime, so constructor-injected state (a captured TimeSpan) is
+// lost and reads back as default(TimeSpan). Keeping the projection STATELESS sidesteps that — the
+// timeout is config/DI, which the endpoint has and the projection does not. (Surfaced by the CW/Marten
+// 9.12 upgrade, chore/004: the prior ctor-injected deadline silently became "now".)
public class OrderAwaitingPayment
{
public string Id { get; set; } = string.Empty; // the orderId (stream key)
public string CustomerId { get; set; } = string.Empty;
public decimal Total { get; set; }
- public DateTimeOffset Deadline { get; set; } // when OrderPaymentTimeout will fire
+ public DateTimeOffset PlacedAt { get; set; } // OrderPlaced append time; Deadline = PlacedAt + timeout (on read)
}
+// The read-shaped row returned by GET /orders/awaiting-payment: the stored PlacedAt projected forward by
+// the configured payment timeout into the visible Deadline. Distinct from the stored view so the
+// projection stays a pure fact-recorder and the policy (the timeout) is applied once, on read.
+public record OrderAwaitingPaymentRow(string Id, string CustomerId, decimal Total, DateTimeOffset Deadline);
+
// Inline single-stream projection (ADR 008) over the same Order stream that OrderStatusView folds —
// two projections, one stream: projection shape is a per-projection choice, and this one's is
-// "a row exists only while the order is non-terminal". The configured PaymentDeadline duration is
-// constructor-injected (instance registration in Program.cs) so the row's visible deadline matches
-// the schedule PlaceOrder actually set.
+// "a row exists only while the order is non-terminal". STATELESS by design (see the view remarks) —
+// it records the placement timestamp; the endpoint adds the configured timeout on read.
public partial class OrdersAwaitingPaymentProjection : SingleStreamProjection
{
- private readonly TimeSpan _paymentTimeout;
-
- public OrdersAwaitingPaymentProjection(TimeSpan paymentTimeout) => _paymentTimeout = paymentTimeout;
-
// The IEvent wrapper exposes the event's append timestamp (Marten's using-metadata convention):
- // the deadline is placement time plus the configured timeout.
+ // store it raw; the deadline is computed on read from the configured timeout.
public void Apply(IEvent e, OrderAwaitingPayment view)
{
view.CustomerId = e.Data.CustomerId;
view.Total = e.Data.Total;
- view.Deadline = e.Timestamp.Add(_paymentTimeout);
+ view.PlacedAt = e.Timestamp;
}
// Conditional deletes (Marten 9 ShouldDelete method convention): any terminal event removes the
diff --git a/src/CritterMart.Orders/Program.cs b/src/CritterMart.Orders/Program.cs
index 2676ea3..e08733d 100644
--- a/src/CritterMart.Orders/Program.cs
+++ b/src/CritterMart.Orders/Program.cs
@@ -22,7 +22,7 @@
// The payment deadline (slice 4.7): how long a placed order may sit non-terminal before the
// scheduled OrderPaymentTimeout cancels it. Config-driven so the Aspire demo can shorten it
// (e.g. 30s) without recompiling; one value feeds both the PlaceOrder schedule and the
-// OrdersAwaitingPayment projection's visible deadline.
+// awaiting-payment endpoint's visible deadline (computed on read — the projection is stateless).
var paymentTimeout = builder.Configuration.GetValue("Orders:PaymentTimeout")
?? PaymentDeadline.Default;
builder.Services.AddSingleton(new PaymentDeadline(paymentTimeout));
@@ -30,7 +30,7 @@
// The cart inactivity window (slice 3.4): how long a cart may sit without activity before the
// scheduled CartActivityTimeout abandons it. Config-driven like the payment deadline above; one
// value feeds the AddToCart schedule, the abandonment handler's fire-and-check decision, and the
-// CartsAwaitingActivity projection's visible deadline.
+// awaiting-activity endpoint's visible deadline (computed on read — the projection is stateless).
var cartActivityTimeout = builder.Configuration.GetValue("Orders:CartActivityTimeout")
?? CartActivityDeadline.Default;
builder.Services.AddSingleton(new CartActivityDeadline(cartActivityTimeout));
@@ -71,13 +71,14 @@
opts.Projections.Snapshot(SnapshotLifecycle.Inline);
// The Bruun todo-list (slice 4.7): a second inline projection over the same Order stream.
- // Instance-registered (not generic) because the projection carries the configured payment
- // timeout — the row's visible deadline must match the schedule PlaceOrder actually sets.
- opts.Projections.Add(new OrdersAwaitingPaymentProjection(paymentTimeout), ProjectionLifecycle.Inline);
-
- // The cart-side Bruun todo-list (slice 3.4): the same shape over the Cart stream —
- // instance-registered with the configured inactivity window.
- opts.Projections.Add(new CartsAwaitingActivityProjection(cartActivityTimeout), ProjectionLifecycle.Inline);
+ // STATELESS + generic-registered: it records the placement timestamp only; the awaiting-payment
+ // endpoint adds the configured timeout on read. (Marten 9.x re-materializes instance-registered
+ // projections, dropping constructor-injected state — chore/004; see OrdersAwaitingPayment remarks.)
+ opts.Projections.Add(ProjectionLifecycle.Inline);
+
+ // The cart-side Bruun todo-list (slice 3.4): the same stateless shape over the Cart stream —
+ // records the latest activity timestamp; the awaiting-activity endpoint adds the window on read.
+ opts.Projections.Add(ProjectionLifecycle.Inline);
// The round-one ASYNC projection teaser (ADR 008, slice 3.4): registered with the async
// lifecycle but NO AddAsyncDaemon anywhere — the daily abandonment report stays empty
diff --git a/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs b/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs
index c3be13d..f625b8f 100644
--- a/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs
+++ b/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs
@@ -5,46 +5,52 @@ namespace CritterMart.Orders.Shopping;
// The cart-side Bruun todo-list (Workshop 001 § 5 / § 7: CartsAwaitingActivity*): one row per
// open cart, readable at GET /carts/awaiting-activity. The row is created when CartCreated folds,
-// its deadline advances as activity events fold, and it is DELETED when either terminal event
-// folds — a Marten conditional delete. The abandonment handler does NOT read this view (the Cart
-// stream is the single source of truth for the abandonment decision; design.md Decision 2) —
+// its activity timestamp advances as activity events fold, and it is DELETED when either terminal
+// event folds — a Marten conditional delete. The abandonment handler does NOT read this view (the
+// Cart stream is the single source of truth for the abandonment decision; design.md Decision 2) —
// this is the observable face of the automation, mirroring Order/OrdersAwaitingPayment.
+//
+// The view stores LastActivityAt (a fact: the most recent activity event's append time); the visible
+// Deadline is computed at READ time as LastActivityAt + the configured inactivity window. The policy
+// lives on the read side so the projection stays STATELESS — under Marten 9.x an instance-registered
+// inline projection is re-materialized and constructor-injected state (a captured TimeSpan) is lost
+// (chore/004, the CW/Marten 9.12 upgrade). Mirrors OrdersAwaitingPayment.
public class CartAwaitingActivity
{
public string Id { get; set; } = string.Empty; // the cartId (stream key)
public string CustomerId { get; set; } = string.Empty;
- public DateTimeOffset Deadline { get; set; } // when the cart becomes abandonable
+ public DateTimeOffset LastActivityAt { get; set; } // most recent activity time; Deadline = LastActivityAt + window (on read)
}
+// The read-shaped row returned by GET /carts/awaiting-activity: the stored LastActivityAt projected
+// forward by the configured inactivity window into the visible Deadline. Distinct from the stored view
+// so the projection stays a pure fact-recorder and the window is applied once, on read.
+public record CartAwaitingActivityRow(string Id, string CustomerId, DateTimeOffset Deadline);
+
// Inline single-stream projection (ADR 008) over the same Cart stream that CartView folds — two
// inline projections plus one async projection (CartAbandonmentReport), all over the same events:
-// projection shape and lifecycle are per-projection choices, not per-event ones. The configured
-// window is constructor-injected (instance registration in Program.cs) so the row's visible
-// deadline matches what the abandonment handler will actually decide.
+// projection shape and lifecycle are per-projection choices, not per-event ones. STATELESS by design
+// (see the view remarks) — it records the latest activity timestamp; the endpoint adds the window on read.
public partial class CartsAwaitingActivityProjection : SingleStreamProjection
{
- private readonly TimeSpan _activityWindow;
-
- public CartsAwaitingActivityProjection(TimeSpan activityWindow) => _activityWindow = activityWindow;
-
// The IEvent wrapper exposes the event's append timestamp (Marten's using-metadata convention):
- // the deadline is the activity time plus the configured window.
+ // store it raw; the deadline is computed on read from the configured inactivity window.
public void Apply(IEvent e, CartAwaitingActivity view)
{
view.CustomerId = e.Data.CustomerId;
- view.Deadline = e.Timestamp.Add(_activityWindow);
+ view.LastActivityAt = e.Timestamp;
}
- // Every activity event pushes the deadline out — the visible mirror of fire-and-check: the
+ // Every activity event pushes the activity timestamp out — the visible mirror of fire-and-check: the
// pending scheduled message is NOT moved (Wolverine can't); the fired handler re-aims instead.
public void Apply(IEvent e, CartAwaitingActivity view) =>
- view.Deadline = e.Timestamp.Add(_activityWindow);
+ view.LastActivityAt = e.Timestamp;
public void Apply(IEvent e, CartAwaitingActivity view) =>
- view.Deadline = e.Timestamp.Add(_activityWindow);
+ view.LastActivityAt = e.Timestamp;
public void Apply(IEvent e, CartAwaitingActivity view) =>
- view.Deadline = e.Timestamp.Add(_activityWindow);
+ view.LastActivityAt = e.Timestamp;
// Conditional deletes (Marten 9 ShouldDelete method convention): either terminal event —
// checkout (slice 4.1) or abandonment (slice 3.4) — removes the row from the todo-list.
diff --git a/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs b/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs
index b8aff5e..8ede53d 100644
--- a/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs
+++ b/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs
@@ -201,7 +201,7 @@ public async Task the_awaiting_activity_list_shows_open_carts_and_drops_closed_o
_.Get.Url("/carts/awaiting-activity");
_.StatusCodeShouldBe(200);
});
- var rows = listed.ReadAsJson>()!;
+ var rows = listed.ReadAsJson>()!;
var row = rows.ShouldHaveSingleItem();
row.Id.ShouldBe(cartId);
row.CustomerId.ShouldBe("customer-V");
@@ -216,7 +216,7 @@ public async Task the_awaiting_activity_list_shows_open_carts_and_drops_closed_o
_.Get.Url("/carts/awaiting-activity");
_.StatusCodeShouldBe(200);
});
- afterAbandon.ReadAsJson>()!.ShouldBeEmpty();
+ afterAbandon.ReadAsJson>()!.ShouldBeEmpty();
}
// ADR 008's teaching beat, proven: the async CartAbandonmentReport is EMPTY until an
diff --git a/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs b/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs
index 55c69a0..3543634 100644
--- a/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs
+++ b/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs
@@ -6,49 +6,50 @@
namespace CritterMart.Orders.Tests;
// Pure-function unit tests for the CartsAwaitingActivity todo-list fold (slice 3.4) — no database,
-// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside
-// the other projection fold tests. Mirrors OrdersAwaitingPaymentProjectionTests: Apply methods
-// take IEvent wrappers (Marten's using-metadata convention) so the visible deadline is computed
-// from each activity event's append timestamp; the tests construct the wrappers with Event.
+// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside the
+// other projection fold tests. Mirrors OrdersAwaitingPaymentProjectionTests: Apply methods take
+// IEvent wrappers (Marten's using-metadata convention) so each activity event's append timestamp is
+// recorded; the tests construct the wrappers with Event. The projection is STATELESS — the visible
+// deadline (LastActivityAt + window) is applied on read in the endpoint (chore/004).
public class CartsAwaitingActivityProjectionTests
{
private static readonly ProductSnapshot CosmicCritterPlush = new("Cosmic Critter Plush", 24.99m);
- private static readonly TimeSpan Window = TimeSpan.FromHours(2);
private static readonly DateTimeOffset T0 = new(2026, 6, 2, 12, 0, 0, TimeSpan.Zero);
- private readonly CartsAwaitingActivityProjection _projection = new(Window);
+ private readonly CartsAwaitingActivityProjection _projection = new();
private static Event At(T data, DateTimeOffset timestamp) where T : notnull =>
new(data) { Timestamp = timestamp };
- // CartCreated creates the row: the cart joins the todo-list with a deadline of creation
- // time plus the configured inactivity window.
+ // CartCreated creates the row: the cart joins the todo-list recording its creation timestamp
+ // (the read endpoint adds the configured inactivity window to produce the visible deadline).
[Fact]
- public void cart_created_creates_a_row_with_the_deadline()
+ public void cart_created_records_the_activity_timestamp()
{
var view = new CartAwaitingActivity();
_projection.Apply(At(new CartCreated("cart-1", "customer-X"), T0), view);
view.CustomerId.ShouldBe("customer-X");
- view.Deadline.ShouldBe(T0.Add(Window));
+ view.LastActivityAt.ShouldBe(T0);
}
- // Every activity event pushes the deadline out — the visible mirror of fire-and-check.
+ // Every activity event advances the recorded activity timestamp — the visible mirror of
+ // fire-and-check (the read endpoint projects it forward by the window).
[Fact]
- public void cart_activity_advances_the_deadline()
+ public void cart_activity_advances_the_activity_timestamp()
{
var view = new CartAwaitingActivity();
_projection.Apply(At(new CartCreated("cart-1", "customer-X"), T0), view);
_projection.Apply(At(new CartItemAdded("crit-001", 1, CosmicCritterPlush), T0.AddMinutes(10)), view);
- view.Deadline.ShouldBe(T0.AddMinutes(10).Add(Window));
+ view.LastActivityAt.ShouldBe(T0.AddMinutes(10));
_projection.Apply(At(new CartItemQuantityChanged("crit-001", 3), T0.AddMinutes(30)), view);
- view.Deadline.ShouldBe(T0.AddMinutes(30).Add(Window));
+ view.LastActivityAt.ShouldBe(T0.AddMinutes(30));
_projection.Apply(At(new CartItemRemoved("crit-001"), T0.AddMinutes(55)), view);
- view.Deadline.ShouldBe(T0.AddMinutes(55).Add(Window));
+ view.LastActivityAt.ShouldBe(T0.AddMinutes(55));
}
// The conditional delete (Marten ShouldDelete convention): a checked-out cart leaves the list.
diff --git a/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs b/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs
index 412a947..d4ff61e 100644
--- a/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs
+++ b/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs
@@ -6,21 +6,21 @@
namespace CritterMart.Orders.Tests;
// Pure-function unit tests for the OrdersAwaitingPayment todo-list fold (slice 4.7) — no database,
-// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside
-// the other projection fold tests. The Apply takes an IEvent wrapper (Marten's
-// using-metadata convention) so the deadline can be computed from the event's append timestamp;
-// the test constructs the wrapper directly with Event.
+// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside the
+// other projection fold tests. The Apply takes an IEvent wrapper (Marten's using-metadata
+// convention) so it can record the event's append timestamp; the test constructs the wrapper directly
+// with Event. The projection is STATELESS — the visible deadline (PlacedAt + timeout) is applied on
+// read in the endpoint, not here (chore/004; see OrdersAwaitingPayment remarks).
public class OrdersAwaitingPaymentProjectionTests
{
private static readonly OrderLine Plush = new("crit-001", 2, "Cosmic Critter Plush", 24.99m);
- private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(10);
- private readonly OrdersAwaitingPaymentProjection _projection = new(Timeout);
+ private readonly OrdersAwaitingPaymentProjection _projection = new();
- // OrderPlaced creates the row: the order joins the todo-list with a deadline of placement
- // time plus the configured payment timeout.
+ // OrderPlaced creates the row: the order joins the todo-list recording its placement timestamp
+ // (the read endpoint adds the configured timeout to produce the visible deadline).
[Fact]
- public void order_placed_creates_a_row_with_the_deadline()
+ public void order_placed_creates_a_row_recording_placed_at()
{
var view = new OrderAwaitingPayment();
var placedAt = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
@@ -33,7 +33,7 @@ public void order_placed_creates_a_row_with_the_deadline()
view.CustomerId.ShouldBe("customer-X");
view.Total.ShouldBe(49.98m);
- view.Deadline.ShouldBe(placedAt.Add(Timeout));
+ view.PlacedAt.ShouldBe(placedAt);
}
// The conditional delete (Marten ShouldDelete convention): a confirmed order leaves the list.
diff --git a/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs b/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs
index 7e52520..2634752 100644
--- a/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs
+++ b/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs
@@ -200,7 +200,7 @@ await _fixture.Host.Scenario(_ =>
_.Get.Url("/orders/awaiting-payment");
_.StatusCodeShouldBe(200);
});
- var rows = listed.ReadAsJson>()!;
+ var rows = listed.ReadAsJson>()!;
var row = rows.ShouldHaveSingleItem();
row.Id.ShouldBe(orderId);
row.Total.ShouldBe(Total);
@@ -214,6 +214,6 @@ await _fixture.Host.Scenario(_ =>
_.Get.Url("/orders/awaiting-payment");
_.StatusCodeShouldBe(200);
});
- afterSettle.ReadAsJson>()!.ShouldBeEmpty();
+ afterSettle.ReadAsJson>()!.ShouldBeEmpty();
}
}