Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e1d02c6
docs: Adds AGENTS.md and llms.txt for AI agent guidance
SayliS Apr 28, 2026
3825640
chore: Removes FUNDING.yml
SayliS Apr 28, 2026
d80c9a1
chore: Targets net10 and refreshes Microsoft.* packages and copyright
SayliS Apr 28, 2026
18f3389
chore: Bumps direct dependencies
SayliS Apr 28, 2026
bbb8726
docs: Rewrites and refines Cronus framework documentation
SayliS Apr 28, 2026
e8b2884
docs: Adds five framework reference pages (atomic actions, Aspire wir…
SayliS Apr 28, 2026
09249c7
chore: Updates CI pipeline to use .NET 10 SDK
SayliS Apr 28, 2026
7d1888c
fix: Migrates publish step to bash + elders-nuget group with newVer gate
SayliS Apr 29, 2026
d22fa6c
Adds async overload to RetryableOperation
SayliS Apr 29, 2026
6e74b7b
Refactors Publisher base classes for async
SayliS Apr 29, 2026
5bfaf98
Refactors Publisher concrete to async with TryExecuteAsync retry
SayliS Apr 29, 2026
f887d9e
Migrates ICronusStartup interfaces and implementers to async
SayliS Apr 29, 2026
9b48a2c
Migrates remaining sync Publish and RequestTimeout call sites to async
SayliS Apr 29, 2026
c355496
Reads ActivitySource version from assembly metadata
SayliS Apr 29, 2026
bc8d25b
Adds async stress tests for Publisher
SayliS Apr 29, 2026
57a5d85
Bumps Cronus.DomainModeling to 12.0.0-preview.8
SayliS Apr 29, 2026
2c54f9e
major: Fixes lost stack trace when retries are exhausted in TryExecut…
SayliS Apr 29, 2026
b0f9380
fix: Fixes projection bootstrap race by synthesizing discovery-time v…
SayliS May 11, 2026
801f872
fix: Awaits projection initialization and injects discovery-time vers…
SayliS May 11, 2026
117bf47
fix: Disables PR-validation builds in Azure Pipelines
SayliS May 11, 2026
2c860b6
fix: Force-refreshes tags before semantic-release in Azure Pipelines
SayliS May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .github/FUNDING.yml

This file was deleted.

219 changes: 219 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# AGENTS.md

Operating rules for AI coding agents (Claude Code, Cursor, Aider, Copilot Chat, Codex CLI) working in any project that consumes [Elders.Cronus](https://github.com/Elders/Cronus). This file curates facts that already live in the canonical [`docs/`](docs/) tree and the Cronus knowledge base — read it first, follow the pointers when you need depth.

## What Cronus is

Cronus is a .NET DDD/CQRS/Event Sourcing framework maintained by Elders OSS. It targets `net8.0;net9.0` (see `src/Elders.Cronus/Elders.Cronus.csproj`). Domain code is organised around aggregates that emit events, application services that handle commands, and projections / sagas / ports / triggers / gateways that react to events through RabbitMQ-backed transport with Cassandra-backed event store and projections.

## Hard rules — the things that will break the build or mislead you

These are not style preferences. Violating one of them either fails to compile, fails at runtime, or silently corrupts the wire contract.

1. **All publishing is async.** `IPublisher<T>` exposes `PublishAsync(...)` only. There is no sync `Publish(...)`. The lint pattern `sync-publish` flags any `publisher.Publish(` call.

```csharp
// RIGHT
await publisher.PublishAsync(command);

// WRONG — does not exist
publisher.Publish(command);
```

2. **Every `IMessage` needs a stable contract id and ordered members.** Every `ICommand`, `IEvent`, `IPublicEvent`, `ISignal` (and every projection state, value record persisted in a snapshot, etc.) must carry `[DataContract(Namespace = BC.Name, Name = "<fresh GUID>")]` plus `[DataMember(Order = N)]` on every persisted property. The GUID is the wire identity — once a message is in production, **never** change it.

```csharp
[DataContract(Name = "728fc4e7-628b-4962-bd68-97c98aa05694")]
public class TaskCreated : IEvent
{
TaskCreated() { }

public TaskCreated(TaskId id, string name, DateTimeOffset timestamp)
{
Id = id; Name = name; Timestamp = timestamp;
}

[DataMember(Order = 1)] public TaskId Id { get; private set; }
[DataMember(Order = 2)] public string Name { get; private set; }
[DataMember(Order = 3)] public DateTimeOffset Timestamp { get; private set; }
}
```

3. **Aggregate IDs use the non-generic `AggregateRootId(string tenant, string arName, string id)`.** The constructor order is `(tenant, arName, id)`. The generic `AggregateRootId<T>` form is commented out in source and is not available. Do not invent `AggregateUrn`, `IUrn`, or `StringTenantId` — those are removed types.

```csharp
[DataContract(Name = "d5e50e1f-5886-4608-9361-9fe0eb440a6b")]
public class TaskId : AggregateRootId
{
TaskId() { }
public TaskId(string tenant, string id) : base(tenant, "task", id) { }
}
```

4. **Aggregate roots inherit `AggregateRoot<TState>` and mutate state only through events.** Keep a `private` parameterless constructor for replay, validate invariants in public methods, and call `Apply(new SomeEvent(...))` to record changes. State is folded by `public void When(TEvent e)` handlers on the `AggregateRootState<TRoot, TRootId>` class. The aggregate must remain **synchronous** — no I/O, no `async`. See [`docs/cronus-framework/domain-modeling/aggregate.md`](docs/cronus-framework/domain-modeling/aggregate.md).

5. **Cross-aggregate flow goes through a Saga (process manager).** Aggregates do not subscribe to other aggregates' events, do not call other aggregates, and do not load anything via the repository. If you need to react to one aggregate's event by issuing a command on another, that is a saga's job. See [`docs/cronus-framework/domain-modeling/handlers/sagas.md`](docs/cronus-framework/domain-modeling/handlers/sagas.md).

## Pick the right handler

Six handler kinds. Pick by **intent**, not by capability. Verbatim from [`docs/cronus-framework/domain-modeling/handlers/README.md`](docs/cronus-framework/domain-modeling/handlers/README.md):

| Handler | Reacts to | Produces | Side effects allowed? |
| --- | --- | --- | --- |
| Application Service | `ICommand` | New events on a single aggregate | No — should not perform I/O outside the event store |
| Projection | `IEvent` | A read model (snapshot or external store) | No — must not publish commands or events |
| Saga | `IEvent`, `IScheduledMessage` | New `ICommand` messages; scheduled timeouts | No business-facing side effects — coordinate aggregates |
| Port | `IEvent` | New `ICommand` messages | Yes — the classic "send email", "call external API" place |
| Trigger | `IEvent`, `ISignal` | Anything — typically starts a job or a downstream workflow | Yes |
| Gateway | `IEvent` | New `ICommand` messages, with tracked infrastructure state | Yes — owns metadata required by an external system |

Rule of thumb:

- One command mutates one aggregate → **Application Service**.
- Read model from events → **Projection**.
- Coordinate several aggregates → **Saga**.
- React to an event with one outbound side effect (email, HTTP call) → **Port**.
- Kick off a job or long-running workflow → **Trigger**.
- Port that needs persistent infra state (push tokens, badges) → **Gateway**.

## Pick the right message type

Every message implements `IMessage` and carries a `Timestamp`. Source: [`docs/cronus-framework/domain-modeling/messages/README.md`](docs/cronus-framework/domain-modeling/messages/README.md).

| Message type | Intent | Consumed by |
| --- | --- | --- |
| `ICommand` | Request a business change. May be rejected by the aggregate. Imperative name (`CreateTask`). | Application Service |
| `IEvent` | Record a fact already committed inside this bounded context. Past-tense name (`TaskCreated`). | Projection, Saga, Port, Trigger, Gateway |
| `IPublicEvent` | Announce a change to the outside world (published language). Carries the originating `Tenant`. | Subscribers in other bounded contexts |
| `ISignal` | Trigger arbitrary side-effects (heartbeats, rebuilds, process pings). | Trigger |

Publishing is always async:

```csharp
Task<bool> PublishAsync(TMessage message, Dictionary<string, string> headers = null);
Task<bool> PublishAsync(TMessage message, DateTime publishAt, Dictionary<string, string> headers = null);
Task<bool> PublishAsync(TMessage message, TimeSpan publishAfter, Dictionary<string, string> headers = null);
```

`false` from `PublishAsync` means the transport rejected the message — surface it.

## Banned legacy types

These names exist in old samples, blog posts, and Cronus v6/v7 code. They are gone in current Cronus and the lint database flags every one of them. Never propose or generate them:

- **`ValueObject<T>`** — base class is removed. Use C# `record` types with `[DataContract(Name = "<guid>")]`. KB concept: `value-object-record-pattern`.
- **`IUrn`**, **`AggregateUrn`** — removed; use `AggregateRootId` and `AggregateRootId.TryParse`.
- **`StringTenantId`** — removed; tenant is a `string` segment of `AggregateRootId`.
- **`AggregateRootId<T>`** (generic form) — commented out in source. Use the non-generic `AggregateRootId(tenant, arName, id)`.
- **`IAggregateRootId<T>`** — removed; the lint pattern is `fake-iaggregaterootid-generic`.
- **`AggregateRootApplicationService<T>`** — old base class. Use `ApplicationService<TAggregate>`.
- **`Cronus.Persistence.Git-*`**, **`Cronus.Persistence.MSSQL`**, **`Cronus.Serialization.Proteus`** — legacy persistence/serialization satellites. Don't add them. The current stack is `Cronus.Persistence.Cassandra`, `Cronus.Projections.Cassandra`, `Cronus.Transport.RabbitMQ`, `Cronus.Serialization.NewtonsoftJson`.
- **Sync `IPublisher.Publish(...)`** — removed; use `PublishAsync`.
- **Sync `IProjectionReader.Get(...)`** — removed; use the async `GetAsync`.
- **Sync `repository.Save(...)`** — removed; use `SaveAsync`.
- **`repository.TryLoad<T>(id, out var ar)`** — removed; use `await repository.LoadAsync<T>(id)` and inspect the `ReadResult<AR>` (`IsSuccess`, `NotFound`, `HasError`).
- **`public void Handle(TMessage m)` on a handler** — removed; handlers are `Task HandleAsync(TMessage m)`.

If the user asks for one of these, tell them it has been removed and offer the modern equivalent.

## `AddCronus(configuration)` setup

`services.AddCronus(configuration)` is the single entry point — it registers core services, scans your assemblies for handlers, and binds options. The required configuration keys are:

- `Cronus:BoundedContext` — alphanumeric/underscore name of the service. Validates against `^\b([\w\d_]+$)`.
- `Cronus:Tenants` — non-empty string array; same character set per element.
- `Cronus:Persistence:Cassandra:ConnectionString` — Cassandra event-store connection.
- `Cronus:Projections:Cassandra:ConnectionString` — Cassandra projections-store connection.
- `Cronus:Transport:RabbitMQ:*` — Server, Port, VHost, Username, Password.

Every key, type and default lives in [`docs/cronus-framework/configuration.md`](docs/cronus-framework/configuration.md). When in doubt, read it — do not invent option names.

Common process-split flags (default `true`):

- `Cronus:ApplicationServicesEnabled`
- `Cronus:ProjectionsEnabled`
- `Cronus:SagasEnabled`
- `Cronus:PortsEnabled`
- `Cronus:GatewaysEnabled`
- `Cronus:TriggersEnabled`

For an API process that only publishes commands, turn all six off and let a separate worker host run them.

For local dev with [.NET Aspire](https://learn.microsoft.com/dotnet/aspire/), the AppHost spins up Cassandra/RabbitMQ/Redis/Consul/Elasticsearch and injects connection details as `Cronus__*` environment variables that `AddCronus` picks up automatically — see KB concept `aspire-cronus-wiring`.

## `Bootstraps` enum and `[CronusStartup]`

One-time host startup work goes in `ICronusStartup` / `ICronusTenantStartup` and is ordered by `[CronusStartup(Bootstraps.X)]`:

| Phase | Value | Use it for |
| --- | ---: | --- |
| `Environment` | `0` | Process-wide switches, logger setup |
| `ExternalResource` | `10` | Provision DB keyspaces, broker exchanges |
| `Configuration` | `20` | Finalise options |
| `Aggregates` | `30` | One-time work for aggregates |
| `Ports` | `40` | One-time work for ports |
| `Sagas` | `50` | One-time work for sagas |
| `EventStoreIndices` | `55` | Register per-tenant event-store indices |
| `Projections` | `60` | One-time work for projections |
| `Gateways` | `70` | One-time work for gateways |
| `Runtime` | `1000` | Default — anything else |

Pick the smallest phase number that satisfies your ordering. Startups must be safe to run repeatedly. The attribute does **nothing** on a discovery — do not put it there. Source: [`docs/cronus-framework/extensibility/startup-attribute.md`](docs/cronus-framework/extensibility/startup-attribute.md).

## Knowledge base for grounded answers

A curated KB of Cronus symbols, doc snippets, lint rules and concepts lives at `E:/Projects/ai stuff/Cronus-AIed/`. Use the CLI before generating non-trivial Cronus code:

```bash
CRONUS_KB_TRACE=1 python "E:/Projects/ai stuff/Cronus-AIed/benchmark/scripts/knowledge-lookup.py" \
{concepts,symbols,docs,snippets,lint} ...
```

Always set `CRONUS_KB_TRACE=1` so usage telemetry accumulates.

The 15 curated concepts (run `concepts` with no args to list them) include `aspire-cronus-wiring`, `tenant-flow`, `multi-process-topology`, `discoveries-pattern`, `port-external-io`, `saga-external-service`, `trigger-signal-rpc`, `public-event-federation`, `aggregate-with-entities`, `value-object-record-pattern`, `contract-versioning`, `dual-store-projections`, `projection-versioning`, `atomic-action-locking`, `approval-workflow`.

Before writing a saga / projection / port / etc., ground the shape:

```bash
CRONUS_KB_TRACE=1 python ".../knowledge-lookup.py" concepts --id <best-guess> --expand
```

Other useful sub-commands:

- `symbols --kind aggregate-root --canonical` — production-quality real symbols of a given kind.
- `docs --topic "<text>"` — scope to specific docs.
- `lint --file <path>` — run the lint patterns against a file you just wrote.
- `lint --list` — see the patterns (the `legacy` patterns map to the **Banned legacy types** section above).

## Verification before claiming done

Cronus tasks are not done until both of these are green from a clean checkout:

```shell
dotnet build
dotnet test
```

Lint your output too:

```shell
CRONUS_KB_TRACE=1 python ".../knowledge-lookup.py" lint --file path/to/your/file.cs
```

If the lint reports any `legacy` or `bug` finding, fix it before declaring success. "I think this works" is not a status.

## Pointers

Canonical doc pages on this branch:

- [Bounded context](docs/cronus-framework/domain-modeling/bounded-context.md)
- [Aggregate](docs/cronus-framework/domain-modeling/aggregate.md)
- [Aggregate IDs](docs/cronus-framework/domain-modeling/ids.md)
- [Messages overview](docs/cronus-framework/domain-modeling/messages/README.md) — commands, events, public events, signals
- [Handlers overview](docs/cronus-framework/domain-modeling/handlers/README.md) — application services, projections, sagas, ports, triggers, gateways
- [Configuration](docs/cronus-framework/configuration.md) — every `Cronus:*` key
- [Discoveries](docs/cronus-framework/extensibility/discoveries.md) and [`[CronusStartup]`](docs/cronus-framework/extensibility/startup-attribute.md)
- [Workflows](docs/cronus-framework/workflows.md) — message-processing pipeline
- [Indices](docs/cronus-framework/indices.md) — event-store secondary indices
- [Quick start: setup](docs/getting-started/quick-start/setup.md) and [persist first event](docs/getting-started/quick-start/persist-first-event.md)
68 changes: 23 additions & 45 deletions ci/azure-pipelines.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
---
variables:
PROJECT_DIR: Elders.Cronus
- group: elders-nuget
- name: PROJECT_DIR
value: Elders.Cronus

trigger:
branches:
include: [master,beta,preview,"*.x"]
paths:
exclude: [CHANGELOG.md]

pr: none

pool:
vmImage: 'ubuntu-22.04'

stages:
- stage: RunTestsDotnet9
displayName: 'Run tests for dotnet 9'
- stage: RunTestsDotnet10
displayName: 'Run tests for dotnet 10'
jobs:
- job: run_tests

Expand All @@ -25,50 +29,20 @@ stages:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '9.x'
includePreviewVersions: true
version: '10.x'
includePreviewVersions: false

- task: DotNetCoreCLI@2
name: test
inputs:
command: test
projects: '**/*Tests.csproj'
arguments: '--framework net9.0'

- stage: RunTestsDotnet8
displayName: 'Run tests for dotnet 8'
jobs:
- job: run_tests

steps:
- checkout: self
clean: true
persistCredentials: true

- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '9.x'
includePreviewVersions: true

- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.x'
includePreviewVersions: true

- task: DotNetCoreCLI@2
name: test
inputs:
command: test
projects: '**/*Tests.csproj'
arguments: '--framework net8.0'
arguments: '--framework net10.0'

- stage : DeployStage
displayName: 'Deploy stage'
dependsOn:
- RunTestsDotnet8
- RunTestsDotnet9
- RunTestsDotnet10

jobs:
- job: build_pack_publish
Expand All @@ -81,8 +55,8 @@ stages:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '9.x'
includePreviewVersions: true
version: '10.x'
includePreviewVersions: false

- task: DotNetCoreCLI@2
name: build
Expand All @@ -98,6 +72,12 @@ stages:
inputs:
targetType: 'inline'
script: |
# Force-refresh tags from origin. git fetch's default tag policy does NOT
# update tags that already exist locally, which leaves the agent's workspace
# stuck on stale tag positions across builds (specifically when a tag was
# force-moved on origin). semantic-release then mis-computes the next
# version. --force --tags makes the agent always match origin's tag refs.
git fetch --tags --force origin
time curl -L https://github.com/Elders/blob/releases/download/SemRel-01/node_modules.tar.gz | tar mx -I pigz
time npx semantic-release --no-ci
# few commands for debugging purposes
Expand All @@ -106,12 +86,10 @@ stages:
echo dotnet nuget `dotnet nuget --version`
echo dotnet `dotnet --version`

- task: NuGetCommand@2
- task: Bash@3
name: publish
enabled: true
displayName: nuget push to nuget.org
condition: and(eq(variables['newVer'], 'yes'), succeeded())
inputs:
command: 'push'
packagesToPush: '$(Build.StagingDirectory)/*.nupkg'
nuGetFeedType: 'external'
publishFeedCredentials: 'CI-AzurePipelines'
targetType: 'inline'
script: 'dotnet nuget push $(Build.StagingDirectory)/*.nupkg --api-key $(NUGET_KEY) --source https://api.nuget.org/v3/index.json --skip-duplicate'
16 changes: 15 additions & 1 deletion docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,25 @@
* [EventStore Player](cronus-framework/event-store/eventstore-player.md)
* [Migrations](cronus-framework/event-store/migrations/README.md)
* [Copy EventStore](cronus-framework/event-store/migrations/copy-eventstore.md)
* [Projections](cronus-framework/projections/README.md)
* [Versioning](cronus-framework/projections/versioning.md)
* [Projection Markers](cronus-framework/projections/projection-markers.md)
* [Snapshots](cronus-framework/projections/snapshots.md)
* [Workflows](cronus-framework/workflows.md)
* [Indices](cronus-framework/indices.md)
* [Jobs](cronus-framework/jobs.md)
* [Cluster](cronus-framework/cluster.md)
* [Cluster](cronus-framework/cluster/README.md)
* [Jobs](cronus-framework/cluster/jobs.md)
* [Messaging](cronus-framework/messaging/README.md)
* [Serialization](cronus-framework/messaging/serialization.md)
* [Atomic Actions](cronus-framework/atomic-actions.md)
* [Configuration](cronus-framework/configuration.md)
* [Aspire and Cronus](cronus-framework/aspire-cronus-wiring.md)
* [Multi-process topology](cronus-framework/multi-process-topology.md)
* [Integrity Validation](cronus-framework/integrity-validation-and-dangerzone.md)
* [Extensibility](cronus-framework/extensibility/README.md)
* [Discoveries](cronus-framework/extensibility/discoveries.md)
* [Startup Attribute](cronus-framework/extensibility/startup-attribute.md)
* [Fault Handling](cronus-framework/extensibility/fault-handling.md)
* [Observability](cronus-framework/extensibility/observability.md)
* [Unit testing](cronus-framework/unit-testing.md)
Loading