This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Alberto is a DCB (Dynamic Consistency Boundary) Event Store for .NET 10.0. The repository is a monorepo containing packable core libraries (/src), example applications (/apps), an operator CLI (/tools), and tests (/tests).
There is no frontend in this repository. The event store is a library plus a terminal CLI.
dotnet run --project apps/Alberto.AppHostAspire starts PostgreSQL, runs the Orders migrations, then starts the Orders API. K6 load tests are registered as a manually-triggered resource in the Aspire dashboard.
dotnet build # Build all projects
dotnet test # Run all xUnit tests
dotnet test --filter "FullyQualifiedName~TestName" # Run single testPostgres-backed tests use Testcontainers and need a running Docker daemon.
cd tests/Alberto.Orders.LoadTests
npm install && npm run build
npm run test:smoke # Quick validation
npm run test:load # Full load testdotnet run --project tools/Alberto.Cli -- statusdotnet run -c Release --project benchmarks/Alberto.Benchmarks -- --filter '*Append*'/src/ # Core libraries: 10 packable, 3 not
Alberto/ # Event store abstractions, control loop, middleware
Alberto.Commands/ # Command handling
Alberto.Commands.Analyzers/ # Roslyn analyzers for the command pipeline (not packable)
Alberto.InMemory/ # In-memory backend (dev/test)
Alberto.Postgres/ # PostgreSQL backend, migrations
Alberto.EntityFramework/ # EF-backed projections
Alberto.Messaging/ # Transactional outbox abstractions
Alberto.Messaging.Postgres/ # PostgreSQL outbox store
Alberto.Telemetry/ # OpenTelemetry instrumentation
Alberto.Testing/ # In-memory test helpers for consumers
Alberto.Testing.Xunit/ # xUnit v3 fixtures and collection definitions
Alberto.Admin/ # IAdminReader/IAdminOperator: parked (not packable)
Alberto.Admin.Postgres/ # Its only implementation: parked (not packable)
/apps/ # Example applications
Alberto.AppHost/ # Aspire orchestration
Alberto.Examples.Shared/ # MutationResult and the Result→GraphQL error mapping
ServiceDefaults/ # Shared Aspire service configuration
Alberto.Orders/ # Orders example
Alberto.Orders/ # The module: Contracts/, Features/ (one folder per slice), Platform/
Alberto.Orders.Api/ # Host: Program.cs, tenant interceptor
Alberto.Orders.Migrations/ # EF migrations runner
Alberto.Payments/ # Payments example
Alberto.Payments/ # The module, same shape as Orders
/tools/
Alberto.Cli/ # Operator CLI (Spectre.Console + System.CommandLine)
/tests/
Alberto.Tests/ # Unit + Testcontainers integration tests (xUnit 3)
Alberto.Tests.SampleEvents/ # Sample [EventType] records in a separate assembly, for
# assembly-scanning tests
Alberto.AotSmoke/ # Console app published with PublishAot in CI and run against
# Postgres; exits non-zero on failure (see "Native AOT" below)
Alberto.Examples.Tests/ # Tests over the Orders/Payments examples, incl. GraphQL
# schema snapshots
Alberto.Orders.LoadTests/ # K6 load tests (TypeScript)
/benchmarks/
Alberto.Benchmarks/ # BenchmarkDotNet append/read/checkpoint benchmarks
Alberto.Benchmarks.Core/ # Shared harness: event plans, BDN import, result comparison
Alberto.Benchmarks.Compare/ # CLI that diffs two benchmark runs and renders a report
Note: apps/Alberto.Payments is in the solution and builds, but it has no host of its own and is not orchestrated by the AppHost. Its slices are registered by the Orders API host, which serves their GraphQL fields alongside Orders'.
- Event Sourcing with DCB: Append-only event log with dynamic consistency boundaries
- Vertical slices in the examples:
apps/Alberto.Ordersandapps/Alberto.Paymentsare sliced by behaviour, not layer. One folder per slice underFeatures/, holding that slice's input type, state record, evolver, decision function, boundary and GraphQL operation. Slices share the event log and nothing else, no shared state record, no shared evolver, no base state.Contracts/(events, status enums, problem codes, tag keys) andPlatform/(DI,DbContext, EF migrations) are the two deliberate exceptions, named so they cannot be mistaken for domain code that happens to be shared. Five slices foldOrderCreated, each projecting a different part of it; that duplication is the pattern working. See docs/architecture/vertical-slices.md - Async Processing:
ControlLooppolls the event log and dispatches through a middleware chain to projections/reactors. See docs/architecture/async-processing.md - Middleware:
MiddlewareRunnerbuilds both the single-event (ConsumeEventContext) and batch (BatchConsumeContext) chains. Retry/dead-letter logic is shared viaRetryAndDeadLetterCorebehindIMiddlewareContext - Zero-downtime projection rebuilds: opt in with
.WithRebuilds().RebuildCoordinatorreplays the log into a shadow copy of a projection's state under its own checkpoint, then swaps versions in one transaction. Driven byalberto ops rebuild start|status|promote|abort - Multi-Tenant: X-Tenant-Id header propagation, tenant-isolated queries, tenant leases
- Store imprint: single-tenant and multi-tenant are two disjoint migration sets sharing one journal, so
.WithTenancy()cannot be added to or removed from a store that already has data. There is no bridging script and notenant_idbackfill.PostgresMigrator.Migraterefuses the wrong set before running anything, throwingAlbertoStoreMismatchException(ALB0021). The mode is read fromalberto_store_imprint(a table the migrator creates itself, since the check that must precede every script cannot depend on a script), falling back to sniffingalberto_eventsfor atenant_idcolumn, which covers both stores predating the imprint and stores left half-migrated. A store with noalberto_eventsis fresh and may become either mode. Pointing a module at an empty database is deliberately not covered: nothing contradicts, so it migrates cleanly and serves an empty store - Projection tenancy: a state store's tenancy is fixed when the store is built and decided by the schema, not by the caller: a module that declared
.WithTenancy()is migrated withtenant_id NOT NULLin its primary key, so a store built without one fails every write with42P10, and the reverse mismatch fails with42703.AddProjectiontherefore takes aFunc<string?, IStateStore<TState>>: the projection builds one store per tenant and routes each event to the store for the tenant it carries. A cross-tenant aggregate on a tenant-enabled module stores its single blended document underTenantScope.CrossTenant("*"), which readers pass too: resolvers resolve the writer's own factory from DI ({moduleKey}:{processorId}) so the only thing they decide is which tenant to read - Tenant sharding (opt-in, not the default): a module's tenants can be spread over several PostgreSQL databases with
.WithTenancy(t => t.AcrossPostgresDatabases(...)), row-level tenancy still applying inside each one. Each shard is a complete module registered under the DI key{moduleKey}#{shardId}(composed only byShardKey);ShardRoutingEventStorepicks the database per call fromTenantShardResolver, which reads thealberto_tenant_shardscatalog in a separate control database. Positions are per database. Never compare one shard's with another's. See docs/architecture/tenant-sharding.md - Discarding a rebuild version: neither promotion nor abort deletes the version it discards. A reader resolves the active version and then queries it, so a flip that deleted the superseded rows would strand any reader holding the old number between those two steps. Instead the transition only makes the version unreachable;
RebuildCoordinator's sweep reclaims it viaIProjectionRebuildCoordinatorStore.DiscardStateVersionAsynconceReclaimGracePeriod(2×ProjectionVersions.RefreshInterval) has elapsed sinceCompletedAt. Abort gets the same treatment for a second reason: a shadow loop only learns of the abort on its next poll, so its last writes land after the transition and the sweep is what actually removes them - Leases and fencing: checkpoint writes can be fenced against a held lease via
IFencedCheckpointStore - Transactional outbox:
IOutboxStorewithpending → processing → delivered/failed, claimed viaFOR UPDATE SKIP LOCKEDunder a claim lease (claim_id,claimed_by,claim_expires_at). A relay that dies mid-delivery does not strand its row:ClaimPendingAsyncre-claims anyprocessingentry whoseclaim_expires_athas passed or was never set.RetryFailedAsyncis a separate operator action and matchesfailedonly - Message transports are pluggable, and Alberto ships none:
IMessageTransportis a three-method adapter seam. Alberto guarantees the message is durable and handed over at least once; everything pastPublishAsyncbelongs to the application. No broker binding ships, for two reasons: a binding puts a broker client version in the release matrix for the sake of an adapter that is about twenty lines of code (a worked one lives intests/Alberto.Tests.Messaging.Rebus), and it picks a winner among buses Alberto has no basis to prefer. Rebus, MassTransit, Wolverine and a hand-rolled client are indistinguishable from where Alberto sits, andInMemoryTransportis the only implementation in the repo. There is also no inbound path, by design: receiving belongs to a bus, whose handler turns a message into a command that appends events. That is a scope boundary, not a gap: Alberto is an event store, not a messaging framework. Postgres-as-broker is reached by giving your bus a Postgres transport, which is whyAlberto.Messaging.Postgresis outbox-only. See docs/architecture/message-transports.md - GraphQL (Orders example only): HotChocolate 15.x
- Native AOT and trimming: Alberto, Alberto.Commands, Alberto.Postgres, Alberto.Messaging, Alberto.Messaging.Postgres and Alberto.Telemetry set
IsAotCompatible(andEnableConfigurationBindingGenerator), so an IL2xxx/IL3xxx warning fails their build. Every event-type fact (id, version, tags, JSON contract) is read through anIEventTypeRegistry:WithEventsFrom(assembly)builds one by reflection and is annotated[RequiresUnreferencedCode]/[RequiresDynamicCode];WithEvents(registry)takes one built withEventTypeRegistry.CreateBuilder()andJsonTypeInfofrom the app's ownJsonSerializerContext, and is the AOT path. Any other API that serializes a caller's type by reflection is annotated the same way and has aJsonTypeInfo<T>overload (PostgresStateStore<TState>, messagingMap). The reflection-parity test inEventTypeRegistryTestsandtests/Alberto.AotSmoke(theaot-smokeCI job) are what keep the two paths equal. The builder's typed overloads (Add(ctx.Default.X, e => [new("order", e.OrderId)])) take the tag extractor without a cast; because a hand-written extractor restates the[Tag]attributes,EventTypeRegistryVerifier.Verify(registry)in Alberto.Testing checks the two agree (plus id and version against[EventType]) and belongs in one test per shipped registry. Do not "fix" a new analyzer warning with a suppression unless the justification is a real guarantee, as inReflectionJson(feature switch) orAlbertoConfigurationScanner(type-level annotation)
The operator surface is the CLI in tools/Alberto.Cli. There is no admin HTTP API. src/Alberto.Admin holds the IAdminReader/IAdminOperator abstraction the CLI's 14 command files are built on (it serves no endpoint) and AddAlbertoPostgresAdmin in src/Alberto.Admin.Postgres is its only implementation.
The whole admin surface is parked, not missing, and that includes its two projects. A GraphQL admin API, an MCP server, a React console and a BFF live on feature/admin-surface, held out of 1.0 so their field and tool names are not frozen by semver. Alberto.Admin and Alberto.Admin.Postgres are both IsPackable=false for the same reason: shipping IAdminReader/IAdminOperator at 1.0 would freeze the abstraction under semver before the things that consume it exist. They build, they are in the solution, they are tested, and the CLI references them by project. They just do not go to nuget.org. Unparking is IsPackable=true plus capturing PublicAPI.Shipped.txt (the analyzer is gated on IsPackable, so it is inert until then).
Alberto.Admin.Postgres exists only because Alberto.Postgres is packable. PostgresAdminDataAccess, PostgresAdminOperator and PostgresAdminServiceCollectionExtensions used to live there, which made its nupkg carry an unresolvable Alberto.Admin dependency and 33 public members returning parked types. The three files keep namespace Alberto.Postgres so no consumer's usings changed, and they reach back for internals (SchemaQualifier) via InternalsVisibleTo.
Do not rebuild the front doors on main. Extend that branch. Keep IAdminReader/IAdminOperator additive when changing them here, or the branch stops merging cleanly.
- Per-processor mutations go through the core interfaces:
ICheckpointStore(SaveAsync,ResetAsync,RewindAsync) andIDeadLetterStore(CountAsync,ClearAsync,MarkForRetryAsync). PostgresAdminDataAccess(src/Alberto.Admin.Postgres) holds the inspection queries and the composite transactional mutations (RetryByRewindAsync,ReleaseTenantLeasesAsync) that span multiple tables and so cannot be composed from per-processor interfaces.SaveAsyncis monotonic by design (GREATEST).RewindAsyncis the deliberate escape hatch for operator-initiated rewinds and is the only way to move a checkpoint backwards.- Sharded modules:
ShardResolverturns--shard/--all-shardsplus.alberto/config.jsoninto the databases a command runs against. Reads fan out by default; mutations refuse without a selection.alberto shards list|where|assignmanages the catalog. Shard connection strings live in config, never in the catalog table.
| Layer | Technology |
|---|---|
| Framework | .NET 10.0, ASP.NET Core |
| GraphQL (Orders example) | HotChocolate 15.1.15 |
| Database | PostgreSQL (Npgsql 10.0.2) |
| Migrations | DbUp-PostgreSQL 7.0.1 (event store), EF Core 10.0.7 (Orders) |
| Observability | OpenTelemetry 1.15.3 |
| Testing | xUnit v3 3.2.2, FluentAssertions 8.9.0, Testcontainers 4.11.0 |
| CLI | Spectre.Console 0.55.2, System.CommandLine 2.0.7 |
| Load Testing | K6 with TypeScript |
- NuGet: Centralized versions in
Directory.Packages.props - npm: Per-project
package.jsonfiles
- Solution file:
Alberto.slnx(modern .NET format) - Build settings:
Directory.Build.props
Documented so they are not mistaken for working features:
(None currently. The two rebuild-window gaps that were listed here are closed. See "Discarding a rebuild version" above.)