Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 30 additions & 28 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
</PropertyGroup>
<!-- .NET Aspire 13 (orchestration; ADR 004) -->
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.4.3" />
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.4.3" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.4.3" />
<PackageVersion Include="Aspire.Hosting.RabbitMQ" Version="13.4.3" />
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.RabbitMQ" Version="13.4.6" />
</ItemGroup>
<!-- Observability (OpenTelemetry; ADR 005) -->
<ItemGroup>
Expand All @@ -19,41 +19,43 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
</ItemGroup>
<!-- Critter Stack 2026 line (Wolverine 6 / Marten 9 / JasperFx 2).
HELD AT 6.12.0 — NOT 6.13.x — to match the CritterWatch line installed below. CritterWatch
1.0.0-alpha.3 is compiled against WolverineFx.Marten 6.12.0; loading 6.13.x into the same
process 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 version
CritterWatch was built against. DO NOT let Dependabot bump these to 6.13.x until an upstream
CritterWatch release targets the 6.13 line — that would silently crash the monitoring console
again (diagnosed 2026-06-24; see the dependency-update runbook). -->
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. -->
<ItemGroup>
<PackageVersion Include="WolverineFx" Version="6.12.0" />
<PackageVersion Include="WolverineFx.EntityFrameworkCore" Version="6.12.0" />
<PackageVersion Include="WolverineFx.HealthChecks" Version="6.12.0" />
<PackageVersion Include="WolverineFx.Http" Version="6.12.0" />
<PackageVersion Include="WolverineFx.Marten" Version="6.12.0" />
<PackageVersion Include="WolverineFx.Postgresql" Version="6.12.0" />
<PackageVersion Include="WolverineFx.RabbitMQ" Version="6.12.0" />
<PackageVersion Include="WolverineFx.RuntimeCompilation" Version="6.12.0" />
<PackageVersion Include="WolverineFx" Version="6.16.0" />
<PackageVersion Include="WolverineFx.EntityFrameworkCore" Version="6.16.0" />
<PackageVersion Include="WolverineFx.HealthChecks" Version="6.16.0" />
<PackageVersion Include="WolverineFx.Http" Version="6.16.0" />
<PackageVersion Include="WolverineFx.Marten" Version="6.16.0" />
<PackageVersion Include="WolverineFx.Postgresql" Version="6.16.0" />
<PackageVersion Include="WolverineFx.RabbitMQ" Version="6.16.0" />
<PackageVersion Include="WolverineFx.RuntimeCompilation" Version="6.16.0" />
</ItemGroup>
<!-- EF Core provider — the Identity spike runs Wolverine over EF Core / Npgsql instead of Marten
(the persistence-agnostic teaching beat; a data store per ADR 009, NOT an auth provider).
.NET 10 / EF Core 10 line. -->
<ItemGroup>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<!-- CritterWatch monitoring console + per-service client (out-of-band trial). Pinned to the
1.0.0-alpha line — alpha.3 targets WolverineFx.Marten 6.12.0, which is why the Wolverine
block above is held at 6.12.0 (see that comment). Bump the two together, never apart. -->
<!-- CritterWatch monitoring console + per-service client (out-of-band trial). 1.0.0-beta.1 targets
WolverineFx(.Marten) 6.16.0, which is why the Wolverine block above is pinned to 6.16.0 (see that
comment). Bump the two together, never apart. -->
<ItemGroup>
<PackageVersion Include="CritterWatch" Version="1.0.0-alpha.3" />
<PackageVersion Include="Wolverine.CritterWatch" Version="1.0.0-alpha.3" />
<PackageVersion Include="CritterWatch" Version="1.0.0-beta.1" />
<PackageVersion Include="Wolverine.CritterWatch" Version="1.0.0-beta.1" />
</ItemGroup>
<!-- CritterWatch (still as of 1.0.0-alpha.3) transitively pulls MessagePack 2.5.301, which has a
<!-- CritterWatch (still as of 1.0.0-beta.1) transitively pulls MessagePack 2.5.302, which has a
high-severity CVE (GHSA-hv8m-jj95-wg3x). The fix is MessagePack >= 3.0.214 — a major version
bump CritterWatch has not yet taken. Confirmed unaddressed on 1.0.0-alpha.3 (2026-06-24).
Suppress until an upstream CritterWatch release moves to MessagePack 3.x. -->
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. -->
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-hv8m-jj95-wg3x" />
</ItemGroup>
Expand Down
54 changes: 54 additions & 0 deletions docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md
Original file line number Diff line number Diff line change
@@ -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 `<NuGetAuditSuppress>` 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).
2 changes: 1 addition & 1 deletion src/CritterMart.AppHost/CritterMart.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">

<Sdk Name="Aspire.AppHost.Sdk" Version="13.3.5" />
<Sdk Name="Aspire.AppHost.Sdk" Version="13.4.6" />

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand Down
2 changes: 1 addition & 1 deletion src/CritterMart.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 8 additions & 3 deletions src/CritterMart.Orders/Features/AddToCart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,16 @@ public static async Task<IResult> 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<IResult> GetAwaitingActivity(IQuerySession session)
public static async Task<IResult> 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<CartAwaitingActivity>()
.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);
}
}
11 changes: 8 additions & 3 deletions src/CritterMart.Orders/Features/PlaceOrder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,16 @@ public static async Task<IResult> 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<IResult> GetAwaitingPayment(IQuerySession session)
public static async Task<IResult> 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<OrderAwaitingPayment>()
.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);
}
}
Loading