Skip to content

Read a command's key without Chronicle - #2431

Merged
woksin merged 7 commits into
mainfrom
feat/arc-core-command-key
Jul 30, 2026
Merged

Read a command's key without Chronicle#2431
woksin merged 7 commits into
mainfrom
feat/arc-core-command-key

Conversation

@woksin

@woksin woksin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A read model is loaded by the command's key, and only Chronicle resolved one.

Added

Fixed

woksin and others added 4 commits July 30, 2026 22:31
A read model is loaded by the command's key, and only the Chronicle
integration resolved one. An application whose read models are backed by
Entity Framework Core or MongoDB and which has no Chronicle therefore had
no key at all, and every read model it injected into a command failed as a
validation error.

Arc now reads the key from the command itself, but only when nothing wrote
one - Chronicle always writes one, empty included, so an application with
Chronicle resolves keys exactly as it always has.

Two rules, both things the application says out loud: the command composes
its key through ICanProvideKeyForCommand, or marks the property holding it
with the data annotations [Key]. Nothing is inferred from the shape of a
command - one carrying two identifiers has no answer that is not a guess,
and one that grew a second identifier later would silently stop resolving
a read model injected somewhere else entirely. An application that keys its
commands its own way says so once through ICanResolveKeyForCommand, which
is asked before the rule Arc ships whichever order the two are discovered
in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Two attributes are spelled [Key], and in an application with Chronicle the
data annotations one silently does nothing: Chronicle resolves keys from
its own KeyAttribute, finds no key property, invents a fresh event source
id, and every read model keyed by the command resolves to nothing -
surfacing as 'the entity does not exist' rather than as the wiring mistake
it is. ARCCHR0008 makes it a build warning.

It is reported only where Chronicle is referenced, and only on a command:
the data annotations attribute is what an Entity Framework Core read model
marks its primary key with, and what Arc reads without Chronicle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
@woksin woksin added the minor label Jul 30, 2026
@woksin

woksin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Closes the boundary #2425 shipped and documented: only the Chronicle integration wrote CommandContextKeys.ResolvedKey, so an application with Entity Framework Core or MongoDB and no Chronicle had no key and every read model injected into a command failed as a validation error.

The one thing that makes this safe

Arc reads a command's key only when nothing wrote one. That is load-bearing, so it is verified rather than assumed: EventSourceValuesProvider.Provide() writes ResolvedKey for every command — including the empty key that says the command carried nothing usable — so with Chronicle registered the fallback is unreachable and behavior is unchanged.

Chronicle.Specs/…/for_EventSourceValuesProvider/when_building_command_context_values/with_any_command_at_all.cs asserts exactly that across three command shapes (a key property, no key at all, a key it cannot compose). If a future change ever left the key unwritten for some shape, that spec fails rather than quietly handing key resolution to rules that know nothing of event source ids.

A written key stands as it is, empty included — overturning an integration's "no usable key" verdict by reading the command behind its back would be worse than the gap this closes. Specs cover the written, written-empty, and unwritten cases separately.

Worth your eyes

CommandContextExtensions.GetResolvedKey now takes an optional IServiceProvider and resolves ICommandKeys from it. An extension method reaching into DI is a smell, and I took it deliberately: the alternative was a second ICommandContextValuesProvider writing the same key, and CommandContextValuesBuilder merges provider output last-wins over IInstancesOf<> discovery order — so which of Chronicle's key and Arc's won would have been undefined. Resolving at read time makes precedence explicit in one place. The public no-argument overload is unchanged in signature and now delegates through commandContext.ServiceProvider.

ReadModelUnresolvableDependencyClassifier passes its own serviceProvider, because the CommandContext registered in DI carries none of its own.

Why no inference from a command's shape

The rules are only what the application says out loud. Inferring "the single id-shaped property" would resolve RenameCustomer(Guid CustomerId, …) fine and have no honest answer for MoveItem(Guid CartId, Guid ItemId) — and worse, a command that grew a second identifier later would silently stop resolving a read model injected somewhere else entirely. ICanProvideKeyForCommand covers the composite case explicitly instead.

CommandKeys puts DefaultKeyForCommandResolver last regardless of discovery order, so an application's own rule always decides; a spec asserts both orderings give the same answer.

ARCCHR0008

The failure mode it catches is silent: data annotations [Key] in a Chronicle application compiles, reads correctly, and does nothing — Chronicle finds no key property, invents a fresh event source id, and the read model comes back missing, which surfaces as "the entity does not exist" rather than as a wiring mistake.

It is gated on Cratis.Chronicle.Events.EventSourceId being resolvable in the compilation, and only fires on [Command] types — an EF read model's [Key] is exactly right and is left alone. Both negatives have specs. Chronicle.CodeAnalysis.Specs/Testing/TestProject.cs gains a metadata reference to the data annotations assembly, without which the test sources would not compile.

Verification

Clean dotnet build -c Release from the repository root, all three TFMs: 0 errors, 1 warning — the pre-existing CS0436 in TestApps/Chronicle that reproduces on main.

All 15 spec projects green: Arc.Core 1593 (17 new), Chronicle 563 (3 new), Chronicle.CodeAnalysis 40 (4 new), plus Screenplay 1165, ProxyGenerator 1039, MongoDB 148, EF 300 and the rest unchanged. The new specs were also run isolated by filter, not only as part of their project — a whole-project green hid an ordering bug in #2425 earlier today.

Documentation/verify-markdown.sh passes, 317 links scanned.

Not addressed

ARCCHR0008 catches the confusable attribute on a command. It says nothing about a data annotations [Key] on a command in an application that has no Chronicle, because there it is correct — but it also cannot warn when Chronicle is added to such an application later. Worth considering as part of the MissingWithChronicleAnalyzer story rather than here.

Review

Authored by an agent with no human review. The GetResolvedKey DI decision above is the part I would most want a second opinion on.

Comment on lines +30 to +36
foreach (var resolver in _resolvers)
{
if (resolver.Resolve(command) is { Length: > 0 } key)
{
return key;
}
}
@woksin

woksin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

The four CANCELLED checks (dotnet-build and the specs / proxy-specs / screenplay-end-to-end jobs that fan out from it) are not failures.

dotnet-build.yml runs on pull_request_target, where github.ref is the base branch — so its concurrency group is shared by every pull request targeting main, and a newer run cancels whatever is in flight. This run was superseded by another branch's.

Not rerunning it: a rerun would cancel that other pull request's in-progress run in turn. A maintainer rerun at merge time settles it.

Everything that does not fan out from dotnet-build passed — CodeQL, all three Analyze jobs, link-verification, markdown-lint, release. Local verification stands in for the rest: clean dotnet build -c Release across all three TFMs with 0 errors, and all 15 spec projects green, with the new specs also run isolated by filter.

woksin and others added 3 commits July 30, 2026 23:00
The analyzer shipped with no reference page, the failure page described
only the Chronicle way of declaring a key, and the one page written for
readers running Arc without an event store said nothing about a command
taking the read model it acts on - which is the audience the whole change
is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
The page had grown to cover five topics and 297 lines, past the point one
page holds together: where to declare the dependency and what nullable
means is a different question from which store the read model comes from
and what key loads it. The second is also the half a reader without
Chronicle needs, and it was buried at the bottom of a page about
Chronicle read models.

Every inbound link is re-pointed, and the anchors both pages are linked by
are verified to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p
@woksin
woksin merged commit 2c9d247 into main Jul 30, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant