Using the Wolverine “Side Effect” Model to Simplify Code

First off, let me peel some egg off my face because I had allowed Claude to write quite a bit of code without close enough examination until just now. Arguably, we’re all good because we do have test coverage for the code I just refactored, so it’s all good in the end, but maybe just remember that a human in the loop is a good idea. And also know that all CritterWatch code will be closely reviewed before we flip that to 1.0!

Here’s an HTTP endpoint from CritterWatch, our forthcoming monitoring console for the Critter Stack. It adds a tenant to a monitored service:

[WolverinePost("/api/critterwatch/tenants/{serviceName}/add")]
[Middleware(typeof(RequireMultiTenancyLicense))]
public static async Task AddTenant(
string serviceName,
AddTenantRequest request,
IDocumentSession session,
IMessageBus bus,
[FromServices] AuditLogService auditLog,
HttpContext httpContext)
{
// ... append an event, publish a command ...
await auditLog.LogAsync("AddTenant", serviceName, null,
$"Added tenant '{request.TenantId}' to {serviceName}",
new Dictionary<string, string> { ["tenantId"] = request.TenantId },
initiatedBy: AuditActor.From(httpContext.User));
}

Six parameters. Two of them are there for the audit log alone, and one of those — HttpContext — exists for a single expression: httpContext.User. We don’t read the request, the headers, the response, or anything else on it. We take the entire ASP.NET Core request context as a dependency to get at one ClaimsPrincipal.

That has a cost you feel in the test project. To test this method you need a store, a bus, an audit service and a request context. The audit behaviour — did we record the right action, against the right service, attributed to the right operator? — is only observable by standing all of that up and then querying the audit table afterwards.

Returning the intent instead of performing it

Wolverine has an interface called ISideEffect. It’s about as small as an interface gets:

public interface ISideEffect : IWolverineReturnType, INotToBeRouted;

There’s no method on it. The contract is a convention: return one of these from a handler or HTTP endpoint, and Wolverine will call any public Execute() or ExecuteAsync() method on it after your method returns. The interesting part is what happens to that method’s parameters — Wolverine registers each one as a dependency of the chain and resolves it for you.

So the audit log becomes a record:

public record AuditLog(
string Action,
string ServiceName,
string? TargetUri = null,
string? Details = null,
Dictionary<string, string>? Parameters = null,
string? InitiatedBy = null) : ISideEffect
{
public Task ExecuteAsync(AuditLogService auditLog, ClaimsPrincipal? user)
{
var actor = string.IsNullOrWhiteSpace(InitiatedBy) ? AuditActor.From(user) : InitiatedBy;
return auditLog.LogAsync(Action, ServiceName, TargetUri, Details, Parameters, actor);
}
}

And the endpoint stops mentioning either dependency:

[WolverinePost("/test/audited/{serviceName}")]
public static AuditLog Post(string serviceName)
=> new("TestAction", serviceName, Details: "smoke");

AuditLogService and ClaimsPrincipal are resolved onto the chain because ExecuteAsync asks for them. The endpoint declares neither. The HttpContext parameter didn’t move somewhere else — it stopped existing. The principal is resolved at execution time rather than threaded through a signature that never wanted it.

Pure Functions FTW!

If you want to peruse our published Wolverine Best Practices, we strongly recommend trying to make the behavioral methods of your message handlers or HTTP endpoints be “pure functions” whenever possible. We also recommend trying to simplify code by opting to remove asynchronous code from your handlers as well as a way to reducing noise code, and that was why I reached quickly for the new AuditLog side effect. Combine side effects with other Wolverine goodies like our cascading messages syntax for publishing messages and the aggregate handler workflow and you get the tools to really simplify any application code related to business logic.

And just to make this clear, using pure functions (when possible) is a great approach because that:

  • Isolates business or workflow logic from infrastructure concerns
  • Promotes testability through fast running unit tests
  • Reduces the code noise from asynchronous invocations

When not to reach for this

ISideEffect is for work you want to describe and let the framework perform. It’s a poor fit when the result of the work feeds the rest of your method — if you need the return value, you need the call, and a side effect only runs after you’ve returned.

It’s also not free indirection. A one-line await that nothing else depends on and nobody wants to test in isolation is fine as it is. What made the audit log worth converting was the ratio: two parameters and a request context, carried by five endpoints, to express one fact about an operation that had already happened.

That ratio is the tell. When a dependency exists only to record that something happened — audit, notification, telemetry, an outbound email — you are almost always better off returning a description of it and letting Wolverine make the call.

Summary

Hoo boy, let’s try to summarize this a bit:

  1. I think having a man in the loop for AI built code is still pretty important
  2. Pure functions are great for testability
  3. Code noise has a real cost for your ability to reason about code and how it works — and I think that is still true even with LLMs doing so much more of the grunt work now
  4. I think that our JasperFx curated AI Skills are valuable for developing with Wolverine and the rest of the Critter Stack because it’ll keep your LLM using more idiomatic features that can drastically shrink your code compared to typical .NET codebases, improve testability, and opt into Wolverine or Marten features that enhance performance. You can learn more about our AI Skills here.

Introducing Fisher: Sqlite Backed Document Db & Event Store Critter

I know, you were probably wandering around today and thinking to yourself, my life would be more complete if there was just a library out there that gave you the developer experience of the tried and true Marten library, but backed by Sqlite so you could just get things done on projects that don’t really need a database server.

To that end, let me introduce Fisher, our latest Critter Stack library that is officially our SQLite-backed Event Store and Document Database inside the Critter Stack. I pushed the first Nuget version today as 0.5.0 if you want to pull it down and play with an early version.

Fisher is a document database and event store for .NET, in the same family as Marten and Polecat — except that it runs on SQLite, which means it runs inside your process, and there is no database server anywhere in the picture.

dotnet add package Fisher
builder.Services.AddFisher(opts =>
{
opts.Connection("Data Source=app.db");
});

That’s the whole setup. No container, no connection to a host, no credentials, no waiting for a health check before your integration tests can run. Just go.

Why bother?

The Critter Stack already has two of these. Marten has been running on PostgreSQL for over a decade, and Polecat brought the same model to SQL Server 2025 earlier this year. So why a third?

Because I thought this would be a valuable persistence option for our commercial CritterWatch tool to help adoption, and also as a persistence option for an “AI-related commercial development tool to be named later” from JasperFx.

Because a meaningful number of .NET applications don’t want a database server, and up to now the answer from us was “well, use one anyway.” Think about:

  • Desktop and CLI applications
  • Edge and on-premises deployments where somebody else operates the box
  • Single-node services that will never scale out and shouldn’t pretend they might
  • Embedded reporting

It’s the same API

This is not a new library with a familiar accent. It implements the same JasperFx.Events abstractions the other two do, so a projection you wrote for Marten runs on Fisher unaltered:

// Documents
session.Store(new User { FirstName = "Jane", LastName = "Doe" });
await session.SaveChangesAsync();
var users = await session.Query<User>()
.Where(x => x.LastName == "Doe")
.ToListAsync();
// Eventsvar stream = session.Events.StartStream<Order>(new OrderPlaced("Acme", 199.95m));
await session.SaveChangesAsync();
var order = await session.Events.AggregateStreamAsync<Order>(stream.Id);

Fisher passes all 32 suites and 272 tests of JasperFx.Events.ComplianceTests, the shared cross-store suite Marten and Polecat also enroll in. That’s not me grading my own homework — it’s the same definition of correct that the other two are held to.

What’s in the box for 0.5.0: documents over all four identity types plus strong-typed wrappers, hierarchies, soft deletes, optimistic concurrency in both flavors, patching, bulk insert, duplicated fields, indexes, foreign keys, and a LINQ provider that does joins, grouping, aggregates and both paging styles. On the event side: every projection shape across every lifecycle, the async projection daemon, subscriptions, DCB tags, natural keys, event data masking, stream compacting, and both tenancy styles. Plus Fisher.AspNetCore and Fisher.EntityFrameworkCore.

Why “Fisher?”

This is a Fisher (sometimes called a “Fisher Cat”), yet another member of the Mustilidae family and essentially a “big marten”:

Some important details along the way…

Like I said earlier, Marten has been around for over a decade now and it’s been the most successful OSS project of my career (StructureMap has more downloads, but who cares, there’s a bazillion perfectly decent IoC containers out there). We added Polecat earlier this year to finally extend our event sourcing support to SQL Server using Marten’s API and usage as a pattern. Supporting Sqlite seemed like the obvious next step to have a true embedded database option for some of JasperFx’s work — plus Babu has been advocating for that for awhile!

Along the way as you might expect, we’ve made some intermediate steps to make this new multiple database engine support possible and hopefully sustainable over the long run:

  1. As part of Marten 8.0 last year, a great deal of the abstractions, projections support, and plenty of non-PostgreSQL dependent code for event sourcing in Marten was pulled out into a now shared JasperFx.Events library
  2. I purchased a Claude Max plan for JasperFx, just to be honest here
  3. Polecat 1/2/3 was built against JasperFx.Events in essentially a “just copy Marten” AI prompt
  4. As part of Marten 9.0, we pulled as much common code between Marten and Polecat into lower level, shared Weasel libraries
  5. For Polecat 5.0, we lifted a new shared Weasel.Storage library out of Marten, then shared that dependency with Polecat to standardize a lot more of the internal mechanics of the two libraries and eliminate some Polecat specific code. My hope was and is that that effort will make it easier for us to address problems or even enhancements in a generic way
  6. Recently, we also lifted quite a few automated tests in Marten as a new “event sourcing and document database” compliance test suite that we now share between Marten, Polecat, and Fisher. That effort flushed out some inconsistencies and a few bugs in Polecat, now fixed.
  7. Fisher was mostly built to the new compliance test suites

Again, just to be honest, I don’t think that either Polecat or Fisher would have been economically feasible without the heavy utilization of the AI assisted development. And also, as always, I think the AI assisted development goes a lot better when you can supply very clear acceptance criteria like the compliance tests.

TimescaleDB Support within Marten

This is a recent addition to Marten. We’ve started supporting many of the common PostgreSQL extensions that hare frequently supported by the major cloud providers. Outside of metrics collection or sensor data, I don’t have a great handle on what folks might use this for, so I’d love to hear from other folks what they’d want to do with TimescaleDB.

TimescaleDB support lets Marten turn its tables into TimescaleDB hypertables — automatically time-partitioned tables with columnar compression, retention policies, and continuous aggregates. It ships in the core Marten package under the MIT license, scoped behind its own Marten.TimescaleDB namespace, and is entirely opt-in at runtime via UseTimescaleDB() — stores that never call it pay nothing.

What it gives you today:

  • a one-line UseTimescaleDB() opt-in that registers the timescaledb extension on every database Marten manages
  • ProjectionAsHypertable<T>() to turn a time-bucketed flat table projection into a hypertable, with configurable chunk interval, compression, retention, and continuous aggregates
  • DocumentAsHypertable<T>() to turn an append-heavy document table (audit logs, metrics, activity records) into a hypertable partitioned by one of its own timestamp members
  • full participation in Marten’s schema migration model — the hypertable, its policies, and its continuous aggregates are created idempotently through the normal ApplyAllConfiguredChangesToDatabaseAsync path, and do not show up as drift on subsequent migrations

Requirements

The feature ships in core Marten, so there is no separate package to install — reach it with using Marten.TimescaleDB; and enable it with UseTimescaleDB().

TimescaleDB is a loadable module: unlike PostGIS or pgvector it must be listed in shared_preload_libraries before CREATE EXTENSION timescaledb will succeed. The official timescale/timescaledb images already do this. This repo ships docker-compose.timescaledb.yml (which runs on port 5433 so it can coexist with the main dev database) for local development, and a dedicated CI workflow using the timescale/timescaledb-ha image.

Enabling TimescaleDB on a store

using Marten;
using Marten.TimescaleDB;
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
// Registers CREATE EXTENSION IF NOT EXISTS timescaledb on every database
opts.UseTimescaleDB();
});

Flat table projections as hypertables

The cleanest, highest-value fit is a flat table projection that rolls up events into a time-bucketed table — per-minute/per-hour metrics, IoT rollups, activity counters, and the like. Because the projection’s table is written by the async daemon (or inline), TimescaleDB then gives you time-chunked storage, columnar compression of old chunks, continuous aggregates for dashboards, and automatic retention — all declaratively.

opts.Projections.Add(new MetricsProjection(), ProjectionLifecycle.Async);
opts.UseTimescaleDB(ts =>
{
ts.ProjectionAsHypertable<MetricsProjection>("captured_at", hyper =>
{
hyper.ChunkInterval = TimeSpan.FromHours(1);
hyper.CompressAfter = TimeSpan.FromDays(30);
hyper.RetainFor = TimeSpan.FromDays(365);
hyper.ContinuousAggregate("hourly_metrics", "1 hour",
"avg(value) as avg_val, max(value) as max_val");
});
});
public class MetricsProjection: FlatTableProjection
{
public MetricsProjection(): base("sensor_metrics", SchemaNameSource.EventSchema)
{
// The single primary key IS the time column — see the constraint below.
Table.AddColumn<DateTimeOffset>("captured_at").AsPrimaryKey();
Table.AddColumn<double>("value").NotNull();
Project<SensorReadingRecorded>(map =>
{
map.Map(x => x.Value, "value");
}, tablePrimaryKeySource: x => x.CapturedAt);
}
}

Configuration options

PropertyMaps toNotes
ChunkIntervalcreate_hypertable(..., chunk_time_interval => ...)Width of each time chunk. Defaults to TimescaleDB’s own default (7 days).
CompressAfterALTER TABLE ... SET (timescaledb.compress ...) + add_compression_policy(...)Enables columnar compression of chunks older than this age.
CompressSegmentBy / CompressOrderBycompression settingsOptional segment-by / order-by keys. Order-by defaults to the time column DESC.
RetainForadd_retention_policy(...)Drops chunks older than this age.
ContinuousAggregate(view, bucket, select, groupBy?)CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)A self-refreshing rollup view. Marten creates the view WITH NO DATA; set the refresh policy (add_continuous_aggregate_policy) with your own operational tooling.

Compression / retention policies are applied on creation only

The compression and retention settings (CompressAfterRetainForCompressSegmentBy/CompressOrderBy) are emitted when the hypertable is first created. Later changes to those values are not diffed and re-applied on subsequent migrations — adjust an existing policy with TimescaleDB’s own add_/remove_compression_policy / add_/remove_retention_policy functions (or drop and recreate the hypertable). The hypertable, its policies, and its continuous aggregates are otherwise created idempotently and do not show up as drift.

The partition column must be the projection’s primary key

TimescaleDB requires the partitioning column to participate in every unique/primary key on a hypertable. A FlatTableProjection always has exactly one primary-key column and upserts ON CONFLICT against it, so the only shape that maps cleanly onto a hypertable is one where that single primary-key column is the time column (a time-bucketed rollup). If you configure ProjectionAsHypertable against a projection whose primary key is something else (e.g. the stream id), Marten fails fast at schema-application time with a descriptive error rather than letting TimescaleDB reject the create_hypertable call.

Document tables as hypertables

Append-heavy document types — audit logs, metrics, activity records — can be stored in a hypertable partitioned by one of their own timestamp members:

opts.UseTimescaleDB(ts =>
{
ts.DocumentAsHypertable<AuditEntry>(x => x.CreatedAt, hyper =>
{
hyper.ChunkInterval = TimeSpan.FromDays(1);
hyper.CompressAfter = TimeSpan.FromDays(30);
hyper.RetainFor = TimeSpan.FromDays(365);
});
});

Because TimescaleDB requires the partition column to be part of the primary key, DocumentAsHypertableduplicates the selected member into a NOT NULL column and adds it to the document table’s primary key, making it (id, created_at). Marten’s own schema model is updated to match, so there is no schema drift, and the generated upsert / update / delete SQL picks the composite key up automatically (the same machinery that backs list- and range-partitioned document tables).

The partition member must be immutable

Because the timestamp is now part of the primary key, it must not change for a given document id. Marten’s update path matches on the full primary key, so mutating the timestamp after the first Store would fail to find the existing row. DocumentAsHypertable is intended for append-heavy types whose timestamp is set once on creation and never modified. Loading and deleting by id still work (there is exactly one row per id), though a load by id alone cannot use chunk exclusion and will scan all chunks — query by the time column, or by id plus a time range, for time-partitioned performance.

Multi-tenancy

Hypertables work with Marten’s conjoined (single-table) tenancy — the tenant column is just another column on the chunked table. For database-per-tenant, each tenant database needs the timescaledb extension; UseTimescaleDB() registers it on every database Marten manages, so this is handled for you.

Declarative Testing Helper for Marten or Polecat Projections

We’ve had an undocumented until now API in Marten for years called EventProjectionScenario for declarative testing of Marten projections — somewhat based on the Scenario usage in our Alba library for ASP.Net Core testing. As part of some cleanup this week, I finally added some documentation and lifted that to where Polecat (Event Sourcing with SQL Server) can use it as well.

I’m more curious than anything to get some feedback here if anyone things this would be useful. After CritterWatch 1.0 lands, my attention is going to turn to the Critter Stack’s story for “Spec Driven Development,” and maybe this feature will be part of that.

Scripted Scenarios with EventProjectionScenario

For a more declarative way to test a projection end to end, Marten has a built-in scenario runner on IDocumentStore.Advanced that scripts a sequence of event appends and document assertions, then executes the whole sequence for you:

[Fact]
public async Task happy_path_test_with_inline_projection()
{
// This is from a shared testing context class we use
// to test Marten itself. This is just a short cut to say
// if I have a DocumentStore configured like this...
StoreOptions(opts =>
{
opts.Projections.Add(new UserProjection(), ProjectionLifecycle.Inline);
});
await theStore.Advanced.EventProjectionScenario(scenario =>
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var id3 = Guid.NewGuid();
scenario.Append(Guid.NewGuid(), new CreateUser {UserId = id1, UserName = "Kareem"});
scenario.Append(Guid.NewGuid(), new CreateUser {UserId = id2, UserName = "Magic"});
scenario.Append(Guid.NewGuid(), new CreateUser {UserId = id3, UserName = "James"});
scenario.DocumentShouldExist<User>(id1);
scenario.DocumentShouldExist<User>(id2);
// In this usage you can make assertions against the
// expected state of the projected document
scenario.DocumentShouldExist<User>(id3, user => user.UserName.ShouldBe("James"));
scenario.Append(Guid.NewGuid(), new DeleteUser {UserId = id2});
scenario.DocumentShouldExist<User>(id1);
scenario.DocumentShouldNotExist<User>(id2);
scenario.DocumentShouldExist<User>(id3);
}, TestContext.Current.CancellationToken);
}

The scenario works with any projection lifecycle. If the store has any asynchronous projections registered, the scenario quietly spins up a projection daemon, waits for it to catch up after each batch of appended events, and shuts it down afterward — your test code looks identical either way.

A few things to know about how a scenario executes:

  • The Append() / StartStream() / AppendEvents() calls queue work — nothing touches the database until the scenario executes. The StartStream() overloads that generate their own stream id return that Guid so you can capture it for later assertions.
  • Consecutive appends are batched into a single commit; the pending work is saved whenever the next step is an assertion, and once more at the end of the scenario.
  • Assertion steps run against a query session after the projected data is up to date. DocumentShouldExist<T>() and DocumentShouldNotExist<T>() cover the common cases, and AssertAgainstProjectedData() is the general purpose hook for anything else.
  • If an action step fails, the scenario stops immediately — the remaining steps would be running against a state nobody intended. Failed assertions accumulate instead, and everything is reported at the end in a single ProjectionScenarioException that lists each step and what went wrong. Assertion failures inside the aggregate are typed as ProjectionScenarioAssertionException so tooling can tell them apart from infrastructure failures.

WARNING

By default the scenario deletes all event data plus the storage for every registered projection before it runs, so each scenario starts from a clean slate. Only use this feature against a test database! To run a scenario on top of existing data instead, set scenario.DeleteExistingData = false.

The scenario object exposes a few knobs:

await theStore.Advanced.EventProjectionScenario(scenario =>
{
// Keep any existing event/projection data (the default is to wipe it)
scenario.DeleteExistingData = false;
// Apply the whole scenario to one tenant when using multi-tenancy
scenario.TenantId = "tenant1";
// Maximum time to wait for async projections to catch up
// after each batch of events. The default is 30 seconds
scenario.Timeout = 5.Seconds();
// ... queue up appends and assertions
});

We’ll also have the “Projection Stepper” feature in CritterWatch that will allow you to step through a series of events to see how a projection creates and modifies its view event by event. That functionality is part of the CritterWatch user interface, but also exposed via an MCP endpoint on CritterWatch for easy access for AI agents building and troubleshooting Critter Stack applications using Event Sourcing.

Binary Event Serialization for Marten

This is a potentially big performance optimization you can opt into starting with Marten 9.0. Not coincidentally, we’re using this for CritterWatch to help optimize the responsiveness and database size for a JasperFx client this week.

Marten can serialize individual event types to a binary wire format (MemoryPackMessagePack, or anything else implementing IEventBinarySerializer) instead of the default JSON, trading a few of JSON’s ergonomic wins for a meaningful throughput and storage-size improvement on hot streams. See #4515 for the design discussion.

The opt-in is per event type — binary-serialized and JSON-serialized events coexist in the same mt_events table, so the feature can be rolled out on an existing store with no migration of existing data.

How it works

A second column, bdata bytea NULL, sits alongside the existing data jsonb NOT NULL on mt_events. The row-level discriminator is bdata IS NULL:

Whendatabdata
Event uses the JSON serializerfull JSON payloadNULL
Event uses an IEventBinarySerializerthe placeholder '{}'::jsonbthe serialized bytes

On read, Marten inspects bdata:

  • NULL → existing JSON deserialization path. Pre-feature rows continue to work without conversion.
  • non-null → IEventBinarySerializer.Deserialize(eventType, bytes).

Because the discriminator is on the row and the serializer is resolved per event type, the same stream can carry rows of either format with no special handling at the call site.

Quick start with Marten.MemoryPack

The companion Marten.MemoryPack NuGet package ships a ready-to-use IEventBinarySerializer over MemoryPack:

dotnet add package Marten.MemoryPack

Mark event types you want to serialize as binary with both [BinaryEvent] (so Marten picks them up) and [MemoryPackable] (so MemoryPack can serialize them):

using Marten.Events;
using MemoryPack;
[BinaryEvent]
[MemoryPackable]
public partial record TripStarted(Guid TripId, string DriverName, DateTimeOffset StartedAt);

Wire MemoryPack as the store-wide fallback for [BinaryEvent] types:

using Marten.MemoryPack;
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
// Wire MemoryPack as DefaultBinarySerializer. [BinaryEvent]-marked
// event types resolve to this serializer on registration. Works with
// every EventAppendMode (Rich / Quick / QuickWithServerTimestamps)
// and with BulkEventAppender — see the "Append modes" section.
opts.Events.UseMemoryPackSerializer();
});

Now TripStarted writes through MemoryPack to bdata; un-marked events continue to write JSON to data.

Registration ergonomics

Two equivalent ways to opt an event type in:

// 1. Attribute-driven — uses opts.Events.DefaultBinarySerializer as the resolver.
[BinaryEvent]
[MemoryPackable]
public partial record TripEnded(Guid TripId, DateTimeOffset EndedAt);
// 2. Fluent — wire an explicit per-type serializer (overrides any default).
opts.Events.UseBinarySerializer<TripEnded>(new MemoryPackEventSerializer());

Resolution order on EventMapping construction:

  1. Explicit opts.Events.UseBinarySerializer<TEvent>(...) for that type.
  2. [BinaryEvent] attribute + opts.Events.DefaultBinarySerializer.
  3. Otherwise, plain JSON (existing path).

If a type carries [BinaryEvent] but no per-type serializer was wired AND DefaultBinarySerializer is null, Marten throws at the first append with a remediation message naming both registration entry points.

Bring your own serializer

IEventBinarySerializer is small enough to implement directly against any binary format — MessagePack, protobuf, etc.:

public interface IEventBinarySerializer
{
byte[] Serialize(Type type, object data);
object Deserialize(Type type, byte[] data);
}

The serializer is a singleton — keep its state thread-safe.

On-disk shape

For binary events, data holds the literal {} placeholder so the existing data jsonb NOT NULL constraint stays intact (no schema relaxation):

-- binary-serialized event
select type, data::text, bdata is null
from mt_events where seq_id = 42;
-- type | data | bdata is null
-- --------------|------|---------------
-- trip_started | {} | false
-- JSON-serialized event in the same stream
select type, data::text, bdata is null
from mt_events where seq_id = 43;
-- type | data | bdata is null
-- --------------------- |---------------------------------|---------------
-- trip_comment_added | {"comment": "looking good", …} | true

Migration

Purely additive: the only schema change is bdata bytea NULL on mt_events. Existing rows have bdata = NULL (the column’s default for prior data) and read through the JSON path. Marten’s standard schema migration creates the column for existing installations — no event data conversion required.

Append modes

Binary event serialization works with every EventAppendMode Marten ships — RichQuick, and QuickWithServerTimestamps. The Quick modes route appends through the mt_quick_append_events PostgreSQL function, which carries a bdatas bytea[] parameter that’s inserted into mt_events.bdata in parallel with the existing bodies jsonb[]BulkEventAppender (the COPY-based bulk loader) also supports binary events — its COPY column list includes bdata, and each event row writes either the binary payload or NULL.

You don’t have to think about the append mode: binary opt-in is per event type and works identically across all of them.

Schema evolution — use versioned event types

Marten’s existing event upcasters operate on the JSON wire form and don’t generalize to a byte[] payload, so they don’t apply to binary events. The recommended pattern for evolving a binary event’s shape is introduce a new event type for each version rather than upcasting in place:

// Original
[BinaryEvent]
[MemoryPackable]
public partial record TripStarted(Guid TripId, string DriverName);
// Schema change — new fields. Don't edit TripStarted; add a new type.
[BinaryEvent]
[MemoryPackable]
public partial record TripStartedV2(Guid TripId, string DriverName, DateTimeOffset StartedAt);

When the projection / aggregate handles both versions explicitly, old streams keep replaying through the old type and new appends use the new type:

public class Trip
{
public Guid Id { get; set; }
public string DriverName { get; set; } = "";
public DateTimeOffset? StartedAt { get; set; }
public void Apply(TripStarted e) { Id = e.TripId; DriverName = e.DriverName; }
public void Apply(TripStartedV2 e) { Id = e.TripId; DriverName = e.DriverName; StartedAt = e.StartedAt; }
}

The coexistence design lets old rows (written as TripStarted) and new rows (written as TripStartedV2) live on the same stream without migration.

Why not in-place backward-compatible schema changes?

You can lean on MemoryPack’s backward-compatible field evolution ([MemoryPackOrder], nullable fields, the VersionTolerant mode) for additive-only changes to a single event type. That works as long as the serializer itself can deserialize old payloads into the new shape — but the moment a change goes beyond the serializer’s tolerance rules (renaming, type changes, splitting a field), there’s no JSON-style upcaster path to fall back on. Versioning the event type works for every shape of change and stays explicit about which version each row was written with.

Mixing binary + JSON

If you have an existing JSON-serialized event and want a future version to go binary, the same pattern applies: define a new [BinaryEvent]-marked type for the new version, leave the old (JSON) type and its upcasters alone, and have the aggregate handle both. The per-row dispatch already copes with mixed formats on the same stream.

See also

Fast Web Services with Marten and Polecat

In many .NET systems, writing a web service that returns query results means some combination of:

  1. Query data from EF Core — which is going to do who knows what to build up SQL, execute that, then spend some time materializing the raw database results into .NET objects
  2. Since we’ve all been taught for years that it’s harmful to expose our internal entity shapes to the outside world, maybe you’re running the results through some kind of object to object mapping to a different DTO shape
  3. Finally, after all the database querying and object mapping, you’ll finally use a JSON serializer to write results to the HTTP response stream

Whew. That’s a non-trivial amount of your time (or AI tokens) and a significant amount of runtime overhead with all the transformations and thrashing your memory with all the object allocations involved.

Now let’s talk about some capabilities in Marten and Polecat to sidestep the mass majority of that overhead in some cases — but first, I do need to say that if you’re using Event Sourcing, the persisted data in a Marten or Polecat database for query models is purpose built for clients as it is. No extra mapping necessary. In a way, the “AutoMapper” activity happens directly in projections for a system using Event Sourcing.

If you are building HTTP services on top of Marten or Polecat, both of these tools have a “JSON Streaming” feature that can be used to build very fast web services by writing the raw JSON stored in PostgreSQL or SQL Server directly to the HTTP response for the most efficient possible HTTP web services in the read side of a CQRS architecture.

Core team member Anne Erdtsieck just made some a bunch of extensions to Marten and Polecat‘s ability to stream the raw, persisted JSON data stored in the database straight to HTTP responses, and that makes now a good time to show off what we have.

For Minimal API endpoints (and for frameworks like Wolverine.Http that dispatch any IResult return value), Marten.AspNetCore (Polecat.AspNetCore has similar support) ships seven typed result wrappers that carry the streaming behavior above as endpoint return values while also contributing correct OpenAPI metadata:

TypeSourceResponse shape404 on miss?
StreamOne<T>IQueryable<T> — regular Marten document querySingle Tyes
StreamMany<T>IQueryable<T> — regular Marten document queryJSON array T[]no (empty array = 200)
StreamAggregate<T>IDocumentSession + stream id — event-sourcedSingle Tyes
StreamPaged<T>IQueryable<T> — regular Marten document queryPaged JSON envelopeno (empty page = 200)
StreamPagedByCursor<T>IQueryable<T> (with OrderBy/ThenBy)no (empty array = 200)
StreamEventStateIQuerySession + stream id — event streamSingle StreamStateResponseyes
StreamEventsIQuerySession + stream id — event streamJSON array EventResponse[]yes (configurable)

Each type implements both IResult (so ASP.NET Minimal API dispatches it via ExecuteAsync) and IEndpointMetadataProvider (so Swashbuckle, NSwag, and the built-in OpenAPI generator see the right response shape), while delegating the actual body write to WriteSingle/WriteArray/WriteLatest/WriteStreamState/WriteEvents. Returning one from an endpoint is a concise, typed alternative to writing the HTTP handshake manually.

StreamOne<T> — single document with 404 on miss

app.MapGet("/issues/{id:guid}",
(Guid id, IQuerySession session) =>
new StreamOne<Issue>(session.Query<Issue>().Where(x => x.Id == id)));

Returns 200 application/json with the document JSON on a hit, 404 on a miss. Content-Length and Content-Type are set automatically, matching the behavior of WriteSingle<T>.

StreamMany<T> — JSON array

app.MapGet("/issues/open",
(IQuerySession session) =>
new StreamMany<Issue>(session.Query<Issue>().Where(x => x.Open)));

Returns 200 application/json with a JSON array body. An empty result set yields [], not a 404 — matching the behavior of WriteArray<T>.

StreamPaged<T> — paged JSON envelope (single round trip)

app.MapGet("/issues/paged/{pageNumber:int}/{pageSize:int}",
(int pageNumber, int pageSize, IQuerySession session) =>
new StreamPaged<Issue>(session.Query<Issue>().OrderBy(x => x.Description), pageNumber, pageSize));

Returns 200 application/json with a single JSON envelope combining paging metadata and the matching documents for that page:

{"pageNumber":3,"pageSize":25,"totalItemCount":1207,"pageCount":49,"hasNextPage":true,"hasPreviousPage":true,"items":[...]}

pageNumber is 1-based. totalItemCount and pageCount are computed from a count(*) OVER() window function added to the same SQL query that fetches the page, so the whole response — count and documents both — comes from a single database round trip. Documents inside items are streamed as raw, already-persisted JSON, without a deserialize/serialize step. An empty page still returns 200 with totalItemCount: 0pageCount: 0, and an empty items array — never a 404.

Internally, StreamPaged<T> delegates to the IQueryable<T>.StreamPagedJsonArray() extension method described in the Paging docs, which can also be used directly (e.g. from an MVC controller action) instead of through the IResult wrapper.

StreamAggregate<T> — event-sourced aggregate (latest)

app.MapGet("/orders/{id:guid}",
(Guid id, IDocumentSession session) =>
new StreamAggregate<Order>(session, id));

Returns 200 application/json with the JSON of the latest projected aggregate state, or 404 if no stream exists. A constructor overload accepts string ids for stores configured with string-keyed streams.

StreamEventState — event stream metadata

Writes the high level metadata of a single event stream — Marten’s StreamState — as JSON, or 404 when the stream does not exist:

app.MapGet("/minimal/order/{id:guid}/state",
(Guid id, IQuerySession session)
=> new StreamEventState(session, id));

A constructor overload accepts a string stream key for stores configured with string-keyed streams.

The response body is a StreamStateResponse, not StreamState itself. StreamState.AggregateType is a System.Type, and System.Text.Json refuses to serialize those outright (Serialization and deserialization of 'System.Type' instances is not supported), so the aggregate type is projected down to its simple name in AggregateTypeName:

{
"id": "0198e1b4-5b1c-7a1e-9a3f-2f2f5b6c7d8e",
"key": null,
"version": 2,
"aggregateTypeName": "Order",
"lastTimestamp": "2026-07-26T09:41:02.113Z",
"created": "2026-07-26T09:41:02.098Z",
"isArchived": false
}

StreamEvents — raw events of a stream 9.20

Writes the raw events of a single event stream as a JSON array:

app.MapGet("/minimal/order/{id:guid}/events",
(Guid id, IQuerySession session)
=> new StreamEvents(session, id));

StreamEvents carries the same optional versiontimestamp, and fromVersion filters as FetchStreamAsync(), and there is a string stream key overload as well.

Elements are EventResponse, not IEvent itself — IEvent.EventType is a System.Type and hits the same System.Text.Json wall as above. Use eventTypeName, Marten’s stable event type alias, to discriminate event types on the client. The assembly qualified .NET type name (DotNetTypeName) is deliberately left off the wire:

[
{
"id": "0198e1b4-5b1c-7a1e-9a3f-2f2f5b6c7d8e",
"version": 1,
"sequence": 41,
"streamId": "0198e1b4-5b1c-7a1e-9a3f-2f2f5b6c7d8e",
"streamKey": null,
"eventTypeName": "order_placed",
"timestamp": "2026-07-26T09:41:02.098Z",
"tenantId": "*DEFAULT*",
"isArchived": false,
"causationId": null,
"correlationId": null,
"headers": null,
"data": { "description": "Widget", "amount": 99.95 }
}
]

Empty streams: 404 or an empty array?

FetchStream yields an empty list both for a stream that does not exist and for a filter that excludes every event, and the two cannot be told apart. StreamEvents therefore exposes an OnEmptyStatus that defaults to 404, matching the other single-resource results. Set it to 200 when running off the end of a stream is expected rather than exceptional — paging forward with fromVersion, for example:

// Paging forward through a stream: running off the end is expected, not a 404
app.MapGet("/minimal/order/{id:guid}/events/from/{fromVersion:long}",
(Guid id, long fromVersion, IQuerySession session)
=> new StreamEvents(session, id, fromVersion: fromVersion)
{
OnEmptyStatus = StatusCodes.Status200OK
});

Ten Days of Critter Stack Releases

Most of the big improvements in this blog post came from JasperFx client engagements. Reach out any time to sales@jasperfx.net and we’ll happily chat with you about how we can help your shop succeed with whatever technical challenges you might have!

Let me put a stake into the ground here and say that you simply cannot (yet) vibe code yourself an equivalent of the “Critter Stack” because so much of our deep quality is the direct result of adapting to real life usages and problems over years of constant usage and continuous improvement. In the past we’ve worked with JasperFx clients or the community on issues caused by database maintenance shutdowns, way too many Kubernetes related things as pods spin up and down, and all kinds of unexpected real like episodes that have all directly led to real improvements in the tools. We’ve faced issues from sudden system surges due to unexpectedly big system inputs like import files. We’ve had to endlessly harden MartenPolecat, and Wolverine against infrastructure hiccups and random disconnects our users have faced in real life usage. You simply cannot get that level of built in quality by “rolling your own” over a long weekend.

The last ten days have been one of the heaviest release stretches in the history of the critter stack. Every repository in the family shipped, and the three headline stories are all about the same thing: what happens when a system is under real load, on real hardware, at real scale, and something goes wrong.

Here’s what moved:

PackageWhere we were on July 17Where we are today
Wolverine6.20.06.23.1
Marten9.16.09.20.0
Polecat5.1.05.7.0
Weasel9.16.49.19.0
JasperFx / JasperFx.Events2.28.02.36.2

That’s 4 Wolverine releases, 5 Marten releases, 6 Polecat releases, 3 Weasel releases, and 9 JasperFx releases in ten days. Below are the three things I most want you to know about, followed by everything else.


1. Marten’s Async Daemon Tells You Why It Stopped

This work was inspired by helping a JasperFx client troubleshoot issues last week

The single most frustrating failure mode in an event-sourced system is the silent one: a projection stops advancing, and the only evidence is a chart that flatlines. The daemon knew perfectly well what happened — it just had nowhere to put it.

Classified shard failures

This will be exposed through the CritterWatch user interface and MCP tools in the 1.0 RC release

The async daemon now classifies why a shard is paused or stopped and persists it, so a monitoring tool polling the database sees exactly what an in-process observer sees:

var states = await store.Storage.Database.AllProjectionProgress();
foreach (var state in states.Where(x => x.Failure != null))
{
    // ApplyEvent, EventSerialization, UnknownEventType, ProgressionOutOfOrder, or Other
    Console.WriteLine($"{state.ShardName}: {state.Failure!.Category} on {state.Failure.Event}");
}

ShardFailure is a plain, serializable record — category, the failing event’s sequence and type, the exception message and detail — deliberately not an Exception, so it survives the trip to a monitoring UI. Extended progression tracking grew four new columns to carry it (failure_categoryfailure_event_sequencefailure_event_typefailure_event_tenant_id), and failure_category stores the enum name rather than its ordinal so reordering the enum in a future release can never silently re-label rows an older deployment wrote.

The distinction between categories is the whole point. EventSerialization means a stored body won’t deserialize — you need a serializer or data fix. UnknownEventType means an event alias resolves to no known .NET type in this deployment — usually a missing registration or a rollback past the point where that event type was introduced. Those are different problems with different fixes, and the daemon now says which one you have. A shard that recovers clears its failure columns on the next successful start, so a supervisor built on this doesn’t keep alerting on something you fixed an hour ago.

Graceful shutdown and the drain timeout

This improvement will also help a great deal when Marten/Wolverine decides to rebalance work across a cluster of nodes as you might be scaling up or down

When a shard is stopped, the daemon doesn’t simply cancel it — it drains: lets the in-flight page of events finish applying, then flushes the progression row so the next start picks up exactly where this one left off. If that drain gets cut short, the shard restarts against a stale progression row and throws ProgressionProgressOutOfOrderException. That’s now bounded, per shard, and configurable:

// The default is 5 seconds
opts.Projections.StopAndDrainTimeout = 30.Seconds();

The motivating case is a database-per-tenant deployment with thousands of (projection × tenant) shards all trying to drain inside a Kubernetes termination grace window. A per-shard bound only helps if the process lives long enough to spend it, so pair a raised StopAndDrainTimeout with HostOptions.ShutdownTimeout and the pod’s terminationGracePeriodSeconds. Full write-up in Graceful Shutdown and the Drain Timeout.

The high-water health check became more effective

This part really only impacts users using the new per-tenant event store partitioning — but that’s going to be one of our answers for extreme scalability needs*

The high-water health check previously probed every database in a multi-tenanted store on every probe — a connection fan-out that gets ugly at a few hundred shard databases, and outright wrong when daemon distribution is spread across nodes and a node ends up probing databases it doesn’t host:

Services.AddHealthChecks().AddMartenHighWaterHealthCheck(
    staleThreshold: TimeSpan.FromSeconds(30),

    // Only probe the databases this node actually owns
    databaseFilter: db => LocallyOwnedDatabaseIdentifiers.Contains(db.Identifier),

    // Assert even under DaemonMode.ExternallyManaged (i.e. Wolverine-managed distribution)
    includeExternallyManaged: true);

Under UseTenantPartitionedEvents the check now evaluates the per-tenant HighWaterMark:<tenant> progression rows too, using the liveness heartbeat signal (the sequence-gap fallback is store-global and can’t be applied per tenant).

More reliable integration testing against asynchronous projections

This gobbledygook should really translate to “automated testing against asynchronous projections just got faster and more reliable”

Two long-tail concurrency bugs went with it: the high-water agent’s lost-wakeup race was closed rather than narrowed (jasperfx#572), and a WaitForShardState race against an already-published state was fixed (jasperfx#568). On the PostgreSQL side, Marten 9.20 added an allocation fence so an idle advisory-lock session can no longer hold gap skips open forever (marten#4953) — a fix that had a direct Wolverine counterpart, more on that below.


2. Wolverine’s Agent Assignment Got Its Hard Lesson

We’ve had confirmation from a JasperFx client that these changes made a dramatic improvement in how Wolverine behaved in a hugely complicated system, but I expect this to be an improvement for plenty of other users as well

This one started as an incident report (from a pretty extremely complicated usage well beyond what most people will ever experience) and turned into a nine-part fix.

The setup: a Wolverine cluster distributing thousands of Marten subscription and projection agents across nodes, under rebuild load. The symptom: Wolverine basically panicked and continuously tried to start, stop, and re-assign agents to diffent nodes because it couldn’t tell if anything was healthy or not. Nodes were being ejected while very much alive, resurrecting under new identities, and the leader re-sent the same assignments forever while nothing actually started. The database was taking roughly 96,000 telemetry inserts an hour from the churn alone — into the very database the rebuild was already saturating.

Nine separate defects fed that livelock. All of them are fixed:

The heartbeat was starved by its own work. The node heartbeat was written as the first step of the health-check loop, which also drained agent commands serially. A leader spending sixty seconds burning reply timeouts while starting thousands of subscription agents therefore delayed its own next heartbeat past StaleNodeTimeout — looking dead to its peers precisely when it was doing the most work. The heartbeat now runs on its own independent loop, so no amount of slow command work can starve it.

Resurrection restored a skeleton, not a node. When a peer deleted a still-live node’s row, every store blindly re-inserted a skeleton: fresh node number, empty capabilities, no assignments. A capability-less node is a candidate for nothing, so a 3-node cluster silently shrank to 2 for event-subscription work. MarkHealthCheckAsync now reports existence without ever inserting, and the controller re-registers with the node’s real number, its captured capabilities, and its agent assignment rows — implemented across all nine persistence stores (PostgreSQL, SQL Server, MySQL, SQLite, Oracle, RavenDB, CosmosDB, and the two in-memory/multi-tenanted wrappers).

A 2,100-agent batch answered by a 30-second timeout. Assignments went out as one mega-batch and were started serially on the receiving node — where each Marten subscription-agent start is a daemon shard spin-up with database round trips. That is hours of serial work answered by a 30-second reply window: the reply can never arrive, so the leader records nothing and re-sends the whole ~300KB batch next cycle. Batches are now chunked, started with bounded parallelism, and the reply timeout scales with chunk size.

The leader re-emitted assignments it had already sent. A leader-side pending-assignment ledger now suppresses duplicate AssignAgent commands for work already in flight, with a TTL so a start that never took still gets re-driven. That also removed the matching telemetry-write flood.

Ejection had no hysteresis. A single stale snapshot read — replica lag, a GC pause, an aggressive StaleNodeTimeout — was enough to delete a live node’s row, its in-flight envelope ownership, and its assignments. The irreversible delete now requires N consecutive stale observations, and a follower may never delete the leader’s row; only a node actually holding the leadership lock can do that.

Shutdown couldn’t finish inside a grace window. The node-shutdown drain stopped every local agent serially, so a node with thousands of shards got SIGKILLed mid-drain, abandoning unflushed daemon progression. It now fans out with bounded parallelism, passes CancellationToken.None deliberately (this is the shutdown path — a cancelled drain leaves agents half-stopped), and contains a wedged agent so it can’t abort its peers’ drain.

Every one of these knobs is on Durability, and the defaults are the ones we’d pick for you:

opts.Durability.AgentStartBatchSize        = 50;   // chunk size for assignment batches
opts.Durability.MaxAgentStartParallelism   = 10;   // bounded fan-out starting a chunk
opts.Durability.MaxAgentStopParallelism    = 10;   // symmetric, on the shutdown drain
opts.Durability.StaleNodeEjectionThreshold = 2;    // consecutive stale reads before ejection

Surfacing a paused shard

CritterWatch will use this new capability to help your systems be more resilient

Wolverine deliberately does not restart a shard the daemon paused on a poison event — restarting would fail on the identical event, so the shard would thrash instead of advance. But “we’re not going to restart it” is only defensible if you know. So Wolverine now surfaces it four ways: the agent’s health check reports the failure category, failing event, and root exception type; a NodeRecordType.AgentPaused record lands in the node-record log; IEventSubscriptionAgent.Failure exposes the ShardFailure directly; and there’s an observer hook:

public class AlertingObserver : IWolverineObserver
{
    public Task AgentPaused(Uri agentUri, ShardFailure? failure)
    {
        // Fires once per transition into the failed state -- not on every health check tick
        _alerts.Raise($"{agentUri} paused: {failure?.Category} on event {failure?.Event}");
        return Task.CompletedTask;
    }
}

Only the Other category — a database outage, a timeout, a transient bug, anything you can’t pin on a single event — is treated as potentially self-healing and auto-restarted by the stall detector. Details and the full category table are in When a Projection Fails; it applies identically to Polecat.

Agent start retries

An agent’s very first assignment can race the subsystems it depends on coming up — a subscription shard evaluated before its store’s high-water detection is running, for instance. Previously the loser of a sub-second startup race idled for a full CheckAssignmentPeriod. Now it retries locally first:

opts.Durability.AgentStartRetryAttempts = 2;                            // default; 0 disables
opts.Durability.AgentStartRetryDelay    = TimeSpan.FromMilliseconds(250); // default, × attempt number

See Agent Start Retries.

And the 6.23.1 follow-ups

Three fixes landed on top, all from running the fixed code against real deployments:

  • A pending assignment is now confirmed on delivery rather than on continued assignment — a 6.23.0 regression where a pause→restart cycle left the ledger entry unconfirmed forever.
  • Advisory-lock session hygiene for Marten’s gap-liveness gate, the Wolverine-side twin of marten#4953.
  • Agent restriction changes are merged and persisted before health detection is kickstarted.

3. Polecat Got Materially Faster

Hey, we’re serious about making Polecat a first class citizen within the greater Critter Stack

Polecat — the SQL Server document database and event store — spent this window on performance, and one of the finds was some serious egg on my face.

A one-word bug that cost 6x on string identities

String identity columns in Polecat (pc_streams.idpc_events.stream_idtenant_id, document ids, progression names, tag values) are varchar(250). String parameters were being bound as nvarchar. SQL Server’s data-type precedence rules then convert the column side, not the parameter — so every single id lookup became CONVERT_IMPLICIT(...) over a full index scan instead of a seek.

The numbers, at 50k streams under a SQL collation: StreamIdentity.AsString appends ran at 53/sec versus 304/sec for AsGuid. Version reads were 7,814µs across 990 reads versus 42µs across 3 reads. That’s not a tuning opportunity, that’s a missing index seek on every string-keyed operation in the store.

Every bespoke site that filters a varchar column now binds through AddVarChar/AddIdParameter helpers with a fixed size for plan-cache stability: version reads, FetchStreamFetchForWriting, document exists/metadata, batched loads, DCB tag queries, natural-key operations, the daemon loader and high-water detector, progression, HiLo, and the rebuild/delete admin paths. If you use string stream keys on SQL Server, upgrade to 5.6.0 or later — this one is free.

Server-side Select() projections

On SQL Server 2025’s native json type, a “simple” Select() projection — an anonymous type or DTO composed only of (optionally nested) scalar member accesses — is now translated to a server-side JSON_OBJECT(...) and streamed with no hydrate/reserialize step at all. Emitted keys honor your serializer’s naming policy and [JsonPropertyName]; numbers stay numbers and strings stay quoted.

Two correctness guards ship regardless of whether the optimization kicks in: a non-translatable Select() falls back to a client-side transform when materialized with ToListAsync() (never a silent drop), and attempting to stream a client-side-fallback projection now throws BadLinqExpressionException instead of silently ignoring the Select and returning raw documents.

Streaming paged JSON in one round trip

Both Marten and Polecat now have the full raw-JSON streaming result family, byte-for-byte compatible with each other so clients are interchangeable:

app.MapGet("/issues/paged/{pageNumber:int}/{pageSize:int}",
    (int pageNumber, int pageSize, IQuerySession session) =>
        new StreamPaged<Issue>(session.Query<Issue>().OrderBy(x => x.Description), pageNumber, pageSize));
{"pageNumber":3,"pageSize":25,"totalItemCount":1207,"pageCount":49,"hasNextPage":true,"hasPreviousPage":true,"items":[...]}

The total row count rides along on every row via count(*) OVER() in the same query that fetches the page — so count and documents both come from a single database round trip — and the documents inside items are the already-persisted JSON, streamed straight through with no deserialize/serialize.

For infinite scroll and export feeds, StreamPagedByCursor<T> does keyset (seek) pagination with an opaque, versioned cursor, at constant cost regardless of depth:

app.MapGet("/issues/feed", (string? cursor, IQuerySession session) =>
    new StreamPagedByCursor<Issue>(
        session.Query<Issue>().OrderBy(x => x.Description).ThenBy(x => x.Id), cursor, pageSize: 25));

The terminal ordering key must be the document identity so the ordering is a total order — that’s enforced, not assumed. See Keyset (Cursor) Pagination and Polecat’s paging docs.

Marten 9.20 rounded the family out with StreamEventState and StreamEvents for streaming a single stream’s metadata and raw events (docs).

Batched event fetches

FetchStreamStatePlan and FetchStreamPlan landed in both Marten and Polecat, and Polecat gained a batched event surface it simply didn’t have — batch.Events, with FetchStreamState and FetchStream in Guid and string overloads. Both new batch items compose their SQL from the same canonical column projections and hydrate through the same readers as the standalone path, so batched and standalone can’t drift apart across a schema migration. See Batched Queries.

Native SQL Server 2025 JSON indexes

One JSON index covers many paths at once and accelerates JSON_VALUE equality, JSON_PATH_EXISTS, and JSON_CONTAINS — with no per-path computed columns:

opts.Schema.For<User>().JsonIndex(x => new { x.UserName, x.Department });
opts.Schema.For<Document>().JsonIndex();                                    // whole-document
opts.Schema.For<Article>().JsonIndex(x => x.Tags, i => i.OptimizeForArraySearch = true);

This is the SQL Server counterpart to Marten’s GinIndexJsonData(), and it requires the native json column type (SQL Server 2025) — Polecat throws a clear error rather than emitting invalid DDL if you configure one against nvarchar(max). Covering indexes landed alongside it: Index(..)/UniqueIndex(..) now carry extra members as non-key INCLUDE columns so a query can be satisfied from the index alone. JSON Indexes docs.

Polecat also picked up per-tenant managed partitioning for documents and streams, AggregateToManyAsync(), the HasTag DCB tag operator in LINQ Where(), and tenant-scoped event/tag explorer reads.


4. Weasel Generates EF Core Migrations Now

I myself strongly prefer the “it just works” style of migrations that Marten and later Wolverine and Polecat do, but hey, a large plurality of the .NET community is probably very used to EF Core migrations, so we’re allowing our users to jump on board that train too!

This is a bigger deal than its version number suggests. Weasel 9.18 can emit standard, compilable EF Core migration files from its own schema model — the reverse of the mapping direction it already had. Instead of Weasel applying schema changes itself via db-patch/db-apply, your team applies them with the tooling it already standardized on: dotnet ef database update, idempotent SQL scripts, migration bundles, and versioned migration files a DBA can review.

dotnet run -- db-ef-migration add AddOrderProjection

That writes migration classes with real Up() and Down() bodies, a stub DbContext per database (with __EFMigrationsHistory relocated into the critter-stack schema so it can’t collide with your application’s own EF context), and a weasel-schema-snapshot.json that the next add diffs against. Everything flows through one door — IDatabase.AllObjects() — so Marten system tables, Wolverine envelope storage, Polecat event storage, and EF-projection tables all generate the same way. Verified end-to-end on both EF 9 and EF 10.

Docs: EF Core Migration Generation and Migration Coexistence.

Weasel 9.19 also gave Oracle a first-class command builder with real statement splitting — which is what unblocked Wolverine’s Oracle durability agent running through the shared batching mechanics. ODP.NET has no DbBatch support and won’t execute ;-separated statements in a single command, so this had to be solved at the Weasel layer.

One consistent finding across the decade plus of Critter Stack development is that database query batching is very frequently advantageous for performance, and we’ve taked that very seriously over the years


5. JasperFx: The Shared Core

Nine JasperFx releases in ten days, because it’s where the shared event-store and daemon abstractions live. The highlights, most of which you’ve already met above through Marten and Polecat:

  • ShardFailure + ShardFailureCategory (jasperfx#565/#567) — the classified reason a shard paused, exposed on ISubscriptionAgent and persisted through extended progression.
  • StopAndDrainTimeout (jasperfx#564) — the configurable per-shard drain bound.
  • HighWaterAgent liveness heartbeat (jasperfx#539) — a staleness surface and a local restart seam, which is what the improved health check reads.
  • Lost-wakeup race closed (jasperfx#572) and the WaitForShardState race against an already-published state (jasperfx#568).
  • Batched extended-progression writes (jasperfx#553/#554) — per-database-flush-interval batching, so the telemetry write path stopped being a per-shard-per-tick insert.
  • Natural key extraction widened to bind IEvent<T> sources, stop fabricating aggregates, and fail loudly rather than silently (jasperfx#569).
  • DCB workIDcbAggregateRegistry for runtime discovery, serializable rich EventTagQuery as a DCB source, and a step-instrumented aggregation fold with MultiAggregateProjectionResult.
  • F# support got more robust tuple/record handling and a DerivedVariable reference-propagation fix.

6. Everything Else

A partial list, because the window was busy:

Wolverine

  • Claim checks got size-threshold auto-offload, per-message/per-endpoint store selection, and honor a DI-registered IClaimCheckStore.
  • GCP Pub/Sub: named-broker support for sharded/partitioned topics, plus ListenToPubsubSubscriptionOnNamedBroker.
  • Conventional routing no longer ignores named brokers.
  • Oracle: the durability agent runs through the shared batching mechanics; the durable inbox binds RAW(16) Guids correctly; the message store URI uses the registered wolverinedb agent scheme.
  • Redis: scheduled retries no longer vanish on an unreadable timestamp, and entries that repeatedly fail to deserialize get dead-lettered instead of looping.
  • NServiceBus interop: the EnclosedMessageTypes header is split before resolution, shared across Azure Service Bus, SNS, SQS, and the database transports.
  • HTTP: a raft of OpenAPI and binding fixes — [FromQuery] on arrays and collections, case-insensitive enum array parsing, 415 instead of 404 when no Content-Type reaches an [AcceptsContentType] route, no duplicate description of route-bound [FromQuery]/[FromHeader] parameters, fail-fast when an endpoint advertises a body its HTTP method can’t carry, and explicitly-routed chains mapped inside the constructor so PublishMessage/SendMessage endpoints get their metadata.
  • IHost.ClearAllWolverineStorageAsync(), and resources setup provisions message storage even under AutoCreate.None.
  • Exclusive listener inboxes are now recovered on the listening node.

Marten

  • TimescaleDB support — projection and document hypertables, folded into core Marten.
  • Natural keys hardened: the previous key row is retired when the key changes, and the foreign key guard is scoped to its own table.
  • Simple LINQ Select() projections translate to jsonb_build_object (the Postgres side of the same optimization Polecat got).
  • ETag / If-None-Match (304) support on StreamOne and StreamAggregate.
  • Tenant-scoped event and tag explorer reads.

Upgrading

In this case, everything in the critter stack moved in lockstep — Wolverine 6.23.1 pins Marten 9.20.0, Polecat 5.7.0, and JasperFx 2.36.1+, so upgrading Wolverine pulls the rest forward for you. If you’re on a Marten-or-Polecat-only application, take Marten 9.20.0 / Polecat 5.7.0 directly.

Nothing here is a breaking change. The agent-assignment work is entirely behavioral and needs no configuration to benefit from; the new Durability knobs exist for tuning, not for opting in. The classified shard-failure columns require extended progression tracking, which is still off by default — turn it on with Events.EnableExtendedProgressionTracking if you want database-visible per-shard health, and note that the per-tenant high-water health check needs it too.

If you’re running the critter stack at any real scale, CritterWatch consumes all of the new failure surfacing described above without any work on your part.


Closing Thoughts

I would dearly appreciate it if the world could slow down a bit in the next couple weeks so that release cadence can come back to Earth. I’d also appreciate it if everybody else could chill out a bit in their OSS activity so GitHub actions can be more performant and responsive for me and the Critter Stack community!

Critter Stack Roadmap for the Rest of 2026?

Just to wind down from a busy week, I thought it would be nice to jot down an update about the Critter Stack and JasperFx roadmap as it looks like right now for the rest of the year.

We’ve had a torrid release cadence this whole year with the big highlight being the “Critter Stack 2026” wave of major releases, then quite a few follow up releases to add more features and improve performance and resilience. The real goal of this year for JasperFx was to finally release…

CritterWatch

CritterWatch has ended up being a much, much larger and more ambitious tool than originally conceived as the advent of AI assisted development really changed everything. CritterWatch is absolutely going to still be the management and observability console for the Critter Watch as it was originally conceived. Now though, it will also serve as the central hub of AI assisted development and support for the Critter Stack.

For timing, I’m calling:

  • 1.0 RC 1 for this Monday, July 27th
  • The official, 1.0 GA is targeted for Monday, August 3rd

And of course, incremental releases with new features throughout the rest of the year and to deal with the inevitable feedback once it’s being used by more people. Here are some ideas currently in our backlog for a “1.1” release:

  • Some kind of recurring cron-based message scheduling with deep integration of Wolverine with Quartz.Net and/or TickerQ. I’m currently thinking that Hangfire is just its own huge thing and not looking to mess with that. It’s quite possible that Wolverine gets first class documentation and integration for Quartz.Net and TickerQ first, then the CritterWatch integration is really just provides management and observability over that.
  • Scheduling projection rebuilds or other management actions for off hours
  • More integration for Event Modeling visualizations and code generation? We’re going to have quite a bit of visualization of the cause and effect of a system right off the bat, but we’ll also be moving more into development time assistance. I don’t have any details about what exactly that’s going to be yet.

Spec Driven Development, AI Assisted Development, and Event Modeling

Every major Event Sourcing tool company or community is working on some sort of approach for AI assisted development, and we’re already well into that ourselves. I think a lot of how people see AI usage in development is almost completely a reflection of where their opinions about software development before AI.

For myself, I was hugely influenced by Extreme Programming and I’ve long been deeply skeptical of Model Driven Development or really any kind of purported “low code” approach to software development. That’s carried over to also being unenthusiastic about any approach for generating event sourced applications by first modeling in some kind of custom XML or YAML format or some kind of external DSL. I’m also admittedly dubious about any kind of user interface tooling to generate code. Moreover, I tend to scoff at a lot of these tools as taking more time to do the intermediate model than it would to just write the code with the Critter Stack and our very low code ceremony model.

Instead, JasperFx will be leaning toward much more code centric approaches:

  • Using what we used to call “Executable Specifications” (BDD) for AI assisted development and building tooling to reduce the effort to do so. Right now we’re pursuing Gherkin based tooling as at least one alternative, but we’re not locked into only doing that.
  • Expanding the already existing tooling in CritterWatch for visualizing event sourcing code through the Event Modeling notation at development time or even live in requirements workshops with domain experts rather than generating code from the intermediate models.

And as always, we put a lot of emphasis on low code ceremony approaches as is.

I do actually admire what KurrentDb is doing so far with their Capacitor tool and I’m interested in building out our tooling, but that might be a “build your own lightsaber” learning experience or something very optimized for JasperFx’s own development on the Critter Stack.

Spaghetti against the Wall?

Alright, now it’s time for farther out ideas that aren’t even remotely fleshed out just to see what other folks would find compelling:

  • Improve Wolverine’s story for long running workflows, meaning tasks that might take hours and can’t be done as a single message. I think a huge chunk of this is just having more documentation and examples for already existing capabilities, but I think there’s also an opportunity to exploit Wolverine’s virtual actor subsystem to expand into Temporal.io type territory
  • Maybe a JasperFx curated Hot Chocolate package. Not taking anything away from ChiliCream, but I know there’s performance fat in the Marten integration and repetitive code for integrating Wolverine into Hot Chocolate mutations. I have zero interest in a full blown GraphQL product, but it’s something I’ve thought about from time to time
  • Integrating caching options into Wolverine, but that’s low hanging fruit

What else folks? What would you like to see improved or added?

Wolverine.HTTP Learns the QUERY Verb

I’m mostly out this week on a family vacation and this is the most ambitious blog post I’m going to be up to until I’m back:)

Wolverine.HTTP (in 6.17.0 last week) added support for the HTTP QUERY method (RFC 10008) through a single new [WolverineQuery] attribute. In this post let’s walk through what it is, why you’d reach for it, and how it behaves inside Wolverine’s middleware model.

To me, the new QUERY verb seems pretty logical and probably something that could have been added a long time ago.


What is the QUERY method, and why should I care?

If you’ve ever built a search endpoint with oodles of optional search criteria, you’ve probably felt the tension. Search criteria want to be a body — nested filters, arrays of facets, date ranges, a big structured DTO. But “read-only, cacheable, idempotent” wants to be a GET. And GET famously does not carry a request body in any way you can rely on.

Plenty of other folks cram everything into an ever-growing query string (and start bumping into URL length limits and gnarly encoding), or you POST your search — quietly giving up the semantic promise that this call is safe and idempotent, and confusing every proxy, cache, and reader of your API along the way.

QUERY is the method that resolves that tension. It is safe and idempotent — like GET — but it is allowed to carry a request body — like POST. It’s purpose-built for exactly the search/query endpoints whose criteria are too large or too structured to encode in a URL.


The API surface: one attribute

The entire feature is a single attribute, [WolverineQuery], that sits right alongside the verb attributes you already know — [WolverineGet][WolverinePost][WolverinePut], and friends. There’s no new fluent method to learn and no configuration to flip on.

Here’s a complete search endpoint:

using Wolverine.Http;
public record SearchRequest(string Term, int Page);
public record SearchResults(string Term, int Page, string[] Hits);
// QUERY (RFC 10008) is safe and idempotent like GET, but carries a request body — ideal for
// search endpoints whose criteria are too large or structured for the query string. Wolverine
// binds the request body just like it would for POST.
[WolverineQuery("/search")]
public static SearchResults Search(SearchRequest request)
{
var hits = Enumerable.Range(1, request.Page)
.Select(i => $"{request.Term}-{i}")
.ToArray();
return new SearchResults(request.Term, request.Page, hits);
}

That’s it. The SearchRequest binds from the request body exactly as it would for a POST endpoint — same JSON deserialization, same everything. The only difference on the wire is the HTTP method, which flows straight through to ASP.NET Core as route metadata.


Middleware rules are dependency-based, not verb-based

I think it’s a little bit weird to be using messaging from within a GET or QUERY endpoints because of “query-command separation,” but there are exception cases and that means Wolverine has to support this.

This is the part I want to be very precise about, because it’s the most common wrong assumption.

You might expect a “safe” verb like QUERY to be automatically exempt from transactional or outbox middleware. It is not — and that’s by design. Wolverine has never keyed its middleware decisions off the HTTP verb; it keys them off the dependencies your handler actually takes:

  • Outbox middleware is applied when your handler depends on IMessageBus / IMessageContext.
  • Transactional middleware is applied when your handler takes a persistence dependency like Marten’s IDocumentSession or an EF Core DbContext.

So the /search endpoint above stays free of transactional middleware because it takes no persistence dependency — not because it’s a QUERY. The rule cuts both ways. Take an IDocumentSession on a QUERY endpoint under AutoApplyTransactions() and you’ll get transactional middleware wrapped around it, exactly as you would on a POST:

using Marten;
using Wolverine.Http;
// Taking an IDocumentSession attracts AutoApplyTransactions on a QUERY endpoint
// exactly as it would on a POST — there is no verb-based exemption.
[WolverineQuery("/search/audited")]
public static SearchResults SearchAudited(SearchRequest request, IDocumentSession session)
{
session.Store(new SearchAudit(Guid.NewGuid(), request.Term));
return new SearchResults(request.Term, request.Page, []);
}
// IQuerySession is Marten's read-only session and does NOT trigger transactional
// middleware — the right dependency for a QUERY endpoint that reads the database.
[WolverineQuery("/search/readonly")]
public static SearchResults SearchReadonly(SearchRequest request, IQuerySession session)
{
return new SearchResults(request.Term, request.Page, []);
}

The practical guidance: for a QUERY endpoint that reads the database and should stay non-transactional, take Marten’s read-only IQuerySession instead of an IDocumentSession — or, on EF Core, decorate the endpoint with [NonTransactional].


⚠️ One caveat: OpenAPI 3.1

QUERY only became a first-class operation in OpenAPI 3.2. The OpenAPI 3.1 document produced by the Swashbuckle / Microsoft.OpenApi stack can’t represent it, and naively handing it a QUERY operation throws and breaks document generation for your whole application.

So — matching ASP.NET Core’s own behavior on OpenAPI 3.1 — Wolverine gracefully omits QUERY endpoints from the generated OpenAPI document rather than break generation for everything else. Your QUERY endpoints are fully routable and functional; they’re simply not described in the OpenAPI 3.1 output. First-class OpenAPI docs can follow once the underlying stack emits 3.2.


Testing QUERY endpoints (and an honest Alba caveat)

There is a new major release underway for Alba and I fully expect QUERY support to be part of that. While Alba doensn’t yet have first class QUERY support, you can temporarily use the test server’s HttpClient:

public class query_verb_support : IntegrationContext
{
public query_verb_support(AppFixture fixture) : base(fixture) { }
[Fact]
public async Task query_endpoint_reads_request_body_and_returns_result()
{
// QUERY carries a request body (unlike GET). Alba's scenario helpers assume standard
// verbs, so drive a genuine QUERY request through the test server's HttpClient.
var client = Host.GetTestServer().CreateClient();
var request = new HttpRequestMessage(new HttpMethod("QUERY"), "/search")
{
Content = JsonContent.Create(new SearchRequest("widget", 3))
};
var response = await client.SendAsync(request);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var results = await response.Content.ReadFromJsonAsync<SearchResults>();
results.ShouldNotBeNull();
results.Term.ShouldBe("widget");
results.Page.ShouldBe(3);
results.Hits.ShouldBe(["widget-1", "widget-2", "widget-3"]);
}

Host.GetTestServer() comes from the same Alba/TestServer plumbing your other tests already use — you’re just hand-building the one request whose verb Alba can’t spell for you.

You’ll often also want to assert on routing and middleware wiring directly, without an HTTP round trip. Because the interesting behaviors here are about metadata and middleware, those checks read cleanly against the endpoint graph:

[Fact]
public void query_route_is_registered_with_QUERY_method_metadata()
{
var endpoint = EndpointFor("/search");
var methods = endpoint.Metadata.GetMetaata<HttpMethodMetadata>();
methods.ShouldNotBeNull();
methods.HttpMethods.ShouldContain("QUERY");
}
[Fact]
public void query_endpoint_is_not_wrapped_in_transactional_middleware()
{
// Non-transactional because it takes no persistence dependency — NOT because QUERY is "safe".
var chain = HttpChains.Chains.Single(x => x.RoutePattern!.RawText == "/search");
chain.RequiresOutbox().ShouldBeFalse();
chain.IsTransactional.ShouldBeFalse();
}
[Fact]
public void query_endpoint_with_document_session_is_transactional()
{
// The dependency-based rule cuts both ways: an IDocumentSession dependency
// attracts AutoApplyTransactions on a QUERY endpoint exactly as on a POST.
var chain = HttpChains.Chains.Single(x => x.RoutePattern!.RawText == "/search/audited");
chain.IsTransactional.ShouldBeTrue();
chain.RequiresOutbox().ShouldBeFalse();
}

And, closing the loop on the OpenAPI caveat above, you can pin the “don’t break the document” guarantee:

[Fact]
public void swagger_generation_still_succeeds_with_a_query_endpoint()
{
var generator = Host.Services.GetRequiredService<ISwaggerProvider>();
var doc = generator.GetSwagger("default");
// The QUERY endpoint is gracefully omitted, not thrown on.
doc.Paths.ContainsKey("/search").ShouldBeFalse();

The bottom line

QUERY support in Wolverine.HTTP is deliberately small: one attribute, no new configuration surface, and it reuses the same body binding and the same dependency-based middleware rules you already rely on for every other verb. There’s a tiny bit of logic in Wolverine’s internals that make it a little different than GET of course, but as a user of Wolverine.HTTP all you really care about is that one single [WolverineQuery] attribute. If you’ve been POST-ing your searches and feeling slightly dirty about it, [WolverineQuery] is the verb you’ve been wanting to not feel dirty about how you’re coding.

Full documentation lives in the HTTP Endpoints guide → The HTTP QUERY Method.

Your AI Agent Just Got a Lot Better at the Critter Stack: AI Skills 1.6.0

JasperFx Software and the greater “Critter Stack” community is advancing our tools pretty rapidly and we have (at least for now) a release cadence that’s far more rapid than our competitors in the .NET space. It’s perfectly possible to be an experienced Critter Stack user and not be aware of the latest, greatest features, fixes, and improvements.

That’s the problem JasperFx AI Skills exists to solve. It’s a curated library of agent skills — now 81 of them — covering Wolverine, Marten, Polecat, and CritterWatch, written and maintained by the people who build these tools. Install them once and your agent stops guessing from stale training data and starts working from documentation that’s verified against the actual source code, current as of this month, and organized the way agents actually consume knowledge: task-shaped, example-heavy, and honest about the sharp edges.

Release 1.6.0 is out today, and it’s a big one. Here’s what’s inside.

Ready for the Critter Stack 2026 release

Marten 9 and Wolverine 6 changed the code-generation and deployment story in ways that make most existing internet advice actively wrong. Marten 9 eliminated runtime code generation entirely — no more codegen write step for Marten, no more GeneratedCodeMode knobs, no more Internal/Generated/ folders. Wolverine 6 kept its codegen but moved the Roslyn compiler into the opt-in WolverineFx.RuntimeCompilation package, which means your production image can now run in Static mode with zero Roslyn on disk — roughly 100 MB lighter and Native-AOT-ready.

Every skill in the library that touches code generation was re-audited for this release. The canonical codegen skill now teaches the full Roslyn-free production shape: pre-generate in your Docker build stage, run Static with AssertAllPreGeneratedTypesExist, keep the runtime compiler out of Release builds. And it’s precise about the mixed-host nuance that trips people up: a host running both Marten 9 and Wolverine 6 still needs codegen write — but only for the Wolverine half. Your agent will now get that distinction right instead of cargo-culting a Dockerfile step “for the Critter Stack.”

A new troubleshooting line — with the real error messages

Two new skills anchor a troubleshooting category: message routing and service location & code generation. The second one is my favorite thing in this release. When your Wolverine 6 upgrade throws InvalidServiceLocationException at startup (and it will, because the ServiceLocationPolicy default flipped), the skill has the exact exception text, every reason string the codegen can emit — “opaque lambda factory,” “concrete type is not public,” “directly using IServiceProvider” — and the specific registration fix for each one. Every error message was verified verbatim against the Wolverine and JasperFx source, because an agent pattern-matching on error text needs the real text, not a paraphrase.

It also documents a CI trick that deserves to be better known: dotnet run -- codegen test generates and compiles every handler and endpoint in memory and fails the build on any codegen error — so the opaque registration someone adds on Tuesday fails Tuesday’s PR, not Friday’s deploy.

.NET Aspire, done properly

A new consolidated Wolverine with .NET Aspire skill covers the one pattern that repeats across every resource — AddXWithReferenceWaitFor → read the injected connection string — plus a per-provider matrix for SQL Server, MySQL, Oracle, PostgreSQL, RavenDB, and Cosmos DB persistence, and the transport-side stories for RabbitMQ, Kafka, NATS, and Azure Service Bus (including which ones have UsingNamedConnection helpers and which ones don’t — we checked the source, there are exactly two). The per-transport skills each gained their own Aspire sections, and the skill is refreshingly blunt about AWS SQS/SNS: there is no first-party Aspire integration, so here’s the LocalStack pattern instead.

CritterWatch operations

The skills aren’t just for writing code — they work with CritterWatch, our monitoring and operations console for the Critter Stack, too. Three new skills cover operating a fleet: service actions (like evicting a stale service registration), the embedded CLI (cw-* read commands), and lifecycle diagnostics — on top of the existing setup and routing-diagnostics skills. If you’re running CritterWatch, your agent can now help you install it, wire it into Aspire, and operate it day to day.

Self-contained by design

A principle we hardened this release: if a skill shows you a helper method, the skill carries the complete source. The TrackedHttpCall helper that makes Alba + Wolverine integration testing so pleasant? It’s never shipped in a NuGet — it’s a pattern from Wolverine’s own test suite, and every testing skill now embeds the full method and says so explicitly. Same for the Azure Service Bus emulator helper. No more agents (or humans) hunting for a package reference that doesn’t exist. Where we found the upstream docs implying otherwise, we filed the issues too.

And the steady sharpening

Beyond the headlines: clarified exactly when Marten’s IncludeType<T>() is needed (only when Marten can’t infer event types — explicit Evolve overrides or base-type Apply methods), covered Wolverine 6.17’s HTTP QUERY verb support, [AsParameters] binding patterns, Polecat’s typed streaming result types for HTTP endpoints, migration-guide fixes driven directly by user feedback, and more. Twenty-four merged PRs since 1.5.0, every open issue in the tracker closed.

Getting it

AI Skills is available for purchase at jasperfx.net/our-products. Once you’re licensed, it ships as the JasperFx.AiSkills package on the JasperFx feed:

agentskills-cli add JasperFx.AiSkills

Browse the full catalog and per-release changelog at the AI Skills documentation site to see exactly what your agent would be working from. And if your agent still gets something wrong — file an issue. Half of this release started life as user feedback, and the loop from “the skill told me something stale” to “fixed, verified against source, released” is exactly the point of maintaining these ourselves.