diff --git a/.ai/handoffs/chronicle-client-docs-migration.md b/.ai/handoffs/chronicle-client-docs-migration.md new file mode 100644 index 0000000..ecc6a19 --- /dev/null +++ b/.ai/handoffs/chronicle-client-docs-migration.md @@ -0,0 +1,1068 @@ +# Chronicle Client Docs Migration Handoff + +Last updated: 2026-07-03 (part 32 — post-migration nav audit) + +This handoff exists so a fresh agent session can continue the Chronicle multi-client documentation work without reconstructing the conversation history. + +## 2026-07-01 update + +A regression had crept in since the 2026-06-23 snapshot below (introduced by an interim session not reflected in this file): `../Chronicle/Documentation/testing/read-models/scenario.mdx` had a raw, untabbed ` ```csharp ` fence, and the `testing/**` pages had been converted to `.mdx` using **new** `legacy/testing/**` snippet ids, pushing the C# legacy count from 554 to 557 — both tripped `chronicle-client-docs.yml`'s ratchets. Fixed: + +- Wrapped the stray fence in `testing/read-models/scenario.mdx` in a `` referencing a new `legacy/testing/read-models/scenario/snippet-08` (kept under `legacy/` deliberately — see note below). +- Fully migrated all 16 remaining `code-analysis/CHR00xx.mdx` pages (CHR0001, 0002, 0003, 0005, 0006, 0007, 0012–0021) off `legacy/` and onto real, compiled snippets under `client-snippets/code-analysis/chr00xx/*.md`, following the `CHR0004` precedent exactly (self-contained, `Chr00XX`-prefixed type names to avoid collisions in the single generated validation file, `Fixed`-suffixed names on the "after" side). Deleted the superseded `client-snippets/legacy/code-analysis/**` (~34 files). Along the way, confirmed via a research pass against `Chronicle/Source/Clients/DotNET` that `Cratis.Chronicle.Concepts.Events` (used in several of the old legacy snippets) is **wrong** — it's a Kernel-only namespace, not importable by client code. The correct namespace is `Cratis.Chronicle.Events`. Also confirmed `IProjectionBuilder.From(Action>? callback = default)` takes an `Action`, not a `Func` — a bare `_ => _` identity lambda does not compile (CS0201); call `From()` with no argument to rely on AutoMap instead. +- Ran `python3 Documentation/validate-client-snippets.py` in `../Chronicle` — all code-analysis snippets compile for real against the actual .NET client (this had never been done for these 16 pages before; they were sitting under `legacy/`, which the validator explicitly skips via `VALIDATION_EXCLUDED_PREFIXES`). +- C# legacy snippet count: 554 (stale baseline) → 557 (regressed) → **523** (after the code-analysis migration, net improvement over the original baseline). Lowered `chronicle-client-docs.yml`'s `csharp.snippets.legacyBaseline` from 554 to 523 to lock this in. +- Re-verified full green: `npm run audit:chronicle-client-docs:strict` (0 direct fences), `npm run build` (837 pages), `node scripts/lint-docs.mjs` (0 errors), and a visual spot-check of `/chronicle/code-analysis/chr0001/` in a local dev server. + +**Important nuance learned:** not every `legacy/` snippet is actually "not yet properly homed" — genuinely single-client pages that already match the target model (e.g. `namespaces/dotnet-client.mdx`, `connection-strings/dotnet-client.mdx`) still use `clients="csharp"` tabs pointing at `legacy/**` ids, and that's fine as *placement* (single-client is the correct classification). The `legacy/` prefix specifically means "not yet validated as real, compiled code" — migrating a page off `legacy/` means rewriting its snippet into something that actually compiles (per `VALIDATION_EXCLUDED_PREFIXES` in `../Chronicle/Documentation/validate-client-snippets.py`), not just relocating or renaming a file. The `testing/**` pages (illustrative `Specification`/`ReadModelScenario` examples with placeholder domain types) were deliberately left under `legacy/` rather than forced into "real" compiled form, since making illustrative BDD-spec pseudocode genuinely compilable would require inventing a full fake domain in the shared validator — a bigger, separate piece of work, not a quick fix. Re-assess this if it comes up again. + +Nothing from this session has been committed, pushed, or opened as a PR — that was explicitly deferred pending user confirmation. See "Next Work" below; it is unchanged in substance, just smaller (code-analysis is now done). + +## 2026-07-01 update, part 2 — nav cleanup + projections batch started + +**Navigation cleanup** (separate from the client-docs migration, but touched the same repos): audited `web/scripts/sync-content.mjs` and confirmed the "Client SDKs" sidebar is already a single nested appendix under one unified Chronicle doc, not 5 parallel trees, and `sharedTopicBridge: true` is a real, wired mechanism (excludes a page from the shared-topic-overlap audit in `check-chronicle-client-docs.mjs`). Found and fixed gaps: added `Chronicle.Kotlin/Documentation/guides/event-evolution.md` (+ `guides/toc.yml` entry — Kotlin was missing this bridge entirely), added `Chronicle.Elixir/Documentation/compliance.md` and `projections.md` (Elixir had neither), added `Chronicle.TypeScript/Documentation/projections.md` (TypeScript had no projections bridge). Also linked previously-orphaned bridge pages (reachable only by direct URL, not from any nav or prose) into each repo's own "where to next" entry point: Elixir's `get-started.md` and TypeScript's `getting-started.md` now list every shared-topic bridge page. Verified: build (841 pages), lint (0 errors), `chronicle-client-docs:check` (still green). + +**Projections batch, started** — decided approach: **C# + TypeScript only for now** (both compile-verifiable locally); Kotlin/Java/Elixir explicitly deferred to a session with those toolchains, not guessed at. Two research passes established ground truth before writing anything: + +- Sidebar/nav research (above). +- Client API research: Kotlin/Java, Elixir, and TypeScript **all** have real model-bound (annotation/decorator) AND fluent-builder projection APIs — contrary to the initial assumption (drawn from the composite-keys.mdx precedent) that non-C# clients mostly render "unsupported" stubs. The gaps are narrower: composite keys, children/nested collections, and a few arithmetic operators are unimplemented in some clients — not the whole paradigm. **This means most of the remaining 27 projection files need genuine per-client code, not stub tabs** — a much bigger lift than code-analysis was. Full findings are in this session's transcript if a future session needs the detail (Kotlin: `@FromEvent`/`@SetFrom` in `io.cratis.chronicle.projections`; Elixir: `use Chronicle.ReadModels.ReadModel` + `Chronicle.Projections.Projection` macros; TypeScript: rich decorator set in `Source/projections/modelBound/*.ts` — `fromEvent`, `setFrom`, `childrenFrom`, `count`, `increment`, `decrement`, `notRewindable`, `passive`, `join`, `removedWith`, `removedWithJoin`, `nested`, etc. — near-full parity with C#, confirmed by reading source directly, not assumed). + +**Done in the `projections/projection-declaration-language/**` folder (6 pages, 11 legacy snippet slots)** — the lightest sub-batch, since PDL itself is a language-neutral text format; only the auxiliary C#/TS type declarations embedded in these pages needed migrating: + +- `auto-map.mdx`, `children.mdx`, `counters.mdx`, `nested.mdx`, `expressions.mdx` — plain illustrative read-model/event type declarations, no framework API involved. Real C# (`Documentation/client-snippets/projections/projection-declaration-language/{auto-map,children,counters,nested,expressions}/*.md`) and TypeScript (mirrored in `Chronicle.TypeScript/Documentation/client-snippets/...`) snippets added, `clients="csharp,typescript"`. All types prefixed `Pdl*` to avoid collisions in the single generated validation file per language (same collision-avoidance approach as the CHR0004/CHR00xx precedent). +- `adhoc-querying.mdx` (5 snippets) — this one genuinely is C#-only: verified `IProjections.Query(string declaration, string eventSequenceId = "event-log")` and `ProjectionQueryResult`/`UnableToQueryProjection` are real (`Source/Clients/DotNET/Projections/IProjections.cs`), and confirmed TypeScript's `IProjections` (`Chronicle.TypeScript/Source/projections/IProjections.ts`) has no equivalent ad-hoc query method at all — so `clients="csharp"` only, correctly. Added 4 new `BODY_SNIPPETS` entries to `Chronicle/Documentation/validate-client-snippets.py` for the free-statement snippets (declaring `IProjections projections = default!;` as prelude). +- Ran both validators for real: `python3 Documentation/validate-client-snippets.py` (C#) and `python3 Chronicle.TypeScript/Documentation/validate-client-snippets.py` (TypeScript) — both compiled clean. +- Deleted the 11 superseded legacy files. Legacy count: 523 → 512. Lowered `legacyBaseline` to 512. +- Re-verified: strict audit, full build (841 pages), lint (0 errors). + +**User's standing instruction for this session**: auto-delete superseded legacy snippet files as each batch completes (don't ask per-batch) — they were never committed, so nothing is lost. Still hold off on git commit/push/PR until explicitly asked. + +**Remaining projections work** (not started): `projections/declarative/**` (11 files — note: this is the *fluent C# builder* style, confusingly named "declarative" in the existing doc tree; don't confuse with PDL), and 6 top-level `projections/*.mdx` files (choosing-a-read-model-style, filtering, architecture, tagging-projections, nested-objects-design, eventual-consistency) — none of these have been read yet in detail. Each will need the same treatment: read current legacy content, verify against real Chronicle/Chronicle.TypeScript source, write self-contained prefixed snippets, add validator preludes where needed, run both validators, update `.mdx` tabs to `clients="csharp,typescript"` (or narrower if a feature is genuinely C#-only — check source per-file, don't assume), delete superseded legacy files, re-verify full chain. + +## 2026-07-01 update, part 3 — model-bound folder fully migrated + +Completed **all 10 files** in `projections/model-bound/**` (set-value, children, counters, index, constant-key, joins, removal, not-rewindable, passive, event-sequence-source) — every snippet is now real, compiling C# and TypeScript, verified with both `python3 Documentation/validate-client-snippets.py` (Chronicle) and `python3 Chronicle.TypeScript/Documentation/validate-client-snippets.py` on every batch, not just written from memory. + +**API research done up front, both languages** (worth reusing if this comes up again): C# attributes live in `Cratis.Chronicle.Projections.ModelBound` except `[Passive]` (`Cratis.Chronicle.ReadModels`) and `[EventSequence]`/`[EventLog]` (`Cratis.Chronicle.EventSequences`). TypeScript decorators (`fromEvent`, `setFrom`, `setValue`, `childrenFrom`, `nested`, `join`, `removedWith`, `removedWithJoin`, `clearWith`, `increment`/`decrement`/`count`, `addFrom`/`subtractFrom`, `notRewindable`, `passive`) live in `Source/projections/modelBound/*.ts` and import from `@cratis/chronicle`; signatures are positional (no named args), confirmed against real source per-decorator, not assumed from the C# shape. **Genuine capability gap found:** TypeScript model-bound has no `[NoAutoMap]` equivalent and no `[EventSequence]`/`[EventLog]` equivalent — both stay C#-only (`clients="csharp"`) on those specific snippets; TypeScript's *fluent* builder can still set an event sequence via the `@projection(id, readModelType?, eventSequenceId?)` decorator's third argument, so `event-sequence-source.mdx`'s fluent-comparison snippet does have a TS side. + +**Bugs found and fixed along the way** (none of these were caught by the fact the old content "looked right" — only real compilation caught them): +- `[Join(on?, eventPropertyName?)` is positional — a single bare `nameof(...)` argument binds to `on` (a *model* property name), not `eventPropertyName`. The original legacy content in `children/snippet-08` (nested hierarchy) had this backwards, referencing the wrong event property (`Id` instead of `NewName`) — fixed in both C# and TypeScript. Same class of bug likely lurks in any remaining legacy `Join` usage — check positional vs named args carefully. +- `Join`'s `on:` parameter is a **read model** property name, not an event property name — the original `joins/snippet-01` and `snippet-03` referenced `nameof(CustomerId)` where `CustomerId` didn't exist as a model property at all (would not have compiled). Fixed by adding an explicit `[SetFrom] Guid CustomerId` model property to join against. +- Two **externally-added** pages appeared mid-session (not mine — `code-analysis/CHR0022.mdx` and `CHR0023.mdx`, new analyzer rules for `[OnceOnly]` reactor idempotency and ambiguous `[ChildrenFrom]` parent-key inference) had the same class of bug as the original 16 CHR00xx pages before this session's earlier fix: `violation.md` and `fix.md` declared identically-named types, and `chr0023` referenced undeclared `LedgerId`/`EntryId` types. Fixed using the same `Chr00XX`-prefix-plus-`Fixed`-suffix discipline as CHR0001–0021, since they were blocking the shared validator that this session's own work also depends on. +- `projections/model-bound/children.mdx` and `removal.mdx` had `[!INCLUDE [x](../_snippets/model-bound/*.md)]` DocFX-style includes — a **separate embedding mechanism the audit doesn't scan at all** (`_snippets` is on the audit's exclusion list), so these were raw, un-tabbed `csharp` fences invisible to `chronicle-client-docs:check`. Converted to real `` snippets and deleted the `_snippets/model-bound/` files. **Worth a repo-wide `grep -rl "\[!INCLUDE" Chronicle/Documentation` sweep at some point** — this was only caught because these two specific files happened to be in the folder being migrated; other `_snippets`-based includes could exist elsewhere in the tree and would currently show as unmigrated raw fences with zero audit visibility. +- Original `projections/model-bound/index.mdx` snippet-04 called `.AutoMap()` explicitly in a *fluent* comparison example — kept as-is since that section's whole point is showing the `.AutoMap()` method exists (framework reference, not app-building guidance), but flagging in case a future pass wants to reconsider. + +**TypeScript key-property convention**: confirmed (no explicit `[Key]`-equivalent decorator exists in `Source/projections/modelBound/`) that TS model-bound read models rely on a property literally named `id` for key discovery, unlike C# where `[Key]` can decorate any property name. All TS snippets in this batch use `id` for the primary key regardless of what the C# side calls it — this is a deliberate, evidenced choice (matches the one real sample found, `Samples/Console/projections-model-bound.ts`), not verified against the Kernel's actual runtime resolution logic (that lives in the Chronicle Kernel repo, not the client). If this convention turns out to be wrong, every TS model-bound snippet in this session needs revisiting. + +Legacy count: 512 → **470** after this batch. Baseline ratcheted to 470. Re-verified: strict audit, full build (843 pages), lint (0 errors), and a visual spot-check of `/chronicle/projections/model-bound/children/` in a live dev server (renders correctly, C#/TypeScript tabs visible throughout). + +Next: `projections/declarative/**` (11 files, the fluent C# builder style) and 6 top-level `projections/*.mdx` files, same rigor. + +## 2026-07-01 update, part 4 — found a systemic TypeScript correctness gap, fixed retroactively + +Started the `declarative/**` folder and discovered the folder already had **more files than tracked** — `basic-mapping.mdx`, `convention-based.mdx`, `from-all.mdx`, `from-every.mdx`, and `nested.mdx` (plus `composite-keys.mdx`) had already been migrated externally (pre-dating this session, not caught by the earlier survey — the model-bound folder similarly turned out to have 15 files, not 10; the 5 extra were also already done). All of these passed both validators when re-checked, so no rework needed there. + +**Important discovery while writing `declarative/simple-projection`**: TypeScript events need an `@eventType()` decorator (`Source/events/eventTypeDecorator.ts`, "the TypeScript equivalent of the C# `[EventType]` attribute") for real registration/discovery — confirmed by comparing against the pre-existing validated `basic-mapping/set-from.md` snippet, which used it consistently. **Every TypeScript event class written by this session before this point was missing it** — `tsc` doesn't care (decorators don't affect type-checking), so the validator never caught it, but a reader copying the snippet into a real app would get an event that never registers. Went back and added `@eventType()` (+ import) to every event class across the entire `model-bound/` folder (~30 files) and the PDL batch's one real event snippet. Re-ran both validators after the sweep — still green, confirming no collisions were introduced. Also caught and fixed a matching omission in the **C#** side: `projection-declaration-language/auto-map/event.md`'s event record was missing `[EventType]` too (carried forward uncritically from the original untested legacy content, which had the same bug) — this is exactly the class of error CHR0001 exists to catch. + +**Takeaway for whoever continues this**: before treating any language's snippet as "done" once it compiles, check whether that language has its own **required discovery/registration marker** beyond plain type syntax — compiling clean is necessary but not sufficient. For TypeScript specifically: events need `@eventType()`, read models need `@readModel()` (already applied consistently), children/nested value types need neither (matches C# where only `[Key]` matters for children). If a fresh reviewer finds another missing decorator class, expect the same retroactive-sweep treatment to be worth it — it's mechanical but essential. + +Legacy count: 470 → **467** (only `simple-projection` migrated so far in this pass: 3 real snippets). Baseline ratcheted to 467. Re-verified: strict audit, full build (843 pages), lint (0 errors). + +**Remaining in `declarative/`** (as of that point): constant-key, event-context, from-event-sequence, functions, index, joins, not-rewindable, passive, remove-with-join, set-properties (10 files) — same rigor: check real C# fluent-builder API, check real TypeScript fluent-builder API (note the **fluent builder specifically** has narrower TS support than model-bound decorators — `children()`, `nested()`, `add()`, `subtract()`, `count()`, `usingCompositeKey()`, `usingParentCompositeKey()`, `addChild()`, `setThisValue()` all throw "not implemented yet" in TS's `FromBuilder.ts`/`ProjectionBuilderFor.ts` — those specific declarative pages need `clients="csharp"` only or an explicit unsupported-workflow TS snippet, don't assume parity with what model-bound already achieved). Then the 6 top-level `projections/*.mdx` files. + +## 2026-07-01 update, part 5 — more real bugs found and fixed; `set-properties`, `event-context`, `not-rewindable`, `passive` done + +Completed 4 more `declarative/**` pages (`set-properties`, `event-context`, `not-rewindable`, `passive`), all real C#+TypeScript, both validators green after each batch. `event-context`'s composite-key and children-with-context snippets are correctly `clients="csharp"` only (confirmed via the same TS fluent-builder gap noted above — verified directly against the already-migrated `declarative/children.mdx`/`composite-keys.mdx`, which are also `clients="csharp"` only, as a live precedent). + +**Two more classes of real bugs found in the original legacy content, beyond anything caught by simple compilation**: + +1. **`set-properties.mdx` prose made a false capability claim**: "You can also use `AutoMap()` explicitly for each event type instead of at the projection level" — checked `IFromBuilder`/`IReadModelPropertiesBuilder` in `Source/Clients/DotNET/Projections/`, confirmed no such per-event-type `AutoMap()` exists (it's only ever at the top-level `IProjectionBuilder`). Removed the false claim and its snippet, replaced with an accurate explanation. **This is a prose/documentation bug, not just a snippet bug** — worth remembering that "audit at scale" per `writing-correct-examples.md` applies to claims in the surrounding text too, not only code fences. +2. **`not-rewindable.mdx` had systemic `.To(_ => computedExpression())` misuse across nearly every snippet** — `.To(_ => DateTimeOffset.UtcNow)`, `.To(_ => Environment.MachineName)`, `.To(_ => "RECEIVED")` (a string literal). Per `ISetBuilder`'s real shape (confirmed from source): `.To(...)` only accepts an event-property accessor (extracts a property path, doesn't execute code) — computed/constant values need `.ToValue(...)`, and there's no way to capture "current wall-clock time" or `Environment.MachineName` through the fluent builder at all. The prose repeated the same false claim ("Real-time processing: Current processing time is part of the business logic", "real-time timestamp tracking") in multiple places. Fixed every snippet to use `ToEventContextProperty(c => c.Occurred)` for timestamps (the real mechanism — event-occurred time, not wall-clock-at-projection-time) and `.ToValue(...)` for constants, and corrected the prose claims to match what's actually achievable. Also confirmed `IAllSetBuilder` (used by `.FromEvery()`) only exposes `ToEventSourceId()`/`ToEventContextProperty()` — no `.To()`/`.ToValue()` at all, so `FromEvery` can *never* express a computed-per-event-type value, only event-source-id or context-derived ones. +3. **`passive.mdx`'s service snippet had a nullable-return mismatch**: declared `Task` but the real `IReadModels.GetInstanceById(ReadModelKey key, ...)` returns non-nullable `Task`. Fixed. Also confirmed `ReadModelKey` has an implicit `string`→`ReadModelKey` conversion (so passing a raw string id still compiles), and found the real TypeScript equivalent needs the read model constructor as an explicit argument (`readModels.getInstanceById(SomeReadModel, key)`) since TS lacks reified generics — this differs structurally from the C# single-generic-arg call, by design, not a mistake. + +**Also found and fixed a TypeScript-specific ordering bug that had nothing to do with API correctness**: `@projection('', SomeReadModelClass)` — passing the read model class as the decorator's second argument — throws `TS2449: Class used before its declaration` when that class lives in a different file than the projection (the validator merges all snippet files in alphabetical path order, and decorator arguments are evaluated as values, unlike type references which are hoisted). Fixed by omitting the optional `readModelType` argument (`@projection()`) and relying on the `implements IProjectionFor` type reference instead, which is order-independent. **Every already-existing external snippet that uses this pattern keeps the read model declared in the same file** — that's why they never hit this; a rule worth remembering: if a fluent projection's read model type lives in a separate snippet file, never pass it positionally to `@projection(...)`. + +Legacy count: 467 → **444**. Baseline ratcheted to 444. Re-verified: strict audit, full build (843 pages), lint (0 errors), both C#/TypeScript validators. + +**Remaining in `declarative/`** (as of that point): constant-key, from-event-sequence, functions, index, joins, remove-with-join (6 files). Then the 6 top-level `projections/*.mdx` files. + +## 2026-07-01 update, part 6 — CRITICAL: TypeScript fluent-builder methods that type-check but throw at runtime + +**This is the most important finding of the whole session so far — re-read this before writing or trusting any TypeScript fluent-builder (`declarative/`) snippet.** + +`FromBuilder.ts` (`Chronicle.TypeScript/Source/projections/declarative/FromBuilder.ts`) implements `usingConstantKey()`/`usingConstantParentKey()`/`increment()`/`decrement()` for real, but `usingCompositeKey()`, `usingParentCompositeKey()`, `children()`, `nested()`, `add()`, `subtract()`, `count()`, `addChild()`, `setThisValue()` are **stubbed to literally `throw new Error('X is not implemented yet.')`** — the interface (`IReadModelPropertiesBuilder.ts`) declares the full method signature, so **`tsc` type-checks a call to any of these completely cleanly**. The validator only runs `tsc`, never executes the generated code, so **it cannot and does not catch this** — a snippet using `.count(...)` or `.add(...).with(...)` passes validation and looks done, but would throw the instant a reader's app actually ran the projection. + +Caught this while writing `constant-key/basic.md` (used `.count()`) and `constant-key/multi-event-metrics.md` (same) — both TS files deleted, pages marked `clients="csharp"` only with an explanatory note. `constant-key/increment-decrement.md`'s TS side was partially affected (mixed real `.count()` with real `.increment()`/`.decrement()` in one example) — fixed by dropping the `count()`-based property from the TS version only, keeping C# showing the full three-property example, with a prose note explaining the asymmetry. + +**Then swept every already-existing `declarative/**` TypeScript snippet** (`rg -rn '\.usingCompositeKey\(|\.usingParentCompositeKey\(|\.children\(|\.nested\(|\.add\(|\.subtract\(|\.count\(|\.addChild\(|\.setThisValue\('`) and found one more, in content from **before this session** (not mine): `initial-values/partial-events.md` used `.add(m => m.currentStock).with(e => e.quantity)` — genuinely broken at runtime, sitting in the tree passing the validator this whole time. Deleted that TS file too, changed `initial-values.mdx`'s tab to `clients="csharp"` only with the same kind of note. Swept the `model-bound/` fluent-comparison files too (`from-every/declarative-equivalent.md`, `convention-based/automap-equivalent.md`, `event-sequence-source/fluent.md`, `index/*-fluent.md`) — clean, no matches. + +**Rule going forward, no exceptions**: before writing (or trusting existing) TypeScript for any `declarative/**` fluent-builder snippet, grep the *implementation* file (`Source/projections/declarative/FromBuilder.ts` and `ProjectionBuilderFor.ts`), not just the interface, for the specific method being called. If it throws, the snippet is C#-only — full stop, no clever workaround, no "it type-checks so it's probably fine." Re-run this exact sweep (`rg` command above) over the whole `declarative/**` and `model-bound/**` TypeScript trees again before considering the migration "done" — it's cheap and catches a class of bug the validator structurally cannot. + +Re-verified after these fixes: both C#/TypeScript validators pass again. + +## 2026-07-01 update, part 7 — `projections/declarative/**` is now FULLY MIGRATED + +Finished the remaining 6 files: `constant-key`, `from-event-sequence`, `functions`, `index`, `joins`, `remove-with-join`. **The entire `declarative/` folder (18 files total, including the ones already done pre-session) is now off `legacy/`.** + +Applied the runtime-throw check (part 6 above) rigorously on every new file: +- `constant-key.mdx`: `basic` (Count-based) and `multi-event-metrics` (Count-based) are `clients="csharp"` only; `increment-decrement` and `constant-parent-key` mix real TS (`increment`/`decrement`/`usingConstantKey`) with C#-only (`.Children()`) — split accordingly. +- `functions.mdx`: this whole page is essentially a tour of `Count`/`Increment`/`Decrement`/`Add`/`Subtract` — only the `Increment`/`Decrement` snippet has real TypeScript; `Count` and `Add`/`Subtract` are C#-only. Added an explicit TypeScript-scope note near the top of the page. +- `joins.mdx`: `.Join(j => j.On(...))` **is** fully implemented in TypeScript (confirmed from `ProjectionBuilderFor.ts` — not in the throwing list) — real TS for the basic/read-model/multiple-joins examples. Only the last "joining children" example is C#-only (nests `.Join()` inside `.Children()`). +- `remove-with-join.mdx`: every single example uses `.Children()`, so this entire page is C#-only — no TypeScript files created at all. +- `event-context.mdx` (done in part 5) and `constant-key.mdx`/`functions.mdx` (this part) confirm the same pattern: check the *specific method*, not "the topic sounds like it needs children/composite keys." `Join` alone is fine in TS; `Join` nested inside `Children` is not, because `Children` itself throws. + +**More pre-existing bugs found and fixed in previously-untested legacy content** (same discipline as parts 5-6, now applied to the last declarative batch): +- `functions.mdx`/`combined.md`, `not-rewindable` (already fixed in part 5) — general pattern confirmed: anywhere the legacy content chains `.Count()`/`.Add()`/`.Increment()` together, expect it was never compiled. +- `joins.mdx` and `remove-with-join.mdx`: multiple instances of `.On(m => m.SomeProperty)` or `.Join(...)` referencing a read-model property that was never actually declared on the type (`on:` binds to a **read model** property, not an event property — same root confusion as the model-bound `Join` bug from part 4). Fixed by adding the missing property (populated via `.Set(...).ToEventSourceId()` or similar) everywhere it was missing. +- `remove-with-join.mdx`'s two real-world examples (`user-groups-example`, `project-assignments-example`) had **AutoMap name mismatches between events and read models** that would silently leave properties unset forever: `GroupCreated(Name, Type)` vs `GroupMembership.GroupName/GroupType` (no match), `ProjectInitiated(Name, ...)` vs `ProjectAssignment.ProjectName` (no match), `DeveloperAssignedToProject.AllocationPercentage` vs `ProjectAssignment.Allocation` (no match). Renamed the event properties to match the read model exactly. Also found `UserProfile.UserId` and `DeveloperProfile.DeveloperId` were never populated by anything (no explicit `.Set(...).ToEventSourceId()` and no name match) — added the missing explicit mapping. **AutoMap silently doing nothing for an unmatched property is not a compile error and isn't caught by any validator** — this class of bug requires actually reading each read-model/event pair side by side and checking every property has a source. +- `explicit-keys.md`: `RemovedWithJoin(_ => _.UsingKey(e => e.ProjectId))` where `ProjectCancelled` was declared as an empty record with no `ProjectId` — this one **did** fail the validator (`CS1061`), unlike the AutoMap-mismatch bugs above which compile fine but are silently wrong. Fixed by adding the property. + +Legacy count: 444 → **413**. Baseline ratcheted to 413. Re-verified: strict audit, full build (843 pages), lint (0 errors), both validators, and a visual spot-check of `/chronicle/projections/declarative/constant-key/` (renders correctly, TypeScript-scope notes visible and readable). + +**`projections/` remaining work**: only the 6 top-level files — `choosing-a-read-model-style.mdx`, `filtering.mdx`, `architecture.mdx`, `tagging-projections.mdx`, `nested-objects-design.mdx`, `eventual-consistency.mdx`. None of these have been read yet. Once those are done, the entire `projections/**` tree is migrated — that leaves only whatever remains outside `projections/` for the wider "properly release this" migration debt (per the original Next Work section below: events, reducers, concepts, get-started, configuration, reactors, constraints, compliance, and the smaller single-file pockets — see the legacy-placeholder count breakdown further down, though it is now stale and should be re-run: `rg 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l`). + +**Standing reminders for whoever continues**: (1) always grep `FromBuilder.ts`/`ProjectionBuilderFor.ts` for the literal method being called before writing TypeScript declarative-style snippets — check the implementation, not just the interface; (2) `AutoMap` mismatches between event and read-model property names are a silent, validator-invisible bug class — read every property on both sides; (3) auto-delete superseded legacy files after each batch (standing instruction from earlier in this session); (4) commit/push/PR still explicitly deferred pending user request. + +## 2026-07-01 update, part 8 — `projections/**` is now ENTIRELY MIGRATED (zero legacy references or files left in that tree) + +Finished the last 6 top-level `projections/*.mdx` files: `choosing-a-read-model-style`, `filtering`, `architecture`, `tagging-projections`, `nested-objects-design`, `eventual-consistency`. Confirmed via `grep -rl 'snippet="legacy/' ../Chronicle/Documentation/projections/` and `find ../Chronicle/Documentation/client-snippets/legacy/projections -type f | wc -l` — both zero. **The entire `projections/**` tree (PDL, model-bound, declarative, and all top-level pages) is now off `legacy/`.** + +- `choosing-a-read-model-style.mdx` and `filtering.mdx` (done earlier this part, before the summary boundary): 5 snippets each. `filtering.mdx` is C#-only (`[Tag]`/`[FilterEventsByTag]` have no TypeScript equivalent); confirmed `[ReadModel]` is actually an **Arc** attribute (`Arc.Core/Queries/ModelBound/ReadModelAttribute.cs`), not Chronicle — removed it from a Chronicle-only snippet since the validator project doesn't reference Arc. +- `architecture.mdx`: mostly a mermaid-diagram design page with 2 small illustrative snippets (model-bound vs. declarative). Confirmed `[Projection("id")]` is real (`ProjectionAttribute(string id = "", string? eventSequence = default)`). C#-only (fluent example uses `.Count()`, which is fine in C# but throws in the TS fluent builder per part 6's finding). +- `tagging-projections.mdx` (6 snippets, C#-only): confirmed `TagAttribute`/`TagsAttribute` (`Cratis.Chronicle` namespace, `params string[] tags`, `AllowMultiple = true`) — so both `[Tag("A","B","C")]` and stacked `[Tag("A")] [Tag("B")]` are valid. The legacy content had several snippets that were pure pseudocode (empty `Define` bodies, a floating cheatsheet of `[Tag(...)]` lines with no enclosing declaration — not legal C# on its own). Rewrote all 6 as real, self-contained, compiling examples; folded the "cheatsheet" into one class with all the category tags stacked as multiple `[Tag]` attributes. +- `nested-objects-design.mdx` (3 snippets, C#-only): a cross-cutting design reference for the `nested` projection primitive. Reused the already-validated shape from `declarative/nested/basic.md` and `model-bound/nested/basic-lifecycle.md` (from earlier parts) with renamed/prefixed types. Snippet 3 needed genuine **recursive** nesting (`nested` inside `nested`, not sibling nested objects like `declarative/nested/multiple-nested.md`) — confirmed via source (`INestedBuilder : IProjectionBuilder>`) that `.Nested()` is inherited and callable again inside a nested callback, so `.Nested(m => m.Command, nested => nested.From().Nested(m => m.Validation, inner => inner.From().ClearWith()).ClearWith())` is valid and compiles. +- `eventual-consistency.mdx` (3 snippets, C#-only): the legacy content used entirely fictional APIs (`IEventStore`, `_eventStore.ReadModels.Watch()`, `readModels.GetBook(bookId)`). Verified the real API is `IReadModels` (`Cratis.Chronicle.ReadModels`) with `Watch() : IObservable>` and `GetInstanceById(ReadModelKey key, ...)` — rewrote all 3 examples against the real interface. **Found and fixed a real ambiguous-operator compile error**: `changeset.ModelKey == bookId` (comparing `ReadModelKey` to `EventSourceId`) is genuinely ambiguous (CS9342) because `ReadModelKey` has an implicit conversion *from* `EventSourceId` **and** an implicit conversion *to* `EventSourceId`, so both types' `==` operator become applicable candidates. Fixed by comparing `.Value` (the underlying `string`) on both sides instead of relying on either implicit conversion. + +Legacy count: 413 → **389**. Baseline ratcheted to 389. Re-verified full chain: both validators (C# + TypeScript), `chronicle-client-docs:check` (passes, only Kotlin/Java/Elixir blocked by missing local toolchain as expected), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors — the ~195 "weasel word" warnings are pre-existing prose-style suggestions across the whole corpus, unrelated to this work). + +**Unrelated pre-existing issue noticed, not fixed (out of scope)**: `npm run sync` reports "1 broken toc entries dropped" — traced to `Fundamentals/Documentation/typescript/toc.yml` referencing a `coordinate.md` that doesn't exist in that folder. Nothing to do with Chronicle; flagging for whoever next touches Fundamentals' TypeScript docs. + +**`projections/**` is DONE.** Nothing left under it references `legacy/`. The remaining migration debt is now entirely **outside** `projections/**` — 84 files still reference `legacy/` snippets across: `closing-streams`, `coming-from-crud`, `compliance/**`, `concepts/**` (designing-read-models, event-type-migrations, geospatial, modeling-events, subject, tagging-reactors, tagging), `configuration/**`, `connection-strings/**`, `constraints/**` (declarative + model-bound), `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `events/**` (concurrency, cross-cutting-properties, event-source-id, filtering/*, getting-events, getting-state, index, observing-appends, redaction), `get-started/**`, `hosting/**`, `migrations/**`, `namespaces/**`, `reactors/**`, `read-models/materialized-pagination`, `reducers/**`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`. 389 legacy snippet files remain on disk. This list is fresh as of this update (re-run the same `grep`/`find` commands from part 7 to re-verify if it goes stale). + +**Standing reminders, unchanged**: (1) grep the actual `.ts` implementation files (not just interfaces) before trusting any TypeScript fluent-builder snippet — methods can type-check and still throw at runtime; (2) AutoMap name mismatches between event and read-model properties are silent and validator-invisible — read every property pair; (3) auto-delete superseded legacy files after each batch (standing instruction); (4) commit/push/PR still explicitly deferred pending user request; (5) new this part — watch for `ConceptAs`-derived types with implicit conversions in *both* directions relative to another concept type (e.g. `ReadModelKey` ↔ `EventSourceId`) — direct `==` between them can be ambiguous (CS9342); compare `.Value` explicitly instead. + +## 2026-07-01 update, part 9 — `events/**` is now ENTIRELY MIGRATED; started the post-projections backlog + +After the projections tree was finished (part 8), moved into the wider ~84-file backlog outside `projections/**`. Started with `events/**` since it's the most foundational directory (referenced from nearly everything else) — it is now **completely done**: zero `legacy/` references, zero legacy files on disk under `client-snippets/legacy/events/`. + +Files migrated this part (11 files, ~40 snippets): `index.mdx`, `event-source-id.mdx`, `getting-events.mdx`, `getting-state.mdx`, `observing-appends.mdx`, `cross-cutting-properties.mdx`, `filtering/by-event-source-type.mdx`, `filtering/by-event-stream-type.mdx`, `filtering/by-tag.mdx`, `concurrency.mdx`, `redaction.mdx`. (`filtering/index.mdx` needed no changes — it had no legacy references.) + +Notable findings, all verified against real source in `Chronicle/Source/Clients/DotNET/` before writing anything (same discipline as prior parts): + +- **`events/event-source-id.mdx`, `getting-events.mdx`, `getting-state.mdx`, `observing-appends.mdx`, `cross-cutting-properties.mdx`, `filtering/**`** are all **C#-only** — confirmed TypeScript's public `IEventSequence` (`Chronicle.TypeScript/Source/eventSequences/IEventSequence.ts`) exposes only `append`, `appendMany`, `getTailSequenceNumber(eventSourceId?)`, and `hasEventsFor` — no raw event reading (`GetForEventSourceIdAndEventTypes`, `GetFromSequenceNumber`), no `AppendOperations` observable/`WaitForCompletion`, no `EventSourceId` wrapper type (TS uses plain strings), and no `ICanProvideAdditionalEventInformation`/`[EventSourceType]`/`[EventStreamType]`/`[Tag]`/`[FilterEventsByTag]` equivalents. +- **Two bare top-level-statement legacy snippets rewritten as real methods rather than touching `BODY_SNIPPETS`**: `events/observing-appends.mdx`'s `WaitForCompletion` examples (snippet-03/04) and `events/event-source-id.mdx`'s conversion examples (snippet-01, which had a **real bug**: it declared `EventSourceId id = ...` three times in the same scope — a duplicate-variable compile error). Established pattern: when a legacy snippet is bare statements referencing undeclared locals, wrap it in a real method/class rather than registering a new `BODY_SNIPPETS` prelude entry in `validate-client-snippets.py` — reserve that mechanism for cases already using it (PDL ad-hoc-querying-style call-site fragments). +- **`events/concurrency.mdx` (9 snippets) had the most real bugs found in a single file this session**: (1) one snippet used `new ConcurrencyScope(sequenceNumber: 0, eventSourceId: accountId)` — **camelCase named args**, but the real `ConcurrencyScope` record (`EventSequences/Concurrency/ConcurrencyScope.cs`) declares PascalCase primary-constructor parameters (`SequenceNumber`, `EventSourceId`, ...) — camelCase named args don't bind and fail to compile; the sibling snippet in the same file already used the correct PascalCase form, so the bug was an inconsistency within the same page. (2) The page's closing note claimed "aggregate roots" automatically set `EventStreamType` from a type name or `[EventStreamType]` attribute — **grepped the entire `Source/Clients/DotNET` tree for "AggregateRoot" and found zero matches**; this is not a real Chronicle .NET client concept and the claim was rewritten to correctly describe what `[EventStreamType]` actually does (scopes an *observer*, not an append's concurrency scope) and to recommend `IConcurrencyScopeStrategies` instead. Everything else in this file (`ConcurrencyScopeBuilder`'s fluent methods, `AppendMany(events, concurrencyScopes: dictionary)`, the `eventLog.ForEventSourceId(id, source => ...).Perform()` batch-operations fluent API from `Cratis.Chronicle.EventSequences.Operations`, `IConcurrencyScopeStrategies.GetFor(...)`/`IConcurrencyScopeStrategy.GetScope(...)`) verified correct against real source on the first check. Added a real TypeScript equivalent for the *basic* scope only (`events/concurrency/basic`) — TS's `ConcurrencyScope` (`Chronicle.TypeScript/Source/eventSequences/ConcurrencyScope.ts`) is a much simpler plain object literal (`{ sequenceNumber: bigint, eventSourceId?: boolean, eventStreamType?: string, ... }`, no builder class, `eventSourceId` is a boolean flag not a value) with no per-event-source dictionary batching and no strategies concept — so snippets 2-9 stay C#-only. +- **`events/redaction.mdx` (6 snippets)**: verified `RedactionReason` (has a `.Unknown` static and an implicit `string` conversion), `EventRedacted` (matches the doc's own property table exactly), and both `IEventSequence.Redact` overloads (`Redact(EventSequenceNumber, RedactionReason)` and `Redact(EventSourceId, RedactionReason, params Type[])`) against real source — all correct as originally written, just needed wrapping into self-contained classes with declared event types. Confirmed a reducer handler returning `null` really does signal read-model removal (`ReducerInvoker.cs`: `if (returnValue == null) currentModelState = null;`), validating the doc's claim about `Redacted` handlers. +- A background research subagent spawned to help verify the `ConcurrencyScope` API hit a **session token/rate limit** mid-run and returned essentially no output (`subagent_tokens: 6`, ~4s runtime) — did not rely on it; did the verification directly via `Read`/`grep` against `Source/Clients/DotNET/EventSequences/Concurrency/*.cs` instead. If a future session sees oddly-empty agent results, check for the same session-limit condition before trusting an "agent found nothing" result as a real negative. + +Legacy count: 389 → **352**. Baseline ratcheted to 352. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors, same pre-existing weasel-word warnings). + +**Remaining backlog** (was 84 files/389 snippets before this part; now smaller since all of `events/**` — 11 of those files — is done): `closing-streams`, `coming-from-crud`, `compliance/**`, `concepts/**`, `configuration/**`, `connection-strings/**`, `constraints/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `get-started/**`, `hosting/**`, `migrations/**`, `namespaces/**`, `reactors/**`, `read-models/materialized-pagination`, `reducers/**`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`. Re-run `grep -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the current exact list/count (was 73 files / 352 snippets right after this part). + +**Standing reminders, unchanged from part 8, plus one new one**: (1) grep real `.ts` implementation files before trusting TS fluent-builder snippets; (2) AutoMap name mismatches are silent and validator-invisible; (3) auto-delete superseded legacy files after each batch; (4) commit/push/PR still explicitly deferred; (5) watch for `ConceptAs` types with mutual implicit conversions causing ambiguous `==`; (6) **new** — named-argument casing must match the real parameter names exactly (PascalCase C# records use PascalCase named args) — a legacy snippet mixing casing within the same page is a real, confirmed bug pattern, not just a style nit. + +## 2026-07-01 update, part 10 — started `concepts/**`; 2 files done + +Continued the post-projections backlog into `concepts/**`. Done so far: `designing-read-models.mdx` (2 snippets, C#+TypeScript), `geospatial.mdx` (3 snippets, C#-only), `modeling-events.mdx` (3 snippets, C#-only). + +Notable findings: + +- **`concepts/designing-read-models.mdx`** verified `IEventStore.ReadModels.GetInstanceById`/`GetInstances`/`Materialized.GetInstances(skip, take)` against real source (all correct as originally written) and added genuine TypeScript equivalents by checking the already-migrated `read-models/**` TS snippets for the real shape: `store.readModels.getInstanceById(ReadModelClass, key)` and `store.readModels.materialized.getInstances(ReadModelClass, skip, take)` — TS methods take the read-model **constructor/class** as an explicit first argument (no generic type inference from a type parameter alone), confirmed via `Chronicle.TypeScript/Source/readModels/IReadModels.ts` and `IMaterializedReadModels.ts`. +- **`concepts/geospatial.mdx` had a real framing bug**: its first legacy snippet declared *fake local* `Point`/`LineString`/`Polygon`/`LinearRing` records, directly contradicting the surrounding prose which says these are **real framework types** from `Cratis.Geospatial` (confirmed in `Fundamentals/Source/DotNET/Fundamentals/Geospatial/*.cs` — they matched exactly). Rewrote the snippet to `using Cratis.Geospatial;` and construct real instances instead of shadowing the framework types with same-named local declarations (which would have been a genuine duplicate/confusing-declaration risk once merged with any other snippet that also imports `Cratis.Geospatial`). Confirmed Chronicle.TypeScript has zero geospatial support — page is C#-only. +- **`concepts/modeling-events.mdx` had a real duplicate-declaration bug**: its "nullable smell" snippet declared **two different `OrderPlaced` records** in the same file (one with a nullable `Discount`, one without) — an actual CS0101 duplicate-type compile error in the original, never caught because it lived under `legacy/`. Fixed by renaming the "bad" example to `OrderPlacedWithNullableDiscount`. Also confirmed (by reading `validate-client-snippets.py`'s generated `.csproj`) that the shared C# validator sets **`false`**, so Chronicle's own CHR00xx custom Roslyn analyzers (e.g. CHR0012 "avoid nullable event properties") never fire in this validator regardless of content — confirmed by checking that the already-migrated `code-analysis/chr0012/violation.md` (a deliberately nullable-property "bad" event) has no pragma suppression and still passes. This means intentionally-bad "anti-pattern" example code (nullable event properties, etc.) can be written directly without any special suppression — worth knowing before over-engineering future "❌ bad example" snippets. + +Legacy count: 344 (after this part) — was 352 before. Baseline ratcheted to 344. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**Remaining `concepts/**` work**: `event-type-migrations.mdx` (5 snippets), `subject.mdx` (3), `tagging-reactors.mdx` (5), `tagging.mdx` (14 — the largest single file left in `concepts/`). After `concepts/**`, the backlog outside it is unchanged from part 9's list (`closing-streams`, `coming-from-crud`, `compliance/**`, `configuration/**`, `connection-strings/**`, `constraints/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `get-started/**`, `hosting/**`, `migrations/**`, `namespaces/**`, `reactors/**`, `read-models/materialized-pagination`, `reducers/**`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`). + +**Standing reminders unchanged from part 9**, plus: (7) **new** — when a legacy snippet references an invented "domain" type that's actually a REAL framework type (e.g. geospatial `Point`), check whether the surrounding prose claims it's a framework type before re-declaring it locally — a fake local declaration that shadows a real framework type is a distinct bug class from ordinary invented placeholder domain types (which are fine per `writing-correct-examples.md`); (8) **new** — the shared C# snippet validator has analyzers fully disabled (`RunAnalyzers=false`), so Chronicle's own CHR00xx rules never enforce against docs snippets — don't add defensive pragma suppression for "bad example" snippets, it isn't needed. + +## 2026-07-01 update, part 11 — `concepts/**` is now ENTIRELY MIGRATED + +Finished the remaining 3 files: `event-type-migrations.mdx` (5 snippets), `tagging-reactors.mdx` (5 snippets), `tagging.mdx` (14 snippets — the largest single file in the whole backlog). **The entire `concepts/**` directory is now off `legacy/`** — confirmed zero legacy references and zero legacy files remain under `client-snippets/legacy/concepts/`. + +Notable findings: + +- **`concepts/event-type-migrations.mdx` had the biggest API-naming bug found in this session's `concepts/` work**: the intro text said "implement the `IEventTypeMigrationFor` interface", and the example did exactly that with a single type parameter. Real source (`Events/Migrations/IEventTypeMigrationFor.cs`) shows `IEventTypeMigrationFor` is a near-empty marker interface, and its own XML doc explicitly says "Implementors should extend `EventTypeMigration` instead of implementing this interface directly." The legacy snippet also used a **string-based property builder API** (`pb.Split("FirstName", "Name", " ", 0)`) that doesn't exist at all in C# — the real `IEventMigrationPropertyBuilder` is **expression-based** (`pb.Split(m => m.FirstName, e => e.Name, PropertySeparator.Space, SplitPartIndex.First)`), and the real rename method is `RenamedFrom`, not `Rename`. Rewrote the whole page against the verified base-class + expression-builder API, fixed the intro/heading prose to match, and added full TypeScript equivalents (`@eventTypeMigration(Upgraded, Previous)` class decorator implementing `IEventTypeMigration`, string-keyed `pb.split('firstName', 'name', ' ', 0)` — TS uses plain `keyof T & string` property names and primitives, not expression trees or `PropertySeparator`/`SplitPartIndex` wrapper types) after confirming the full TS migration API surface in `Chronicle.TypeScript/Source/events/migrations/*.ts`. +- **`concepts/tagging-reactors.mdx`**: same shape as `projections/tagging-projections.mdx` from part 8, applied to reactors — legacy classes didn't implement `IReactor` at all (just plain classes with `[Tag]` and ad hoc methods); rewrote all 5 as real `IReactor` implementations with declared event types and injected service interfaces. C#-only (confirmed no `tag()` TS decorator exists, same finding as part 8). +- **`concepts/tagging.mdx` (14 snippets) had multiple real bugs**, on top of being heavily redundant with `tagging-reactors.mdx` and `projections/tagging-projections.mdx` (flagging as content-duplication debt below, not fixed here — out of scope for snippet migration): (1) one snippet declared the **same `UserLoggedIn` record twice** (once under `[Tag]`, again under `[Tags]`) — a duplicate-type compile error, fixed by giving the second an `Alternate` suffix; (2) a reducer handler had the **wrong signature entirely** — `On(UserLoggedIn @event, EventContext context)` with no `current` parameter and returning a never-declared `analytics` variable — rewritten against the real 3-param reducer convention; (3) several snippets were **bare floating `[Tag]` attribute stacks with no declaration target at all** (illegal free-standing C#) — fixed by attaching each stack to a minimal named record/class, matching the same fix pattern used for `tagging-projections.mdx`'s cheatsheet in part 8; (4) **a real cross-file naming collision with my own earlier work**: a new `TaggingSalesReport` record collided with `TaggingSalesReport` already declared in `projections/tagging-projections/multiple-tags-single-attribute.md` from part 8 — caught immediately by the merged-file validator (CS0101) and fixed by renaming to `TaggingConceptsSalesReport`; (5) **a genuine C# overload-resolution gotcha**: `context.Tags.Contains("critical")` (where `Tags` is `IEnumerable` and `Tag` has an implicit `string` conversion) fails to compile with `CS1929`, because the compiler's extension-method lookup resolves to `MemoryExtensions.Contains(ReadOnlySpan, string)` from `System` instead of falling through to `Enumerable.Contains(IEnumerable, Tag)` — implicit conversions are not considered once a different, closer-in-scope `Contains` extension family is chosen as the overload candidate set. Fixed by using `.Any(tag => tag.Value == "critical")` instead of `.Contains(...)` — a real, non-obvious gotcha worth remembering for any future snippet comparing a `ConceptAs`-derived collection element against a raw string via `.Contains(...)`. + +Legacy count: 317 (after this part) — was 336 before. Baseline ratcheted to 317. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**Content-duplication debt noted, not fixed (out of scope for snippet migration)**: `concepts/tagging.mdx`'s "Tagging Observers" section (single tag / multiple tags single attribute / multiple attributes / mixed approach) is near-verbatim the same content as the standalone `concepts/tagging-reactors.mdx` and `projections/tagging-projections.mdx` pages. This is exactly the kind of cross-topic content duplication flagged earlier in this session (mid-session user instruction about avoiding "client specific categories and topics and duplications" — this is a same-language duplication, a related but distinct concern). Worth a follow-up consolidation pass once the mechanical snippet migration is fully done: likely fold `tagging-reactors.mdx` into `tagging.mdx`'s observer section (or vice versa) and cross-link rather than repeat. + +**Remaining backlog outside `concepts/**`** (unchanged list from part 9, now the entire remaining scope): `closing-streams`, `coming-from-crud`, `compliance/**`, `configuration/**`, `connection-strings/**`, `constraints/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `get-started/**`, `hosting/**`, `migrations/**`, `namespaces/**`, `reactors/**`, `read-models/materialized-pagination`, `reducers/**`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`. Re-run `grep -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the current exact list/count (was ~63 files / 317 snippets right after this part, both `projections/**` and `events/**` and `concepts/**` now fully done). + +**Standing reminders, unchanged from part 10, plus two new ones from this part**: (9) **new** — when a legacy interface name looks plausible but isn't the recommended path, check the interface's OWN XML doc comment for an explicit "implementors should extend X instead" pointer — Chronicle's source consistently documents the recommended base class right on the raw interface; (10) **new** — `IEnumerable`/`IEnumerable`-of-a-`ConceptAs` calling `.Contains(rawStringLiteral)` can fail to compile with a confusing `CS1929`/wrong-overload error due to `System.MemoryExtensions.Contains` shadowing `Enumerable.Contains` in overload resolution — prefer `.Any(x => x.Value == "literal")` over `.Contains("literal")` when the element type has an implicit string conversion. + +## 2026-07-01 update, part 12 — `reactors/**` is now ENTIRELY MIGRATED + +Migrated the 5 remaining files: `index.mdx` (1 snippet, C#+TypeScript), `once-only.mdx` (1, C#-only), `event-sequence.mdx` (3, mostly C#-only — only the `[Reactor(eventSequence:)]` form has a real TS equivalent via `@reactor(id, eventSequenceId)`), `external-event-store-subscriptions.mdx` (2, C#-only — no TS `[EventStore]`-equivalent cross-service routing decorator exists), `filtering.mdx` (7, C#-only). **`reactors/**` is now off `legacy/`** — confirmed zero legacy references/files remain under it. + +Verified real APIs before writing: `[EventSequence("...")]` (`EventSequences/EventSequenceAttribute.cs`), `[Reactor(id:, eventSequence:)]` (`Reactors/ReactorAttribute.cs`), `[EventLog]` (a sealed no-arg subclass of `EventSequenceAttribute` pinning to the default log), `[EventStore("...")]` (`AttributeTargets.Class | Assembly` — confirmed it's valid to place directly on a reactor class per the page's own "Observer-Level EventStore" scenario, not just on event types), and the `CHR0013`/`EventStoreCannotBeCombinedWithExplicitEventSequence` cross-reference the page makes (both the diagnostic ID and the runtime exception class are real). Also confirmed `void`-returning handler methods (no `Task`, no `EventContext`) are a real, explicitly-supported reactor handler shape (`EventHandlerMethods.cs`: `methodInfo.ReturnType == typeof(void)` is one of three valid return-type branches) — so `reactors/once-only.mdx`'s `[OnceOnly] public void SendNotification(...)` legacy example didn't need fixing on that front. + +Found and fixed one duplicate-declaration bug: `reactors/filtering.mdx`'s last snippet ("tagging the reactor vs. filtering events") declared **two different classes both named `ExpressShipmentNotifier`** — one to show `[Tag]` labeling, one to show `[FilterEventsByTag]`/`[EventSourceType]` filtering — a genuine CS0101 in the original. Renamed to `LabeledShipmentNotifier` / `FilteredShipmentNotifier` and gave both their own declared event type instead of relying on a same-name event from a sibling snippet. + +Legacy count: 303 (after this part) — was 317 before. Baseline ratcheted to 303. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**`reactors/**` is DONE.** Combined with `projections/**`, `events/**`, and `concepts/**` from parts 8-11, that's four full top-level directories entirely migrated this session. **Remaining backlog**: `closing-streams`, `coming-from-crud`, `compliance/**`, `configuration/**`, `connection-strings/**`, `constraints/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `get-started/**`, `hosting/**`, `migrations/**`, `namespaces/**`, `read-models/materialized-pagination`, `reducers/**` (the next-largest remaining directory — `event-processing.mdx` alone has 16 snippets), `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`. Re-run `grep -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the current exact list/count (was ~61 files / 303 snippets right after this part). + +**Standing reminders**: unchanged from part 11 (items 1-10 all still apply) — no new gotcha classes surfaced in this part beyond the ordinary duplicate-declaration-across-a-single-file pattern already seen several times. + +## 2026-07-01 update, part 13 — CRITICAL: TypeScript reducer handler methods are discovered by NAME, not by parameter type (retroactive fix needed) + +While starting `reducers/**`, discovered a load-bearing TypeScript reducer detail that had been **assumed wrong** in this session's only prior TS reducer snippet (`projections/choosing-a-read-model-style/reducer.md`, written in part 8). Read `Chronicle.TypeScript/Source/reducers/Reducers.ts` lines 367-388 (`getEventTypesFor`): for every known `@eventType()`-decorated class, it computes `methodName = className.charAt(0).toLowerCase() + className.slice(1)` (camelCase of the **exact class name**) and only registers a handler if `typeof proto[methodName] === 'function'`. **The handler method name must be the literal camelCase of the event's class name — not a semantic verb, not a shortened name.** This is fundamentally different from: +- C# reducers/reactors, which discover handlers by the **first parameter's type**, so the method can be named anything (`Deposited`, `OnDepositMade`, whatever reads well). +- TS reactors (`@reactor()`), which — separately confirmed via `ReactorInvoker`-equivalent logic — also appear to work off parameter shape in the invocation path used elsewhere this session (the getting-started reactor snippets used semantic names like `placed` successfully). + +**This means every prefixed TS event class name (e.g. `ChoosingStyleBookRegistered`, `ReducersIndexDepositMade`) requires its reducer handler to be named the full prefixed camelCase (`choosingStyleBookRegistered`, `reducersIndexDepositMade`) — not a shortened domain-only name (`bookRegistered`, `depositMade`).** The original `choosing-a-read-model-style/reducer.md` had handlers named `bookRegistered`/`bookBorrowed`/`bookReturned` (stripped of the `ChoosingStyle` prefix) — this type-checked fine (TS structural typing doesn't care about method names for compilation) but would **never actually fire at runtime** — the exact same class of bug as the `@eventType()` omission found in part 4, just one level more subtle. **Fixed retroactively** — renamed to `choosingStyleBookRegistered`/`choosingStyleBookBorrowed`/`choosingStyleBookReturned` and added an inline comment explaining why. Re-verified: TypeScript validator still passes (tsc can't catch this class of bug either way, so the fix couldn't be verified by compilation — only by reading the source). + +Then wrote `reducers/index.mdx`'s basic example (1 snippet, C#+TypeScript) correctly from the start, using this rule: C# handler methods (`Deposited`, `WithdrawalMade`) can read naturally; the TS handler methods are literally `reducersIndexDepositMade`/`reducersIndexWithdrawalMade` (the full prefixed class name, camelCased) — awkward-looking but correct. + +**Action for whoever continues**: before writing ANY further TS reducer snippet, name the handler method as the exact camelCase of the event class name being handled — verify the event class's exact spelling first, then derive the method name mechanically. Do not shorten or "clean up" the method name for readability; it is not cosmetic, it is the registration key. + +Legacy count unaffected by this part alone (only 1 new file done: `reducers/index.mdx`, 303 → still tracking, ratchet not yet re-run — see next part). This finding did not require touching any other already-completed batch, since `choosing-a-read-model-style/reducer.md` was the only prior TS reducer snippet in the whole corpus (confirmed via `grep -rl "@reducer(" client-snippets/`). + +## 2026-07-01 update, part 14 — `reducers/**` in progress: 4 of 8 files done + +Continuing `reducers/**` (the reducer-side mirror of `reactors/**` from part 12). Done so far: `index.mdx` (1 snippet, C#+TypeScript — see part 13's critical TS reducer finding, applied correctly here), `event-sequence.mdx` (3, mirrors the reactor version), `external-event-store-subscriptions.mdx` (2, mirrors the reactor version), `tagging-reducers.mdx` (5, mirrors `concepts/tagging-reactors.mdx`'s pattern — C#-only, same as all `[Tag]`-only pages this session). + +All verified against real source: `[Reducer(id:, eventSequence:, isActive:)]` (`Reducers/ReducerAttribute.cs` — has an extra `isActive` param over `[Reactor]`, not used yet since `passive-reducers.mdx` is still pending), `CHR0014`/`ReducerCannotCombineEventStoreWithExplicitEventSequence` (confirmed real, matching the reactor's `CHR0013` sibling exactly one ID higher). One real bug found and fixed: `tagging-reducers.mdx`'s single-tag example had a reducer handler method declared with a non-void return type (`OrderAnalytics`) but a body containing only a comment — a genuine CS0161 "not all code paths return a value" compile error in the original; fixed by writing a real accumulating body. + +**Remaining in `reducers/**`**: `filtering.mdx` (7 snippets — expect this to mirror `reactors/filtering.mdx` from part 12 almost exactly, same attributes just on `IReducerFor`), `getting-started.mdx` (6 — likely the first genuinely new reducer content, not a reactor mirror), `passive-reducers.mdx` (8 — will need the `isActive` parameter and/or a `[Passive]`-style attribute, not yet verified), `event-processing.mdx` (16 — the single largest file in the entire remaining backlog, not yet read). + +Legacy count: 292 (after this update) — was 303 before. Baseline ratcheted to 292. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**Standing reminders**: all of parts 9-13 still apply, especially **part 13's TS reducer handler-naming rule** (method name = exact camelCase of the event class name, verified per-snippet, not assumed) — this will matter again for `getting-started.mdx` and any further TS reducer work in the remaining `reducers/**` files. + +## 2026-07-01 update, part 15 — `reducers/**` 6 of 8 files done; retroactive TS reducer fix applied + +Finished `filtering.mdx` (7 snippets, C#-only, mirrors `reactors/filtering.mdx` from part 12 almost exactly — same duplicate-class-name bug pattern found and fixed the same way) and `getting-started.mdx` (6 snippets, C#+TypeScript for the read-model and main reducer example; C#-only for the method-signature-shape snippets and the `[Reducer]` attribute detail, both already covered elsewhere). + +Two real bugs found and fixed in `getting-started.mdx`'s legacy content: +- Two snippets (`sync-signatures`, `async-signatures`) were bare interface-method declarations ending in `;` with no enclosing interface — illegal free-standing C#. Fixed by wrapping each pair in a small generic illustrative interface (`IReducersGettingStartedSyncSignatures` / `...Async...`). +- The main reducer example's `ItemAdded`/`ItemRemoved` handlers had `if (current is null) return null!;` inside a method declared to return the **non-nullable** `OrderSummary` — using the null-forgiving operator to lie to the compiler about a real null return. Since `ReducerInvoker.cs` treats a null return as "no state" (not an error), the honest fix is to declare these two handlers' return type as `TReadModel?` (nullable) and return a genuine `null` — compiles cleanly with no forgiving operator, and matches the real runtime semantics (returning null when `current` was already null is a harmless no-op, not data loss). + +Applied part 13's TS reducer handler-naming rule correctly in the new `getting-started/reducer-implementation.md` TypeScript snippet (methods named `reducersGettingStartedOrderCreated` etc. — the full prefixed camelCase class name, verified against each event class actually declared in the same file before naming the handler). + +Legacy count: 279 (after this part) — was 292 before. Baseline ratcheted to 279. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**Remaining in `reducers/**`** (2 files): `passive-reducers.mdx` (8 snippets — will need `[Reducer(isActive: false)]` and/or a dedicated `[Passive]`-style attribute, not yet verified against source), `event-processing.mdx` (16 snippets — the single largest file in the entire remaining backlog, not yet read). After those two, `reducers/**` will be the fifth full top-level directory done this session (after `projections/**`, `events/**`, `concepts/**`, `reactors/**`). + +**Standing reminders**: all of parts 9-13 apply; part 13's TS-reducer-handler-naming rule is now the single most error-prone detail for any further TS reducer work — always derive the method name mechanically from the exact declared event class name, never from what reads naturally. + +## 2026-07-01 update, part 16 — `reducers/**` is now ENTIRELY MIGRATED (fifth full directory this session) + +Finished the last file, `event-processing.mdx` — 16 snippets, the single largest file in the whole backlog. **`reducers/**` is now off `legacy/`** (confirmed zero references/files remain). Combined with `projections/**`, `events/**`, `concepts/**`, and `reactors/**` from earlier parts, that's **six full top-level directories fully migrated this session**. + +(Correction to the count above: that's five directories — `projections/**`, `events/**`, `concepts/**`, `reactors/**`, `reducers/**` — not six.) + +This file had the highest concentration of real bugs of any single file this session: + +- **A collection-aliasing bug appearing twice** (`collection-building-pattern.md`'s activity log, `reuse-collections.md`'s item list): both handlers took `current?.Items ?? new List()` and **mutated that list in place** before returning a new wrapper record around it. Since the *previous* state instance holds a reference to the same list object, this silently corrupts any held snapshot or prior read of that state — directly contradicting the page's own "Use record types — prefer immutable" best practice. Fixed by copying into a new list (`new List(current?.Items ?? [])`) before mutating. This is a distinct bug class from anything found earlier in the session — not a compile error, a **silent aliasing/mutation correctness bug** that would only surface as mysteriously-corrupted historical snapshots in production. +- **A meaningful "skip vs. remove" semantic confusion, not just a compile-time `null!` issue**: `conditional-processing-pattern.md`'s `DepositMade` handler had `if (current is null || !current.IsActive) return null!;` — but when `current` is **not null** (just inactive), returning `null` would **delete the read model**, not "skip the deposit" as the comment claimed. Fixed by returning `current` unchanged in that branch (a true no-op) and reserving `return null` for the case where `current` was already `null`. This distinction — "return null to remove state" vs. "return current to leave it alone" — is meaningfully different from the earlier `null!` compile-fix pattern (parts 8, 15) and is now called out explicitly in the page's rewritten prose (Error Handling section and Best Practice #5). +- The usual `null!`-on-non-nullable-return-type compile bug appeared four more times (`method-discovery`, `first-event` implicitly avoided it, `conditional-processing-pattern`'s `Closed` handler, `skip-invalid-state`) — fixed the same way as prior parts (declare `TReadModel?`, return real `null`). +- **A type-name collision risk correctly avoided**: the page wants to show `EventContext`'s member shape as a reference sheet. Declaring a literal `public record EventContext { ... }` would have **shadowed or collided with the real `Cratis.Chronicle.Events.EventContext`** used unqualified by nearly every other snippet in the corpus (bare `EventContext` after `using Cratis.Chronicle.Events;` — C# would prefer a global-namespace type of the same name over the used-namespace one, silently breaking every other file). Used a distinctly-named illustrative type (`EventProcessingEventContextShape`) instead, with real member types (`EventSequenceNumber`, `EventSourceId`, `EventType`, `CorrelationId`, `Causation`, `Identity` — cross-referenced against the real `EventContext.cs` to get the shape accurate). +- **The prose itself was misleading**: "Event Method Discovery" claimed methods are found by matching the event type *name* (`OnOrderCreated` for `OrderCreated`) — but discovery is by **first-parameter type**, not method name (confirmed via `ReducerInvoker.cs`/`MethodsByEventType` in part 12/14). Rewrote that bullet to state the real mechanism and reframe the naming convention as a style choice, not a requirement. + +Legacy count: 255 (after this part) — was 279 before. Baseline ratcheted to 255. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**Remaining backlog**: `closing-streams`, `coming-from-crud`, `compliance/**`, `configuration/**`, `connection-strings/**`, `constraints/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `get-started/**`, `hosting/**`, `migrations/**`, `namespaces/**`, `read-models/materialized-pagination`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`. Note `reactors/event-processing.mdx` was already fully migrated **before this session** (part of the original pre-session handoff baseline) — its snippets (`EventProcessingOrderPlaced` etc.) live in `client-snippets/reactors/event-processing/**` and share the merged-compilation namespace with everything in `client-snippets/reducers/event-processing/**` — a real naming collision was caught and fixed during this part (renamed my new `EventProcessingOrderPlaced` to `EventProcessingContextOrderPlaced`). Re-run `grep -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the current exact list/count (was ~55 files / 255 snippets right after this part). + +**Standing reminders**: all of parts 9-15 apply, plus (11) **new** — when a snippet mutates a collection obtained from `current`, always copy first; a previous state instance may still be referenced elsewhere (snapshots, other readers) and in-place mutation silently corrupts it; (12) **new** — "skip this event" and "remove this read model" are different outcomes for a reducer handler — `return current` for the former, `return null` (nullable-typed) for the latter — do not conflate them; (13) **new** — before naming an illustrative type after a real framework concept (like `EventContext`), check whether that real type's bare name is used unqualified elsewhere in the corpus — a same-named global-namespace declaration can silently shadow the real one for every other file. + +## 2026-07-01 update, part 17 — CRITICAL: TypeScript REACTOR handler methods are ALSO discovered by NAME (not parameter type) — same rule as part 13's reducer finding, now confirmed for reactors too, with 4 files retroactively fixed + +While working `get-started/common.mdx`, verified `Chronicle.TypeScript/Source/reactors/Reactors.ts` (`getEventTypesFor`, lines ~292-309) and found it uses the **exact same discovery mechanism as reducers** (part 13): `methodName = className.charAt(0).toLowerCase() + className.slice(1)`, then checks `typeof proto[methodName] === 'function'`. **TS reactor handler methods must be named the exact camelCase of the event's class name — not a semantic verb, not a shortened name.** This directly contradicts what part 13 assumed ("TS reactors ... appear to work off parameter shape" — that assumption was never actually verified for reactors, only inferred by analogy; it was wrong). + +Searched the entire corpus for `@reactor(` and found **6 files total**, of which **4 had this exact bug**: + +- **Two files from THIS session** (part 12): `reactors/index/basic-example.md` (method `confirmed` → fixed to `reactorsIndexEmailConfirmed`), `reactors/event-sequence/reactor-attribute.md` (method `shipmentDispatched` → fixed to `eventSequenceReactorAttributeShipmentDispatched`). +- **Two files pre-dating this session** (part of `reactors/getting-started.mdx`, already migrated before this session started): `reactors/getting-started/reactor.md` and `reactors/getting-started/stable-id.md`, both using method name `placed` for event class `ReactorOrderPlaced` — should be `reactorOrderPlaced`. Fixed both, matching the same discipline used for the pre-existing CHR0022/CHR0023 bugs and the choosing-a-read-model-style reducer bug in part 13: fix real bugs regardless of who/when they were introduced. +- Two files were **already correct** by coincidence: `get-started/test-reactor.md` (event `TestEvent`, method `testEvent` — happens to match), `reactors/getting-started/register.md` (doesn't reference a handler method name at all). + +Checked the owning `.mdx` prose for each fixed file to confirm no textual cross-reference to the old (wrong) method name would be broken by the rename — none found in any case. + +**Also found and fixed a second, distinct TS cross-file ordering bug** (the same class as the part 6 `TS2449: Class used before its declaration` finding, but this time the culprit decorators were `@fromEvent`/`@setValue`/`@setFrom`/`@removedWith` — none of which have an "omit the value argument" escape hatch like `@projection()`'s optional `readModelType` did). In `get-started/common/`, `events.md` (declaring `GetStartedBookAdded`/`BookBorrowed`/`BookReturned`) sorted alphabetically **after** `book-read-model.md` and `borrowed-book-read-model.md`, both of which reference those event classes as decorator **value** arguments — decorators evaluate top-to-bottom at class-definition time (module load), unlike plain method-body references which run later and don't care about declaration order. Fixed by renaming the file to `a-events.md` (sorts first alphabetically in the folder) **in both the C# and TypeScript client-snippets trees** — the snippet ID is shared across languages via ``, so both sides must use the same relative path even though only the TS side had the ordering problem. No established numeric/underscore-prefix convention existed anywhere else in the corpus; used a plain `a-` prefix as the simplest working fix. **If this ordering problem recurs, check whether the referencing file can just be renamed to sort after the declaring file — much simpler than restructuring the declarations.** + +Also (separately, non-TS-specific): added `` to the generated validator project in `Chronicle/Documentation/validate-client-snippets.py` — done **with explicit user confirmation** (asked via AskUserQuestion, since this touches shared validator tooling with a real new external dependency, unlike routine `BODY_SNIPPETS` tweaks). This was needed for `get-started/common.mdx`'s "query the sink natively" snippet, which uses the real `IMongoCollection` type — not otherwise reachable through the `Infrastructure`/`DotNET` client projects the validator references (MongoDB.Driver is only referenced deep in the Kernel's `Storage.MongoDB` project). The version is already centrally pinned in `Directory.Packages.props` (`3.9.0`), so no explicit version was needed on the new `PackageReference`. + +`get-started/common.mdx` (8 snippets, C#+TypeScript for 7 of them; the MongoDB native-driver one is C#-only) is now fully migrated — legacy count not yet re-measured/ratcheted as of this note (see next part for the numbers). + +**Standing reminders, unchanged from parts 9-16, plus two new ones**: (14) **new, supersedes part 13's narrower framing** — BOTH TypeScript reactors AND reducers discover handler methods by exact camelCase-of-event-class-name, not by parameter type; always verify the exact event class name (including any topic prefix) before naming a TS reactor/reducer handler method, for every new snippet from now on; (15) **new** — the TS cross-file class-ordering bug (part 6) isn't limited to `@projection()`'s `readModelType` argument — any decorator that takes an event/read-model class as a **value** argument (`@fromEvent`, `@setValue`, `@setFrom`, `@removedWith`, `@eventTypeMigration`, `@reducer`'s `readModel` param, etc.) is susceptible; when splitting a topic's events into their own file for narrative/tab-ordering reasons, make sure that file's name sorts alphabetically **before** every sibling file that references those event classes as decorator values. + +## 2026-07-01 update, part 18 — `get-started/**` is now ENTIRELY MIGRATED (sixth full directory this session) + +Finished all 6 files: `common.mdx` (8 snippets, part 17), `choose-hosting-model.mdx` (5, Aspire AppHost), `aspnetcore.mdx` (5), `worker.mdx` (4), `mongodb.mdx` (1), `console.mdx` (1). **`get-started/**` is now off `legacy/`** — confirmed zero references/files remain. + +This directory required **three separate validator-tooling additions**, each confirmed with the user via `AskUserQuestion` before making the change (per the CLAUDE.md rule on not touching dependency manifests without being asked) — all added to `Chronicle/Documentation/validate-client-snippets.py`'s generated project: + +1. `` — for `IMongoCollection` native-driver queries shown in `common.mdx`, `aspnetcore.mdx`, `worker.mdx`, and `mongodb.mdx`. Version is already centrally pinned in `Directory.Packages.props` (`3.9.0`), so no explicit version needed. +2. `` — for `choose-hosting-model.mdx`'s `AddCratisChronicle`/`WithMongoDB`/`WithPostgreSql`/`WithMsSql`/`WithSqlite` (all verified real in `Source/Clients/Aspire`). One snippet needed adapting: the legacy content used `builder.AddProject("api")`, where `Projects.MyApi` is generated by the Aspire AppHost SDK from actual solution project references — something that cannot exist in a standalone single-file validator. Replaced with `builder.AddContainer("api", "my-org/my-api")` (a real, always-available core Aspire resource) with a comment noting the more common `AddProject` pattern. Also discovered `AddPostgres`/`AddSqlServer` need **separate** Aspire hosting-integration NuGet packages (`Aspire.Hosting.PostgreSQL`/`Aspire.Hosting.SqlServer`) not referenced by the Aspire.csproj project and not centrally pinned — rather than escalate to a fourth dependency addition, rewrote those two snippets to use `builder.AddConnectionString(...)` instead (which `WithPostgreSql`/`WithMsSql` both accept directly, since their parameter type is the generic `IResourceBuilder`, not a Postgres/SqlServer-specific type) — fully accurate, zero new dependencies. +3. `` — for `aspnetcore.mdx`'s `WebApplicationBuilder.AddCratisChronicle`/`IApplicationBuilder.UseCratisChronicle` (the ASP.NET-Core-specific overloads — confirmed `worker.mdx`'s `AddCratisChronicle` is a *different* overload already in the base `DotNET` project, needing no change). + +**A second confirmed instance of part 17's critical TS-reactor-naming bug class**, this time causing an actual **compile** failure (not just a silent runtime bug): in `get-started/common/`, the file `events.md` (declaring `GetStartedBookAdded`/`BookBorrowed`/`BookReturned`) sorted alphabetically **after** `book-read-model.md` and `borrowed-book-read-model.md`, which reference those classes as `@fromEvent`/`@setValue`/`@setFrom`/`@removedWith` decorator values — reproducing the exact `TS2449: Class used before its declaration` ordering bug from part 6/17. Fixed by renaming the file (both C# and TypeScript client-snippets copies, since the snippet ID is shared) to `a-events.md`, which sorts first alphabetically in the folder. + +**Other real bugs found and fixed**, following the same "verify against source, don't trust plausible code" discipline: +- `get-started/console/connect.md`: the legacy top-level-statements snippet used `using var client = new ChronicleClient(...)` then returned the resulting `eventStore` — when wrapped in a method (required since bare statements can't be a snippet), returning a value obtained through a `using`-scoped client would hand the caller an `IEventStore` tied to an already-disposed connection the instant the method returns. Rewrote to keep `client` and `eventStore` both in scope for the whole illustrated unit of work instead of returning either past the `using` boundary. +- Confirmed `void`-adjacent CS0219 "assigned but never used" traps in a couple of spots (an unused `eventStore` local after removing dead prose-only comment usage) by giving the variable a real, minimal use (`Console.WriteLine($"Connected to event store: {eventStore.Name}")`) rather than leaving a comment as the only "usage". +- `get-started/common.mdx`'s model-bound TypeScript projections (`book-read-model.md`, `borrowed-book-read-model.md`) were written using **explicit** `@setFrom` for every property (including same-named ones) rather than assuming AutoMap-by-name applies in TypeScript the way it does in C# — following the only directly-analogous validated precedent in the corpus (`MbSetValueOrder`'s TS mirror, which does the same for a matching-name property) rather than guessing, since the TS-side AutoMap policy is enforced server/kernel-side and isn't independently visible in the TS client source the way C#'s `AutoMap()`/`NoAutoMap()` logic is. + +Legacy count: 231 (after this part) — was 247 before. Baseline ratcheted to 231. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**`get-started/**` is DONE.** Combined with `projections/**`, `events/**`, `concepts/**`, `reactors/**`, `reducers/**` from parts 8-16, that's **six full top-level directories fully migrated this session**. **Remaining backlog**: `closing-streams`, `coming-from-crud`, `compliance/**`, `configuration/**`, `connection-strings/**`, `constraints/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `hosting/**`, `migrations/**`, `namespaces/**`, `read-models/materialized-pagination`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`. **Note**: `tutorial/*.mdx` (`first-event.mdx`, `read-model.mdx`, `reacting.mdx`) build the *exact same* library/Book domain as `get-started/common.mdx` per that page's own prose ("the tutorial builds exactly this library") — this session's `get-started/common.mdx` work deliberately prefixed everything `GetStarted*` (`GetStartedBookAdded`, `GetStartedBook`, etc.) specifically so the tutorial batch can freely use the clean, unprefixed domain names (`BookAdded`, `Book`, etc.) without a collision — keep that in mind and verify no clash when that batch is done. Re-run `grep -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the current exact list/count (was ~46 files / 231 snippets right after this part). + +**Standing reminders**: all of parts 9-17 apply. Part 17's finding (TS reactors/reducers discover handlers by exact camelCase-of-event-class-name) is now confirmed to cause both silent runtime bugs (wrong method name, still compiles) AND hard compile errors (wrong file ordering for decorator value references) — treat both failure modes as live risks for every future TS snippet in this corpus. + +## 2026-07-01 update, part 19 — `constraints/**` is now ENTIRELY MIGRATED (seventh full directory this session) + +Migrated all 5 files: `declarative/index.mdx` (1 snippet), `declarative/unique.mdx` (7), `declarative/unique-event-type.mdx` (4), `model-bound/unique.mdx` (6), `model-bound/unique-event-type.mdx` (3) — 21 snippets total. **`constraints/**` is now off `legacy/`** — confirmed zero references/files remain. + +Verified the full C# constraint API against real source (`Events/Constraints/*.cs`): `IConstraint.Define(IConstraintBuilder)`, `IConstraintBuilder.Unique(Action)` / `.Unique(message?, name?)` / `.Unique(messageCallback, name?)`, `IUniqueConstraintBuilder.On(...)/.WithName(...)/.IgnoreCasing()/.RemovedWith()/.WithMessage(string|callback)`, the model-bound `[Unique(name?, message?)]` and `[RemoveConstraint(name)]` attributes — all matched the legacy content's API usage exactly. Also discovered and verified the full **TypeScript declarative-constraint API** for the first time this session (`@constraint(id?)` class decorator + `IConstraint.define()`, `IConstraintBuilder.unique(callback)/.uniqueFor(eventType, message?, name?)`, `IUniqueConstraintBuilder.on(eventType, ...propertyAccessors)/.withName()/.ignoreCasing()/.removedWith()/.withMessage()/.withMessageFrom()`) — added real TypeScript snippets for `declarative/index.mdx` and all of `declarative/unique.mdx`/`unique-event-type.mdx` except the two dynamic-message-callback snippets, since TS's `withMessageFrom(() => string)` takes **no parameters at all** — a genuine capability gap versus C#'s `ConstraintViolationMessageProvider(ConstraintViolation violation)`, which can inspect `violation.ConstraintName`/`violation.Details`. Confirmed **`constraints/model-bound/**` is C#-only** — no property-level `@unique`/`@removeConstraint` decorators exist anywhere in the TypeScript client (only the declarative/fluent builder form is exposed there). + +**Found a genuinely new, valuable bug class**: `[Unique]` applied directly to a positional record parameter (`public record Foo([Unique] string Name, ...)`) **fails to compile** with `CS0592: Attribute 'Unique' is not valid on this declaration type` — because `UniqueAttribute`'s `[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)]` does not include `Parameter`, and C# defaults an attribute on a record positional parameter to target the **parameter** unless you explicitly write `[property: Unique(...)]`. This is a distinct, previously-unseen bug class in this session (not a fabricated API, not a missing declaration — a real C# attribute-target subtlety that silently breaks unless you know the rule). Fixed in all 4 affected `constraints/model-bound/unique/*.md` snippets by adding the `property:` target specifier. **Any future snippet in this corpus that applies an attribute to a positional record parameter, where that attribute's `AttributeUsage` doesn't include `AttributeTargets.Parameter`, needs the same `[property: ...]` fix** — this pattern likely also applies to `[Key]`, `[Subject]`, `[PII]`, and any other property-only attribute used on positional records; worth spot-checking earlier batches if time allows, though none of the compile failures so far have surfaced it (this may be because most earlier snippets happened to declare these on properties that were the ONLY constructor parameter needing the attribute, or fewer positional-record + property-only-attribute combinations arose). + +Legacy count: 210 (after this part) — was 231 before. Baseline ratcheted to 210. Re-verified full chain: both validators, `chronicle-client-docs:check`, `npm run build` (843 pages), lint (0 errors). + +**`constraints/**` is DONE.** Combined with `projections/**`, `events/**`, `concepts/**`, `reactors/**`, `reducers/**`, `get-started/**` from parts 8-18, that's **seven full top-level directories fully migrated this session**. **Remaining backlog**: `closing-streams`, `coming-from-crud`, `compliance/**`, `configuration/**`, `connection-strings/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `hosting/**`, `migrations/**`, `namespaces/**`, `read-models/materialized-pagination`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**`, `tutorial/**`, `understanding-constraints`. Re-run `grep -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the current exact list/count (was ~41 files / 210 snippets right after this part). + +**Standing reminders**: all of parts 9-18 apply, plus (16) **new** — when applying a property-scoped attribute (`[Unique]`, and by extension any custom attribute without `AttributeTargets.Parameter`) to a positional record parameter, use the `[property: AttributeName(...)]` target specifier — bare `[AttributeName(...)]` targets the parameter by default and fails to compile unless the attribute explicitly allows `Parameter` as a target. + +## 2026-07-02 update, part 20 — 5 small/standalone files done; found and fixed a real environment hazard (stray sibling directory) + +Continued the post-`constraints/**` backlog (parts 9-19 covered `events/**`, `concepts/**`, `reactors/**`, `reducers/**`, `get-started/**`, `constraints/**`). This part started on the remaining ~41-file backlog (`closing-streams`, `coming-from-crud`, `compliance/**`, `configuration/**`, `connection-strings/**`, `contributing/kernel/**`, `event-seeding/seeding-with-csharp`, `hosting/**`, `migrations/**`, `namespaces/**`, `read-models/materialized-pagination`, `scenarios/**`, `sinks/index`, `subscriptions/**`, `testing/**` [deliberately retained], `tutorial/**`, `understanding-constraints`) and finished 5 of the smaller standalone files: + +- **`closing-streams/index.mdx`** (2 real C# snippets, `clients="csharp"` — TypeScript's `IEventSequence` has no `CompleteStream`/close-stream concept at all, confirmed from source). Found and removed a **fabricated API**: the legacy "Checking whether a stream is closed" section called `eventLog.IsStreamCompleted(...)`, which does not exist anywhere in `Cratis.Chronicle` — grepped the whole DotNET client for `IsStreamCompleted`/`StreamStatus` and found nothing. Removed that section and its prose claim entirely rather than inventing a fake read API. Also found the real `Result.Match(...)` doesn't work with two `void`-returning (`Console.WriteLine`) lambdas — `Match` can't infer `TResult` from void branches (CS0411); the class only has `Match`/`Switch` via `OneOfBase`, and `Switch(Action, Action)` is the correct one for side-effecting branches. No existing precedent in the corpus for either method — worth remembering for any future `Result` snippet with void branches. +- **`coming-from-crud.mdx`** (C#+TypeScript for the 3 Chronicle-side snippets; the CRUD/EF-Core comparison snippet is illustrative pseudocode with undefined locals like `customer`/`db`/`newAddress` — not Chronicle SDK code at all, so it was rewritten as a plain ` ```text ` fence directly in the page instead of going through the snippet/legacy system, since forcing it through `ChronicleClientTabs` would require adding an EF Core package dependency to the validator for a non-Chronicle illustration). The three real snippets (events, `[FromEvent]`/`[Count]` model-bound read model, write-and-read) reused the already-validated model-bound projection shape from `projections/model-bound/counters/**` (part 3) and `read-models/**`'s `GetInstanceById` shape (part 10) — both verified against source again rather than assumed. +- **`understanding-constraints.mdx`** (5 snippets total). Fixed the same `[Unique]`-on-positional-record-parameter bug documented in part 19 (`[property: Unique(...)]` needed, not bare `[Unique(...)]`) — this is the first time that specific bug class recurred outside `constraints/**` itself, confirming it's worth grep-checking anywhere `[Unique]` appears on a positional record. The dedicated-`IConstraint` example got a real C#+TypeScript pair by directly reusing the already-validated `constraints/declarative/unique.mdx` combined-builder shape (`.WithName().On().On().IgnoreCasing().RemovedWith().WithMessage()` / `.withName().on().on().ignoreCasing().removedWith().withMessage()`). The three `[Unique]`/`[RemoveConstraint]` model-bound snippets stay C#-only (confirmed in part 19: TS model-bound has no property-level unique/remove-constraint decorators). +- **`sinks/index.mdx`** — partially migrated, **deliberately**: the "Enabling the SQL Sink" section's *first* snippet (`options.DefaultSinkTypeId = WellKnownSinkTypes.SQL` via `IHostApplicationBuilder.AddCratisChronicle`) is real client-side config, migrated to a real validated C#-only snippet. The *second* snippet (`siloBuilder.AddChronicleToSilo(chronicle => chronicle.WithSql(options))`) is genuine **Kernel/Orleans-silo hosting code** — it uses `Cratis.Chronicle.Configuration.ChronicleOptions`/`IChronicleBuilder` (Kernel-side types, different namespace from the client-side `Cratis.Chronicle.ChronicleOptions`) and `WithSql` from `Source/Kernel/Storage.Sql`, which pulls in Orleans hosting, EF Core, and the full Kernel Core project graph — a much heavier validator dependency than the MongoDB.Driver/Aspire/AspNetCore additions from parts 17-18. Left this one snippet's `legacy/sinks/index/snippet-02` file and reference **in place** (deliberately, same category as `testing/**`'s illustrative-BDD exception from the very first 2026-07-01 update) rather than silently adding a heavy new Kernel dependency to the client-facing validator without asking. **Re-assess if this recurs** — `hosting/**` (still in the backlog) likely has the same shape of problem for its Kernel-silo-hosting content. +- **`event-seeding/seeding-with-csharp.mdx`** (7 snippets, all C#-only — confirmed TypeScript has zero seeding support, `find`/`grep` for "seeding" under `Chronicle.TypeScript/Source` returned nothing). Found the same **missing-`[EventType]`** bug class as parts 4/8/10 — none of the legacy seed event records had `[EventType]`, and `EventSeeding.For` calls `_eventTypes.GetEventTypeFor(typeof(TEvent))`, which requires registration. Fixed on every event declared in the file. The `#if DEBUG` "development-only seeding" snippet has a real, pre-existing precedent in the corpus (`reducers/passive-reducers/development-testing.md`, part 14) — kept the same shape (a full class wrapped in `#if DEBUG`/`#endif`); note this means that specific class is **not actually exercised by the Release-only validator build** (same accepted limitation as the existing precedent, not a new gap). + +Legacy count: 210 → **191**. Baseline ratcheted to 191 in `web/chronicle-client-docs.yml`. Re-verified full chain: `npm run chronicle-client-docs:check` (passed, only Kotlin/Java/Elixir blocked by missing local toolchains as expected), `npm run audit:chronicle-client-docs:strict` (0 direct fences), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), both C# (`../Chronicle/Documentation/validate-client-snippets.py`) and TypeScript (`Documentation/Chronicle.TypeScript/Documentation/validate-client-snippets.py`) validators passing for real (not just "no errors because nothing references the new types" — verified the actual snippet files exist on disk before trusting the green run, see next paragraph). + +**Important environment hazard found and fixed — read this before writing any TypeScript (or Kotlin/Elixir) snippet file in a fresh session**: this session's `Write` calls initially used the absolute path `/Volumes/sourcecode/repos/cratis/Chronicle.TypeScript/...` for the TypeScript snippets. That path is **wrong** — the real submodule lives at `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle.TypeScript/...` (i.e. `Documentation/Chronicle.TypeScript`, not a `Chronicle.TypeScript` sibling next to `Documentation`). Using the wrong absolute path silently created a **new, stray, non-submodule directory** at the sibling location (`/Volumes/sourcecode/repos/cratis/Chronicle.TypeScript`) containing only the files this session wrote. Because `web/chronicle-client-docs.yml`'s path-resolution (`firstExistingPath`) prefers a sibling path *if it exists at all*, that stray directory **shadowed the real submodule** for every TypeScript snippet/validator lookup — not just the two pages this session touched — which is why `npm run chronicle-client-docs:check` briefly reported dozens of "missing TypeScript snippet" errors for entirely unrelated, already-completed pages (`projections/model-bound/**`, `reactors/**`, `read-models/**`, `reducers/**`, etc.) that were never actually broken. Fixed by deleting the stray sibling directory (after confirming via `find` that it contained only the 4 files this session had just written, nothing pre-existing) and re-writing the TypeScript snippet content directly into the real submodule path. **Lesson for next session**: always use a path relative to the `Documentation` working directory (e.g. plain `Chronicle.TypeScript/Documentation/client-snippets/...` from `cwd=Documentation`) rather than constructing an absolute sibling-style path from memory for the Kotlin/Elixir/TypeScript submodules — or if using an absolute path, verify it resolves under `.../Documentation/Chronicle.TypeScript/...`, `.../Documentation/Chronicle.Kotlin/...`, `.../Documentation/Chronicle.Elixir/...` first. Also worth a quick `ls /Volumes/sourcecode/repos/cratis/ | grep -i chronicle` sanity check at the start of a fresh session to confirm no stray sibling directories exist from a prior mistake. + +Also note: `` **without** an explicit `clients=` attribute defaults to requiring **all 5** registered clients (csharp, kotlin, elixir, typescript — java is excluded by default per `includeByDefault: false` in the manifest). Any page that is only C#+TypeScript for now (the norm established since part 8) **must** say `clients="csharp,typescript"` explicitly, or the shared-doc audit fails looking for nonexistent Kotlin/Elixir snippet files. Caught and fixed this on `coming-from-crud.mdx` and `understanding-constraints.mdx`'s new tabs in this part — worth double-checking on every new tab going forward. + +**Remaining backlog** (unchanged in scope from part 19, minus the 5 files done this part): `compliance/**` (4 files: `client`, `pii-with-concepts`, `pii`, `read-models`), `configuration/**` (6: `camel-casing`, `chronicle-options`, `grpc-message-size`, `index`, `structural-dependencies`, `tls`), `connection-strings/**` (2: `configuration`, `dotnet-client`), `contributing/kernel/**` (2: `contracts`, `patches/index` — likely genuine Kernel-internals content, classify before migrating, may deserve the same deliberate-legacy treatment as `sinks/index`'s second snippet), `hosting/**` (2: `aspire`, `local-certificates` — `aspire` likely fine, `local-certificates` may hit the same Kernel-hosting-dependency wall as `sinks/index`), `migrations/**` (2: `dotnet-client`, `validation`), `namespaces/**` (2: `aspnetcore`, `dotnet-client`), `read-models/materialized-pagination` (1, 7 snippets), `scenarios/**` (3: `react-to-an-event`, `real-time-query`, `test-a-slice`), `subscriptions/**` (3: `explicit-subscriptions`, `implicit-subscriptions`, `outbox-inbox`), `tutorial/**` (3: `first-event`, `reacting`, `read-model` — note part 18's reminder: `get-started/common.mdx` deliberately used a `GetStarted*` prefix specifically so this tutorial batch can use clean, unprefixed domain names like `BookAdded`/`Book` without collision, since both build the same library/Book domain). `testing/**` (7 files) remains deliberately un-migrated per the very first 2026-07-01 note (illustrative BDD pseudocode, bigger separate effort). `sinks/index.mdx` has 1 remaining deliberately-retained legacy reference (documented above). Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count (was 39 files / 191 snippets right after this part, `sinks/index` and `event-seeding/seeding-with-csharp` counted as "in progress"/"done" respectively since one legacy file remains in `sinks`). + +**Standing reminders, unchanged from part 19, plus three new ones**: (17) **new** — always verify a "read status" or "check whether X" API actually exists in source before writing a snippet for it; legacy content invented `IsStreamCompleted` out of thin air and it doesn't exist — if the real client has no such query, remove the doc section rather than fabricate the call; (18) **new** — before deleting any directory outside the repos this session is supposed to touch, or before combining a destructive command (`rm -rf`) with other setup steps in one Bash call, split it into a separate, minimal, targeted step — a permission denial on a compound command silently skips the ENTIRE command including harmless setup steps that ran before the destructive part in the same invocation, which can leave the appearance of partial progress (e.g. files that look copied via `find` output in the same denied command never actually got copied); (19) **new** — `` with no `clients=` attribute defaults to all 5 registered clients; always pin `clients="csharp,typescript"` explicitly on any C#+TypeScript-only page, and always sanity-check `ls /Volumes/sourcecode/repos/cratis/ | grep -i chronicle` at the start of a session to rule out a stray sibling submodule directory before trusting any TypeScript/Kotlin/Elixir validator "success" result. + +## 2026-07-02 update, part 21 — `connection-strings/**` and `namespaces/**` fully migrated + +Continued straight on from part 20 in the same session. Finished both remaining files in `connection-strings/**` and both in `namespaces/**` (4 files, 17 snippets, all C#-only — confirmed no TypeScript equivalents exist for `ChronicleClient`/`ChronicleOptions`/`IEventStoreNamespaceResolver`/`ChronicleAspNetCoreOptions`, all .NET-client-specific types). + +- **`connection-strings/dotnet-client.mdx`** (4 snippets) — found a **fabricated fluent API**: the legacy `ChronicleConnectionStringBuilder` snippet chained `.WithHost().WithPort().WithCredentials().Build()` as if they were instance methods; the real `ChronicleConnectionStringBuilder` class (`Source/Clients/Connections/ChronicleConnectionStringBuilder.cs`) only has plain settable properties (`Host`, `Port`, `Username`, `Password`) and a `Build()` returning `string` — the fluent chain only works because `WithHost`/`WithPort`/`WithCredentials`/`Build` are **extension methods** in a separate file (`ChronicleConnectionStringBuilderExtensions.cs`); confirmed both files match before trusting the legacy content. Also found the bare `var options = ...; var client = ...;` two-line pattern (repeated across all 4 snippets) would leave `options` unused (CS0219, and `TreatWarningsAsErrors=true` in the validator project makes that a hard failure) once wrapped in a real method — fixed by always passing `options` into the `ChronicleClient` constructor rather than leaving it a dead local. All 4 snippets wrapped as static factory methods on a small class (bare top-level statements can't repeat across multiple snippet files — only one file may have top-level statements in a compilation unit, confirmed by a real `CS8803` build failure the first time this was tried). +- **`connection-strings/configuration.mdx`** (1 snippet) — real, wrapped in the same `Host.CreateApplicationBuilder(args)` + `AddCratisChronicle` shape used throughout `get-started/**`. +- **`namespaces/dotnet-client.mdx`** (6 snippets) — the first legacy snippet re-declared the **real framework interface** `IEventStoreNamespaceResolver` verbatim as a bare interface declaration; per the part 10/16 shadowing-risk precedent (real framework type re-declared locally), rewrote it as a real *implementation* of the actual `Cratis.Chronicle.IEventStoreNamespaceResolver` instead of redeclaring the interface shape, avoiding a would-be global-namespace-vs-`Cratis.Chronicle`-namespace ambiguity for any other file that does `using Cratis.Chronicle;` and references the bare name. Verified `ClaimsBasedNamespaceResolver(string claimType = "tenant_id")`, `DefaultEventStoreNamespaceResolver`, `ChronicleClientOptions.EventStoreNamespaceResolverType`, and `IChronicleBuilder.WithNamespaceResolver(...)` all real against source. Fixed an inconsistent parameter name in the legacy content (`new TenantNamespaceResolver(config)` where the class's real constructor parameter — shown two snippets later in the same page — is `ITenantContext tenantContext`, not `config`). +- **`namespaces/aspnetcore.mdx`** (5 snippets) — verified `ChronicleAspNetCoreOptions : ChronicleClientOptions`, `WithHttpHeaderNamespaceResolver(headerName = "x-cratis-tenant-id")`, `WithSubdomainNamespaceResolver()`, and `WebApplicationBuilder.AddCratisChronicle(Action?, ...)` all real. Hit a **global merged-usings collision**: adding `using Microsoft.AspNetCore.Http;` (needed for `Results.Ok()` in the full web-app example) made the bare name `Tags` ambiguous against `Cratis.Chronicle.TagsAttribute` in every other already-migrated file that has `using Cratis.Chronicle;` and references `[Tags]` unqualified (`CS0104`) — because the C# validator's generator merges **all** snippet files' `using` directives into one global list (`generate_source()`'s `*sorted(usings)`), a `using` added in any one snippet file can break unrelated files elsewhere in the corpus. Fixed by fully-qualifying `Microsoft.AspNetCore.Http.Results.Ok()` inline instead of importing the namespace — avoids the global collision at the cost of a slightly less idiomatic-looking single call in one snippet. + +Legacy count: 191 → **175**. Baseline ratcheted to 175. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run audit:chronicle-client-docs:strict` (0 direct fences), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), C# validator passing for real. + +**New standing reminder**: (20) **new** — the C# snippet validator merges every snippet file's `using` directives into one global list for the whole generated compilation unit (not per-file scoping); before adding a new `using` to any snippet, consider whether its imported namespace could introduce a bare-name collision with a type already used unqualified elsewhere in the corpus (this is the same root cause as standing reminder (13)'s `EventContext`-shadowing risk, but triggered by a `using` import rather than a same-named local declaration) — when in doubt, fully-qualify the one call site instead of adding a new global `using`. + +**Remaining backlog** (34 files / 175 snippets, unchanged from part 20 minus the 4 files done this part): `compliance/**` (4), `configuration/**` (6), `contributing/kernel/**` (2, likely genuine Kernel-internals — classify before migrating), `hosting/**` (2, `local-certificates` may hit the same Kernel-hosting-dependency wall as `sinks/index`'s retained snippet), `migrations/**` (2), `read-models/materialized-pagination` (1, 7 snippets), `scenarios/**` (3), `subscriptions/**` (3), `tutorial/**` (3, remember the `GetStarted*`-prefix-avoidance note from part 18). `testing/**` (7, deliberately retained) and `sinks/index.mdx` (1 remaining snippet, deliberately retained per part 20) are documented exceptions, not overlooked. Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count. + +## 2026-07-03 update, part 22 — `compliance/**` fully migrated; TypeScript `@cratis/fundamentals` ConceptAs support established for the first time + +New session, continuing straight from part 21. Finished all 4 files in `compliance/**` (23 snippets): `client.mdx`, `pii-with-concepts.mdx`, `pii.mdx`, `read-models.mdx`. + +**First-time discovery: TypeScript has full PII/compliance support**, not previously verified in this session. `Chronicle.TypeScript/Source/compliance/pii.ts` exports a real `pii(details?: string): PropertyDecorator & ClassDecorator` — usable both on read-model/event properties (`@pii() name = '';`) and on `ConceptAs`-derived classes (`@pii() class X extends ConceptAs {}`), confirmed by reading the decorator implementation directly (not the JSDoc example alone, which references a `field()` decorator that turned out **not to exist** anywhere in source — do not trust JSDoc `@example` blocks in this codebase without grepping for the symbols they use). `ConceptAs` itself is **not** re-exported from `@cratis/chronicle`'s barrel (`Source/index.ts` only re-exports `Guid` from fundamentals) — it must be imported directly from `@cratis/fundamentals`, which **is** installed at `Chronicle.TypeScript/node_modules/@cratis/fundamentals` (real package, resolves fine via Node's upward `node_modules` walk from the generated `Source/.docs-snippets/` validation file). + +**Validator tooling change made to support this** (in `Chronicle.TypeScript/Documentation/validate-client-snippets.py`, mirroring the existing `@cratis/chronicle.contracts` named-import handling): added a `FUNDAMENTALS_NAMED_IMPORT_RE` pattern and a `fundamentals_named_imports` set so that `import { ConceptAs } from '@cratis/fundamentals';` lines across multiple snippet files get **deduplicated** into one import in the generated file instead of literally repeating (which caused a real `TS2300: Duplicate identifier 'ConceptAs'` the first time — every snippet file's raw, non-`@cratis/chronicle` import lines get preserved as-is and concatenated, so importing the same external symbol from more than one snippet file breaks without this kind of dedup). This is a lightweight, in-scope extension of an existing mechanism (not a new dependency) — no confirmation sought, consistent with how `BODY_SNIPPETS` entries have been added freely all session. + +**Scope decision, worth re-checking later**: TypeScript equivalents were added for `@pii()` on plain event/read-model properties and on `ConceptAs` classes (both directly confirmed real). TypeScript equivalents were **deliberately not attempted** for (a) a `ConceptAs`-typed property actually declared on an event/read-model class (no established precedent anywhere in the corpus for how TS's reflection-based schema generator recognizes a concept-typed field when the property has a union type or a bare concept type — verifying this properly needs deeper study of `JsonSchemaGenerator.ts`'s `isConceptAs` runtime-type-reflection path than time allowed this session) and (b) the "EventSourceId cannot be PII" restriction (TypeScript has no `EventSourceId` wrapper type at all — confirmed earlier in the session — so this scenario doesn't exist in TS). Everything in category (a) stayed C#-only for now; if a future session verifies the concept-typed-event-property pattern for TS, revisit `compliance/client/concept-usage.md`, `compliance/client/combining.md`, and `compliance/pii-with-concepts/surrogate-key.md` to add real TS equivalents. + +**Real bugs found and fixed in previously-untested legacy content**: +- `compliance/client.mdx` snippet-01: `[PII] string FirstName` on a positional record parameter — checked `PIIAttribute`'s `AttributeUsage` and (unlike `[Unique]` in part 19) it explicitly includes `AttributeTargets.Parameter`, so **no** `[property: ...]` fix was needed here; worth remembering this is attribute-by-attribute, not a blanket rule — check each attribute's own `AttributeUsage` rather than assuming the part 19 finding applies universally. +- `compliance/client.mdx` snippet-05 and `pii-with-concepts.mdx` snippet-01 both redeclared **duplicate type names within the same legacy file** (`EmployeeRegistered`/`EmployeeNameChanged` shown twice, once "bad" once "good") — the same duplicate-declaration bug class seen repeatedly all session; fixed with distinct suffixed names (`...Good`, `...Comparison...`). +- `pii-with-concepts.mdx`: the prose said "For a `Guid`-backed concept:" immediately before a snippet that was actually `ConceptAs` (a `NationalIdNumber`, string-valued, not Guid-backed at all) — a real prose/code mismatch bug, not just a snippet bug (same class as part 15's `set-properties.mdx` false-claim finding). Fixed the prose to accurately describe what the snippet shows (a `details`-documented concept) instead of a fabricated "Guid-backed" framing. +- `pii.mdx`'s "Attribute definition" section tried to show the real `PIIAttribute` class's own signature as a compiled snippet — redeclaring the real `Cratis.Chronicle.Compliance.GDPR.PIIAttribute` type would have shadowed it globally for every other file using `using Cratis.Chronicle.Compliance.GDPR;` + bare `[PII]` (the same class of risk as part 10/16's `EventContext`/`Point` shadowing findings, and this page sits alongside three other pages that all do exactly that). Converted this one reference-only section to a plain ` ```text ` fence showing both the verified C# and TypeScript signatures side by side — it was never really "usage code", just a documented signature, so it doesn't belong in the compiled-snippet system at all. +- `pii.mdx`'s "Non-ConceptAs class types are not supported" section claimed a runtime `PIIAppliedToNonConceptAsType` restriction; checked the real TypeScript `pii()` implementation and confirmed it has **no such check at all** — applying `@pii()` to a plain class in TS silently succeeds (a genuine, confirmed cross-client capability difference, not a documentation gap). Added an explicit prose note about this instead of fabricating a TS example that would falsely claim parity. +- `compliance/read-models.mdx`: all three snippets used a fabricated read-model shape — `[ReadModel]` (the same Arc-not-Chronicel attribute mistake already caught and fixed in part 8's `projections/filtering.mdx`, confirmed again by grepping the validator's project references: still no Arc `ProjectReference`) plus a nonexistent `IMongoCollection.ObserveById(...)` extension method with no precedent anywhere in the corpus. Rewrote all three snippets against real, already-validated Chronicle patterns instead: model-bound `[FromEvent]`/`[Key]` projection (matching the `projections/model-bound/**` precedent), a real `IReducerFor` handler (matching the `reducers/getting-started` precedent's sync `(event, current, context) => TReadModel` shape), and `IReadModels.GetInstanceById(ReadModelKey key)` for the querying example (matching the `read-models/**` precedent) — confirmed `ReadModelKey` has an implicit conversion from `Guid` (`Source/Clients/DotNET/ReadModels/ReadModelKey.cs`), so the snippet could keep the original's `Guid`-keyed feel without inventing new API surface. + +Legacy count: 175 → **152**. Baseline ratcheted to 152. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), both C#/TypeScript validators passing for real. + +**New standing reminders**: (21) **new** — don't trust a source file's own JSDoc `@example` block without grepping that the symbols it references actually exist; this codebase has at least one JSDoc example (`pii.ts`) referencing a `field()` decorator that was never implemented; (22) **new** — `AttributeUsage` restrictions are per-attribute, not a blanket C# rule — check each attribute's own `AttributeTargets` flags (part 19's `[Unique]`-needs-`[property:]` finding does NOT automatically apply to `[PII]`, which explicitly allows `Parameter`); (23) **new** — before redeclaring any real framework type's signature "for reference" in a snippet (not as a usage example), check whether that redeclaration would collide with the type being used unqualified elsewhere in the corpus; if it's purely a signature reference rather than real usage code, a plain ` ```text ` fence outside the `ChronicleClientTabs` system is more honest and avoids the collision risk entirely; (24) **new** — when one client (e.g. TypeScript) is confirmed to be intentionally *more permissive* than another (no runtime restriction where C# throws), say so explicitly in prose rather than omitting the client from that specific example — that is real, useful information for a reader picking a client. + +**Remaining backlog** (30 files / 152 snippets): `configuration/**` (6), `contributing/kernel/**` (2, likely genuine Kernel-internals — classify before migrating), `hosting/**` (2, `local-certificates` may hit the same Kernel-hosting-dependency wall as `sinks/index`'s retained snippet), `migrations/**` (2), `read-models/materialized-pagination` (1, 7 snippets), `scenarios/**` (3), `subscriptions/**` (3), `tutorial/**` (3, remember the `GetStarted*`-prefix-avoidance note from part 18). `testing/**` (7, deliberately retained) and `sinks/index.mdx` (1 remaining snippet, deliberately retained per part 20) are documented exceptions. Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count. + +## 2026-07-03 update, part 23 — `configuration/**` fully migrated + +Continued in the same session straight after part 22. Finished all 6 files in `configuration/**` (22 snippets, all C#-only — configuration/hosting-registration is inherently .NET-client-specific): `index.mdx`, `chronicle-options.mdx`, `camel-casing.mdx`, `grpc-message-size.mdx`, `structural-dependencies.mdx`, `tls.mdx`. + +**Real bugs found and fixed in previously-untested legacy content**: +- `chronicle-options.mdx`: both the top-level `appsettings.json` example and the `DefaultSinkTypeId` section's JSON example showed `"DefaultSinkTypeId": "f7d3a1e2-4b5c-4d6e-8f9a-0b1c2d3e4f5a"` — a fabricated GUID. Checked `SinkTypeId` (`Source/Clients/DotNET/Sinks/SinkTypeId.cs`) — it's a plain string-backed `ConceptAs`, and `WellKnownSinkTypes.SQL`/`.MongoDB` are the literal strings `"SQL"`/`"MongoDB"`, never GUIDs. The adjacent compiled C# snippet in the same section already correctly used `WellKnownSinkTypes.SQL` — only the JSON prose examples were wrong. Fixed both to `"DefaultSinkTypeId": "SQL"`. +- `camel-casing.mdx`'s final projection snippet used `protected override void DefineModel(IProjectionBuilderFor builder)` — neither the method name (`DefineModel` vs real `Define`) nor the modifiers (`protected override` implies an abstract base class; `IProjectionFor` is an **interface**, not a base class) match the real `IProjectionFor.Define(IProjectionBuilderFor builder)` contract (`Source/Clients/DotNET/Projections/IProjectionFor.cs`). Fixed to the real `public void Define(...)` interface implementation. +- `structural-dependencies.mdx`'s custom-artifacts-provider example implemented only 3 of `IClientArtifactsProvider`'s **15** required members (`EventTypes`, `Projections`, `Reactors`, with a `// ... other members` comment eliding the rest) — legal as illustrative prose but would not compile as a real implementation. Implemented all 15 members for real (most returning `[]`), confirmed against the full interface in `Source/Clients/DotNET/IClientArtifactsProvider.cs`. Also verified `CompositeAssemblyProvider`/`ProjectReferencedAssemblies.Instance`/`PackageReferencedAssemblies.Instance` are real — they live in `Cratis.Types` (Fundamentals), not the Chronicle DotNET client itself, confirmed by grepping the Fundamentals repo directly since a first grep of `Chronicle/Source/Clients/DotNET` alone came up empty (worth remembering: not every real symbol used by the DotNET client lives inside `Chronicle/Source/Clients/DotNET` — some come from the transitively-referenced Fundamentals package, so an empty grep of one repo doesn't mean the symbol is fake, check the dependency too before concluding "not real"). + +Legacy count: 152 → **131**. Baseline ratcheted to 131. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), C# validator passing for real (no TypeScript work this part — every `configuration/**` example is inherently .NET-client hosting/registration code). + +**New standing reminder**: (25) **new** — before concluding a symbol referenced by Chronicle's DotNET client is fabricated because it doesn't appear under `Chronicle/Source/Clients/DotNET`, check the Fundamentals repo (`Cratis.Types`, `Cratis.Serialization`, etc.) too — several real, commonly-used types (`CompositeAssemblyProvider`, `ProjectReferencedAssemblies`, `CamelCaseNamingPolicy`, `DefaultNamingPolicy`, `ConceptAs`) live there and are only transitively referenced, not locally defined. + +**Remaining backlog** (24 files / 131 snippets): `contributing/kernel/**` (2, likely genuine Kernel-internals — classify before migrating), `hosting/**` (2, `local-certificates` may hit the same Kernel-hosting-dependency wall as `sinks/index`'s retained snippet), `migrations/**` (2), `read-models/materialized-pagination` (1, 7 snippets), `scenarios/**` (3), `subscriptions/**` (3), `tutorial/**` (3, remember the `GetStarted*`-prefix-avoidance note from part 18). `testing/**` (7, deliberately retained) and `sinks/index.mdx` (1 remaining snippet, deliberately retained per part 20) are documented exceptions. Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count. + +## 2026-07-03 update, part 24 — `migrations/**` fully migrated + +Continued in the same session straight after part 23. Finished both files in `migrations/**` (9 snippets, all C#-only): `dotnet-client.mdx`, `validation.mdx`. Both pages cover the same `EventTypeMigration` API already established and validated in part 11's `concepts/event-type-migrations.mdx` — reused that exact API shape directly rather than re-deriving it from source. + +**Investigated and resolved a real correctness question before writing anything**: the already-validated part-11 precedent (`concepts/event-type-migrations/defining-migrations.md`) has the older generation's `[EventType]` with no explicit id (defaults to its own type name) while the newer generation's `[EventType("author-registered", generation: 2)]` sets an explicit, *different* string id. This looked suspicious — if the two generations don't share an event type id, how does Chronicle know they're the same event type? Traced `EventTypeMigration`'s constructor (`Source/Clients/DotNET/Events/Migrations/EventTypeMigration.cs`): it only extracts `From`/`To` **generation numbers** from each type's own attribute — it does not compare or require matching ids between `TUpgrade` and `TPrevious`. The migrator binds the two generations together via the C# generic type parameters themselves (`EventTypeMigration`), not via matching `[EventType]` id strings — `TPrevious`'s own id is irrelevant to registration; only `TUpgrade`'s id becomes the canonical event type identifier for the whole chain. Confirmed this is by design, not a latent bug — safe to keep reusing the established pattern. + +**Real bugs found and fixed in previously-untested legacy content**: +- `migrations/validation.mdx`'s `DefaultValue` example called `.RenamedFrom(t => t.Name, s => s.Name)` — renaming a property to its own identical name is a no-op dressed up as an operation, and it obscured the section's actual point (declaring a default for a genuinely *new* property, `Status`). Removed the pointless `RenamedFrom` call — matches the already-established pattern from part-11's `default-value.md`, where a property unchanged across generations (`TrackingNumber`) is left with no explicit migration-builder operation at all. +- Continued applying the naming-collision-avoidance discipline used all session: every event/migrator declared in `migrations/**` got a distinct `MigrationsDotnetClient*`/`MigrationsValidation*` prefix (not reusing `Migrations*` from `concepts/event-type-migrations`), and every generation-2+ `[EventType]` got an explicit, unique string id (e.g. `"dotnet-client-author-registered"`) rather than relying on type-name fallback, both to avoid cross-file collisions in the single merged validation file and to make the shared-identity-across-generations mechanism visible in the docs themselves. + +Legacy count: 131 → **122**. Baseline ratcheted to 122. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), C# validator passing for real. (Hit one transient, unrelated incremental-build glitch mid-session — `Cratis.Chronicle.Concepts` namespace briefly failed to resolve in `PIIManager.cs`, a file this session never touched; a bare re-run of the same validator command succeeded cleanly with 0 errors, confirming it was a stale/racy incremental-build artifact, not a real regression. If this recurs, re-run before investigating further.) + +**Remaining backlog** (22 files / 122 snippets): `contributing/kernel/**` (2, likely genuine Kernel-internals — classify before migrating), `hosting/**` (2, `local-certificates` may hit the same Kernel-hosting-dependency wall as `sinks/index`'s retained snippet), `read-models/materialized-pagination` (1, 7 snippets), `scenarios/**` (3), `subscriptions/**` (3), `tutorial/**` (3, remember the `GetStarted*`-prefix-avoidance note from part 18). `testing/**` (7, deliberately retained) and `sinks/index.mdx` (1 remaining snippet, deliberately retained per part 20) are documented exceptions. Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count. + +## 2026-07-03 update, part 25 — MAJOR MILESTONE: `scenarios/**` fully migrated, testing infrastructure wired up for real, `testing/**` fully migrated except one deliberate exception + +Continued in the same session straight after part 24. This part covers a big scope expansion: the user explicitly approved investing in wiring up `Cratis.Chronicle.Testing` + `Cratis.Specifications` into the C# validator (previously deferred since the very first 2026-07-01 session), which unlocked `scenarios/test-a-slice.mdx` (3 snippets) and almost all of `testing/**` (7 files, 44 of 53 total snippets — `event-append-collection.mdx`'s 9 snippets remain deliberately deferred, see below). Also finished `scenarios/react-to-an-event.mdx` (4) and `scenarios/real-time-query.mdx` (5) earlier in this part before the testing-infrastructure work. + +### Validator changes (`../Chronicle/Documentation/validate-client-snippets.py`) + +Added to the generated validation project: +```xml + + + + + +``` +All four package versions are already centrally pinned in `Directory.Packages.props` (`Cratis.Specifications`/`.XUnit` 3.0.5, `xunit` 2.9.3, `NSubstitute` 5.3.0) — no new version decisions needed. `Testing.csproj` transitively pulls in the full Kernel (`Core`, `Storage`, `Concepts` — aliased project references) since `EventScenario`/`ReadModelScenario` run the real in-process kernel grain, not a mock — this is a heavier build than every previous addition this session, but it's still **compile-only** (like everything else the validator does) and builds in ~4s once the Kernel projects are warm. + +**This is the single biggest scope decision of the whole migration effort so far** — it was explicitly asked and approved via `AskUserQuestion` rather than assumed, because it reverses a standing deferral from the very first session. + +### Real-execution verification (new discipline for this part only) + +Because this was genuinely new, unverified territory (first time `EventScenario`/`ReadModelScenario` specs were written for these docs), a throwaway scratch project (`.spec-check/`, deleted after use — never committed) was built to actually **run** the 3 `scenarios/test-a-slice.mdx` specs with `dotnet test`, not just compile them. This required additional packages beyond what the shipped validator needs (`Microsoft.Orleans.*` 10.2.1, `Cratis.Arc`/`Cratis.Arc.Core` 20.48.4, `Microsoft.Extensions.Telemetry.Abstractions` 10.7.0 — mirrored from the real `Source/Clients/Testing.Specs/Testing.Specs.csproj`, which is Chronicle's own spec project for this exact API) because `EventScenario`'s constraint discovery (`Cratis.Chronicle.Testing.Defaults` static initializer) needs Orleans assemblies loadable at runtime even though compilation doesn't. All 5 specs (from `append-spec.md` + `constraint-spec.md` + `read-model-spec.md`) **passed for real** — confirms the constraint fires, the read model projects correctly, and the append succeeds, not just that the C# is syntactically valid. This is NOT part of the shipped validator (which stays compile-only, matching every other snippet in the corpus) — it was a one-time confidence check before rolling the pattern out to 44 more snippets. **Do not repeat this full dotnet-test-with-Orleans setup for every future testing snippet** — it's expensive and the compile-only validator is the established, correct bar for this corpus; this was extra-credit verification for a first-of-its-kind pattern, not a new requirement. + +### Files completed this part + +- `scenarios/react-to-an-event.mdx` (4 snippets, C#-only) — `IReactor`, reading state via `IReadModels.GetInstanceById` vs. materialized, returning events as side effects, explicit `IEventLog.Append` with throw-on-failure. +- `scenarios/real-time-query.mdx` (5 snippets, C#-only) — `GetInstanceById`/`GetInstances`/`Materialized.GetInstances`/`Watch`/`Materialized.ObserveInstances`, all reused already-validated `read-models/**` API shapes directly. +- `scenarios/test-a-slice.mdx` (3 snippets, C#-only) — `EventScenario` + `ReadModelScenario` + `Cratis.Specifications`, verified passing for real (see above). +- `testing/index.mdx` (1 snippet). +- `testing/events/scenario.mdx` (6 snippets) — `EventScenario` basics, `Given`/`When` builders, `AppendMany`. +- `testing/events/assertions.mdx` (7 snippets) — the full `Should*` assertion family on `IAppendResult`. +- `testing/events/event-sequence-assertions.mdx` (9 snippets) — `ShouldHaveTailSequenceNumber`/`ShouldHaveAppendedEvent` (all 8 overloads verified individually against `EventSequenceShouldExtensions.cs`), plus `AppendedEventWithResult.ShouldHaveEvent`/`ShouldBeForEventSource`. +- `testing/reactors/scenario.mdx` (4 snippets) — `ReactorScenario`, NSubstitute mocking. +- `testing/read-models/scenario.mdx` (9 snippets) — `ReadModelScenario`, initial state, DI, pre-seeding instances via `Given.ForEventSourceId(id).ReadModel(...)`, reducer/fluent/model-bound auto-detection, joins (`InstanceForEventSourceId`), `[ChildrenFrom]`. + +### Real bugs found and fixed (same "verify against source" discipline as every prior part) + +- `read-models/scenario.mdx` snippet-07 (model-bound example): the legacy content applied `[FromEvent]` **at property level** (`[FromEvent] string Carrier`) — checked `FromEventAttribute`'s `AttributeUsage` and it is `Class | Struct` **only**, not `Property` — this would not compile. Fixed by moving `[FromEvent]` to class level for both event types and relying on AutoMap for the matching-named `Carrier`/`DeliveredAt` properties, instead of inventing a nonexistent property-level equivalent. +- Multiple `[ReadModel]`-attribute occurrences in `testing/read-models/scenario.mdx`'s legacy content (`ProductView`, `DeliveryStatus`, `Timesheet`) — same Arc-not-Chronicle mistake caught repeatedly since part 8; confirmed again the validator still has no Arc reference, so all three were declared without it. +- `testing/read-models/scenario.mdx`'s `basic.md`: the legacy content's read model property was named `SomeProperty` while the event property was `Value` — AutoMap only matches identical names, so this would silently leave `SomeProperty` unset (the class of AutoMap-mismatch bug flagged as validator-invisible since part 8). Renamed the read model property to `Value` to match, rather than adding an explicit `[SetFrom]` — simpler and the doc's point wasn't about explicit mapping. +- Two genuinely new nullable/unused-parameter compile errors caught by the validator's `Nullable=enable`/`TreatWarningsAsErrors=true` settings, both fixed: `Specification` subclasses need `field = null!;` initializers for fields only assigned in `Establish()`/`Because()` (CS8618 — the reflection-based lifecycle isn't visible to nullable analysis); a mock constructor parameter that was never read inside the method body (CS9113) needed a real call site, not just capture — fixed by actually calling `pricingService.GetBasePrice()` in the reducer (which conveniently also keeps the existing `0m` assertion true, since an unconfigured NSubstitute mock returns `default`). + +### Deliberately deferred: `testing/event-append-collection.mdx` (9 snippets) + +Investigated `IEventAppendCollection`/`StartCollectingAppends()`/`ChronicleInProcessFixture` — these live in a **different, heavier** project than `Cratis.Chronicle.Testing`: `Source/Clients/XUnit.Integration` (`Cratis.Chronicle.XUnit.Integration`), which pulls in `Testcontainers` (real Docker-backed MongoDB), `Microsoft.Orleans.TestingHost`, `Microsoft.AspNetCore.Mvc.Testing`, and `Cratis.Arc.MongoDB` — a genuine **out-of-process integration-test** tier (per CLAUDE.md's own guidance: "Reserve out-of-process integration specs for host/infra/transport boundaries"), not the lightweight in-process scenario family this part just wired up. Adding this would mean the docs validator needs Docker/Testcontainers available in CI — a materially bigger and riskier ask than what "wiring up the testing packages" was scoped to cover. Left this file's 9 snippets under `legacy/` with this reasoning documented; re-assess in a future session if there's appetite for that heavier lift specifically (it was not re-confirmed with the user in this part, since it's a natural, bounded extension of the sinks/index.mdx-style precedent already established without repeated confirmation). + +Legacy count: 122 → **74**. Baseline ratcheted to 74. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), both C# and TypeScript validators passing for real. + +**New standing reminders**: (26) **new** — when wiring a new package/project into the validator, check whether its runtime behavior needs assemblies beyond what compilation requires (Orleans here) — compiling clean is not the same bar as running clean; the shipped validator only needs the former, but a first-time confidence check is worth doing with the latter; (27) **new** — `Cratis.Chronicle.Testing` (in-process, lightweight) and `Cratis.Chronicle.XUnit.Integration` (out-of-process, Testcontainers-backed) are two distinctly-scoped testing tiers in this codebase — don't conflate them when a legacy snippet's API surface (`IEventAppendCollection`, fixtures, `[Collection(...)]`) doesn't match what `Cratis.Chronicle.Testing` actually exposes; (28) **new** — `Specification`-derived classes with fields assigned only in `Establish()`/`Because()` (not the constructor) need explicit `= null!;` initializers under `Nullable=enable` — the reflection-based lifecycle invocation is invisible to the compiler's definite-assignment analysis. + +**Remaining backlog** (13 files / 74 snippets): `contributing/kernel/**` (2, likely genuine Kernel-internals — classify before migrating), `hosting/**` (2, `local-certificates` may hit the same Kernel-hosting-dependency wall as `sinks/index`'s retained snippet), `read-models/materialized-pagination` (1, 7 snippets), `subscriptions/**` (3), `tutorial/**` (3, remember the `GetStarted*`-prefix-avoidance note from part 18). `testing/event-append-collection.mdx` (9, deliberately deferred per above) and `sinks/index.mdx` (1 remaining snippet, deliberately retained per part 20) are documented exceptions. Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count. + +## 2026-07-03 update, part 26 — `subscriptions/**` fully migrated + +Continued in the same session straight after part 25. Finished all 3 files in `subscriptions/**` (19 snippets, all C#-only — outbox/inbox and event-store subscriptions are .NET-client-specific concepts, no TypeScript equivalent verified or attempted): `explicit-subscriptions.mdx`, `implicit-subscriptions.mdx`, `outbox-inbox.mdx`. + +**Real bugs found and fixed**: +- `implicit-subscriptions.mdx`'s `FulfillmentProjection` example used `.Set(m => m.Status).To(e => "Dispatched")` — the same `.To()`-with-a-constant-instead-of-a-property-accessor bug documented in part 15 (`not-rewindable.mdx`). `.To(...)` only accepts a real event-property accessor expression; a string literal needs `.ToValue(...)`. Fixed both occurrences (`"Dispatched"`, `"Failed"`) in this file — this bug class has now recurred in a completely different topic area, reinforcing it's worth grep-checking (`\.To\(_? *=> *"` or similar) across any remaining unmigrated fluent-projection content. +- `implicit-subscriptions.mdx`'s two assembly-level `[assembly: EventStore("fulfillment-service")]` examples (`AssemblyInfo.cs` and `GlobalUsings.cs` options) could not both be compiled for real in the shared single-assembly validator — `EventStoreAttribute` has `AllowMultiple = false`, and more importantly an assembly-level attribute in this validator would apply to the **entire merged compilation** (hundreds of unrelated event types from every other migrated file), silently reclassifying them under `fulfillment-service` for kernel subscription routing purposes — an invisible, corpus-wide side effect neither option should have. Converted both to plain ` ```text ` fences (matching the part-22 precedent for "genuinely real code that can't safely compile in the shared assembly") rather than fabricating isolated per-file assemblies or skipping the section. + +**Namespace-collision technique used for the first time**: `subscriptions/implicit-subscriptions/event-types-with-assembly-attribute.md` needed to declare bare-named `ShipmentDispatched`/`ShipmentFailed` types (matching the doc's "now types only need `[EventType]`" narrative) without colliding with the already-declared, differently-shaped `SubscriptionsImplicitShipmentDispatched` in a sibling snippet on the same page. Wrapped the declarations in an explicit **block-scoped** `namespace SubscriptionsImplicitAssemblyExample { ... }` (braces, not the `namespace X;` file-scoped form) so the bare names resolve independently. Note the file-scoped `namespace X;` form would have been a real bug here — since all snippet files concatenate into one physical `.cs` file, a file-scoped namespace declaration in one snippet would silently re-scope every subsequent snippet appended after it in the generated file. **Standing rule: never use the file-scoped `namespace X;` form in a snippet that needs an isolating namespace — always use the brace-delimited block form.** + +Legacy count: 74 → **55**. Baseline ratcheted to 55. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), C# validator passing for real. + +**New standing reminders**: (29) **new** — the `.To(event => constant)` vs `.ToValue(constant)` bug (part 15) is not confined to one file; grep for it (`\.To\(\w* *=> *"` or similar non-member-access lambda bodies passed to `.To(`) before trusting any not-yet-migrated fluent-projection content; (30) **new** — never emit a real, compiled `[assembly: ...]` attribute into a snippet file for this validator — the whole corpus merges into one assembly, so an assembly-level attribute from any one snippet silently applies to every other already-migrated snippet's types too; present assembly-attribute examples as ` ```text ` reference fences instead; (31) **new** — when a snippet must declare bare-named types to avoid colliding with a differently-shaped declaration of the same name elsewhere in the corpus, use a brace-delimited `namespace X { ... }` block, never the file-scoped `namespace X;` form (the latter silently re-scopes every later-concatenated snippet in the shared generated file). + +**Remaining backlog** (10 files / 55 snippets): `contributing/kernel/**` (2, likely genuine Kernel-internals — classify before migrating), `hosting/**` (2, `local-certificates` may hit the same Kernel-hosting-dependency wall as `sinks/index`'s retained snippet), `read-models/materialized-pagination` (1, 7 snippets), `tutorial/**` (3, remember the `GetStarted*`-prefix-avoidance note from part 18). `testing/event-append-collection.mdx` (9, deliberately deferred per part 25) and `sinks/index.mdx` (1 remaining snippet, deliberately retained per part 20) are documented exceptions. Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count. + +## 2026-07-03 update, part 27 — `read-models/materialized-pagination.mdx` and `tutorial/**` fully migrated + +Continued in the same session straight after part 26. Finished `read-models/materialized-pagination.mdx` (7 snippets) and all of `tutorial/**` (3 files, 14 snippets) — all C#-only, all reusing already-validated real APIs (`Materialized.GetInstances`/`ObserveInstances` from part 25's `scenarios/real-time-query.mdx`; `[FromEvent]`/`[SetValue]`/`[SetFrom]`/`[RemovedWith]` model-bound projections from earlier parts; `IReactor`/`[OnceOnly]` from part 12). + +**Confirmed the part-18 plan worked**: `get-started/common.mdx` (part 18) deliberately prefixed its library/Book domain as `GetStarted*` specifically so `tutorial/**` could use the clean, unprefixed names (`BookAdded`, `BookId`, `Book`, `BookBorrowed`, `BookReturned`, `BorrowedBook`) — grepped the corpus first to confirm no collision existed, then used the clean names as planned. + +**Real bugs found and fixed**: +- `tutorial/reacting.mdx`'s legacy content declared **the same class name `WaitlistNotifier` three times** (snippets 01, 02, 03) with three different bodies (basic, `[OnceOnly]`, reading state via `IReadModels`) — the same duplicate-declaration-across-a-single-page pattern seen repeatedly all session. Fixed with distinct suffixed names (`WaitlistNotifier`, `WaitlistNotifierOnceOnly`, `WaitlistNotifierWithBookTitle`, `WaitlistNotifierExplicitAppend`, `WaitlistNotifierSideEffect`) — five reactor classes, each independently valid (multiple reactor classes may each declare their own handler for the same event type with no conflict, unlike the earlier `WaitlistNotifier`-redeclared-verbatim bug). +- Both `tutorial/read-model.mdx`'s `IMongoCollection`-based query classes (`Books`, `BorrowedBooks`) needed `using MongoDB.Driver;` added — confirmed `MongoDB.Driver` is already a validator package reference from earlier session work, so no new dependency was needed, just the import. + +Legacy count: 55 → **34**. Baseline ratcheted to 34. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), both C# and TypeScript validators passing for real. + +**Remaining backlog** (6 files / 34 snippets): `contributing/kernel/**` (2, likely genuine Kernel-internals — classify before migrating, may need the same deliberate-legacy treatment as `sinks/index`'s retained snippet), `hosting/**` (2, `local-certificates` may hit the same Kernel-hosting-dependency wall). `testing/event-append-collection.mdx` (9, deliberately deferred per part 25 — needs the heavier `Cratis.Chronicle.XUnit.Integration`/Testcontainers tier) and `sinks/index.mdx` (1 remaining snippet, deliberately retained per part 20) are documented exceptions — together they account for 10 of the 34 remaining snippets. That leaves only `contributing/kernel/**` and `hosting/**` (4 files) as genuinely unstarted work. Re-run `rg -rl 'snippet="legacy/' ../Chronicle/Documentation` and `find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l` for the exact current list/count. + +## 2026-07-03 update, part 28 — MIGRATION EFFECTIVELY COMPLETE: `hosting/**` and `contributing/kernel/contracts.mdx` fully migrated; only 3 deliberately-deferred files remain + +Continued in the same session straight after part 27. Finished `hosting/**` (2 files, 10 snippets) and `contributing/kernel/contracts.mdx` (8 snippets) — all C#-only. This closes out every file in the backlog **except** the three explicitly-classified exceptions documented across parts 20/25 (`sinks/index.mdx`'s Kernel-hosting snippet, `testing/event-append-collection.mdx`, and now `contributing/kernel/patches/index.mdx`). + +### `hosting/aspire.mdx` and `hosting/local-certificates.mdx` + +Reused the exact validated Aspire API surface from `get-started/choose-hosting-model.mdx` (part 18: `AddCratisChronicle()`, `.WithMongoDB`/`.WithPostgreSql`/`.WithMsSql`/`.WithSqlite`, the `AddContainer(...)` substitution for `Projects.MyApi` since that type doesn't exist outside a real solution) and the `Tls`/`ChronicleOptions` pattern from `configuration/tls.mdx` (part 23). No new APIs to verify — pure reuse, fast pass. + +### `contributing/kernel/contracts.mdx` — first genuinely Kernel-internal file migrated (not deferred) + +This page documents `Cratis.Chronicle.Contracts` — the gRPC contract definitions shared between client and kernel (`[ProtoContract]`/`[ProtoMember]`, `[Service]`/`[Operation]` from `protobuf-net.Grpc`, `SerializableDateTimeOffset`, `OneOf` from `Contracts.Primitives`). Classified this as **in-scope for real compilation** (unlike `patches/index.mdx`, see below) because: +- It's about the **shared contract layer** between client and kernel, not kernel-only internals. +- All the types involved (`ProtoContract`, `OneOf`, `SerializableDateTimeOffset`) turned out to be public, straightforward to reference. + +**Validator change**: added `` directly (previously `Contracts.csproj` was only built transitively as a dependency of `DotNET.csproj`/`Testing.csproj`, which does **not** expose its public types to the validator project — confirmed by a real compile failure: `using Cratis.Chronicle.Contracts.Primitives;` failed with `CS0234` even though the DLL was clearly being built in the same solution). This is a lightweight, direct, un-aliased reference — no `extern alias` needed, unlike `Testing.csproj`'s Kernel Core/Concepts references, because `Contracts.csproj` doesn't share ambiguous type names with the client assembly. + +**Real bug found and fixed (not just a snippet bug — a factual prose error)**: the page claimed `SerializableDateTimeOffset` "serializes a `DateTimeOffset` as ticks and UTC offset in minutes." Read the real implementation (`Source/Kernel/Contracts/Primitives/SerializableDateTimeOffset.cs`) — it actually serializes as an **ISO 8601 string** using the round-trip format specifier `"O"` (e.g. `"2024-01-15T12:30:00.0000000+02:00"`), with `Value` typed as `string`, not any tick/offset encoding. Fixed the prose to describe the real serialization format. + +### `contributing/kernel/patches/index.mdx` — classified as deliberately out of scope, NOT migrated + +This page documents the Kernel Core's internal version-patch system: `ICanApplyPatch`, `IStorage` (server-side, not the client `IEventStore`), `SemanticVersion`, `PatchManager` Orleans grain, direct `storage.GetEventStore(EventStoreName.System).Reactors.Rename(...)` calls. This is qualitatively different from `contracts.mdx` — it's genuine **Kernel Core server internals** (the same category `Testing.csproj` needs `extern alias KernelCore`/`KernelConcepts` to safely reference, specifically because Kernel Core and the client assembly have colliding type names in similar namespaces). Wiring this in for real would mean either an aliased reference to `Core.csproj`/`Storage.csproj` (mirroring `EventScenario.cs`'s `extern alias` pattern) or risking real namespace collisions across the whole shared validation assembly. Given the scope and risk versus one page's worth of content, left this deliberately under `legacy/` — matching the same judgment already applied to `sinks/index.mdx`'s Kernel-hosting snippet (part 20) without re-confirming with the user, since it's a natural extension of that established precedent. + +Legacy count: 34 → **17** (7 files migrated this part: `hosting/aspire.mdx`, `hosting/local-certificates.mdx`, `contributing/kernel/contracts.mdx` — 19 snippets total). Baseline ratcheted to 17. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings), both C# and TypeScript validators passing for real. + +### Session-wide summary (parts 20-28, this session) + +Started this session at 210 legacy snippets (post-`constraints/**`, per part 19's baseline) and ended at **17** — a 92% reduction. Fully migrated: `closing-streams`, `coming-from-crud`, `understanding-constraints`, `event-seeding/seeding-with-csharp`, `connection-strings/**`, `namespaces/**`, `compliance/**`, `configuration/**`, `migrations/**`, `scenarios/**`, `testing/**` (except `event-append-collection.mdx`), `subscriptions/**`, `read-models/materialized-pagination`, `tutorial/**`, `hosting/**`, `contributing/kernel/contracts.mdx`. The single biggest scope decision was wiring `Cratis.Chronicle.Testing` + `Cratis.Specifications` into the validator (part 25, explicitly approved), which unlocked the entire in-process testing-scenario family. + +### What remains — genuinely done, or a future session's explicit choice + +**3 files / 17 snippets, all deliberately classified as out of current scope, not overlooked**: +1. `sinks/index.mdx` — 1 snippet (Kernel/Orleans-silo hosting code needing `Storage.Sql` + Kernel Core, part 20). +2. `testing/event-append-collection.mdx` — 9 snippets (needs `Cratis.Chronicle.XUnit.Integration`'s Testcontainers/Docker-backed out-of-process tier, part 25). +3. `contributing/kernel/patches/index.mdx` — 7 snippets (needs aliased Kernel Core/Storage references, this part). + +Each is documented with the specific technical reason and the exact heavier dependency/mechanism it would need. If a future session wants to close these out, each is its own bounded, well-scoped follow-up — not accidental debt. + +**New standing reminder**: (32) **new** — a project being *built* transitively as part of the validator's dependency graph does **not** mean its public types are *visible* to the validator project; only a **direct** `ProjectReference` exposes a project's public API for `using` — confirmed by `Contracts.csproj` (built via `DotNET.csproj`'s dependency chain, but `Cratis.Chronicle.Contracts.Primitives` types were unresolvable until a direct reference was added). When a snippet needs a namespace from a project that "already gets built" in the log output, check compilation for real before assuming it's already accessible. + +**Nothing has been committed** — still deferred pending explicit user request, per the standing instruction carried through this entire multi-session effort. + +## 2026-07-03 update, part 29 — `sinks/index.mdx` fully migrated: the extern-alias pattern for genuine Kernel-internal snippets, established for the first time + +New session, continuing straight from part 28's 3-deliberately-deferred-files state (17 legacy snippets: `sinks/index.mdx` 1, `testing/event-append-collection.mdx` 9, `contributing/kernel/patches/index.mdx` 7). Re-verified the fresh-session commands (git status across all 6 repos, `npm run sync`, `npm run chronicle-client-docs:check`) — matched the handoff exactly, no drift. **Noted, not touched**: `../Chronicle` has unrelated modified/untracked files from a different task (PII manager changes, `Source/Kernel/Core.Specs/Compliance/GDPR/**`, `Integration/Client/for_PIIManager/`, `FINDINGS-readmodel-child-collection-pii-not-encrypted.md`, `NOTIFICATION_DELIVERY_BUG_DIAGNOSIS.md`) — not part of this migration, left exactly as found. + +Asked the user which of the 3 deferred files to tackle; chose `sinks/index.mdx` (1 snippet) as the smallest. **Turned out to need the identical aliasing mechanism as `patches/index.mdx`** — not actually a smaller *risk* category, just a smaller snippet count. Successfully wired it up for real; this establishes a reusable pattern for `patches/index.mdx` if a future session tackles it. + +### The problem: Kernel Core and the client assembly share a root namespace + +`sinks/index/snippet-02`'s content (`siloBuilder.AddChronicleToSilo(chronicle => chronicle.WithSql(options))`) needs `Cratis.Chronicle.Configuration.IChronicleBuilder`/`ChronicleOptions` (Kernel `Core.csproj`) and `Cratis.Chronicle.Setup.SqlChronicleBuilderExtensions.WithSql` (Kernel `Storage.Sql.csproj`) — both have `RootNamespace` = `Cratis.Chronicle`, **the same root namespace as the client `DotNET.csproj`** (already unaliased in the validator). Directly (unaliased) referencing `Core.csproj` risks `CS0433` ("type exists in both A and B") the instant any of the corpus's ~660 already-migrated snippets references a bare type name that happens to collide — this is exactly why `Cratis.Chronicle.Testing`'s own source (`EventScenario.cs` etc.) uses `extern alias KernelCore;`/`extern alias KernelConcepts;` internally, per part 25's precedent. + +### The fix: extern alias, applied for the first time in this validator + +1. **`generate_project()`**: added `Core.csproj`, `Storage/Storage.csproj`, `Concepts/Concepts.csproj`, and `Storage.Sql/Storage.Sql.csproj` as `` entries all sharing **one** `KernelStorageSql` (single alias name, unlike Testing.csproj's two separate aliases, because this validator only ever needs "the non-client-conflicting Kernel slice" as one undifferentiated unit, not to distinguish Core from Concepts internally). +2. **`generate_source()`**: emit a hardcoded `"extern alias KernelStorageSql;"` line immediately after the `#pragma` line and before the sorted `usings` block — `extern alias` must be the first thing in a compilation unit, before any `using`, and the generator concatenates every snippet into one physical file, so this **must** come from the generator itself, never from snippet content (a snippet-embedded `using Alias::Namespace;` line would either get missed by `USING_DIRECTIVE_RE` (no `::` in the pattern) and land illegally inside a method body, or if hoisted, would still need the `extern alias` to already be first-in-file — simplest to keep both fully generator-owned). +3. Added two **hardcoded, alias-qualified global usings** to the `usings` set literal (not sourced from any snippet, so no regex changes needed): `using KernelStorageSql::Orleans.Hosting;` (for `AddChronicleToSilo`'s extension-method syntax — confirmed via `rg` that no snippet anywhere in the corpus already imports plain `Orleans.Hosting`, so zero collision risk) and `using KernelStorageSql::Cratis.Chronicle.Setup;` (for `WithSql` — note the real class `SqlChronicleBuilderExtensions` lives in namespace `Cratis.Chronicle.Setup`, **not** `Cratis.Chronicle.Storage.Sql` as the folder name suggests; verified by reading `Source/Kernel/Storage.Sql/SqlChronicleBuilderExtensions.cs` directly rather than guessing from the project name). +4. **Deliberately did NOT add a using for `Cratis.Chronicle.Configuration`** (would bring `ChronicleOptions`/`IChronicleBuilder` into unqualified scope, colliding with the client's own bare `ChronicleOptions` from `using Cratis.Chronicle;` — used by dozens of already-migrated snippets — instant corpus-wide `CS0104`). Instead, the `BODY_SNIPPETS` prelude for this one snippet types the local fully alias-qualified inline: `KernelStorageSql::Cratis.Chronicle.Configuration.ChronicleOptions options = new();` — no using needed for an inline qualified name reference, so zero global exposure. +5. The **rendered/compiled snippet body itself required zero alias syntax** — since the necessary usings are generator-hardcoded (invisible to the `.md` fence) and the prelude (also invisible, Python-only) declares typed locals, the visible `client-snippets/sinks/index/register-sql-storage.md` fence is exactly the plain, idiomatic code a real reader would write (`siloBuilder.AddChronicleToSilo(chronicle => { chronicle.WithSql(options); });`), identical to the original legacy content — a real reader's own Kernel-host project would never need any alias at all, since they wouldn't also reference the client assembly. + +### Verification + +`python3 Documentation/validate-client-snippets.py` compiled clean (0 warnings, 0 errors) after two iterations (first attempt used `Cratis.Chronicle.Storage.Sql` as the extension method's namespace by assumption — wrong, fixed after re-reading the real source to `Cratis.Chronicle.Setup`). Updated `sinks/index.mdx`'s tab to point at the new `sinks/index/register-sql-storage` snippet id, deleted the superseded `client-snippets/legacy/sinks/**`. Re-ran the full chain: `npm run chronicle-client-docs:check` (C#: 16 legacy snippets, below the then-baseline of 17 — passed), `npm run build` (843 pages, 0 errors, matching the pre-change count), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings, unchanged), and a live dev-server screenshot of `/chronicle/sinks/` confirming the Prerequisites section renders the real C# snippet correctly. Ratcheted `web/chronicle-client-docs.yml`'s `csharp.snippets.legacyBaseline` from 17 to 16. + +Legacy count: 17 → **16**. Only 2 files remain, both still deliberately deferred exactly as classified in part 25/28: `testing/event-append-collection.mdx` (9 snippets, needs the heavier `Cratis.Chronicle.XUnit.Integration` Testcontainers/Docker-backed tier — a different, bigger lift than aliasing, not attempted this part) and `contributing/kernel/patches/index.mdx` (7 snippets, needs the **same extern-alias mechanism** just established here — a future session can very likely reuse the `KernelStorageSql` alias name and pattern directly, extending `generate_project()`'s existing aliased references rather than inventing a new alias, provided `patches/index.mdx`'s specific APIs — `ICanApplyPatch`, `IStorage` server-side, `SemanticVersion`, `PatchManager` grain — resolve from the same `Core.csproj`/`Storage.csproj` reference set already wired in; verify this before assuming, since `patches/index.mdx` may need additional Kernel projects like `Server.csproj` or grain-specific packages not yet referenced). + +**New standing reminder**: (33) **new** — `extern alias` directives in this validator's generated file must be emitted by the Python generator itself (hardcoded), never sourced from snippet `.md` content — they must be the first thing in the compilation unit (before all `using`s), and the generator's existing `USING_DIRECTIVE_RE`-based hoisting mechanism has no way to place a snippet-embedded alias-qualified line at that position. Pair every new Kernel-internal alias with hardcoded global usings for ONLY the specific narrow namespaces needed (never the type-heavy namespace like `Cratis.Chronicle.Configuration` whose bare names collide with client types already used unqualified everywhere) — type those specific locals via inline alias-qualified names in `BODY_SNIPPETS` preludes instead, keeping the visible snippet fence itself alias-free and identical to what a real (non-shared-validator) reader would write. + +**Nothing has been committed** — still deferred pending explicit user request, per the standing instruction carried through this entire multi-session effort. + +## 2026-07-03 update, part 30 — `contributing/kernel/patches/index.mdx` fully migrated: MIGRATION IS NOW COMPLETE except one deliberately-deferred file + +Continued straight from part 29 in the same session (user said "continue" after part 29 finished). Tackled the second-to-last deferred file, `contributing/kernel/patches/index.mdx` (7 snippets) — this turned out to need the same extern-alias mechanism as `sinks/index.mdx`, exactly as part 29 predicted, but with a genuinely new wrinkle worth understanding before touching this area again. + +### What actually collides — narrower than assumed + +Before writing anything, empirically tested (via a disposable `.collision-test/` scratch project built directly against real Chronicle projects, deleted after use — never committed) which Kernel Core/Storage/Concepts types actually collide with the client assembly, instead of assuming everything needs aliasing. Confirmed via real `dotnet build` runs, not guessed: + +- **No collision** (safe to import globally, even unaliased): `Cratis.Chronicle.Patching.ICanApplyPatch`, `Cratis.Chronicle.Storage.IStorage`, `Cratis.Chronicle.Concepts.System.SemanticVersion`, `Cratis.Chronicle.Storage.Observation.Reactors.IReactorDefinitionsStorage`. +- **Genuine collision** (both client and Kernel Core define a same-named type in a same-named namespace path): `Cratis.Chronicle.EventStoreName` (client) vs `Cratis.Chronicle.Concepts.EventStoreName` (Kernel) — confirmed `CS0104`. `Cratis.Chronicle.Events.IEventTypes` (client) vs `Cratis.Chronicle.EventTypes.IEventTypes` (Kernel) — confirmed `CS0104`. `Cratis.Chronicle.Reactors.ReactorId` (client) vs `Cratis.Chronicle.Concepts.Observation.Reactors.ReactorId` (Kernel) — confirmed `CS0104`. + +Kept the already-added `KernelStorageSql`-aliased `Core.csproj`/`Storage.csproj`/`Concepts.csproj`/`Storage.Sql.csproj` references from part 29 exactly as-is (reusing the same alias name, no new project references needed) — added three more generator-hardcoded global aliased usings for the non-colliding namespaces: `using KernelStorageSql::Cratis.Chronicle.Patching;`, `using KernelStorageSql::Cratis.Chronicle.Storage;`, `using KernelStorageSql::Cratis.Chronicle.Concepts.System;` (plus a plain `using Microsoft.Extensions.Logging;`, standard BCL-adjacent namespace, no collision risk). + +**Avoided the `ReactorId` collision entirely without any aliasing**, by leaning on `ReactorId`'s implicit `string → ReactorId` conversion (confirmed in source) and type inference: every place the original legacy content explicitly declared `var newId = new ReactorId(...)` was rewritten to pass a plain `string` directly to `Rename(currentId, newIdValue)`, letting the compiler apply the implicit conversion at the call site — so the bare type name `ReactorId` never needs to appear in the visible code at all. Property-chain access (`reactor.Identifier.Value`) also never needs to name the type, since it flows entirely through `var`/inference. **General technique worth remembering**: a colliding `ConceptAs`-derived type can often be avoided entirely by using its implicit conversion + inference, rather than aliasing it — cheaper than the alias-scoping trick below, when it applies. + +### A new technique: generator-owned namespace+alias wrapper for declaration-style snippets (not just BODY_SNIPPETS) + +`EventStoreName` and `IEventTypes` **do** need explicit type names (`EventStoreName.System` static access; `IEventTypes eventTypes` constructor parameter type) — no inference trick avoids that. First attempt: had each `.md` snippet open its own `namespace Cratis.Chronicle.Patches { using EventStoreName = KernelStorageSql::Cratis.Chronicle.Concepts.EventStoreName; ... }` block directly in the visible fence content (per reminder (31)'s brace-delimited-namespace technique). **This compiled fine but leaked internal validator plumbing into the public docs** — the rendered snippet showed a literal `using EventStoreName = KernelStorageSql::Cratis.Chronicle.Concepts.EventStoreName;` line that would never appear in a real Kernel contributor's own file (they'd just write `using Cratis.Chronicle.Concepts;`, no alias needed, since they don't have the client assembly loaded). Caught this by screenshotting the rendered page and actually reading the visible code, not just trusting "it compiles." + +**Fixed by extending the generator itself**, not the snippets: added `DECLARATION_NAMESPACES` (snippet key → namespace name) and `DECLARATION_NAMESPACE_ALIASES` (namespace name → list of alias `using` lines) dicts to `validate-client-snippets.py`, plus a `wrap_in_namespace()` function called from `generate_source()` for every declaration-style snippet (the `else` branch that used to just do `declarations.append(body)`). The wrapper injects the `namespace X { using Alias = ...; }` shell **around** the snippet's body entirely in Python, invisible to the `.md` file — mirroring exactly what `BODY_SNIPPETS`'s prelude mechanism already does for method-body snippets, just for top-level declarations instead. Rewrote all 7 `.md` files to drop their namespace/alias wrapper text, keeping only the bare class/interface declarations a real contributor would paste into their own file. Re-verified via the generated markdown mirror (`web/dist/chronicle/contributing/kernel/patches.md`) that the rendered snippet is now indistinguishable from ordinary Kernel-internal code. + +**Standing reminder for whoever continues**: (34) **new** — before assuming a whole set of Kernel-only types needs `extern alias` treatment, test each one individually against the client assembly in a scratch project — collisions are per-type (same namespace path AND same simple name), not per-project; most Kernel types (interfaces, records, enums) don't collide with anything client-side, only a handful of deliberately-named ones do (`EventStoreName`, `IEventTypes`, `ReactorId` so far). (35) **new** — a `ConceptAs`-derived Kernel type with an implicit `string`/primitive conversion can often skip aliasing entirely: prefer passing the primitive at call sites and relying on `var`/inference over declaring the bare colliding type name. (36) **new** — when a declaration-style (non-BODY_SNIPPETS) snippet genuinely needs an alias for a colliding Kernel type, do **not** write the `namespace X { using Alias = ...; }` wrapper directly in the `.md` file — it leaks into the rendered docs. Use the generator's `DECLARATION_NAMESPACES`/`DECLARATION_NAMESPACE_ALIASES`/`wrap_in_namespace()` mechanism (added this part) to inject the wrapper invisibly, and always re-verify by reading the actual rendered markdown mirror or a screenshot — compiling clean is not sufficient proof the visible snippet is clean. + +### Content notes + +- `basic-structure.md`, `dependencies.md`, `idempotency.md`, `rename-reactors-example.md` needed `EventStoreName`; `dependencies.md` additionally needed `IEventTypes`. `semantic-logging.md` and `implement-down.md` needed neither (rewritten to avoid explicit type names entirely — e.g. `storage.GetEventStores()` inferred via `var`). +- `spec.md`: rewrote the original illustrative BDD-folder pseudocode (`given.a_my_patch_context`, `/* assertion */` — not real syntax) into a real, compiling `Cratis.Specifications` spec (`Specification`/`Establish`/`Because`/`[Fact]`), mirroring the exact convention already established in `testing/reactors/scenario/email-example.md` and the real Kernel's own `Core.Specs/Patching/for_PatchManager/PatchManagerBehaviorSpecs.cs` (confirmed by reading it directly — the most authoritative example of "how Kernel patches are actually tested in this codebase"). Kept it deliberately simple (reuses `PatchesRollbackPatch` from `implement-down.md`, only needs an `ILogger` mock, no `IStorage`/`IEventStoreStorage` mock chain) to avoid a more elaborate nested-mock setup that would have needed additional collision handling for `IEventStoreStorage`/`ReactorDefinition` with limited illustrative value. +- All 7 snippets prefixed with `Patches*` (or kept `RenameReactors` unprefixed, matching the doc's own "Example: RenameReactors Patch" heading — verified no name collision anywhere else in the corpus first). +- `clients="csharp"` only, unchanged — this content has no other-client equivalent (Kernel-internal). + +Legacy count: 16 → **9**. Baseline ratcheted to 9. Re-verified full chain: `npm run chronicle-client-docs:check` (passed), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings, unchanged), C# validator passing for real, and a screenshot of `/chronicle/contributing/kernel/patches/` confirming every snippet renders as clean, idiomatic Kernel code with zero alias artifacts visible. + +### Migration status: effectively complete + +**Only 1 file / 9 snippets remain, deliberately deferred**: `testing/event-append-collection.mdx` — needs `Cratis.Chronicle.XUnit.Integration`'s Testcontainers/Docker-backed out-of-process tier (a materially bigger, differently-scoped lift than anything aliasing can solve — see part 25's reasoning, unchanged). This is now the **only** remaining item in the entire multi-session migration. If a future session tackles it, expect to wire a genuinely new, heavier dependency (Docker-in-CI) rather than reusing anything from parts 29/30. + +**Nothing has been committed** — still deferred pending explicit user request, per the standing instruction carried through this entire multi-session effort. + +## 2026-07-03 update, part 31 — `testing/event-append-collection.mdx` fully migrated: LEGACY SNIPPET MIGRATION IS NOW 100% COMPLETE + +Continued straight from part 30 in the same session (user said "continue"). Tackled the last remaining deliberately-deferred file, `testing/event-append-collection.mdx` (9 snippets) — the one that needed the heavier `Cratis.Chronicle.XUnit.Integration` Testcontainers/Docker-backed tier, deferred since the very first 2026-07-01 session. **Legacy count: 9 → 0. The entire `client-snippets/legacy/` directory tree has been deleted — nothing remains.** + +### Key realization: "needs Docker" was about *running*, not *compiling* + +Part 25 deferred this file reasoning that wiring it in would need "Docker/Testcontainers available in CI." Re-examined that assumption this part: the shared validator **only ever runs `dotnet build`, never `dotnet test`**. `DotNet.Testcontainers` (the package `Cratis.Chronicle.XUnit.Integration` depends on) is a plain NuGet library — referencing its types (`IContainer`, `ContainerBuilder`, etc.) and even instantiating `ChronicleInProcessFixture` as a **declared type** compiles fine with zero Docker daemon involvement; only *executing* code that calls `.Build()`/`.StartAsync()` on a container needs Docker running. Confirmed via a disposable scratch project (deleted after use) referencing `XUnit.Integration.csproj` directly (unaliased — its own internal Kernel Core/Concepts references are already `PrivateAssets="all"` in its own `.csproj`, so they don't leak into a consumer) — it compiled clean, no `dotnet test` involved, no Docker required. **Asked the user to confirm before adding this new dependency** (per CLAUDE.md's rule on dependency manifests), presenting this exact finding; approved. + +### Finding the real, undocumented boilerplate + +The legacy content's snippets referenced `Specification(fixture)` (non-generic call) and `[Collection(ChronicleCollection.Name)]`, but **neither `ChronicleCollection` nor a non-generic `Specification` exists anywhere in `Cratis.Chronicle.XUnit.Integration`** — grepped the whole framework and found nothing. The real framework class is generic: `Specification`. This was a **genuine, permanently-broken gap in the original doc** — it silently assumed boilerplate that was never defined or explained anywhere. + +Found the answer by reading a **real, working reference implementation** that happens to exist elsewhere in this same working tree — `.claude/worktrees/agent-a2134f4eb8e3f70ec/Integration/DotNET.InProcess/for_EventAppendCollection/**` (a stray git worktree from unrelated parallel work in this shared repo, read-only, never modified). Its project root defines exactly the missing pieces: +```csharp +// Specification.cs +public class Specification(ChronicleInProcessFixture fixture) : Specification(fixture) +{ + public override bool AutoDiscoverArtifacts => false; +} + +// ChronicleCollection.cs +[CollectionDefinition(Name)] +public class ChronicleCollection : ICollectionFixture +{ + public const string Name = "Chronicle"; +} +``` +**This is one-time, per-project boilerplate that belongs to the consuming test project, not the framework** — confirmed this is the missing piece, not a bug in the framework. Added a new **"One-Time Test Project Setup"** section to the `.mdx` (a genuine content fix, not just a snippet fix) explaining this and showing it once; every other example on the page builds on it. The same reference worktree's `for_EventAppendCollection/**` test files (a full, real, working suite covering single events, multiple events, reactor follow-ups, and unique-constraint violations) served as the authoritative pattern for every snippet written this part — read directly rather than guessed, exactly the same "verify against real source" discipline used throughout this migration, just against real *usage* code instead of library source. + +### The "color-color" pattern, confirmed intentional + +The reference code repeatedly does `public EventSourceId EventSourceId; ... EventSourceId = EventSourceId.New();` — a field with the **same name as its own type**, then calling a static method on that name. This is legal, well-defined C# (the "color-color" rule: if `X.Y` doesn't resolve to an instance member access, the compiler retries treating `X` as a type name) — confirmed by reading it working in real code before trusting it, not just assuming a happy accident. + +### Real, new bug classes and fixes found this part + +- **CS8981** (`context` — an all-lowercase type name — "may become a reserved word"): the real reference project's own nested `context` classes hit this too under strict Nullable/analyzer settings; our validator's `TreatWarningsAsErrors=true` surfaces it as a hard error where the real repo's laxer settings do not. Fixed with a **visible** `#pragma warning disable CS8981` / `#pragma warning restore CS8981` pair around the nested class — left visible (not hidden via generator plumbing) because it's genuinely useful, applicable advice for any reader using this lowercase-`context` BDD convention under their own strict warnings-as-errors project. +- **Field-initializer nullability**: added `= null!;` to mutable `EventSourceId`/`IEventAppendCollection` fields (the real reference files skip this since their own project isn't `TreatWarningsAsErrors=true`) — same class of fix as reminder (28). +- Needed one new global hardcoded using (`using Cratis.Chronicle.XUnit.Integration;`) and one incidentally-missing one (`using Microsoft.Extensions.DependencyInjection;`, for `AddSingleton` in `ConfigureServices` overrides) added to the generator's hardcoded set — both collision-free (confirmed no existing snippet already imports either differently). + +### Content structure (9 files, all under `client-snippets/testing/event-append-collection/`) + +Rather than mechanically 1:1 mapping the original 9 legacy snippet fragments (many of which were bare, non-compiling excerpts assuming an unstated shared context), restructured into **3 self-contained worked examples**, matching the real reference project's actual shape: +1. **Simple lifecycle** (`scope-lifetime.md` + `single-event.md`) — a single `ItemRegistered` event, no reactor, teaching the collector lifecycle and basic assertions. `scope-lifetime.md` also carries the one-time `ChronicleCollection`/`Specification` setup (shown once, referenced by every other file in the same namespace). +2. **Multiple events** (`multiple-events.md`) — two directly-appended events (`FirstItemAdded`/`SecondItemAdded`), teaching the LINQ-locate pattern, standalone (no reactor needed for this teaching point). +3. **Reactor follow-up** (`shipment-events-and-reactor.md` + `shipment-given-context.md` + `shipment-spec.md`) — `OrderPlaced`→`ShipmentScheduled` via `ShipmentReactor`, demonstrating `WaitTillActive()`/`WaitForCount()`. **Reused for both the "Waiting for Asynchronous Appends" and "Full Example" doc sections** (both point at the same 3 snippet ids) since the original legacy content's snippet-04 and snippet-09 were, on inspection, textually identical — not two examples, one example shown twice for pedagogical reasons. Consolidating them avoided a duplicate-declaration problem and is more honest about what the page is actually showing. +4. **Constraint violation** (`violation-events-and-reactor.md` + `violation-given-context.md` + `violation-spec.md`) — a reactor whose follow-up event has a `[Unique]`-style declarative constraint; appends the same unique value from two different event sources so the second follow-up violates the constraint, mirroring the real reference's `and_the_constraint_is_satisfied`/`and_the_constraint_is_violated` pair (which cleverly reuses **one** `Because()` run for both outcomes — the first follow-up succeeds, the second on the same value fails). + +All 9 files independently open `namespace Cratis.Chronicle.Docs.EventAppendCollection { ... }` (brace-delimited, matching reminder 31) **directly in the visible snippet text** — unlike part 30's `EventStoreName` alias problem, this namespace wrapper is not internal validator plumbing to hide; it is exactly what a real reader's own test project file would look like (matching the real reference project's own `namespace X.given;`-per-file convention), so showing it is honest, not leaky. + +Verified full chain: `python3 Documentation/validate-client-snippets.py` (0 warnings, 0 errors), `npm run chronicle-client-docs:check` (C#: 0 legacy snippets — at baseline), `npm run build` (843 pages, 0 errors), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel-word warnings, unchanged), and a full-page screenshot of `/chronicle/testing/event-append-collection/` confirming every section renders with real, compiling code. + +**New standing reminders**: (37) **new** — before deferring a snippet migration because it "needs Docker/a live service," check whether the validator actually *executes* anything (ours never does — compile-only) versus merely *referencing* a library that happens to also support running against Docker; a library reference compiles fine without the live dependency. (38) **new** — when legacy doc content references a type/member that doesn't exist anywhere in the framework (`ChronicleCollection`, non-generic `Specification`), before concluding it's fabricated, check whether it's meant to be **one-time consumer-project boilerplate** the doc simply forgot to show — if a real, working reference implementation exists anywhere in the working tree (even in an unrelated stray worktree), read it directly as ground truth rather than inventing the shape from scratch. (39) **new** — a field named identically to its own type, then called like `Field.StaticMethod()`, is legal C# ("color-color" resolution) — don't "fix" this pattern on sight, verify first whether the referenced member is instance or static. (40) **new** — an all-lowercase type name (e.g. a BDD-style nested `context` class) triggers CS8981 under strict warnings-as-errors; wrap it in a visible `#pragma warning disable/restore CS8981` pair rather than renaming away from an established naming convention — this is genuinely useful advice for the reader, not validator-only plumbing, so it's fine to show. + +### Migration status: COMPLETE + +**Zero legacy snippet files remain.** `client-snippets/legacy/` has been deleted entirely (was already empty of files, now the directory tree itself is gone too). `chronicle-client-docs.yml`'s `csharp.snippets.legacyBaseline` is ratcheted to **0**. Every shared Chronicle doc page is now either a real, validated `` multi-client example or genuinely client-specific/language-neutral content — the original objective ("shared Chronicle docs explain Chronicle concepts once… strict audit passes with zero raw client-language fences") is fully met for C#, and TypeScript/Kotlin/Java/Elixir are exactly where prior parts left them (0 legacy baselines, real content where APIs support it, explicit unsupported-workflow tabs where they don't). + +**Nothing has been committed** — still deferred pending explicit user request, per the standing instruction carried through this entire multi-session effort. If a future session is asked to commit/push/open a PR, follow the **ship-changes** skill and review the full diff first — this was a large, multi-week, multi-session effort touching hundreds of files across `Documentation`, `Chronicle`, and the client repos. + +## 2026-07-03 update, part 32 — post-migration navigation audit: found and fixed a serious, previously-invisible sidebar bug + +User asked, after part 31 declared the migration complete: is this ready to merge, does the left sidebar make everything navigable, is anything hidden, and what do the client-specific docs actually hold. Answering this properly required new investigation, not just re-stating prior parts' green-check summaries — and it surfaced a real, sizeable bug that had been present (and unnoticed) for most of this entire multi-session effort. + +### Merge-readiness findings + +- **`../Chronicle` is checked out on `feat/client-pii-key-erasure`** (an unrelated in-progress PII feature branch, 5 commits ahead of `main`, 0 behind) — **not on `main`**. All of this migration's Chronicle-side edits are uncommitted changes sitting on top of that branch. This needs to move to a fresh branch off `main` before it can become a clean, standalone PR — flagged to the user, not resolved (branch surgery is a user decision, not something to do unprompted). +- Two untracked debugging notes at the Chronicle repo root (`FINDINGS-readmodel-child-collection-pii-not-encrypted.md`, `NOTIFICATION_DELIVERY_BUG_DIAGNOSIS.md`) are leftovers from that same unrelated work — not part of this migration, must not be committed with it. +- `Documentation`, `Chronicle.Kotlin`, `Chronicle.Elixir`, `Chronicle.TypeScript`, `../.github` are all clean and on `main`. +- Kotlin/Java/Elixir snippet validators remain **CI-only verified** — no session in this entire migration (including this one) has had those toolchains locally. Worth flagging explicitly every time "ready to merge" comes up, since it's easy to read "0 legacy snippets, validator passed" and forget 3 of the 5 clients' compile step has never actually been watched run locally. + +### THE BUG: 161 broken `.mdxx` hrefs across 34 `toc.yml` files, invisible to every check run all session + +Dispatched a research agent to audit sidebar completeness by reading `sync-content.mjs`'s actual toc-to-sidebar logic (not just screenshotting a page and assuming it's representative). It found: 161 `href:` values across 34 `toc.yml` files under `Chronicle/Documentation/**` end in **`.mdxx`** (double x) instead of `.mdx` — e.g. `href: coming-from-crud.mdxx`, `href: reactors/getting-started.mdxx`. Root cause: a naive string-replace bug from some earlier session's bulk `.md`→`.mdx` rename (`href.replace('.md', '.mdx')` applied to a value that was *already* `.mdx` turns it into `.mdxx`, since `.mdx`'s first three characters literally are `.md`) — confirmed via `git diff`, the corruption exists only in the uncommitted working tree, not in any historical commit, so it was introduced by tooling somewhere in this multi-session effort, not inherited from upstream. + +**Why nothing caught this for the whole session**: `sync-content.mjs`'s `entryToItem()` has an early-return guard, `if (!/\.mdx?$/.test(clean)) return null;`, which silently drops any href failing that regex — `.mdxx` fails it. Critically, this early return happens **before** the `droppedSidebarEntries` counter increment a few lines later (which only fires for hrefs that parse as `.md`/`.mdx` but don't match a real page slug). So the "N broken toc entries dropped" message every `npm run sync` in this whole migration displayed was **only ever counting a different, unrelated pre-existing issue** (a stale Fundamentals `coordinate.md` reference) — the 161 silently-dropped Chronicle sidebar entries never appeared in that count, in any build log, in any lint pass, or in any prior visual QA screenshot (since direct-URL page checks don't exercise the sidebar-building code path at all). Confirmed empirically with a scratch check: every one of the 161 `.mdxx` hrefs, with the extra `x` stripped, resolves to a real, existing `.mdx` file — so the fix is unambiguous, not a judgment call. + +**Fixed**: global `.mdxx` → `.mdx` regex substitution across all 34 files. Rebuilt and visually confirmed — `Reactors` went from effectively empty to showing all 7 real sub-pages; page count unchanged (843, since this only affects sidebar *reachability*, not page generation). + +### Additional real gaps found and fixed (separate from the `.mdxx` bug — genuine missing/orphaned toc entries) + +- **4 sections missing an "Overview" first entry** (every other section has one; these didn't): `concepts/toc.yml`, `contributing/toc.yml`, `contributing/kernel/toc.yml`, `get-started/toc.yml`. Added `- name: Overview / href: index.md(x)` to each. +- **2 genuine ghost/orphan pages**: `get-started/common.mdx` and `get-started/mongodb.mdx` are DocFX `[!INCLUDE]` fragments consumed by `console.mdx`/`worker.mdx`/`aspnetcore.mdx`, but they sat as plain siblings in `get-started/` instead of in a `_snippets`/`_includes` folder (the convention already used correctly elsewhere, e.g. `projections/_snippets/`) — so `walk()` was building them into their own real, reachable-by-URL duplicate pages *in addition to* being inlined elsewhere. Moved both into a new `get-started/_includes/` folder, updated the three `[!INCLUDE]` references to `./_includes/common.mdx` / `./_includes/mongodb.mdx`. Verified the `workbench.png` image `common.mdx` references still resolves after the move (image paths resolve relative to the *including* page's own directory, which didn't move — confirmed by checking the built asset pipeline output). Page count dropped 843→841 as expected (2 fewer standalone pages). +- **Presented 3 further findings as judgment calls rather than deciding unilaterally** (all confirmed via `AskUserQuestion`, all approved): + 1. `hosting/configuration/encryption-certificates.md` — a 42-line, stale, unreferenced-anywhere duplicate of the real, wired, 202-line `hosting/encryption-certificate.md` (older config shape, e.g. references a since-changed `authentication.authority`/`encryptionCertificate.certificatePath` JSON layout). **Deleted** (approved). + 2. `concepts/tagging.mdx`, `concepts/tagging-reactors.mdx`, `reducers/tagging-reducers.mdx` — real, correctly-migrated content (from parts 8/11/14) that was never added to any `toc.yml`, matching a "content-duplication debt" flagged back in part 11 (their content overlaps with the already-wired `projections/tagging-projections.mdx`) and left unresolved since. **Added to nav as-is** (approved) — added `Tagging`/`Tagging Reactors` to `concepts/toc.yml` near the existing "Event Metadata Tags" entry, and `Tagging Reducers` to `reducers/toc.yml`. The underlying content-duplication-across-3-pages issue itself is **still unresolved** — a future session doing a documentation-quality pass (not a snippet migration) should look at consolidating `concepts/tagging.mdx`'s "Tagging Observers" section, `concepts/tagging-reactors.mdx`, and `reducers/tagging-reducers.mdx`, per part 11's original note. + 3. `projections/immediate-projections.md` — explicitly marked `> **Status**: This documentation is in progress and will be updated soon.` in its own body. **Left out of nav** (approved) — respecting the in-progress marker rather than surfacing a stub. + +### One false-positive lead worth remembering + +The research agent's *first* pass at orphan-detection flagged several files (`concepts/tagging.md`, `testing/event-append-collection.md`, `get-started/common.md`, `get-started/mongodb.md` — note: no `x` before `.md`) as orphans, but those were **stale artifacts from the `Documentation/Chronicle` git *submodule*** (which this whole migration has never edited — recall the "Local Layout Gotcha" section below: `sync-content.mjs` prefers the sibling `../Chronicle` clone over the submodule, and this entire multi-session effort has only ever edited the sibling). The agent's own report flagged this inconsistency in its footer, which is what triggered re-verification against the live sibling checkout — confirming those specific files don't exist there at all (correctly superseded by their real `.mdx` counterparts). **Standing reminder for whoever continues**: (41) **new** — when a research agent (or yourself) reports "orphaned"/"missing" files, always confirm against the **sibling `../Chronicle` checkout**, never the `Documentation/Chronicle` submodule — the submodule pointer is stale by design throughout this whole effort and will produce false positives/negatives that look like real findings. + +### Client-specific docs content — confirmed sensible, one real drift flagged (not yet fixed) + +A second research agent read every prose page across all 4 client-specific doc trees (`Chronicle/Documentation/clients/dotnet`, `Chronicle.Kotlin/Documentation`, `Chronicle.Elixir/Documentation`, `Chronicle.TypeScript/Documentation`) plus `contributing/clients/**`. Summary: the "client-specific pages are for installation/host-setup/decorators/troubleshooting only, not concept re-explanation" charter is being followed well — Kotlin/Elixir/TypeScript's `concepts/`/`guides/` files all turned out to already be thin `sharedTopicBridge: true` redirect stubs (not scope creep, as file-count alone might suggest), and the remaining pages are genuine language-specific API surface (e.g. TypeScript's `AsyncLocalStorage`-based identity/correlation/auditing pages have no .NET equivalent, justifying their existence). Two things **not yet fixed, flagged for a future session**: +- **Elixir's `context.md`** (284 lines) duplicates shared correlation/causation concept framing already covered in `events/cross-cutting-properties.md` — the process-scoping mechanics are legitimately Elixir-specific, but the "why this matters" prose isn't. Worth trimming to link out instead. +- **Elixir's `seeding/toc.yml`** exists but is never referenced by the root `Chronicle.Elixir/Documentation/toc.yml` — the whole Event Seeding topic is unreachable in that client's own nav (separate from anything in the Chronicle repo). +- Java has **no `publicDocs` entry at all** in `chronicle-client-docs.yml` (not just `includeByDefault: false`, which only affects snippet-tab defaults) — its entire story rides on code fragments plugged into shared-doc tabs, no dedicated install/troubleshooting page. Confirmed deliberate given its beta/opt-in status, but worth revisiting if/when Java graduates to a default-included client. + +Re-verified full chain after every fix this part: `npm run sync`, `npm run build` (840 pages after all fixes — down from 843 at the start of this part, all decreases accounted for and expected), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing weasel warnings, unchanged), `npm run chronicle-client-docs:check` (still 0 legacy snippets, 0 direct fences, 0 shared-topic overlap, C#/TypeScript validators passing), and visual screenshots confirming the Reactors section, get-started/console page (image intact), and concepts/tagging page all render correctly with working prev/next nav. + +**New standing reminders**: (42) **new** — the sidebar-building code's "broken toc entries dropped" counter is **not comprehensive** — it only counts hrefs that parse as valid `.md`/`.mdx` paths but fail to resolve to a real page slug; a malformed extension (like `.mdxx`) fails silently through an earlier, uncounted code path. Don't trust that counter as a complete signal of sidebar health; periodically grep all `toc.yml` files directly for well-formed `href:` values, or read `entryToItem()`'s actual guard clauses before trusting the count. (43) **new** — "0 errors in build/lint/audit" describes compile/generation correctness, not navigability — a page can build perfectly and be completely unreachable from the sidebar; these are different signals and both need checking when the question is "is this navigable," not just "does this build." + +**Nothing has been committed.** The Chronicle repo's branch situation (item 1 above) needs the user's decision before any commit/PR work begins. + +## Objective + +Make Chronicle documentation language agnostic while still showing usable client SDK examples for: + +- C# / .NET +- Kotlin +- Java +- Elixir +- TypeScript + +The target end state is: + +- Shared Chronicle docs explain Chronicle concepts once. +- Every shared Chronicle client SDK example is switchable through synchronized tabs. +- Each client repo owns and validates its own snippets. +- C# / .NET is treated as a client too, with its own public client page under Client SDKs. +- Client-specific docs are available on the site, but are not used as a dumping ground for equivalent examples. +- Strict audit passes with zero raw client-language fences in shared Chronicle docs. + +Current status (superseded by the 2026-07-01 update above for exact counts): shared Chronicle docs have been converted away from direct client-language fences in the current tree. `npm run audit:chronicle-client-docs:strict` passes with 0 direct client-language fences and 683 `` placeholders. Remaining migration debt is mostly C# legacy snippet ownership: 523 files remain under `../Chronicle/Documentation/client-snippets/legacy/**` (code-analysis is now fully migrated off legacy; see below for the biggest remaining areas). The TypeScript, Kotlin, Java, and Elixir legacy baselines are 0, and public shared-topic client pages are 0. + +Most recent batch migrated `../Chronicle/Documentation/projections/declarative/composite-keys.mdx` from legacy C# snippets to synchronized tabs for C#, Kotlin, Java, Elixir, and TypeScript. C# has real validated examples. Kotlin, Java, Elixir, and TypeScript explicitly render unsupported-workflow snippets because those clients do not currently expose the declarative composite-key projection API. + +The same workstream also migrated duplicate direct-fence pages for: + +- `../Chronicle/Documentation/reactors/event-processing.mdx` +- `../Chronicle/Documentation/reactors/side-effects.mdx` +- `../Chronicle/Documentation/code-analysis/CHR0004.mdx` + +Those pages use validated C# snippets only because they document .NET-specific reactor side-effect return handling and analyzer behavior. + +## Non-Negotiable Model + +Use the `chronicle-client-docs` skill before changing this area. + +Ownership: + +- Shared, language-neutral prose: `../Chronicle/Documentation/**` +- C# snippets: `../Chronicle/Documentation/client-snippets/**` +- .NET-specific public client docs: `../Chronicle/Documentation/clients/dotnet/**` +- Kotlin snippets and public client docs: `Chronicle.Kotlin/Documentation/**` +- Java snippets: `Chronicle.Kotlin/Documentation/client-snippets-java/**` +- Elixir snippets and public client docs: `Chronicle.Elixir/Documentation/**` +- TypeScript snippets and public client docs: `Chronicle.TypeScript/Documentation/**` +- Generated site output: `web/src/content/docs/chronicle/**` + +Do not edit generated files under `web/src/content/docs/chronicle/**`. + +Equivalent examples belong in shared docs with: + +```mdx + +``` + +The same extensionless snippet id must exist in each participating client repo: + +```text +Chronicle/Documentation/client-snippets/events/appending/example.md +Chronicle.Kotlin/Documentation/client-snippets/events/appending/example.md +Chronicle.Kotlin/Documentation/client-snippets-java/events/appending/example.md +Chronicle.Elixir/Documentation/client-snippets/events/appending/example.md +Chronicle.TypeScript/Documentation/client-snippets/events/appending/example.md +``` + +Use `clients="csharp,elixir"` only when the concept genuinely exists for a subset of clients. Use a custom `syncKey` only when tab groups on a page should not follow each other. + +Client-specific public pages are only for genuinely client-specific content such as installation, host setup, decorators or annotations, package behavior, language-specific APIs, framework integration, or troubleshooting. + +## Local Layout Gotcha + +`web/scripts/sync-content.mjs` prefers sibling repos next to `Documentation`, then falls back to submodules inside the Documentation repo. + +In this working tree: + +- Shared Chronicle and C#/.NET snippets are read from sibling `../Chronicle`, not `Documentation/Chronicle`. +- Kotlin, Elixir, and TypeScript client repos are currently available as submodules under `Documentation/Chronicle.Kotlin`, `Documentation/Chronicle.Elixir`, and `Documentation/Chronicle.TypeScript`. +- CI checks out all source repos as siblings, matching the preferred layout. + +When editing Chronicle shared docs locally, edit `../Chronicle/Documentation/**`, not `Chronicle/Documentation/**`, unless the sibling repo is absent. + +## What Has Been Implemented + +Documentation repo: + +- `.gitmodules` + - Added submodules for `Chronicle.Kotlin`, `Chronicle.Elixir`, and `Chronicle.TypeScript`. +- `web/scripts/sync-content.mjs` + - Added `CHRONICLE_CLIENT_SNIPPETS` registry for `csharp`, `kotlin`, `java`, `elixir`, `typescript`. + - Added `CHRONICLE_CLIENT_DOCS` registry for `.NET client`, Kotlin client, Elixir client, TypeScript client. + - Added `` expansion into Starlight `Tabs` / `TabItem`. + - Supports `.md` and `.mdx` snippet files. + - Skips `Documentation/client-snippets/**` as public pages. + - Skips top-level `Chronicle/Documentation/clients` in the shared Chronicle import so `.NET` pages only appear under `/chronicle/clients/dotnet/`. + - Imports client-specific docs under `/chronicle/clients//`. + - Adds Client SDKs to the Chronicle sidebar after Start here. +- `web/scripts/audit-chronicle-client-docs.mjs` + - Checks roots for all 5 clients. + - Checks every shared `` has required snippet files. + - Counts direct shared client-language fences. + - Has ratchet baseline mode and strict mode. +- `web/scripts/chronicle-client-docs-baseline.json` + - Direct client-language fence baseline has been ratcheted to 0. +- `web/package.json` + - Added `audit:chronicle-client-docs`. + - Added `audit:chronicle-client-docs:strict`. + - Added audit to `npm run check`. +- `.github/workflows/docs-site.yml` + - Checks out Chronicle.Kotlin, Chronicle.Elixir, and Chronicle.TypeScript. + - Runs the Chronicle client-docs audit before build. +- `web/src/styles/cratis.css` + - Styles `starlight-tabs[data-sync-key^='chronicle-client']` as a visible segmented language switcher. +- `.ai/skills/chronicle-client-docs/SKILL.md` + - New skill with ownership model, workflow, validation, client-specific page rules, and new-client instructions. +- `.ai/rules/documentation.md`, `.ai/rules/editing-cratis-docs.md`, `.ai/skills/edit-cratis-docs/SKILL.md` + - Updated to enforce language-neutral Chronicle shared docs and client-owned snippets. + +Chronicle repo (`../Chronicle`): + +- `Documentation/get-started/index.mdx` + - Replaced C#-only client-flow/event/projection/reactor examples with ``. +- `Documentation/events/appending.mdx` + - Replaces old `appending.md`. + - Uses snippets for schema validation, occurred timestamp, and example. +- `Documentation/events/toc.yml` + - Points Appending to `appending.mdx`. +- `Documentation/client-snippets/**` + - C# snippets for the current shared examples. +- `Documentation/clients/dotnet/**` + - Public .NET client pages that render under `/chronicle/clients/dotnet/`. +- `Documentation/validate-client-snippets.py` + - Compiles C# snippets against the .NET Chronicle client. +- `.github/workflows/client-snippets.yml` + - Runs the C# snippet validator in CI. +- `Documentation/contributing/clients/index.md` + - Documents the multi-client docs model, snippet ownership, validation, client-specific pages, and migration audit. + +Chronicle.Kotlin, Chronicle.Elixir, Chronicle.TypeScript: + +- Added `Documentation/client-snippets/**`. +- Added `Documentation/validate-client-snippets.py`. +- Added `.github/workflows/client-snippets.yml`. +- Added `.github/workflows/documentation.yml` to dispatch the Documentation repo build when `Documentation/**` changes land on `main`. + +Chronicle.TypeScript also has small docs fixes: + +- `Documentation/compliance/read-models.md` + - Fixed imported link to `/chronicle/projections/`. + - Changed heading punctuation from `DO:` / `DON'T:` to `Do` / `Do not`. +- `Documentation/compliance/pii.md` + - Changed heading punctuation from `DO:` / `DON'T:` to `Do` / `Do not`. + +Contributing repo (`../.github`): + +- `characteristics.md` +- `consuming-pre-releases.md` + +These were cleaned to remove `here` link text so aggregate `lint-docs` can pass. + +## Current Working Tree Notes + +Nothing has been committed. + +There are unrelated pre-existing changes in `../Chronicle`: + +- `Directory.Packages.props` +- `Source/Kernel/Storage.Sql/Storage.Sql.csproj` + +Do not revert those unless explicitly asked. + +The Documentation repo has staged submodule metadata additions from earlier work: + +- `.gitmodules` +- `Chronicle.Kotlin` +- `Chronicle.Elixir` +- `Chronicle.TypeScript` + +There are also many modified submodule pointers for existing product repos. Do not assume they are all part of this change without inspecting. + +## Latest Verification Snapshot + +From `Documentation/web`: + +```bash +npm run chronicle-client-docs:check +npm run audit:chronicle-client-docs +npm run audit:chronicle-client-docs:strict +npm run build +node scripts/lint-docs.mjs +``` + +Results: + +- `npm run chronicle-client-docs:check` passed. + - 5 clients checked. + - C#: 554 legacy snippets. + - Kotlin, Java, Elixir, TypeScript: 0 legacy snippets. + - Public shared-topic client pages: 0 for all clients. + - C# and TypeScript snippet validators passed. + - Kotlin, Java, and Elixir validators were blocked by missing local toolchains. +- `npm run audit:chronicle-client-docs` passed. + - 5 clients checked. + - 680 `` placeholders found. + - 0 direct client-language fences in shared Chronicle docs. +- `npm run audit:chronicle-client-docs:strict` passed. +- `npm run build` passed. + - 837 pages built. + - Existing warnings: + - Mermaid prerender Chrome setup failed, diagrams render client-side. + - Vite chunk-size warning. + - `/404` route conflict warning. + - Astro markdown deprecation warnings. +- `node scripts/lint-docs.mjs` passed with `0 error(s)` and 194 prose warnings. + +Snippet validators: + +```bash +cd /Volumes/sourcecode/repos/cratis/Chronicle +python3 Documentation/validate-client-snippets.py +``` + +Passed for C#/.NET, zero warnings and zero errors. + +TypeScript passed with: + +```bash +cd /Volumes/sourcecode/repos/cratis/Documentation +python3 Chronicle.TypeScript/Documentation/validate-client-snippets.py +``` + +Kotlin validator could not run locally: + +- Missing Java runtime. This blocks both Kotlin and Java snippet validation. + +Elixir validator could not run locally: + +- Missing `mix`. + +Rely on their GitHub workflows unless installing those toolchains locally. + +Visual QA: + +Dev server was started with: + +```bash +cd /Volumes/sourcecode/repos/cratis/Documentation/web +npm run dev -- --host 127.0.0.1 +``` + +Served at: + +```text +http://127.0.0.1:4321/ +``` + +Captured screenshots with: + +```bash +node scripts/screenshot.mjs http://127.0.0.1:4321/chronicle/projections/declarative/composite-keys/ /tmp/chronicle-projections-declarative-composite-keys-light.png light 1440 +node scripts/screenshot.mjs http://127.0.0.1:4321/chronicle/projections/declarative/composite-keys/ /tmp/chronicle-projections-declarative-composite-keys-dark.png dark 1440 +node scripts/screenshot.mjs http://127.0.0.1:4321/chronicle/code-analysis/chr0004/ /tmp/chronicle-code-analysis-chr0004-light.png light 1440 +node scripts/screenshot.mjs http://127.0.0.1:4321/chronicle/code-analysis/chr0004/ /tmp/chronicle-code-analysis-chr0004-dark.png dark 1440 +``` + +Screenshots looked good in both themes. Composite-key tabs are visible and readable, including Kotlin, Java, Elixir, and TypeScript unsupported-workflow tabs. CHR0004 sidebar text matches the page title. The dev server was stopped after QA, and generated `web/src/content/docs/architecture/` was removed. + +## Current Honest Assessment + +The shared Chronicle docs now match the intended language-agnostic model for direct client-language fences: strict audit passes. The remaining debt is cleanup of legacy C# snippet files and placeholders that still point at `legacy/**` ids. Those pages render, but the snippets are still C#-owned legacy artifacts instead of multi-client snippet ids. + +## Next Work To Actually Hit The Mark + +1. Find remaining legacy snippet placeholders: + + ```bash + cd /Volumes/sourcecode/repos/cratis/Documentation + rg 'snippet="legacy/' ../Chronicle/Documentation + find ../Chronicle/Documentation/client-snippets/legacy -type f | wc -l + ``` + + As of 2026-07-01, 133 shared-doc files still reference `legacy/` placeholders (523 backing files), by area: projections (33), events (11), reducers (8), testing (7 — deliberately left as-is, see the 2026-07-01 note above), concepts (7), get-started (6), configuration (6), reactors (5), constraints (5), compliance (4), tutorial (3), subscriptions (3), scenarios (3), namespaces (2), migrations (2), hosting (2), contributing (2), connection-strings (2), plus single-file pockets (understanding-constraints, sinks, read-models, event-seeding, coming-from-crud, closing-streams). `code-analysis` is now fully done (0 remaining) — it was the one genuinely single-client, self-contained batch; everything else in this list needs real Kotlin/Java/Elixir/TypeScript equivalents (or explicit "unsupported workflow" snippets) authored against each client's actual SDK, which is a materially bigger lift per page than code-analysis was. + +2. For each shared Chronicle page with legacy placeholders, classify the example: + + - Equivalent client SDK behavior: replace with ``. + - Genuinely .NET-specific content: move to `../Chronicle/Documentation/clients/dotnet/**` or link to that page. + - Language-neutral content: convert to prose, JSON, YAML, shell, Mermaid, or PDL where appropriate. + - Kernel/framework internals/contributing-only examples: decide whether they are shared Chronicle docs or contributing/kernel docs that can remain C# because they are not client SDK behavior. If they remain, the audit may need an explicit allowlist category instead of counting them as migration debt. + +3. For every replacement placeholder, add snippets in each participating client repo. Use explicit unsupported snippets for clients that do not support the workflow yet. + +4. Update validators if a snippet needs compile-only context: + + - C#: `../Chronicle/Documentation/validate-client-snippets.py` + - Kotlin: `Chronicle.Kotlin/Documentation/validate-client-snippets.py` + - Elixir: `Chronicle.Elixir/Documentation/validate-client-snippets.py` + - TypeScript: `Chronicle.TypeScript/Documentation/validate-client-snippets.py` + + Many body snippets need prelude variables. Add those in each validator's `BODY_SNIPPETS`. + +5. Run validators for changed clients. + +6. Run aggregate checks: + + ```bash + cd /Volumes/sourcecode/repos/cratis/Documentation/web + npm run chronicle-client-docs:check + npm run audit:chronicle-client-docs + npm run audit:chronicle-client-docs:strict + npm run build + node scripts/lint-docs.mjs + ``` + +7. Ratchet `web/chronicle-client-docs.yml` only when the C# legacy count drops. Do not increase any baseline. + +## Important Caution About The Audit + +The strict audit currently counts all direct `csharp`, `cs`, `kotlin`, `java`, `elixir`, `typescript`, `ts`, and `tsx` fences under shared `../Chronicle/Documentation/**`, excluding: + +- `client-snippets` +- top-level `clients` +- `_includes`, `_shared`, `_snippets` + +It does not understand intent. If future direct fences appear, classify them before suppressing anything: migrate them, move them to a better location, change their fence language if they are not SDK examples, or add a precise allowlist with documentation. + +Do not simply suppress broad paths. The whole point is to keep shared Chronicle docs language agnostic. + +## Commands A Fresh Session Should Start With + +```bash +cd /Volumes/sourcecode/repos/cratis/Documentation +git status --short +git -C ../Chronicle status --short +git -C Chronicle.Kotlin status --short +git -C Chronicle.Elixir status --short +git -C Chronicle.TypeScript status --short +git -C ../.github status --short +``` + +Then: + +```bash +cd web +npm run sync +npm run chronicle-client-docs:check +npm run audit:chronicle-client-docs +npm run audit:chronicle-client-docs:strict +``` + +Use the legacy snippet search output to pick the next migration batch. + +## What Not To Do + +- Do not put all language snippets into the Chronicle repo. +- Do not create separate public client sections just to hold equivalent examples. +- Do not edit `web/src/content/docs/chronicle/**`. +- Do not call the legacy migration complete while `../Chronicle/Documentation/client-snippets/legacy/**` still contains referenced snippets. +- Do not revert unrelated changes in `../Chronicle/Directory.Packages.props` or `../Chronicle/Source/Kernel/Storage.Sql/Storage.Sql.csproj`. +- Do not update the baseline upward. It is a ratchet, not a hiding place. diff --git a/.ai/handoffs/chronicle-client-parity-migration.md b/.ai/handoffs/chronicle-client-parity-migration.md new file mode 100644 index 0000000..0626b65 --- /dev/null +++ b/.ai/handoffs/chronicle-client-parity-migration.md @@ -0,0 +1,502 @@ +# Chronicle Client Parity Migration Handoff + +Last updated: 2026-07-04 (part 3 — get-started + tutorial fully done) + +This is a **new, separate effort** from `chronicle-client-docs-migration.md` (that migration — eliminating `legacy/` C# snippets — is complete). This one is about **client coverage/parity**: making sure every shared Chronicle doc page that *can* show all 5 clients (csharp, kotlin, java, elixir, typescript) actually does, and that client-specific doc pages hold only genuinely client-specific content (not duplicated concept explanations). + +## User's directive (verbatim intent) + +Chronicle docs should be **language/ecosystem agnostic at the base** — concepts explained once, with language switchable via ``. Per-language install/setup/deploy content is expected and correct to be client-specific (e.g. .NET hosting content shouldn't appear for Kotlin). But everywhere a real equivalent API exists across clients, the docs should show it — not silently default to C#-only or C#+TypeScript-only because nobody finished the work. Client-specific doc pages (`clients/dotnet/**`, `Chronicle.Kotlin/Documentation/**`, `Chronicle.Elixir/Documentation/**`, `Chronicle.TypeScript/Documentation/**`) should hold ONLY installation/host-setup/decorators/package-behavior/troubleshooting — not re-explanations of shared concepts. + +User chose: **"Everything, broadest scope first"** or (not narrowly get-started/tutorial only, though that's the best-understood starting point) and **"source-verify only"** for new Kotlin/Java/Elixir snippets (no local compiler available — see Environment Constraint below). + +## Environment constraint — read this before writing any Kotlin/Java/Elixir snippet + +This environment has **no Java runtime, no Kotlin compiler, no Elixir/mix** installed (confirmed via `which`/`java -version`/`elixir --version` — all absent). This means: +- C# and TypeScript snippets can still be verified for real via `python3 Documentation/validate-client-snippets.py` (Chronicle) and `python3 Chronicle.TypeScript/Documentation/validate-client-snippets.py` — do this for every batch, same as the whole legacy migration. +- Kotlin/Java/Elixir snippets can ONLY be verified by **reading real SDK source** (interfaces, classes, decorators/annotations, actual method signatures) before writing anything — same "verify against real source" discipline as always, just without the final compile confirmation. **Treat CI as the real compile gate for these three languages** — this was explicitly discussed and accepted by the user as the tradeoff for proceeding without local toolchains. +- Before claiming a Kotlin/Java/Elixir API exists, cite the exact file path + symbol read, not just "should be fine." + +## Where the SDKs live + +- C#: `/Volumes/sourcecode/repos/cratis/Chronicle/Source/Clients/DotNET` (and friends: `Aspire`, `AspNetCore`, `Testing`, `XUnit.Integration`) +- Kotlin/Java: `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle.Kotlin` — Java is JVM-interop over the same annotations/types, not a separate SDK; graded together in the audit below. +- Elixir: `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle.Elixir` +- TypeScript: `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle.TypeScript` + +Shared docs + C# snippets live in the **live sibling checkout** `/Volumes/sourcecode/repos/cratis/Chronicle/Documentation` — NOT `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle` (a stale submodule pointer never edited this whole multi-session effort; a prior investigation got false-positive "orphan" results by accidentally checking the submodule — always double check which one you're in). + +## Scope, quantified (2026-07-03, before this effort started) + +Total shared-doc `` across `Chronicle/Documentation/**` (excluding `client-snippets/**`, `clients/**`): **685**. + +- 478 `clients="csharp"` only +- 152 `clients="csharp,typescript"` only +- 24 show all 5 (or csharp+kotlin+java+typescript, missing only elixir, in 1 case) +- 16 `clients="csharp,elixir,typescript"` (missing kotlin/java) +- 6 `clients="csharp,elixir"` +- 4 `clients="typescript"` only (genuinely TS-only content, e.g. AsyncLocalStorage-based identity/correlation — no C# equivalent claimed) + +Per-directory breakdown (total tabs / csharp-only / csharp+ts-only / full-or-near-full): +| Directory | Total | csharp-only | csharp+ts-only | full/near-full | +|---|---|---|---|---| +| projections | 210 | 82 | 104 | 10 | +| testing | 46 | 46 | 0 | 0 | +| reducers | 48 | 44 | 4 | 0 | +| events | 44 | 34 | 3 | 6 | +| reactors | 34 | 28 | 2 | 4 | +| concepts | 35 | 28 | 7 | 0 | +| code-analysis | 41 | 41 | 0 | 0 | +| get-started | 28 | 17 | 7 | 4 | +| constraints | 21 | 11 | 10 | 0 | +| compliance | 22 | 13 | 9 | 0 | +| contributing | 21 | 15 | 0 | 0 | +| configuration | 21 | 21 | 0 | 0 | +| subscriptions | 17 | 17 | 0 | 0 | +| read-models | 15 | 7 | 2 | 1 | +| tutorial | 14 | 14 | 0 | 0 | +| scenarios | 12 | 12 | 0 | 0 | +| namespaces | 11 | 11 | 0 | 0 | +| hosting | 9 | 9 | 0 | 0 | +| migrations | 9 | 9 | 0 | 0 | +| event-seeding | 7 | 7 | 0 | 0 | +| connection-strings | 5 | 5 | 0 | 0 | +| closing-streams | 2 | 2 | 0 | 0 | +| sinks | 2 | 2 | 0 | 0 | + +**`code-analysis/**` (41 tabs) is almost certainly all-genuine-gap** — it documents C# Roslyn analyzer diagnostics (CHR00xx rules), which are inherently .NET/Roslyn-specific and have no meaning for Kotlin/Elixir/TypeScript. Don't spend time re-auditing this directory; treat as correctly-scoped C#-only unless a specific page's content proves otherwise. + +## Get-started/Tutorial audit findings (done — this is the reference classification for the whole effort's methodology) + +Full per-tab classification already done by a research agent (2026-07-03) reading real SDK source for every restricted tab in `get-started/**` and `tutorial/**`. Full detail was in the agent's report (not preserved verbatim here — re-run a similar audit for other directories rather than trying to recover the exact original text). Headline findings, useful for every other directory too: + +### Confirmed genuinely-C#-only (all missing clients — correctly scoped, don't touch) +- Aspire orchestration (`get-started/choose-hosting-model/**`) — `Aspire.Hosting`/`Cratis.Chronicle.Aspire` is .NET-exclusive; zero equivalent packages anywhere else. +- ASP.NET Core DI/pipeline integration (`get-started/aspnetcore/**`) — no Spring/Ktor/Phoenix/Express Chronicle package exists. +- .NET Generic Host (`get-started/worker/**`) — `Microsoft.Extensions.Hosting` is .NET-exclusive. +- MongoDB BSON casing-convention bridging (`get-started/mongodb/conventions`) — a .NET-`MongoDB.Driver`-specific PascalCase/camelCase mismatch; other ecosystems don't have this specific problem. + +### Under-migrated — real equivalent APIs proven, snippets just never written +- `get-started/common/{a-events,append,reactor}` — Kotlin `@EventType`/`eventLog.append()`/`@Reactor`, Elixir `use Chronicle.Events.EventType`, all proven via already-existing `get-started/client-flow.md`/`test-event.md`/`test-reactor.md` snippets in those repos. +- `get-started/console/connect` — literally already shown for all 5 clients elsewhere (`client-flow.md`); this tab is a pure duplicate that was never opened up to more clients. +- `tutorial/first-event/book-added`, `tutorial/read-model/events`, `tutorial/reacting/notification-sent-event` — basic event type definitions, trivially portable, same shape as already-proven snippets. +- `tutorial/reacting/waitlist-notifier` — Kotlin has real constructor-injection equivalent (`IReactorsService.register(reactor: Any)`, `observation/ReactorsService.kt`); Elixir/TypeScript achievable with ordinary function calls inside the handler (different idiom, same result). + +### Mixed — split verdict per client (need per-client judgment, not blanket treatment) +- `get-started/common/book-read-model` / `tutorial/read-model/book` — **Kotlin/Java: genuine gap** (no literal/conditional `[SetValue]` equivalent; `ISetBuilderFor` only offers `.to()/.toEventSourceId()/.toProperty()` per `IProjectionBuilderFor.kt`). **Elixir: under-migrated** (`set:` DSL supports literals, `read_models/read_model.ex`). **TypeScript: under-migrated** (`setValue()`/`setFrom()` exist, and a TS snippet for this exact concept ALREADY EXISTS at `Chronicle.TypeScript/Documentation/client-snippets/get-started/common/book-read-model.md` but isn't wired into the shared tab's `clients=` list — check this class of "already exists, just not wired" case in every directory before writing new content from scratch). +- `get-started/common/borrowed-book-read-model` / `tutorial/read-model/borrowed-book` — Kotlin/Java: genuine gap (no `RemovedWith` equivalent anywhere). Elixir: under-migrated (`removed_with/2` macro, `read_model.ex:187`). TypeScript: under-migrated (`removedWith()`, `projections/modelBound/removedWith.ts`). +- `get-started/common/query-read-models` — Kotlin/Java: genuine gap, partial (`getInstanceByKey` exists, no "get all instances" method at all, `IReadModelsService.kt`). Elixir: under-migrated (`get_instances/2`/`all/2`, `read_models.ex`). +- `get-started/common/materialized-paging` — Kotlin/Java: genuine gap (no paging API found at all). Elixir: under-migrated (`query/2` with `:page`/`:page_size`, `read_models.ex:341`). +- `tutorial/reacting/reading-state` — Kotlin/Java: under-migrated (`getInstanceByKey` + manual constructor injection both proven). Elixir: under-migrated with caveat (no context injection; call `Chronicle.ReadModels.get_instance_by_id/3` directly against the registered client process, requires the read model be `passive: true`). TypeScript: genuine gap for the injection mechanism specifically (`getInstanceById` exists but reactors are always no-arg-constructed, `Reactors.ts:206` — would need a different snippet shape, e.g. shared client singleton import, not a direct port of the C# constructor-injection pattern). +- `tutorial/reacting/explicit-append` — all three (Kotlin/Java, Elixir, TypeScript): under-migrated, real equivalents proven (`AppendResult.isSuccess` in Kotlin `AppendResult.kt` and TS `eventSequences/AppendResult.ts`; Elixir `Chronicle.append/2` returns `:ok | {:error, term()}`). + +### Confirmed genuine gap, no equivalent anywhere in any of the 4 non-C# clients +- Typed `EventSourceId` wrapper — Kotlin/Elixir/TypeScript all use raw strings for event-source ids, no wrapper-with-factory pattern (`tutorial/first-event/book-id`, `tutorial/first-event/append` — the append tab specifically builds on `BookId.New()`; a plain-string-id variant of the same snippet would be under-migrated instead of a gap). +- `[OnceOnly]` idempotency (`tutorial/reacting/once-only`) — zero hits searching Kotlin/Elixir/TypeScript source for onceonly/once_only/idempoten* patterns. +- Reactor-return-value auto-append convention (`tutorial/reacting/side-effect`) — none of the three auto-append a reactor handler's return value; Kotlin's dispatch loop, Elixir's `dispatch_event/2`, and TypeScript's `Reactors.ts:257` all discard/ignore it. Explicit append (`IEventLog.Append` from inside the handler) is the only path in all three. + +### Borderline, doc-scope choice rather than SDK gap +- `get-started/common/mongo-query`, `tutorial/read-model/{query,borrowed-books-query}` — raw native-MongoDB-driver access bypassing the Chronicle client entirely (explicitly framed that way in prose). Every language has its own MongoDB driver so equivalent snippets are technically writable, but this isn't a Chronicle SDK capability question — low priority, would not require any client SDK verification, just driver-idiom snippets. + +## Client-specific docs duplication findings (done — a second, separate audit) + +Worst offenders found (full list; a research agent read every prose page in all 4 client-specific trees and compared content, not just checked `sharedTopicBridge: true` frontmatter presence): + +1. **Correlation/identity/causation explained 4 times, no cross-reference** — Elixir's `context.md` (284 lines, no bridge) + TypeScript's `correlation.md` (109) + `identity.md` (91) + `auditing.md` (100), none bridged to each other or to a shared doc. **Clearest consolidation candidate in the whole audit.** +2. **Jobs and Webhooks have NO shared doc page at all** — only Elixir (`jobs.md` 111 lines, `webhooks.md` 129 lines) and TypeScript (`jobs.md` 46 lines, `webhooks.md` 64 lines) document them, at very different depths, no shared anchor to bridge to. Kotlin and .NET don't document Jobs/Webhooks at all. **This is a real coverage gap in the shared corpus** (a `Chronicle/Documentation/**` jobs/webhooks concept page needs to exist), not just a client-side duplication problem. +3. **Elixir `event-store-subscriptions.md`** (140 lines, no bridge) re-teaches outbox/inbox/implicit-explicit-subscriptions already owned by shared `subscriptions/{index.md,implicit-subscriptions.mdx,explicit-subscriptions.mdx,outbox-inbox.mdx}`. +4. **Elixir `sinks.md`** (91 lines) vs **TypeScript `sinks.md`** (55 lines) — both re-explain "what is a sink" duplicating shared `sinks/index.mdx`, ~40% depth mismatch, neither bridges. +5. **Elixir `event-stores.md`** (203 lines, no bridge) — its Overview re-explains event store/namespace/multi-tenancy concepts already owned by shared `concepts/event-store.md`/`concepts/namespaces.md` before reaching legitimate Elixir-specific discovery-API mechanics. +6. **Kotlin `get-started/index.md`** (137 lines, no bridge) — explains reactors/reducers as general concepts inline, no links to shared `reactors/`/`reducers/` pages. +7. **Kotlin `reference/annotations.md`**'s `@Pii` entry over-explains the general compliance/encryption-at-rest mechanism instead of just the annotation's own parameters. +8. **Kotlin `reference/configuration.md`**'s "Namespace" section over-explains general tenant isolation with no link out. +9. **`.NET clients/dotnet/getting-started.md`** (71 lines) re-explains general event-sourcing concepts in prose (append→projection/reducer/reactor flow) rather than bridging per-concept; only 2 prose pages total in the whole .NET client tree, neither uses `sharedTopicBridge: true` (0/2, vs Kotlin 14/20, Elixir 12/23, TypeScript 12/20). +10. TypeScript's own bridge convention is inconsistently applied: 12/20 files use `sharedTopicBridge: true` correctly, but `correlation.md`, `identity.md`, `auditing.md`, `sinks.md`, `jobs.md`, `webhooks.md` (6 files) skip it entirely. + +## 2026-07-04 update, part 2 — `get-started/common/**` batch 1 done (7 of 8 tabs) + +Wired real Kotlin/Java/Elixir content (or honest "does not support this workflow yet" stubs for confirmed genuine gaps) into 7 of the 8 `get-started/common/**` tabs: + +- **`a-events`, `append`, `reactor`** (under-migrated, real content added for all 3 new clients): Kotlin/Java `@EventType`/`eventLog.append()`/`@Reactor`, Elixir `use Chronicle.Events.EventType`/`Chronicle.append/2`/`use Chronicle.Reactors.Reactor`. Verified against real source: Kotlin's `ReactorsService.kt` confirms reactors are discovered by **second-parameter type** (like C#), not by method name (unlike TypeScript) — checked directly, not assumed from the single-method `test-reactor.md` precedent. Java requires the existing `BuildersKt.runBlocking(...)`-plus-`Continuation` boilerplate for any snippet touching a suspend function (`append`) — verbose, but it's the established, only way to call Kotlin suspend APIs from Java in this corpus (mirrors `client-flow.md`). +- **`book-read-model`, `borrowed-book-read-model`, `query-read-models`, `materialized-paging`** (mixed verdict): added real Elixir content (`set:`/`removed_with`/`Chronicle.all/2`/`Chronicle.ReadModels.query/2` with `page`/`page_size` — confirmed via `read_model.ex`'s own extensive moduledoc and `read_models.ex`'s `query/2`). Added "does not support this workflow yet" stubs for Kotlin **and** Java (confirmed: `IProjectionBuilderFor.kt` has no literal-value setter, only `SetFrom`; no `RemovedWith` annotation exists anywhere; `IReadModelsService.kt` only has `getInstanceByKey`, no "get all" or paging method at all). TypeScript was already real and wired for these (pre-existing, just needed `clients=` widened to include the 3 new ones). +- Fixed a genuine prose bug found while doing this: the shared tip about wrapping ids in a typed `BookId` said "the tutorial shows exactly that" without scoping — but the typed-id wrapper is a confirmed C#-only capability (see genuine-gap list below), so the tip was factually wrong for Kotlin/Elixir/TypeScript readers now seeing this section. Rewrote it to name the C#-specific pattern explicitly and state plainly that the other three clients use plain string ids. Same fix applied to the `OnLoan`/`RemovedWith`/"get all instances"/paging prose — each now says explicitly which languages lack the capability, rather than silently only-ever-showing C#/TypeScript and leaving the reader to guess why other tabs are simply missing. + +**Not yet done from `get-started/common/**`**: `mongo-query` (native MongoDB driver access, doc-scope choice not an SDK gap — low priority, see plan below). + +Verified: `python3 Documentation/validate-client-snippets.py` (C#, 0 errors — untouched but re-verified since shared prose changed), `python3 Chronicle.TypeScript/Documentation/validate-client-snippets.py` (TypeScript, 0 errors), `npm run chronicle-client-docs:check` (audit passed, confirms every listed client for every touched tab has a real snippet file on disk — this check runs regardless of local toolchain availability, so it's a genuine signal even for Kotlin/Java/Elixir), full `npm run build` (841 pages), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing warnings unchanged), and a live screenshot + markdown-mirror text check confirming Elixir's real content and Kotlin/Java's stub text both render correctly in the actual tabs. + +**Not verified**: Kotlin/Java/Elixir compilation for real (no local toolchain, per the accepted tradeoff). Every API claim in this batch was checked against real SDK source with file path citations (see above) — this is the most that's achievable without CI. If a future session gets access to Kotlin/Java/Elixir toolchains, running `Documentation/Chronicle.Kotlin/Documentation/validate-client-snippets.py` and the Elixir equivalent against this batch specifically would be the highest-value thing to confirm. + +**Remaining in `get-started/**` + `tutorial/**`** (per the part-1 classification): `get-started/console/connect` (pure duplicate, trivial port), `tutorial/first-event/{book-added,book-id,append}`, `tutorial/read-model/{events,book,borrowed-book,query,borrowed-books-query}`, `tutorial/reacting/{notification-sent-event,waitlist-notifier,reading-state,explicit-append,once-only,side-effect}`. Same methodology: verify per-client source, write real content or honest gap-notes, never guess. + +## 2026-07-04 update, part 3 — `get-started/**` and `tutorial/**` are now FULLY DONE (the whole audited section from part 1) + +Finished the rest of `get-started/console/connect` and all of `tutorial/**` (first-event, read-model, reacting — 14 tabs across 3 pages). Combined with part 2's `get-started/common/**` batch, **the entire part-1 audited scope is complete**. + +### `get-started/console/connect` (1 tab) + +Pure duplicate case confirmed correct — wrote real Kotlin/Java/Elixir/TypeScript content mirroring the already-proven `client-flow.md` pattern (connect + get event store + print name), verified `ChronicleOptions.development()`/`getEventStore(name, namespace)` exact signatures per client first (Java needed both args explicit — no `@JvmOverloads` on `getEventStore`, confirmed by grep). + +### `tutorial/first-event.mdx` (3 tabs) + +- `book-added` — under-migrated, real content all 4. +- `book-id` — genuine gap confirmed (typed `EventSourceId` doesn't exist anywhere else) — unsupported stubs, and rewrote the surrounding prose to say so explicitly instead of just silently narrowing the tab. +- `append` — real content for all 4, using **plain generated string/Guid ids** (not a typed wrapper) — matches part-1's note that a plain-id variant of this snippet is under-migrated, not a gap. + +### `tutorial/read-model.mdx` (5 tabs, 2 skipped) + +- `events` (renamed to `a-events` — see bug below), `book`, `borrowed-book` — same mixed-verdict shape as `get-started/common`'s equivalents (Kotlin/Java gap on `[SetValue]`/`[RemovedWith]`; Elixir/TypeScript real). Prose updated to name the gap explicitly at each tab. +- `query`, `borrowed-books-query` — left `csharp`-only, deliberately, per part 1's "borderline, doc-scope choice not an SDK gap, low priority" call (native MongoDB driver access bypassing Chronicle entirely; every language has its own driver, but porting isn't a Chronicle-SDK-parity question). + +### `tutorial/reacting.mdx` (6 tabs) — found and corrected a part-1 classification error via deeper verification + +- `notification-sent-event` — under-migrated, trivial, real content all 4. +- `waitlist-notifier` — under-migrated, real content all 4 (Kotlin/Java: constructor-injected notification interface, real per `IReactorsService.register(reactor: Any)` taking a pre-built instance; Elixir/TypeScript: module-level function call instead of constructor injection, since neither supports reactor DI). +- `once-only` — genuine gap confirmed, all 4 (zero hits for onceonly/once_only/idempoten* patterns anywhere in 3 SDKs' source). +- **`reading-state` and `explicit-append` — corrected from part 1's "under-migrated" to a genuine gap for Kotlin/Java (both) and TypeScript (explicit-append only... actually both)**: part 1 assumed Kotlin/Java could call `getInstanceByKey`/`append` from inside a reactor handler because the SDK *has* those methods. Deeper verification this part found they're both `suspend fun`, while `ReactorsService.kt`'s dispatch loop calls handler methods via plain `fn.call(reactor, event, ctx)` — **not** `callSuspend()` — meaning **reactor handler methods cannot be `suspend` at all**, so they structurally cannot `await` any suspend API. There's no established/safe bridge pattern in this corpus (a nested `runBlocking` inside the dispatch coroutine's own scope is the only technical option and is fragile enough — thread-pool exhaustion risk — that teaching it felt irresponsible without being able to test it). Marked both **unsupported** for Kotlin/Java. Separately, TypeScript reactors are confirmed always constructed via a hardcoded no-arg constructor (`Reactors.ts`, literal `new (reactorType as new () => ...)()`) with no DI mechanism and no established module-singleton workaround anywhere in the corpus — marked `explicit-append` unsupported for TypeScript too (matching `reading-state`, which part 1 had already correctly flagged as a TS gap). **Elixir has neither limitation** (`Chronicle.append/2` and `Chronicle.read_model/3` are plain module functions needing no injected reference, and Elixir reactor handlers are ordinary synchronous callbacks with no suspend/coroutine distinction) — real content for both, verified against `chronicle.ex`'s own moduledoc-documented API. +- `side-effect` — genuine gap confirmed, all 4 (none of the 3 non-C# SDKs auto-append a reactor handler's return value; explicit append is the only path everywhere but C#). + +### Bugs found and fixed while writing this batch + +- **TS decorator-value ordering bug recurred** (the exact class documented in the legacy-migration handoff's reminders 6/15/17): `tutorial/read-model/book.md` (`@setValue(BookAdded/BookBorrowed/BookReturned, ...)`) sorted alphabetically **before** `tutorial/read-model/events.md` (which declares those event classes) — `"book" < "events"` — causing `TS2449: Class used before its declaration`. Fixed with the established technique: renamed to `a-events.md` in **all 4 non-C# client repos plus the C# one** (the snippet id is shared across every `` participant, so the id must be renamed everywhere, not just where the bug manifested), and updated the shared `.mdx`'s `snippet=` attribute to match. +- **Elixir cross-file consistency**: `waitlist-notifier.md` and `reading-state.md`/`explicit-append.md` all reference a `MyApp.NotificationService` stub module — since the Elixir validator concatenates every snippet file into **one** compilation (confirmed by reading `generate_test()`), defining the same module twice across snippet files is a real duplicate-module error. Fixed by defining the stub **once** (in `waitlist-notifier.md`, with both a 1-arg and 2-arg `notify_next_in_line` clause so every caller's arity is covered) and having every other file that needs it just `alias` it, never redeclare it. Same reasoning applied to `MyApp.ReadModels.Book` — `reading-state.md` originally redefined it; fixed to alias the one already declared in `read-model/book.md`. +- **TypeScript `Guid` import path**: initially imported `Guid` from `@cratis/fundamentals` (technically where it's defined) instead of `@cratis/chronicle`, which every existing validated snippet in the corpus actually imports it from (a re-export). Caught by grepping existing precedent before trusting my own assumption — fixed to match established convention. +- **TypeScript `noUnusedParameters`/no-DOM-lib constraints**: `Source/tsconfig.json` has `lib: ["ES2022"]` only (no `"DOM"`) and no `@types/node` dependency — so neither the Web Crypto global `crypto.randomUUID()` nor `import { randomUUID } from 'node:crypto'` type-checks. Used `Guid.create()` (the corpus's own real, already-imported-elsewhere utility) for generating illustrative ids instead of reaching for a runtime API the project's own compiler config doesn't support. + +Verified: C# validator (`validate-client-snippets.py`, 0 errors) and TypeScript validator (0 errors, this is the one that actually caught the decorator-ordering bug for real) both run for real after every batch; Kotlin/Java/Elixir remain source-verification-only per the accepted tradeoff. Full chain re-run after the whole batch: `npm run chronicle-client-docs:check` (0 legacy, 0 direct fences, all snippet files present for every listed client), `npm run build` (841 pages, unchanged), `node scripts/lint-docs.mjs` (0 errors, 195 pre-existing warnings), and a live screenshot + markdown-mirror check of `/chronicle/tutorial/reacting/` confirming every tab (real content and "not supported" stubs alike) renders correctly. + +**New standing reminders for this effort** (distinct from the legacy-migration handoff's list, though related): (1) **new** — before marking a Kotlin/Java snippet "under-migrated" because the SDK *has* the needed method, check whether that method is `suspend` AND whether the calling context (e.g. `ReactorsService.kt`'s dispatch) invokes handlers via plain `.call()` rather than `.callSuspend()` — if so, the handler itself cannot be suspend, and the API is structurally unreachable from that context regardless of whether it "exists." (2) **new** — TypeScript reactors have a hardcoded no-arg constructor (`Reactors.ts`) with no DI mechanism at all; any pattern requiring a reactor to hold an injected reference (an event store, a service) is a TypeScript gap unless the corpus already has an established module-singleton workaround — don't invent one ad hoc. (3) **new** — the Elixir validator merges every snippet file into one compilation (confirmed via `generate_test()`); never redeclare a module (event type, read model, stub service) that another snippet file in the same directory tree already declares — alias/reference it instead, and if multiple call arities are needed across files, add every arity to the ONE shared declaration. + +## Part 4 — `events/**` (concurrency, cross-cutting-properties, event-source-id, getting-events, getting-state, redaction, filtering, observing-appends, appending*, transactions) + +Went through every remaining file directly under `Chronicle/Documentation/events/` (concurrency, cross-cutting-properties, and event-source-id were covered earlier in part 3's tail-end and are not repeated here). Two important repo-layout facts surfaced this part and are worth recording permanently: + +- **Two live checkouts of the C# `Chronicle` repo exist on this machine**: `/Volumes/sourcecode/repos/cratis/Chronicle` (a standalone sibling clone, at a newer commit, with the actual ``-based `events/**` content) and `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle` (a git submodule reference, at an older commit, still showing pre-migration raw C# fences with stale/wrong API shapes). Per `sync-content.mjs`'s `firstExisting()` resolution (sibling path tried first, submodule path as fallback), **the sibling `cratis/Chronicle` wins and is what the site actually builds from** — the submodule copy is inert as long as the sibling exists. Always edit `/Volumes/sourcecode/repos/cratis/Chronicle/Documentation/**`, never `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle/**`. +- **Kotlin, Elixir, and TypeScript have no equivalent standalone sibling** — only `/Volumes/sourcecode/repos/cratis/Documentation/Chronicle.Kotlin`, `Documentation/Chronicle.Elixir`, `Documentation/Chronicle.TypeScript` exist, so for those three clients the submodule-style path *is* the real, active one. This matches every read/write already done in this effort for those three clients — no correction needed there, just confirmed and now written down so it's not re-litigated. + +### `events/getting-events.mdx` (3 tabs) + +- `for-event-source` (C# `GetForEventSourceIdAndEventTypes`) — **Elixir has a real equivalent**: `Chronicle.EventSequences.EventLog.get_for_event_source(id, event_types: [...])`, confirmed via full source read of `event_log.ex` (public `def get_for_event_source/2`, not delegated at the top-level `Chronicle` module but directly callable). Kotlin/Java/TypeScript: confirmed genuine gap — their public `IEventSequence`/`IEventLog` surfaces expose only `append`/`appendMany`/`hasEventsFor` (Kotlin/Java) or those plus `getTailSequenceNumber` (TypeScript); no raw event reading anywhere. Real Elixir snippet written; explicit "does not support this workflow yet" stubs added for Kotlin/Java/TypeScript (asymmetric — Elixir proves the capability isn't inherently C#-only, so the other three needed an honest note, not silent omission); `clients=` widened to all 5. +- `from-checkpoint` (`GetFromSequenceNumber`) — genuine gap for **all** non-C# clients (Elixir's `get_tail_sequence_number` hardcodes `EventTypes: []` with no from-sequence-number equivalent at all; Kotlin/Java/TypeScript have nothing). Left `csharp`-only, no stubs — uniform gap, matches the `concurrency.mdx` precedent (asymmetric gaps get honest stubs; uniform C#-only capabilities don't need clutter). +- `tail` (combines `GetTailSequenceNumber` + `GetFromSequenceNumber`) — same uniform-gap reasoning; left `csharp`-only. Added a cross-reference note pointing to `getting-state` for the tail-*number* alone, which Elixir/TypeScript can do. + +### `events/getting-state.mdx` (3 tabs) + +- `tail` (bare `GetTailSequenceNumber()`) — **Elixir and TypeScript both have real equivalents** (`Chronicle.get_tail_sequence_number()` top-level delegate; TS `IEventSequence.getTailSequenceNumber(eventSourceId?)`). Kotlin/Java: confirmed **no such method exists anywhere** in the Kotlin SDK source (`grep` for `TailSequenceNumber` across all of `Source/` returned nothing) — genuine gap. Real snippets written for Elixir/TypeScript; stubs added for Kotlin/Java; `clients=` widened to all 5. +- `tail-for-event-source` (scoped by event source + event-type filter) — genuine gap for all non-C# clients: Elixir's `get_tail_sequence_number` hardcodes `EventTypes: []` in its gRPC request with no options to override it (confirmed by reading the full function body, not just its doc comment); TypeScript's `getTailSequenceNumber` takes only an optional `eventSourceId`, no event-type filter; Kotlin/Java have no method at all. Left `csharp`-only. +- `tail-for-observer` (`GetTailSequenceNumberForObserver`) — genuine gap, all non-C# clients; nothing resembling observer-aware tail computation exists anywhere. Left `csharp`-only. + +### `events/redaction.mdx` (6 tabs) — confirmed uniform genuine gap, no changes + +Grepped all three non-C# SDK source trees for `redact`/`Redact` — zero hits anywhere (Kotlin, Elixir, TypeScript). Redaction is a C#-only capability today across the board. Left entirely as `csharp`-only; no stub files (uniform gap, not asymmetric). + +### `events/filtering/**` (3 files: by-event-source-type, by-event-stream-type, by-tag; 6-7 tabs total) — confirmed uniform genuine gap, no changes + +These declare `[EventSourceType]`/`[EventStreamType]`/`[FilterEventsByTag]` on a **reducer or reactor** to filter which events it's dispatched. Checked each non-C# client's reactor registration mechanism directly: Kotlin's `@Reactor` annotation has only an `id` property (`Reactor.kt`); Elixir's `use Chronicle.Reactors.Reactor` + `@handles` only declares event types, no source-type/stream-type/tag filter option (confirmed via the macro's full moduledoc); TypeScript's `reactor()` decorator takes only `id` and `eventSequenceId` (the `FilterTags`/`EventSourceType`/`EventStreamType` fields visible in `Reactors.ts`'s internal registration payload are hardcoded "no filter" defaults, not exposed as decorator parameters anywhere). Confirmed uniform C#-only gap across all three attributes/tabs. Left as `csharp`-only, no stubs. + +### `events/observing-appends.mdx` (4 tabs: shape, subscribing, wait-for-completion-append, wait-for-completion-append-many) — confirmed uniform genuine gap, no changes + +This is Chronicle's `IEventSequence.AppendOperations` Rx.NET-style `IObservable` plus the `WaitForCompletion()` extension from `Cratis.Chronicle.Observation` — grepped all three non-C# SDKs for any append-observable or wait-for-completion concept; zero hits (TypeScript's generated gRPC contracts do define `WaitForObserverCompletionRequest`/`Response` types internally, but there is no public wrapper function anywhere in `Source/` exposing them — confirmed by searching the actual hand-written `Source/` tree, not the generated `.contracts` package). Left `csharp`-only, no changes. + +### `events/appending.mdx`, `appending-many.mdx`, `appending-with-tags.mdx`, `transactions.mdx` — already fully migrated, no changes needed + +All tabs in these four files are already at `clients="csharp,kotlin,java,elixir,typescript"` except `appending.mdx`'s `occurred` tab, which is correctly `csharp,elixir`-only (Kotlin/Java/TypeScript `AppendOptions` types confirmed via source to have no `occurred`/timestamp-override field at all — genuine gap, already correctly scoped from earlier work, re-verified this part and left untouched). + +### Verification + +Ran the full chain after this batch: `npm run sync` (670 `ChronicleClientTabs` placeholders now, up from before this part's additions; 1 pre-existing broken toc entry unrelated to this work — no toc.yml files were touched this part), `npm run chronicle-client-docs:check` (0 legacy snippets across all 5 clients, 0 direct fences, C# validator passed for real, TypeScript validator passed for real, Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (841 pages, clean), `node scripts/lint-docs.mjs` (0 errors, 196 pre-existing style warnings), and a markdown-mirror spot check of `dist/chronicle/events/getting-events.md` confirming the Elixir tab shows real code and the Kotlin/Java/TypeScript tabs render the exact `does not support this workflow yet` stub text. + +**events/** is now fully audited — every remaining gap is a confirmed, source-verified genuine SDK limitation (not an oversight), and every asymmetric gap (where at least one non-C# client can do something the others can't) has an honest inline prose note plus an explicit stub tab. No further events/** work is needed unless the underlying SDKs gain new capabilities. + +## Part 5 — `reactors/**` and `reducers/**` + +### Key finding: reducers are a real, working SDK feature in Kotlin and Elixir — just never wired into the shared docs + +Before this part, `reducers/getting-started.mdx` only listed `csharp,typescript`, which reads as "Kotlin/Java/Elixir don't have reducers." That's wrong — confirmed via source that Kotlin (`observation/Reducer.kt`, `ReducersService.kt`, `IReducersService.kt`) and Elixir (`reducers/reducer.ex`) both have complete, working reducer implementations. This was a genuine **under-migration**, not a gap — the biggest finding of this part. + +- **Kotlin/Java dispatch** (`ReducersService.kt`): handler methods take `(event)` or `(event, current)` — **no `EventContext` parameter, ever** (dispatch is hardcoded to `params.size == 2` or `3`, and the 3rd param is always `current`, never context). Return value **is** the new read-model state (unlike reactors, where the return value is ignored) and read-model type is inferred from the first handler's return type. Non-suspend only (`fn.call`, not `callSuspend`). +- **Elixir dispatch** (`reducer.ex`): a single `reduce/3` callback per module, pattern-matched per event struct, `use Chronicle.Reducers.Reducer, model: ReadModelModule, id: "..."`. **Does** receive a context map as the 3rd argument (`event_source_id`, `sequence_number`, `occurred`, `observation_state`) — richer than Kotlin/Java here. +- **TypeScript dispatch** (`Reducers.ts` line ~327): `await reducerInstance[entry.methodName](content, currentState)` — **2 args only, no context**, but the call **is** awaited, so an `async` handler works (unlike Kotlin/Java, which can't await). Confirmed by reading the actual dispatch loop, not just the type signature. + +Wrote real Kotlin/Java/Elixir content (matching each language's real constraints above) and widened `clients=` to all 5 for: `reducers/getting-started.mdx`'s `read-model`, `reducer-implementation`, `retrieving-state` tabs, `reducers/index.mdx`'s and `reactors/index.mdx`'s `basic-example` tabs (both were also stuck at `csharp,typescript` despite Kotlin/Elixir having no blocker), and `reactors/event-processing.mdx`'s `event-context` tab (a plain event+context handler — every client supports this, confirmed via each one's real dispatch mechanism). + +### `reducer-attribute` — a genuine TypeScript under-migration caught mid-investigation + +`reducers/getting-started.mdx`'s `reducer-attribute` tab was `csharp`-only, but TypeScript's `reducer(id, eventSequenceId, readModel)` decorator already supports both `id` and an explicit event sequence — confirmed via `reducer.ts`'s real signature. Wrote the TS snippet and widened to `csharp,kotlin,java,elixir,typescript` (Kotlin/Java/Elixir get `id`-only, since none of their reducer registration options include an event-sequence override — confirmed via `Reducer.kt`'s annotation properties and the Elixir moduledoc's documented `use` options). Same finding applied to `reactors/event-sequence.mdx`'s equivalent `reactor-attribute` tab, which was already correctly `csharp,typescript` from earlier work — re-verified, no change needed there. + +### Confirmed uniform genuine gaps (no widening, honest `:::note` added instead of silent omission) + +- **`reactors/side-effects.mdx`** (12 tabs) — auto-appending a reactor's return value as a side-effect event is C#-only. Kotlin/Java ignore the return value entirely (confirmed in `ReactorsService.kt`'s dispatch, which never reads `fn.call()`'s result); Elixir's `handle/2` return (`:ok`/`{:error, _}`) is a success/failure signal, not an event; TypeScript's dispatch doesn't inspect the return either. All 4 other clients append follow-up events by calling the append API explicitly inside the handler instead. +- **`reactors/once-only.mdx`** — `OnceOnly` attribute has no equivalent anywhere else; side effects must be designed idempotent instead (already the documented convention elsewhere). +- **`reactors/filtering.mdx`, `reducers/filtering.mdx`** (observer-level `[FilterEventsByTag]`/`[EventSourceType]`/`[EventStreamType]`) and **`reactors/external-event-store-subscriptions.mdx`, `reducers/external-event-store-subscriptions.mdx`** (`[EventStore]`-attribute-driven inbox routing) — confirmed C#-only via the same reasoning as `events/filtering.mdx` in part 4 (no filter/EventStore attribute concept anywhere in Kotlin's `@Reactor`/`@Reducer`, Elixir's `use ... Reactor/Reducer`, or TypeScript's `reactor()`/`reducer()`). +- **`reactors/event-sequence.mdx`, `reducers/event-sequence.mdx`** (the standalone `[EventSequence]`/`[EventLog]` attributes, as opposed to the inline parameter already covered above) — C#-only; TypeScript reaches the same end result through the inline decorator parameter instead of a separate attribute, so it's not a capability gap for TS, just a different syntax already shown elsewhere on the same page. +- **`reactors/event-processing.mdx`**'s `dependencies`/`read-model-key` tabs and **`reducers/passive-reducers.mdx`** (`isActive`/`[Passive]`) and **`reducers/tagging-reducers.mdx`** (`[Tag]`/`[Tags]`) — all confirmed C#-only; none of the other 3 clients' registration mechanisms expose DI-resolved extra parameters, an active/passive toggle, or tagging. +- **`reactors/event-processing.mdx`**'s `supported-signatures` tab and **`reducers/event-processing.mdx`**'s 13 non-context pattern/signature tabs (method-discovery, basic-sync/async patterns, first-event, the 5 "processing pattern" tabs, skip-invalid-state, recording-errors, minimize-object-creation, reuse-collections) — **left as a single C# reference with one clarifying `:::note` per page** rather than translated 4x each. Rationale (a deliberate scope call, not a gap-avoidance shortcut): these are elaborations/idioms on top of a mechanism already fully and correctly shown per-client in `getting-started.mdx` — porting 13+12 pattern tabs across 4 languages each would be ~100 more snippet files for illustrative variations on `with`-expression-style immutable updates, not new API surface. The note tells the reader exactly which primitives differ per client (context availability, async support) and points back to `getting-started` for the real per-client shape. + +### Verification + +`npm run sync` (670 placeholders, unchanged — this batch only widened `clients=` on existing tabs and added 2 new snippet files' worth of content to already-existing single-tab pages, no brand-new `` lines), `npm run chronicle-client-docs:check` (0 legacy, 0 direct fences, C# and TypeScript validators passed for real, Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (841 pages, clean), `node scripts/lint-docs.mjs` (0 errors, 196 pre-existing warnings), and a markdown-mirror spot check of `dist/chronicle/reducers/getting-started.md` confirming the Elixir tab renders real, correct code. + +**Standing reminder added**: before concluding "this client doesn't support X" from a `clients=` attribute alone, **check the actual source** — a narrow `clients=` list can just mean "nobody wired this tab yet," not "the SDK can't do it." This part's `reducers/getting-started.mdx` (`csharp,typescript` for a feature Kotlin and Elixir both fully support) is exactly that trap, and it was sitting in the single most-visible reducers page. + +## Part 6 — `concepts/**` + +Went through all 20 files in `Chronicle/Documentation/concepts/`; 14 are pure prose (`.md`, no ``) and needed no work. Of the 6 with tabs: + +### Under-migrated, fixed with real content + +- **`event-type-migrations.mdx`** (5 tabs, was `csharp,typescript`) — **Elixir has a full, real migration system** (`Chronicle.Events.Migration` behaviour + `MigrationBuilder` with `rename_property`, `default_value`, `split_property`, `combine_properties` — confirmed via source and its own test file for the exact 0-based `part` index convention). Wrote real Elixir content for all 5 tabs (defining-migrations, split, combine, rename, default-value), widened to all 5 clients. Kotlin/Java confirmed a genuine, total gap — zero hits for "migration" anywhere in the Kotlin SDK source — added stubs and a clarifying note. +- **`designing-read-models.mdx`** (2 tabs, was `csharp,typescript`) — `strongly-consistent` (a plain `getInstanceByKey`/`read_model` call) is real everywhere; wrote Kotlin/Java/Elixir content reusing the same pattern established in part 5's reducer work. `query-ladder` (get-all-instances + materialized paging) — **Elixir has both** (`Chronicle.all/1` for strongly-consistent replay-all, `Chronicle.ReadModels.query/2` for materialized paging with `page`/`page_size`, confirmed via `read_models.ex`); wrote real Elixir content and widened. Kotlin/Java confirmed a genuine gap — `IReadModelsService` has only `getInstanceByKey`, no bulk/paged read at all. +- **`modeling-events.mdx`** (3 tabs, was `csharp`-only) — these are pure event-shape illustrations (fact-vs-intent naming, single-purpose events, no-nullable-property pattern) with no framework-API dependency at all, so genuinely portable to every client. Wrote real Kotlin/Java/Elixir/TypeScript content and widened all 3 tabs to all 5 clients. +- **`subject.mdx`** (3 tabs, was `csharp`-only) — **Elixir's `append/3` has a real `:subject` option** (confirmed in `event_log.ex`'s moduledoc and the request-building code). Wrote Elixir content for `default` and `explicit`, widened those two, added Kotlin/Java/TypeScript stubs (asymmetric — Elixir proves it isn't inherently C#-only). The third tab, `implicit-with-attribute` (a `[Subject]` property attribute for auto-derivation), stays C#-only — confirmed no such attribute exists in Elixir's `EventType` macro either, so it's still a genuine gap even though Elixir supports the *explicit* subject path. + +### Confirmed uniform genuine gaps (no widening, `:::note` added) + +- **`geospatial.mdx`** — `Point`/`LineString`/`Polygon` from Cratis.Fundamentals have zero equivalent in any of the other 3 client SDKs (grepped all three; no geospatial type anywhere). +- **`tagging-reactors.mdx`** — same `[Tag]`/`[Tags]`-on-observer mechanism already confirmed gap in part 5 for reducers; applied the identical note to the reactor version. +- **`tagging.mdx`** (14 tabs) — mostly the same static-tag/observer-tag mechanism (uniform gap), **except** dynamic append-time tags: Elixir's `append/3` supports a real `:tags` option (confirmed in the moduledoc), so wrote real Elixir content for `dynamic-event-tags` (noting in the snippet comment that the resulting tag count differs from C#'s example, since Elixir can't add the *static* tags that C#'s version of the same event also carries) and widened that one tab; Kotlin/Java/TypeScript get stubs (no `tags` append option in any of the three, confirmed via each language's `AppendOptions`/append signature). One consolidated `:::note` at the top of the page covers the remaining 13 static-tag/observer-tag/context tabs rather than 13 individual notes. + +### Verification + +`npm run sync`, `npm run chronicle-client-docs:check` (0 legacy, 0 direct fences, C# and TypeScript validators passed for real — this is what actually confirmed the new Elixir-inspired TS content already existed correctly and the new C#-side widening didn't break anything), `npm run build` (841 pages, clean), `node scripts/lint-docs.mjs` (0 errors, 196 pre-existing warnings), and a markdown-mirror spot check of `dist/chronicle/concepts/event-type-migrations.md` confirming the new Elixir tab renders real, correct code. + +### What's left in this effort + +- [x] `get-started/**` + `tutorial/**` +- [x] `events/**` +- [x] `reactors/**`, `reducers/**` +- [x] `concepts/**` +- [x] `projections/**` (largest section — 210 tabs, done via 3 parallel agents — see part 7) +- [x] `constraints/**`, `compliance/**`, `testing/**`, `scenarios/**` (see part 8) +- [x] `subscriptions/**`, `read-models/**` (see part 9) +- [x] `namespaces/**`, `hosting/**`, `migrations/**`, `configuration/**`, `connection-strings/**`, `event-seeding/**`, `closing-streams/**`, `sinks/**`, `contributing/**` (see part 10 — mostly confirmed out of scope/uniform-gap, one real Elixir win) +- [x] `code-analysis/**` — confirmed genuinely C#/Roslyn-only (41 tabs, all `csharp`; the page's own first sentence already says "for the .NET client"). Zero changes needed, no verification re-run required. +- [x] **The entire client-tabs sweep of `Chronicle/Documentation/**` is now complete** — every directory with `` has been audited at least once. +- [x] **Client-specific docs consolidation — fully done.** New shared Jobs/Webhooks page (part 11); correlation/identity/causation new shared page + trims, including a bonus discovery of Kotlin's fully-undocumented correlation/identity/causation system (part 12); Elixir sinks/event-stores/subscriptions link-adds, Kotlin inline-concept links, TypeScript sharedTopicBridge cross-links, the dotnet-client placement question (decided: leave in place), and three new client-specific Seeding pages after discovering Kotlin/TypeScript also have real seeding support (part 13). + +**The chronicle-client-parity-migration effort is complete** — see part 13's closing summary. + +## Part 7 — `projections/**` (210 tabs, done via 3 parallel agents) + +Given the size of this section, split the work across 3 parallel agents by subfolder rather than working it sequentially: **declarative** (`projections/declarative/**`, the fluent `IProjectionFor` builder), **model-bound** (`projections/model-bound/**`, the attribute-based `[FromEvent]`/`[SetFrom]` style), and **PDL + standalone** (`projections/projection-declaration-language/**` plus `architecture.mdx`, `choosing-a-read-model-style.mdx`, `eventual-consistency.mdx`, `filtering.mdx`, `nested-objects-design.mdx`, `tagging-projections.mdx`). Each agent was briefed with the full established methodology from parts 1-6 (source-verify only, real content for under-migrated, honest stubs for asymmetric gaps, one `:::note` for uniform gaps, never fabricate an API) plus prior findings relevant to their scope. + +### Key capability findings (source-verified by the agents) + +- **Kotlin/Java's fluent declarative builder** (`IProjectionBuilderFor`) is extremely minimal — only `from(EventClass) { set(prop).to(event) }`. No children, joins, `fromEvery`, composite/constant keys, event-context access, functions, not-rewindable, or passive support. Java additionally can't practically call the API via JVM interop at all (no Java-friendly overload for the `KClass` parameter) — Java is a stub across nearly this entire subfolder. +- **Kotlin/Java's model-bound (attribute) builder** only has `@FromEvent`/`@SetFrom` — no `AddFrom`/`SubtractFrom`/`SetFromContext`/`FromEvery`/`ChildrenFrom`/`Join`/counters/`RemovedWith`/`ConstantKey`/`NotRewindable`/`Passive`/`SetValue`. But since AutoMap is unconditionally enabled at kernel-registration level (confirmed in `ProjectionsService.kt`), bare `@FromEvent` convention-based mapping (no `@SetFrom` needed) fully works — this was a real **under-migration** (docs said gap, source said it works), not a genuine gap. +- **Elixir's projection DSL** (`read_model.ex`/`projection.ex`) is genuinely rich: `from`/`join`/`removed_with`/`from_every` with real constant-key, count/add/subtract, event-context (`:occurred`, `"$context.x"`), and passive-projection support. Real limits found: `IsRewindable` and the event sequence are hardcoded (no override), literal non-numeric constants (strings/atoms) have no safe expression path (only integers), and `from_every` registration silently drops all but the first declaration per module (a genuine limitation, documented rather than guessed at). +- **TypeScript's declarative builder** has several methods that exist and type-check but literally `throw new Error('... not implemented yet.')` at runtime (`children()`, `nested()`, `usingCompositeKey()`, `add()`, `subtract()`, `count()`) — confirmed by reading the actual method bodies, not just the type signatures. These were stubbed with the exact runtime-error wording rather than the usual "does not support this workflow yet" marker, since the method technically exists but throws. +- **PDL** (Projection Declaration Language) tabs mostly show the event/read-model *shapes* PDL operates on (language-agnostic PDL text itself isn't a per-client concept) — these ported to all 5 clients easily. The one genuinely client-API-specific PDL tab, ad-hoc PDL-string querying (`IProjections.Query()`), is a confirmed uniform C#-only gap (no client but C# supports registering/running an ad-hoc PDL string at runtime). +- `projections/architecture.mdx`'s counting example, `choosing-a-read-model-style.mdx`'s constant-value assignment, `eventual-consistency.mdx`'s `watch()`/observable read models, and `nested-objects-design.mdx`'s `@Nested`/`@ClearWith` were all found to be asymmetric (real in 1-2 non-C# clients, gap in others) and handled tab-by-tab rather than page-wide. + +### Scale of the change + +Roughly 20-25 tabs across the three scopes got real new content per applicable client (several each for Kotlin, Java, Elixir, TypeScript); roughly 200+ honest stub files were added for confirmed asymmetric gaps (mostly Kotlin/Java, since Elixir and TypeScript's declarative/model-bound surfaces are each individually much richer); a smaller number of whole-page or per-tab `:::note[Client coverage]` blocks were added for uniform gaps (composite keys, children/nested without AutoMap, PDL ad-hoc querying, `[Tag]`-based projection tagging). + +### Bugs the agents hit and fixed + +- An Elixir `defstruct [:a], b: 0` two-argument call is a syntax error — Elixir's `defstruct` only accepts a single list/keyword-list argument; fixed to one combined list. +- The recurring TypeScript cross-file `Guid` import inconsistency (`@cratis/fundamentals` vs the corpus-standard `@cratis/chronicle` re-export) resurfaced in 3 files from different agents editing near each other — all fixed to the standard import. +- A stub file's marker text didn't exactly match the validator's required substring (`"does not support this workflow yet"`), which meant the TypeScript validator tried to actually compile it — fixed the wording. +- A doubly-nested TypeScript `.nested(m => ...)` fluent chain lost generic type inference; fixed with explicit `.nested(...)` type arguments. +- One Elixir snippet referenced a field on the wrong event (a copy-paste slip caught before it shipped, not after). + +### Verification + +Each agent ran the full chain independently; after all three finished, ran the combined chain once more myself (since three agents mutated overlapping shared state — the `chronicle-client-docs.yml` config, the sync pipeline, etc. — concurrently): `npm run sync` (322 chronicle pages, 1 pre-existing broken toc entry, unrelated), `npm run chronicle-client-docs:check` (670 placeholders, 0 legacy, 0 direct fences, **C# validator passed**, **TypeScript validator passed** — this is the real confirmation that ~250+ new/changed snippet files across all three agents compile for real — Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (841 pages, zero errors), `node scripts/lint-docs.mjs` (0 errors, 196 pre-existing warnings), and spot-checked rendering in `dist/chronicle/projections/declarative/auto-map.md` (Elixir tab renders real code) and `dist/chronicle/projections/model-bound/` (17 files contain real Kotlin content). + +**Standing reminder added**: for a section this large, splitting work across parallel agents by natural subfolder boundary (declarative vs model-bound vs PDL) worked well and didn't produce conflicts, because each agent's file scope was disjoint — but always re-run the FULL verification chain yourself after all agents finish, since shared config/build state can still interact even when file scopes don't overlap. + +## Part 8 — `constraints/**`, `compliance/**`, `testing/**`, `scenarios/**` (partial: `subscriptions/**`/`read-models/**` still open) + +Note on process: a first attempt to delegate `constraints/**`+`compliance/**` to an agent **failed** — it hit a hard session/usage limit and returned an error instead of doing any work (confirmed via `git status`/file timestamps: zero files touched). Redid this batch directly instead of re-delegating. Lesson: after delegating, always verify the agent's *claimed* work actually landed on disk before trusting a completion report — this one didn't even get that far, but a partially-completed agent could plausibly claim more than it did. + +### Key finding: constraints are a real, working feature in Kotlin, Java, and Elixir — narrower than C#, but genuinely there + +Confirmed via source (`io.cratis.chronicle.constraints/*.kt`, `chronicle/events/constraints.ex`, `Chronicle.TypeScript/Source/events/constraints/*`) that all three non-C# clients have a working `IConstraint`/unique-constraint mechanism — another case like reducers (part 5) where the shared docs read as C#-only/C#+TS but the SDKs support more than that. + +- **Kotlin's `IConstraintBuilder`/`IUniqueConstraintBuilder`** is real but narrower than C#'s: `on(eventClass, property)` + `ignoreCasing()` + `withMessage(string)` work; there is **no `RemovedWith`, no `WithName`, and no multi-event chaining** (the concrete `UniqueConstraintBuilder` only stores one `eventClass`/`propertyName` — calling `.on()` twice overwrites rather than accumulates). A code comment in `ConstraintBuilder.kt` itself admits the property-name extraction from the `(TEvent) -> TValue?` lambda is a "workaround" that just grabs the event's first declared field — every C# example in this section happens to use single-property events, so this doesn't produce wrong behavior in the snippets written, but it's a real, documented landmine for any future multi-property use: **do not write a Kotlin unique-constraint example targeting anything but an event's first (or only) declared field** without independently re-verifying this hasn't been fixed. +- **Kotlin/Java's `uniqueFor(eventClass, message)`** (unique EVENT TYPE, not property) has none of the lambda-property-extraction risk above and was used for the "unique event type" declarative tabs — real, safe, no caveat needed. +- **Java interop risk, handled conservatively**: no established precedent anywhere in this corpus for a Java caller passing a Kotlin `Function1`-typed lambda parameter (e.g. `unique(configure: (IUniqueConstraintBuilder) -> Unit)`) — Kotlin's `Unit`-returning SAM conversion from Java 8 lambda syntax is plausible but unverified without a real compiler. Rather than guess, treated every Kotlin API requiring a **lambda-typed parameter** as a Java gap (stub), and only wrote real Java content for the simpler `uniqueFor(KClass, String)` shape (matching the already-established `JvmClassMappingKt.getKotlinClass()` bridge pattern). Same caution applied to a Kotlin `@Pii`-on-a-Java-record-component question later in this batch (Kotlin's `@Pii` target set of `FIELD, PROPERTY, CLASS` has no established Java-record-component precedent in this corpus, unlike the well-established `CLASS`-only annotations like `@EventType`/`@Reactor`/`@Reducer`/`@ReadModel` — treated as unverified, left as a Java stub rather than guessed). +- **Elixir has TWO real, distinct mechanisms mapping cleanly onto C#'s two doc sections**: a raw imperative `Chronicle.Events.Constraints.register(channel, event_store, [%{...}])` call (closer in spirit to "declarative" — a separate explicit call, not an attribute — but its documented public shape has **no `RemovedWith`/message support** reachable safely, so it wasn't used for the declarative/** pages at all to avoid relying on an undocumented internal map shape), and the **event-type-attribute macros `unique(fields, opts)`/`unique_event_type(opts)`/`remove_constraint(name)`** (a true model-bound match for C#'s `[Unique]`/`[RemoveConstraint]` attributes — confirmed `remove_constraint` is `accumulate: true`, so it correctly supports "stack multiple `[RemoveConstraint]` attributes"). Used the macros for **all** of `constraints/model-bound/**`; left `constraints/declarative/**` **without** Elixir content at all (added a note instead), since Elixir genuinely has no separate declarative-class-style API safe to demonstrate. +- **Elixir has no violation-message support anywhere** (neither the raw `register` call nor the `unique`/`unique_event_type` macros have a message field) — a genuine, uniform-for-Elixir gap on top of everything else; `constraints/model-bound/unique.mdx`'s "violation message" tab stayed `csharp`-only with a note, even though the rest of that page widened to include Elixir. +- **PII (`compliance/**`) is real in all three non-C# clients** at the property level: Kotlin `@Pii(description)`, Elixir `pii(field, details)`, TypeScript `@pii()`. Widened `compliance/pii.mdx`'s and `compliance/client.mdx`'s property-level tabs accordingly. The **class-level "mark a `ConceptAs` type as PII"** variant stays C#-only — not really a "gap" so much as there being no established typed-value-wrapper convention for Kotlin/Elixir anywhere in this corpus to hang the class-level attribute off of; noted rather than silently left. +- **`testing/**` (46 tabs) confirmed a genuine, deep uniform gap** — `Cratis.Chronicle.Testing`'s in-process `*Scenario` helpers are a C#-specific library on top of xUnit/Cratis.Specifications; grepped Kotlin/Elixir/TypeScript for anything analogous and found only each language's own generic test-runner boilerplate (Kotest/JUnit, ExUnit, Vitest), nothing Chronicle-specific. Added one `:::note` at the top of `testing/index.mdx`; left every one of the 46 tabs untouched (matches the earlier `code-analysis/**` planned treatment). +- **`scenarios/**`**: these are downstream recipes over concepts already assessed elsewhere in this effort, so mostly reused already-established findings rather than re-deriving: `react-to-an-event.mdx`'s `basic-reactor` (real everywhere), `reading-state`/`explicit-append` (real Elixir only — Kotlin/Java can't per the non-suspend-handler constraint from part 3, and **TypeScript is also a gap here**, not real — TS reactors have no constructor-injection mechanism at all, confirmed in part 3's tutorial work; don't assume TS parity with Elixir just because it's usually the strongest non-C# client), `return-event` (uniform C#-only gap, matches `reactors/side-effects.mdx`). `real-time-query.mdx`'s `get-one` (real everywhere) widened; the other 4 tabs (get-all, materialized paging, watch, observe-page) got one consolidated note reusing the exact per-client breakdown already established in `concepts/designing-read-models.mdx` and `projections/eventual-consistency.mdx` rather than re-verifying from scratch. `test-a-slice.mdx` just points to the `testing/**` note. + +### Verification + +`npm run sync`, `npm run chronicle-client-docs:check` (670 placeholders — unchanged, no new `` lines added this batch, only widened/noted existing ones — 0 legacy, 0 direct fences, **C# validator passed**, **TypeScript validator passed**, Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (841 pages, clean), `node scripts/lint-docs.mjs` (0 errors, 196 pre-existing warnings), and a markdown-mirror spot check of `dist/chronicle/constraints/model-bound/unique.md` confirming the new Elixir tab renders real, correct code. + +**New standing reminders**: (1) when a Kotlin API needs a parameter typed as a Kotlin function type (`(T) -> R`) or targets record/data-class **components** specifically (as opposed to the whole class), and no existing snippet in the corpus already demonstrates a Java caller doing the same thing successfully, don't guess at the Java interop shape — treat it as an unverified Java gap rather than writing speculative bridge code. (2) A source code comment admitting something is a "workaround" is a strong signal to read the *implementation*, not just the public interface, before writing an example that exercises it — the interface alone would have looked completely fine here. + +## Part 9 — `subscriptions/**`, `read-models/**` + +### Key finding: explicit event-store subscriptions are real in Elixir and TypeScript, not just C# + +Confirmed via source that `subscriptions/explicit-subscriptions.mdx`'s `Subscriptions` API maps cleanly onto Elixir's `Chronicle.subscribe_to_event_store/3,4` + `Chronicle.EventStoreSubscriptions.DefinitionBuilder.with_event_type/2` (real, including the 3-arity "no filter, forward everything" form) and TypeScript's `store.subscriptions.subscribe`/`.unsubscribe` + `IEventStoreSubscriptionBuilder.withEventType()` (an almost 1:1 shape match with C#). Widened 6 of the 8 tabs (`basic`, `naming-convention`, `filtering`, `no-filter`, `inbox-reactor`, `unsubscribe`) to all 5 clients with real Elixir/TypeScript content and Kotlin/Java stubs (Kotlin has **zero** event-store-subscription support — confirmed no matches anywhere in the SDK source). Left the two ASP.NET-host-builder-specific tabs (`startup-registration`, `typical-pattern`) C#-only — deliberate scope call, noted in the page. + +- **`subscriptions/implicit-subscriptions.mdx`** confirmed a genuine, deep uniform gap — it's entirely built on the `[EventStore]` attribute (already established absent everywhere else) plus .NET-specific packaging/build mechanics (NuGet, `AssemblyInfo.cs`, `.csproj` ``) with no conceptual equivalent anywhere else. One note, no tab changes. +- **`subscriptions/outbox-inbox.mdx`**: `inbox-reactor` is a plain reactor (already fully portable, was just never wired) — widened to all 5. `inbox-id` (a `EventSequenceId.InboxPrefix` naming-convenience constant) stays C#-only — the other clients don't have a dedicated helper since an inbox sequence id is just a plain string wherever they use event sequence ids at all; noted rather than silently left. + +### `read-models/**` — most of this was already correctly scoped from earlier work; filled the one remaining gap + +`getting-single-instance.mdx`, `getting-collection-instances.mdx`, and `getting-snapshots.mdx` were **already** at full or correct partial width (`csharp,kotlin,java,elixir,typescript` or `csharp,elixir,typescript`) — no work needed, this reflects earlier sessions' `concepts/designing-read-models.mdx`/reducer findings already having been applied here too. The one section still fully C#-only was `materialized-pagination.mdx` (7 tabs): + +- **Elixir has real materialized paging** via `Chronicle.ReadModels.query/2` (page/page_size, not skip/take — a genuinely different pagination model, not just a syntax difference) — confirmed the `QueryResult` struct's exact field names (`instances`, `total_count`, `page`, `page_size`) directly from source before using them. +- **TypeScript has a full, real `IMaterializedReadModels` API** — `getInstances(type, skip?, take?)` **and** `observeInstances(type, skip?, take?)` returning an `AsyncIterable` (TS's live/paginated-observe equivalent to C#'s Rx `IObservable`-returning `ObserveInstances`) — this hadn't been noticed/wired in any earlier pass. Widened `accessing-api`, `basic-usage`, `pagination`, and `observing` to include real Elixir + TypeScript content (Kotlin/Java stubs — confirmed only single-instance lookup exists there, no bulk/paged/observed materialized access at all). +- Left `named-constants` (a C# named-constant-types convenience), `paged-endpoint` (an ASP.NET Core endpoint example), and `observing-in-service` (a second illustration of the same `ObserveInstances` capability already shown in `observing`) untouched — one consolidated note covers all three plus the general per-client breakdown. +- `watching-read-models.mdx` already had its own honest "not every client exposes this yet" framing from earlier work — confirmed still accurate, left as-is. + +### Verification + +`npm run sync`, `npm run chronicle-client-docs:check` (670 placeholders unchanged, 0 legacy, 0 direct fences, **C# validator passed**, **TypeScript validator passed**, Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (841 pages, clean), `node scripts/lint-docs.mjs` (0 errors, 197 pre-existing-class warnings — one more than the prior baseline of 196, checked and confirmed it's an existing "weasel: just" style-advisory hit in unrelated pre-existing prose, not something introduced this batch), and a markdown-mirror spot check of `dist/chronicle/subscriptions/explicit-subscriptions.md` confirming the new coverage note and Elixir tab both render correctly. + +## Part 10 — `namespaces/**`, `hosting/**`, `migrations/**`, `configuration/**`, `connection-strings/**`, `event-seeding/**`, `closing-streams/**`, `sinks/**`, `contributing/**` + +### Key finding: a recurring, now well-established category — ".NET hosting/DI configuration" pages that are genuinely, correctly C#-only + +Across most of this batch, the pattern was the same: a page built around .NET's Generic Host (`Host.CreateApplicationBuilder`, `IServiceCollection`, `AddCratisChronicle`, `appsettings.json` + `Cratis__Chronicle__` env-var binding) or ASP.NET Core/.NET Aspire specifically. None of these have a conceptual equivalent in Kotlin, Elixir, or TypeScript — each of those clients configures a connection through its own constructor/options object (already documented in their own getting-started guides), not a layered configuration-provider system. Confirmed and noted (not widened) for: `configuration/**` (one consolidated note on `index.mdx` covering the whole section — `chronicle-options`, `tls`, `grpc-message-size`, `structural-dependencies`, `camel-casing`), `connection-strings/configuration.mdx` (already self-describing as ".NET client" in its own prose, no note added), `sinks/index.mdx` (SQL sink DI registration), `hosting/aspire.mdx` + `hosting/local-certificates.mdx` (.NET Aspire has no equivalent tooling in any other ecosystem — confirmed genuinely out of scope for a client-parity effort, not just unmigrated). + +### Genuinely per-client-scoped pages (by design, not oversight) — `namespaces/dotnet-client.mdx`, `migrations/dotnet-client.mdx`, `connection-strings/dotnet-client.mdx` + +These three files are literally named "dotnet-client" and sit inside the shared `Chronicle/Documentation/` tree rather than a per-client public-docs folder — they document .NET-specific mechanisms (`IEventStoreNamespaceResolver`, .NET's DI-driven namespace/connection resolution) that don't have an equivalent hosting model to port to. Added a coverage note to `namespaces/dotnet-client.mdx` confirming the scoping is intentional. **Flagged, not fixed**: per the project's own convention (client-specific content belongs in `Chronicle/Documentation/clients/dotnet/**`, not the shared tree), these three pages arguably belong there instead — that's a placement/restructuring call for the "client-specific docs consolidation" workstream, not a `clients=` widening task, so left in place. + +### One real win: `migrations/validation.mdx`'s `default-value` tab + +Confirmed Elixir's `MigrationBuilder.default_value/3` (already used in part 6's `concepts/event-type-migrations.mdx` work) applies here too — wrote real Elixir content, widened to `csharp,kotlin,java,elixir` (Kotlin/Java stubs, confirmed no migration API exists there at all). The `EnableEventTypeGenerationValidation` toggle tab stays C#-only (a .NET hosting-config option, no Elixir equivalent found). + +### `closing-streams/**`: confirmed genuine, uniform gap + +`CompleteStream` (closing an event stream permanently) — grepped all three other SDKs, zero hits anywhere. One note, no widening. + +### `event-seeding/**`: found a real gap in the docs' information architecture, not fixed here + +`event-seeding/seeding-with-csharp.mdx` is a **single-client-only page** (not the shared `` pattern used everywhere else) — this repo's own established convention is "one shared page + per-client tabs," so a per-client page format here is itself an inconsistency, not something a `clients=` widening fixes. Elixir has a real, working seeder (`chronicle/seeding/seeder.ex`) with no companion page. Documented as a known gap in `event-seeding/index.md` rather than improvised — creating a properly-structured shared page (or a "Seeding with Elixir" sibling) is a bigger call than the scope of this pass, and belongs with the consolidation workstream. + +### `contributing/**`: confirmed out of scope entirely — different audience + +This section documents **contributing to Chronicle itself** (building new client SDKs, the kernel's gRPC contracts, protobuf extraction) — a framework-contributor audience, not the end-user "building an app with a Chronicle client" audience this whole parity effort targets. Its existing mixed `clients=` values (csharp-only for kernel contracts, typescript-only for the TS gRPC package build, csharp+elixir for client-types) are **already correct** — each reflects what's actually relevant to that specific contributor topic, not a parity gap. No changes made; confirmed via reading the actual page topics (client SDK internals, kernel services, patches) rather than assumed from the directory name alone. + +### Verification + +`npm run sync`, `npm run chronicle-client-docs:check` (670 placeholders unchanged, 0 legacy, 0 direct fences, **C# validator passed**, **TypeScript validator passed**, Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (841 pages, clean), `node scripts/lint-docs.mjs` (0 errors, 197 warnings, matching the prior batch's baseline), and a markdown-mirror spot check of `dist/chronicle/migrations/validation.md` confirming the new note and Elixir tab render correctly. + +**New standing reminder**: not every directory under `Chronicle/Documentation/` is in scope for client-SDK parity — some (`hosting/**`, `contributing/**`) document server/infra concerns or framework-contributor workflows for a different audience entirely. Check what a section is actually *about* (read a page's real content, not just its directory name) before assuming a narrow `clients=` list is an oversight — confirming "this is correctly scoped, not a gap" is itself useful, verified progress, not a shortcut. + +**This closes out the entire "everything, broadest scope first" client-tabs sweep** — every directory under `Chronicle/Documentation/` with a `` component has now been audited and either widened, stubbed, or confirmed out of scope with a note. + +## Part 11 — New shared Jobs and Webhooks pages (client-specific docs consolidation, item 2) + +The first item tackled from the consolidation list (part 1, item 2): **"Jobs and Webhooks have NO shared doc page at all — only Elixir and TypeScript document them, at very different depths, no shared anchor to bridge to."** Confirmed still true, and confirmed real C# support exists too (`Cratis.Chronicle.Jobs`/`Cratis.Chronicle.Webhooks` in `Chronicle/Source/Clients/DotNET/{Jobs,Webhooks}/`) — it just had never been written up in the shared `Chronicle/Documentation/` tree at all, so this was a genuine coverage gap in the shared corpus, not a `clients=` widening job. + +### What was created + +Two new top-level sections, matching the existing `closing-streams/`/`sinks/` single-page-section pattern (`/index.mdx` + `/toc.yml`): + +- **`Chronicle/Documentation/jobs/index.mdx`** — 4 tabs: list all jobs, get a single job, get a job's steps, stop/resume/delete. Real content for C# (`eventStore.Jobs`), Elixir (`Chronicle.Jobs.*`), and TypeScript (`store.jobs`) — verified each against real source (`IJobs.cs`, `jobs.md`'s already-existing real Elixir/TS prose which I re-verified against source rather than trusting verbatim, `IJobs.ts`). Kotlin/Java: confirmed zero jobs support anywhere in the SDK — stubs. +- **`Chronicle/Documentation/webhooks/index.mdx`** — 2 tabs: register a webhook (imperative — the one style all three of C#/Elixir/TypeScript's *client* APIs share; Elixir and TypeScript also have a discoverable-module/decorator style but C#'s client SDK only exposes the imperative `IWebhooks.Register(...)` form, so imperative is what's genuinely common to all three) and query registered webhooks. Kotlin/Java: stubs. +- Both wired into the main `Chronicle/Documentation/toc.yml` (next to Subscriptions) and into `web/scripts/sync-content.mjs`'s bucket config (`'Read models and processing'` bucket, alongside Subscriptions/Sinks — confirmed the new bucket entry via the built `topics.json`, not just by reading the config). + +### A notable asymmetry found, deliberately not chased further + +C#'s `IWebhooks` interface has **no `Remove`/unregister method** — Elixir (`Chronicle.WebHooks.remove/1`) and TypeScript (`webhooks.remove()`) both have one. This is the rare case in this whole effort where C# is narrower than the other clients on a real capability. Rather than write a 3rd tab exposing this asymmetry (real for Elixir/TS, gap for C#/Kotlin/Java) on a brand-new page, kept the new page's scope to the two operations genuinely common across C#/Elixir/TypeScript (register, query) — a proportionate-effort call given this is new content, not an existing page being audited tab-by-tab. + +### Bug caught and fixed while writing this + +Initially wrote the TypeScript webhook-registration example using `process.env.WEBHOOK_TOKEN` — caught before finishing that `Source/tsconfig.json` has no `@types/node` dependency (the same constraint documented earlier in this effort, part 3), so `process` wouldn't type-check. Fixed by taking the token as a constructor parameter instead, matching the project's own "don't reach for a runtime API the compiler config doesn't support" precedent. + +### Verification + +`npm run sync` (324 chronicle pages, +2 from the new sections), `npm run chronicle-client-docs:check` (676 placeholders, +6 for the new tabs, **C# validator passed**, **TypeScript validator passed**, Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (843 pages, +2, zero errors), `node scripts/lint-docs.mjs` (0 errors, 197 warnings — unchanged, confirming the new pages introduced no broken links), and confirmed via the built `src/generated/topics.json` (parsed with a small Python script, not just eyeballed) that "Jobs" and "Webhooks" both appear as children of the "Read models and processing" bucket, alongside Subscriptions/Sinks — i.e., the nav wiring actually took effect, not just the toc.yml source. + +**Still open from the consolidation list** (unchanged from part 1): correlation/identity/causation trim (Elixir `context.md` 284 lines + TypeScript `correlation.md`/`identity.md`/`auditing.md`, no cross-bridging), Elixir `event-store-subscriptions.md`/`sinks.md`/`event-stores.md` over-explanation trims, Kotlin `get-started/index.md`/`reference/annotations.md`/`reference/configuration.md` inline-concept fixes, .NET/TypeScript `sharedTopicBridge` consistency, plus two new items surfaced in part 10 (the `namespaces`/`migrations`/`connection-strings` "dotnet-client" page placement question, and the `event-seeding` single-client-page-format inconsistency). + +## Part 12 — Correlation/identity/causation consolidation (consolidation item 1) + +The clearest consolidation candidate from part 1's audit. Confirmed the original finding but refined it while doing the work: Elixir's `context.md` and TypeScript's `correlation.md`/`identity.md`/`auditing.md` are **not** low-value duplicated fluff — they're genuinely good, detailed, correct client-specific API reference (real module/class names, real framework-integration examples like Express `AsyncLocalStorage` middleware). The actual problem was narrower than "284 lines of duplication": each page's own **opening 1-2 sentences** re-explain the general concept (what a correlation ID/identity/causation chain *is*) with no link to a shared definition — that's the only part that was genuinely redundant across files. + +### Key finding: Kotlin has a full, completely undocumented correlation/identity/causation system + +While researching the shared page's tabs, found real, working Kotlin/Java support that nobody had ever written up anywhere — not even a hint of it in the original part-1 audit, which only compared Elixir vs TypeScript and didn't check Kotlin at all for this topic: + +- `io.cratis.chronicle.correlation.CorrelationId` (`@JvmInline value class` wrapping a `UUID`) + `correlationIdManager` (`ThreadLocal`-scoped, `.current`/`.set()`/`.clear()`) +- `io.cratis.chronicle.identity.Identity` (plain `data class`: `subject`, `name`, `userName`, `onBehalfOf`, with `.system`/`.notSet`/`.unknown` sentinels and `withoutDuplicates()`) + `identityProvider` (`ThreadLocal`-scoped) +- `io.cratis.chronicle.auditing.CausationType`/`Causation`/`CausationManager` (`ThreadLocal`-scoped chain builder) + +All three are `ThreadLocal`-based rather than coroutine-context-based — a real, worth-noting difference from Elixir (process dictionary) and TypeScript (`AsyncLocalStorage`, which follows async continuations across `await` boundaries the way a coroutine context would): a `ThreadLocal` value does **not** automatically follow a Kotlin coroutine across a dispatcher switch, so this is a genuine caveat for anyone using Kotlin's version inside suspend functions that hop threads. Documented this distinction directly in the new shared page rather than glossing over it. + +### What was created + +**`Chronicle/Documentation/concepts/correlation-identity-causation.mdx`** — a new shared concept page (wired into `concepts/toc.yml`) with three sections (Correlation ID, Identity, Causation), each with a real, source-verified tab per client showing the core "get/set the current value" operation: + +- Correlation: real for C# (`Cratis.Execution.ICorrelationIdAccessor`/`ICorrelationIdModifier` — confirmed this is a **Fundamentals-level** abstraction, not Chronicle-specific, found in the `Fundamentals` sibling repo, not `Chronicle/Source`), Kotlin, Elixir, TypeScript — all four real. +- Identity: real for all four — confirmed C#'s `IIdentityProvider` (`GetCurrent`/`SetCurrentIdentity`/`ClearCurrentIdentity`) is an intentional 1:1 naming match with TypeScript's `IIdentityProvider` (`getCurrent`/`setCurrentIdentity`/`clearCurrentIdentity`), and all four clients share the exact same three sentinel-identity GUIDs (`System`/`NotSet`/`Unknown`) — a nice cross-client consistency this effort hadn't surfaced before. +- Causation: real for C#, Kotlin, Elixir — **skipped Java** for `causationManager.add(type, properties)` specifically, because `CausationType` is a `@JvmInline value class` in Kotlin, and this corpus has no established precedent for how Java callers handle a Kotlin inline value-class *parameter* (as opposed to a return value) — Kotlin's name-mangling behavior for these varies by call shape and isn't something to guess at without a compiler. Treated as an honest Java gap rather than writing unverified bridge code. + +### Trims applied to the 4 existing per-client pages + +Elixir `context.md`: removed the "Overview" section (3 concept-defining bullets) and replaced the opening paragraph with a link to the new shared page — kept every line of the real Elixir API reference (correlation/identity/causation creation, process-scoping, one-off overrides, delegation chains, the full controller example, async/spawn considerations). TypeScript `correlation.md`/`identity.md`/`auditing.md`: trimmed each page's opening definitional sentence to a one-line link-out, kept 100% of the TypeScript-specific reference content (the `AsyncLocalStorage` explanation, Express middleware examples, `ICorrelationIdAccessor`/`ICorrelationIdSetter` interface segregation, custom-provider implementation guidance). + +### Verification + +`npm run sync` (325 chronicle pages, +1 for the new concept page), `npm run chronicle-client-docs:check` (679 placeholders, +3 for the new tabs, **C# validator passed**, **TypeScript validator passed**, Kotlin/Java/Elixir blocked-by-toolchain as expected), `npm run build` (844 pages, +1, zero errors), `node scripts/lint-docs.mjs` (0 errors, 197 warnings — unchanged, confirming the new cross-links to `/chronicle/concepts/correlation-identity-causation/` from both client repos resolve correctly, not broken), and a markdown-mirror spot check confirming both the new shared page's Elixir tab and the trimmed TypeScript `correlation.md`'s link-out render correctly. + +**New standing reminder**: when auditing for "duplicated content across clients," don't assume the fix is always "delete most of it" — real, correct, client-specific API reference is valuable and should be kept; the actual redundancy is often much narrower (here: just the opening definitional sentence) than the page's total line count suggests. Read the whole file before deciding how much to trim, not just its length. + +## Part 13 — Remaining consolidation items (Elixir trims, Kotlin inline fixes, sharedTopicBridge, page-placement questions, event-seeding) + +Closed out the rest of part 1's consolidation list. + +### Elixir trims: lighter touch than expected, again + +Re-read `event-store-subscriptions.md` (140 lines), `sinks.md`, `event-stores.md` (203 lines) in full before touching anything, per the part-12 lesson. Same pattern as before: these are genuinely good, mostly non-duplicated Elixir-specific reference (in `event-store-subscriptions.md`'s case, it documents a **discoverable subscription module** pattern — `use Chronicle.EventStoreSubscriptions.Subscription` — that the shared `subscriptions/explicit-subscriptions.mdx` page doesn't even cover, since I'd only wired the imperative form there). Added one-line links to the relevant shared pages at the top of each (`sinks.md` → `/chronicle/sinks/`; `event-stores.md` → `/chronicle/concepts/event-store/` + `/chronicle/concepts/namespaces/`, also added to its "See Also"; `event-store-subscriptions.md` → the shared subscriptions pages, framed as "this page adds what they don't cover" rather than "see the real explanation elsewhere"). No content deleted from any of the three. + +### Kotlin inline-concept "fixes": already appropriately terse — added links, not trims + +`get-started/index.md` is a genuine step-by-step tutorial (matches the project's own tour-voice/pedagogical convention — brief one-sentence framings for reactors/reducers are correct tutorial style, not redundant over-explanation). Added two inline forward-links (`/chronicle/reactors/`, `/chronicle/reducers/`) at the exact sentences introducing each concept, without touching the teaching prose itself. `reference/annotations.md`'s `@Pii` entry and `reference/configuration.md`'s "Namespace" section were both already short (2-3 sentences) and reference-appropriately terse — not over-explanations needing trimming. Added a one-line link to each (`/chronicle/compliance/pii/`, `/chronicle/concepts/namespaces/`) for a reader who wants the full model, without cutting anything. + +### `sharedTopicBridge` consistency: added missing cross-links to jobs.md/webhooks.md/sinks.md (TypeScript) + +These three now link to the part-11 shared Jobs/Webhooks pages and the existing shared Sinks page respectively. They intentionally do **not** get `sharedTopicBridge: true` — that flag means "pure bridge, no unique content," which isn't true for any of them (all three have substantial TypeScript-specific reference beyond the shared page). This confirms the audit's original framing ("6 files skip it entirely") was imprecise: the fix isn't the frontmatter flag, it's an honest cross-link — see the mechanism note below. + +### Page-placement question (`namespaces`/`migrations`/`connection-strings` "dotnet-client" pages): decided — leave in place + +Traced every inbound link to these three files before deciding. `Chronicle/Documentation/clients/dotnet/index.md` already links to all three at their current URLs (`/chronicle/namespaces/dotnet-client/`, `/chronicle/migrations/dotnet-client/`, `/chronicle/connection-strings/dotnet-client/`), and `get-started/worker.mdx` also links to the namespaces one — nothing is broken today. Moving these three `.mdx` files (plus their `client-snippets/` folders) into `Chronicle/Documentation/clients/dotnet/` to match the stated convention would touch ~4 `toc.yml` files and several cross-links for a purely organizational improvement with no reader-facing benefit. **Decision: leave them where they are.** The existing part-10 coverage notes on `namespaces/dotnet-client.mdx` remain accurate and suficient; this is now considered resolved (as "won't move," not "still open"). + +### Event seeding: bigger finding than part 1 assumed — Kotlin and TypeScript also have real seeding, not just Elixir + +Part 1's finding only compared C# vs Elixir. Checked Kotlin and TypeScript SDK source directly and found **both have complete, working seeding APIs** (`io.cratis.chronicle.seeding` — `@Seeder`, `ICanSeedEvents`, `IEventSeedingBuilder.forEventSource`; `@cratis/chronicle`'s `seeding` module — `@seeder()`, `ICanSeedEvents`, `IEventSeedingBuilder.for`/`.forEventSource`/`.forNamespace`) — this was a real, undiscovered documentation gap across **three** of the four clients, not one. + +Wrote three new client-specific pages, one per client, each verified against real source (Elixir's `Chronicle.Seeding` module docs — `for/4`, `for_event_source/3`, `for_namespace/3` — already close to documentation-ready in its own moduledoc; Kotlin's narrower `forEventSource`-only builder, confirmed no namespace-scoping method exists there — a real, smaller gap relative to C#/Elixir/TypeScript; TypeScript's full `for`/`forEventSource`/`forNamespace` builder, matching C#'s richness closely): + +- `Chronicle.Elixir/Documentation/seeding.md` +- `Chronicle.Kotlin/Documentation/guides/seeding.md` (**replacing** a stale `sharedTopicBridge: true` stub that pre-dated this discovery) +- `Chronicle.TypeScript/Documentation/seeding.md` (**replacing** a stale `sharedTopicBridge: true` stub, same reason) + +**A process bug worth flagging**: initially wrote these as new top-level pages (`seeding-with-elixir.md`, `seeding-with-kotlin.md`, `seeding-with-typescript.md`, mirroring the shared C# page's naming), which created **duplicate nav entries** — Kotlin already had a wired `guides/seeding.md` bridge stub, and TypeScript already had an unwired but real-URL-colliding `seeding.md` bridge stub. Caught this by checking the built `dist/chronicle/clients/**` output for existing `seeding*` files **before** declaring done, found the collision, and fixed it properly: moved the real content into the existing, already-wired file locations (renaming to match each client's own sibling-file convention — bare `seeding.md`/`Seeding`, no "-with-X" suffix, matching `jobs.md`/`webhooks.md`/`sinks.md`), deleted the now-superseded duplicate/stub files, and removed the extra toc.yml entries I'd mistakenly added alongside the pre-existing ones. + +This also required a `chronicle-client-docs.yml` policy fix: `seeding.md`/`seeding/**` were listed in `publicDocsAudit.sharedTopicPatterns` (the mechanism that flags a client's public-docs file as needing `sharedTopicBridge: true` if it overlaps a **fully shared, fully-tabbed** topic). Event-seeding doesn't qualify for that policy — unlike `reactors.md`/`reducers.md`/etc., the shared `Chronicle/Documentation/event-seeding/**` has **zero** multi-client `` coverage (it's C#-only), so each client's own seeding page necessarily carries real, non-duplicated content. Removed `seeding.md`/`seeding/**` from the pattern list (with an explanatory YAML comment) and bumped Kotlin's baseline to 1 (for `guides/seeding.md`, the one legitimate exception living inside the otherwise-pure-bridge `guides/**` folder, which is *still* covered by the broader `guides/**` pattern). + +Updated `event-seeding/index.md`'s "Next steps" to link to all three new pages plus the existing C# one. + +### Verification + +`npm run sync` (326 chronicle pages — net +1 after all the create/rename/delete churn), `npm run chronicle-client-docs:check` (**Audit passed**, shared-topic-overlap check passed with the corrected baseline — 0/0/1/0 for csharp/elixir/kotlin/typescript, **C# validator passed**, **TypeScript validator passed**), `npm run build` (845 pages, zero errors), `node scripts/lint-docs.mjs` (0 errors, 199 warnings — the +2 from the prior baseline are both the same pre-existing "easily" style-advisory phrase, copied verbatim from the already-accepted C# seeding page's Best Practices bullet, not new issues), and confirmed via `dist/chronicle/clients/{kotlin/guides,elixir,typescript}/seeding.md` that all three pages exist at their final URLs with the correct title and no orphaned duplicates remain. + +**New standing reminder**: after adding a new page to a client's public docs, **check the built `dist/chronicle/clients/**` output for pre-existing files at or near the same name/URL before finalizing** — a `sharedTopicBridge: true` stub can pre-date a real capability being discovered, and silently duplicating it (rather than replacing it) leaves two nav entries for the same topic. This is the same class of mistake as the constraints/compliance agent-verification lesson from part 8: verify the end state on disk, don't assume a clean addition. + +This closes out every item from part 1's consolidation list. **The chronicle-client-parity-migration effort, as scoped by the user's original request, is complete**: every `Chronicle/Documentation/**` shared-doc section has been audited for per-client tab parity (parts 1-10), the missing Jobs/Webhooks shared page has been created (part 11), and the client-specific-docs duplication/gap findings from the original audit have all been addressed (parts 12-13, plus the deliberate no-op decisions on `hosting/**`/`contributing/**`/dotnet-client placement). + +## Standing plan (update as work progresses) + +Methodology per section: (1) confirm client's real API for the concept via source, (2) classify genuine-gap vs under-migrated, (3) for under-migrated: write/wire the real snippet, (4) for genuine gap: honest stub (asymmetric) or one clarifying note (uniform) — never a fabricated snippet, (5) verify C#/TypeScript for real via the validators, source-verify Kotlin/Java/Elixir, (6) rebuild + lint + `chronicle-client-docs:check` + visual spot check, (7) update this handoff with a new dated part. For a large section, consider splitting across parallel agents by natural subfolder/topic boundary (see part 7) rather than working sequentially — but always verify the agent's work actually landed (part 8) before trusting its report. + +Remaining sections, in planned order — see "What's left in this effort" above for the checked/unchecked state: +`code-analysis/**` (likely skip, genuinely C#/Roslyn-only, confirm briefly) → client-specific docs consolidation (correlation/identity/causation, new shared Jobs/Webhooks page, sinks/event-stores/subscriptions trims, Kotlin concept-inline fixes, .NET/TypeScript bridge consistency, the `namespaces`/`migrations`/`connection-strings` "dotnet-client" page placement question from part 10, and the `event-seeding` per-client-page inconsistency from part 10 — all still exactly as found in part 1/part 10, untouched since). + +## Standing reminders (carried over from the legacy-snippet migration — still apply) + +All ~43 standing reminders from `chronicle-client-docs-migration.md` still apply where relevant (namespace-collision handling for Kernel-only C# types, TS decorator/reactor/reducer handler-naming-by-exact-camelCase rule, never use file-scoped `namespace X;` in a shared snippet, AutoMap silent-mismatch risk, `.To()` vs `.ToValue()`, assembly-level attributes forbidden in shared snippets, etc.) — re-read that file's "Standing reminders" sections before writing any new C# snippet. This new handoff only tracks what's specific to the client-parity effort. + +Nothing has been committed. Follow the same discipline: verify before writing, run the aggregate chain after every batch, auto-delete superseded/unwired legacy snippet files only if truly superseded (not applicable here — this effort adds tabs, it doesn't retire a `legacy/` tree), hold commit/push/PR until the user explicitly asks. diff --git a/.ai/rules/documentation.md b/.ai/rules/documentation.md index fe4ecc8..b0e2b99 100644 --- a/.ai/rules/documentation.md +++ b/.ai/rules/documentation.md @@ -67,6 +67,7 @@ The project's voice is **direct, practical, and opinionated**. Write like an exp - Every example must be **complete and correct** — no pseudo-code, no `// ...` elisions that leave the reader guessing. - **Short illustrative snippets** may be purpose-built. **Longer or real samples must be embedded from compiled, tested source** (the Samples repo) via the snippet tooling, so they cannot drift as APIs change. Never paste untested code, and never substitute a bare "see the repo" link for showing the code. - Where a feature spans the stack, show **both sides** — the C# slice and the generated TypeScript consumer — with tabs. Full-stack type safety is the story; show it. +- Chronicle's shared documentation is **language agnostic**. For Chronicle client SDK behavior, use the **chronicle-client-docs** skill and ``; each client owns its code under `Documentation/client-snippets/**`. Do not add raw `csharp`, `cs`, `kotlin`, `elixir`, `typescript`, or `ts` fences to shared Chronicle pages for client APIs. Put genuinely client-specific content under the public Client SDK docs instead (`Chronicle/Documentation/clients/dotnet/**` for .NET, and the external client repo's `Documentation/**` for Kotlin, Elixir, and TypeScript). ## Links diff --git a/.ai/rules/editing-cratis-docs.md b/.ai/rules/editing-cratis-docs.md index 8935f8c..fd88d77 100644 --- a/.ai/rules/editing-cratis-docs.md +++ b/.ai/rules/editing-cratis-docs.md @@ -25,6 +25,21 @@ Cratis documentation is **not** one folder. The content lives in **each product' **NEVER edit `Documentation/web/src/content/docs/{chronicle,arc,components,cli,fundamentals,contributing}/`.** Those are **generated and git-ignored** — `web/scripts/sync-content.mjs` regenerates them from the product repos on every build. Edit the **product-repo source** and re-sync. (Site-level pages directly under `web/src/content/docs/` *are* hand-authored — only the per-product subfolders are generated.) +## Chronicle client examples + +Chronicle shared docs are language agnostic. When a Chronicle page needs C#, Kotlin, Elixir, TypeScript, or another client SDK example, use the **chronicle-client-docs** skill. The shared page in `Chronicle/Documentation/**` uses ``; each client repo owns the matching `Documentation/client-snippets/**` file and validation workflow. + +Do not add raw `csharp`, `cs`, `kotlin`, `elixir`, `typescript`, or `ts` fenced examples to shared Chronicle docs for client APIs. The docs audit keeps a baseline of legacy direct C# fences and fails when the count increases. + +Use public Client SDK pages only for genuinely client-specific content: + +- `.NET`: `Chronicle/Documentation/clients/dotnet/**` → `/chronicle/clients/dotnet/**` +- Kotlin: `Chronicle.Kotlin/Documentation/**` → `/chronicle/clients/kotlin/**` +- Elixir: `Chronicle.Elixir/Documentation/**` → `/chronicle/clients/elixir/**` +- TypeScript: `Chronicle.TypeScript/Documentation/**` → `/chronicle/clients/typescript/**` + +Do not create client-specific pages just to hold equivalent snippets; use shared tabs for those. + ## The loop 1. **Edit** the source `.md`/`.mdx` in the owning product repo (or the site-level page in `web/src/content/docs/`). diff --git a/.ai/skills/chronicle-client-docs/SKILL.md b/.ai/skills/chronicle-client-docs/SKILL.md new file mode 100644 index 0000000..8328049 --- /dev/null +++ b/.ai/skills/chronicle-client-docs/SKILL.md @@ -0,0 +1,228 @@ +--- +name: chronicle-client-docs +description: Use this skill whenever adding, changing, reviewing, or validating Chronicle documentation that shows client SDK code across C#, Java, Kotlin, Elixir, TypeScript, or future Chronicle clients. Trigger on Chronicle client snippets, language tabs, , multi-client examples, client-specific docs ownership, or adding another Chronicle client to the shared docs. +--- + +# Chronicle Client Documentation + +Chronicle docs use one language-neutral explanation with synchronized language tabs for client-specific code. The Chronicle repo owns the shared narrative page; each client repo owns its own snippet text and compiles it against that client. + +Use this workflow when editing any Chronicle page that shows client SDK code. + +## Ownership Model + +- Shared, language-neutral page prose lives in `Chronicle/Documentation/**`. +- C# snippets live in `Chronicle/Documentation/client-snippets/**`. +- Kotlin snippets live in `Chronicle.Kotlin/Documentation/client-snippets/**`. +- Java snippets live in `Chronicle.Kotlin/Documentation/client-snippets-java/**`; the JVM client repo owns both Java and Kotlin examples. +- Elixir snippets live in `Chronicle.Elixir/Documentation/client-snippets/**`. +- TypeScript snippets live in `Chronicle.TypeScript/Documentation/client-snippets/**`. +- .NET-specific public client pages live in `Chronicle/Documentation/clients/dotnet/**` and render under `/chronicle/clients/dotnet/**`. +- Kotlin-specific public client pages live in `Chronicle.Kotlin/Documentation/**` and render under `/chronicle/clients/kotlin/**`. +- Elixir-specific public client pages live in `Chronicle.Elixir/Documentation/**` and render under `/chronicle/clients/elixir/**`. +- TypeScript-specific public client pages live in `Chronicle.TypeScript/Documentation/**` and render under `/chronicle/clients/typescript/**`. +- Generated site files under `Documentation/web/src/content/docs/chronicle/**` are not source files. Do not edit them. + +The shared Chronicle page decides where an example appears. The client repo decides what code appears for that language. + +The Documentation repo owns the coordination contract in `web/chronicle-client-docs.yml`. That manifest is the source of truth for: + +- participating client keys and labels +- client-owned snippet roots +- client-specific public docs roots +- shared topic links shown from the generated Client SDKs landing page +- public client-doc overlap baselines for shared-topic migration debt +- local validator commands +- the current `legacy/` snippet count baseline + +Do not add a new Chronicle client by hardcoding it into individual scripts. Update the manifest and let the sync, audit, and aggregate check scripts read it. + +## Core Rule + +Shared Chronicle docs are language agnostic. Do not paste a raw `csharp`, `java`, `kotlin`, `elixir`, `typescript`, `ts`, or `cs` fenced code block into a shared Chronicle page for client SDK behavior. + +Use one of these shapes instead: + +- Shared concept with equivalent client API examples: ``. +- Genuine client-specific behavior: put the page under that client's public docs. +- Language-neutral examples: use prose, diagrams, JSON, YAML, shell commands, Mermaid, or projection declaration language directly. + +If a shared page still has direct C# fences, treat it as migration debt. Do not add more. + +## Add Or Change A Multi-Client Example + +1. Edit the shared page in `Chronicle/Documentation/**`. +2. Add a placeholder where the example should appear: + + ```mdx + + ``` + +3. Add matching snippet files in every participating client repo using the same extensionless snippet ID: + + ```text + Chronicle/Documentation/client-snippets/events/appending/example.md + Chronicle.Kotlin/Documentation/client-snippets-java/events/appending/example.md + Chronicle.Kotlin/Documentation/client-snippets/events/appending/example.md + Chronicle.Elixir/Documentation/client-snippets/events/appending/example.md + Chronicle.TypeScript/Documentation/client-snippets/events/appending/example.md + ``` + +4. Put exactly the code the reader should see in each snippet file, usually as one fenced code block. +5. Prefer `.md` for snippet files. Use `.mdx` only if the snippet file itself needs MDX syntax. +6. Keep the snippet ID extensionless in the shared page. The sync supports both `.md` and `.mdx`. + +Use `clients="csharp,elixir"` only when a concept genuinely exists for only some clients: + +```mdx + +``` + +Prefer keeping every generally supported client visible in shared pages. If a shared workflow is not implemented for one client yet, add a real snippet file for that client that says the feature is not supported yet instead of omitting the tab. This keeps the docs honest and makes the gap visible: + +````md +```text +This Chronicle client does not support this workflow yet. +Track the client SDK issue before using this API from this language. +``` +```` + +Use a custom `syncKey` only when tab groups on the same page should not follow each other. The default is `chronicle-client`, and that is usually correct. + +## Do Not Split Equivalent Examples Into Client Sections + +Do not create public client-specific pages just to hold equivalent snippets. The shared docs should stay unified; snippet folders are source-only and are skipped by the site. + +Client-specific pages are only for content that is genuinely different for that client. + +## Client-Specific Pages + +Use client-specific pages only when the content is genuinely different for that client: installation details, runtime integration, language-specific APIs, idioms, decorators/annotations, framework integration, generated package behavior, or troubleshooting that does not apply to the other clients. + +Those pages still belong to the client repo: + +- `Chronicle/Documentation/clients/dotnet/**` renders under `/chronicle/clients/dotnet/**`. +- `Chronicle.Kotlin/Documentation/**` renders under `/chronicle/clients/kotlin/**`. +- `Chronicle.Elixir/Documentation/**` renders under `/chronicle/clients/elixir/**`. +- `Chronicle.TypeScript/Documentation/**` renders under `/chronicle/clients/typescript/**`. + +The sync skips `Documentation/client-snippets/**`, so snippet files never become public pages. + +When editing a client-specific page: + +1. Edit the owning client repo's `Documentation/**` source. +2. Keep links relative inside that client docs tree; the site rewrites them to the `/chronicle/clients//` route. +3. Update the client repo's `Documentation/toc.yml` when the client has one. If there is no `toc.yml`, the site autogenerates that client's sidebar from the folder. +4. Run that repo's documentation/snippet validation when available. +5. Run `npm run build` in `Documentation/web` to verify the aggregate site. + +If a page starts as client-specific but later applies to every client, move the explanation into the shared Chronicle page and replace duplicated code with ``. + +## Validate Snippets + +Each client repo has a validator: + +```bash +python3 Documentation/validate-client-snippets.py +``` + +Run the validators for every client whose snippets changed. The validator should compile snippets against the owning client source, not just parse Markdown. + +When local toolchains are missing, report that explicitly and rely on the client repo's `Client Snippet Verification` workflow: + +- Kotlin requires Gradle / JDK. +- Java requires Gradle / JDK. +- Elixir requires Mix / Erlang / Elixir. +- TypeScript requires Node/Yarn dependencies. +- C# requires the Chronicle .NET SDK project to build. + +Then run the site build from `Documentation/web`: + +```bash +npm run chronicle-client-docs:check +npm run audit:chronicle-client-docs +npm run build +``` + +`npm run chronicle-client-docs:check` is the preferred aggregate gate. It runs the strict shared-doc audit, checks that `legacy/` snippet counts have not increased, and runs every configured client validator whose local toolchain is available. It reports missing Java, Mix, or similar local toolchains as blocked in local runs. CI should use: + +```bash +npm run chronicle-client-docs:check:ci +``` + +The CI variant treats blocked validators as failures. + +Use strict mode to see whether shared Chronicle docs are fully migrated: + +```bash +npm run audit:chronicle-client-docs:strict +``` + +`audit:chronicle-client-docs` is a ratchet: it fails on missing client docs roots, missing snippets, or any increase in direct client-language fences in shared Chronicle docs. `audit:chronicle-client-docs:strict` fails while any shared direct client-language fences remain. + +For visual changes, start or restart the dev server and inspect the page: + +```bash +npm run dev -- --host 127.0.0.1 +``` + +## Keep CI In Sync + +Every client repo that owns snippets must have a `Client Snippet Verification` workflow that runs when either client source or snippet files change: + +- `Documentation/client-snippets/**` +- `Documentation/validate-client-snippets.py` +- `Source/**` +- `.github/workflows/client-snippets.yml` + +Every external client repo must also dispatch or otherwise trigger the Documentation repo docs build after snippet changes land on `main`, so the published site updates. + +The Documentation repo docs workflow must checkout every client repo listed in `web/chronicle-client-docs.yml`. + +## Add A New Chronicle Client + +1. Add the client repository to the Documentation repo checkout/submodule setup. +2. Add the client to `Documentation/web/chronicle-client-docs.yml`. +3. Use a stable key such as `swift` or `go`, and choose the reader-facing tab/sidebar label. +4. Add `Documentation/client-snippets/**` to the client repo when it participates in shared tabs. +5. Add `Documentation/validate-client-snippets.py` that compiles snippets against that client source. +6. Add the client's `Client Snippet Verification` workflow. +7. Add the client's documentation dispatch workflow so `Documentation/**` changes rebuild the site. +8. Add or update snippet files for the shared Chronicle pages that should include the new client. +9. Run the affected client validators, `npm run chronicle-client-docs:check`, and `npm run build` in `Documentation/web`. + +Do not split the public docs into one section per client just because the registry grows. + +## Client Public Docs Stay Narrow + +Client-specific public docs are for language/runtime details: installation, connection setup, framework integration, annotations/decorators, package behavior, generated APIs, and troubleshooting. + +Chronicle concepts and feature workflows belong in the shared Chronicle docs. If a client page explains events, reactors, reducers, read models, projections, constraints, seeding, transactions, migrations, or compliance in a way that would help multiple clients, move that explanation into the shared page and leave only client-specific API notes behind. + +`npm run chronicle-client-docs:check` audits client public docs for shared-topic overlap using the manifest's `publicDocsAudit` section. The count is a migration debt baseline: it may go down as pages are consolidated, but it must not increase. + +When preserving an old client URL only to point readers to the shared docs, make it a bridge page with frontmatter: + +```yaml +--- +sharedTopicBridge: true +--- +``` + +Bridge pages should not contain full client-specific examples. They should briefly state that the topic is shared, link to the canonical shared page, and link to client-specific setup/reference only when needed. + +## Review Checklist + +- The shared page owns prose, not client-specific API details. +- Shared Chronicle pages do not add new direct client-language fences. +- Shared pages include an explicit "not supported yet" snippet tab when a client lacks a workflow but readers need to see the gap. +- Client public docs do not add new concept/guide pages for shared Chronicle topics. +- .NET-specific pages live under `/chronicle/clients/dotnet/**`, not mixed into shared Chronicle docs. +- Each language's code lives in its client repo. +- Snippet files use the same extensionless ID across clients. +- `.md` is used unless `.mdx` is needed. +- Code examples are complete enough to compile under the owning validator. +- The synchronized tabs render on the shared page. +- No snippet folder becomes a public docs page. +- `npm run chronicle-client-docs:check` passes, or reports only missing local toolchains that CI covers. +- Local validation results and any missing toolchains are reported clearly. diff --git a/.ai/skills/edit-cratis-docs/SKILL.md b/.ai/skills/edit-cratis-docs/SKILL.md index 3b4ab2f..800d779 100644 --- a/.ai/skills/edit-cratis-docs/SKILL.md +++ b/.ai/skills/edit-cratis-docs/SKILL.md @@ -29,6 +29,7 @@ Example: `/chronicle/concepts/event-source/` → `Chronicle/Documentation/concep - Match the page's **Diátaxis type** (tutorial / how-to / explanation / reference) and the **tour voice** (teach, don't dump) — see the **`writing-cratis-docs`** rule (the tour-voice checklist + Starlight authoring tools). Don't mix types. - **Verify every framework API in a code example against real source** before writing it — readers paste them verbatim. (See the `writing-correct-examples` rule; grep Studio `*.cs`/`*.tsx` and the product `Source/` trees.) +- For Chronicle client examples, use the **chronicle-client-docs** skill. Shared Chronicle pages must stay language-neutral and use `` for client SDK code; each client repo owns its own `Documentation/client-snippets/**` files and validator. .NET-specific public pages live under `Chronicle/Documentation/clients/dotnet/**`, not mixed into shared Chronicle docs. - Link rules: product `.md` may use `./foo.md`; links to a `.mdx` page must be **extension-less** (`./foo`); site-level `.mdx` uses clean root-relative URLs (`/arc/...`). ## 3. Sync, preview, verify @@ -39,7 +40,7 @@ npm run dev # serves http://localhost:4321 (re-syncs the product repos) npm run check # the gate: build + lint + link-check ``` -`npm run check` MUST end **0 error(s)** and **0 broken** links (≈187 advisory style warnings are expected). Fix anything it flags. **Restart `npm run dev` after running the gate** — the gate's re-sync degrades a live dev server. +`npm run check` MUST end **0 error(s)** and **0 broken** links (≈187 advisory style warnings are expected). For Chronicle docs, it also runs the Chronicle client-docs audit, which fails on missing client snippets or any increase in direct client-language fences in shared Chronicle docs. Fix anything it flags. **Restart `npm run dev` after running the gate** — the gate's re-sync degrades a live dev server. For visual changes, screenshot the page in light and dark and read the result — use the `qa-cratis-docs` skill. diff --git a/.github/workflows/docs-site.yml b/.github/workflows/docs-site.yml index ff1153b..04bb12d 100644 --- a/.github/workflows/docs-site.yml +++ b/.github/workflows/docs-site.yml @@ -1,9 +1,11 @@ # Builds and deploys the Astro Starlight documentation site (Documentation/web). # -# Content lives in each product repo's `Documentation/` folder. This workflow checks -# each product repo out as a sibling of this repo — matching the local dev layout the -# converter expects (scripts/sync-content.mjs). It uses `docs-overhaul` for -# docs-overhaul validation and `main` for production builds and product-repo dispatches. +# Content lives in each product repo's `Documentation/` folder. Chronicle client +# docs and snippets live in each client repo's `Documentation/` folder. This +# workflow checks each source repo out as a sibling of this repo, matching the +# local dev layout the converter expects (scripts/sync-content.mjs). +# It uses `docs-overhaul` for docs-overhaul validation and `main` for production +# builds and product-repo dispatches. # # The build runs on both `docs-overhaul` (validation) and `main`; product repos # dispatch `build-docs` after docs changes on their `main` branches. The deploy job @@ -37,6 +39,8 @@ concurrency: # Main pushes, manual runs from main, and repository_dispatch runs use main. env: DOCS_REF: ${{ github.ref_name == 'docs-overhaul' && 'docs-overhaul' || 'main' }} + ELIXIR_VERSION: "1.19.5" + OTP_VERSION: "28.5" jobs: build: @@ -55,6 +59,30 @@ jobs: token: ${{ secrets.DOCS_CHECKOUT_TOKEN || github.token }} path: Chronicle + - name: Checkout Chronicle.Kotlin client docs + uses: actions/checkout@v4 + with: + repository: Cratis/Chronicle.Kotlin + ref: main + token: ${{ secrets.DOCS_CHECKOUT_TOKEN || github.token }} + path: Chronicle.Kotlin + + - name: Checkout Chronicle.Elixir client docs + uses: actions/checkout@v4 + with: + repository: Cratis/Chronicle.Elixir + ref: main + token: ${{ secrets.DOCS_CHECKOUT_TOKEN || github.token }} + path: Chronicle.Elixir + + - name: Checkout Chronicle.TypeScript client docs + uses: actions/checkout@v4 + with: + repository: Cratis/Chronicle.TypeScript + ref: main + token: ${{ secrets.DOCS_CHECKOUT_TOKEN || github.token }} + path: Chronicle.TypeScript + - name: Checkout Arc uses: actions/checkout@v4 with: @@ -111,6 +139,36 @@ jobs: working-directory: Documentation/web run: npm ci || npm install + - name: Setup JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.13' + + - name: Setup Elixir and Erlang + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Install Chronicle TypeScript dependencies + working-directory: Chronicle.TypeScript + run: | + corepack enable + yarn install --immutable + + - name: Install Chronicle Elixir dependencies + working-directory: Chronicle.Elixir/Source/chronicle + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + - name: Build Components Storybook working-directory: Components run: | @@ -148,6 +206,10 @@ jobs: working-directory: Documentation/web run: npm run sync && npm run lint:docs + - name: Check Chronicle client docs + working-directory: Documentation/web + run: npm run chronicle-client-docs:check:ci + - name: Build site working-directory: Documentation/web run: npm run build diff --git a/.gitmodules b/.gitmodules index f95b852..3788bac 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,12 @@ [submodule "Architecture"] path = Architecture url = https://github.com/Cratis/Architecture +[submodule "Chronicle.Kotlin"] + path = Chronicle.Kotlin + url = https://github.com/Cratis/Chronicle.Kotlin +[submodule "Chronicle.Elixir"] + path = Chronicle.Elixir + url = https://github.com/Cratis/Chronicle.Elixir +[submodule "Chronicle.TypeScript"] + path = Chronicle.TypeScript + url = https://github.com/Cratis/Chronicle.TypeScript diff --git a/Chronicle.Elixir b/Chronicle.Elixir new file mode 160000 index 0000000..8167837 --- /dev/null +++ b/Chronicle.Elixir @@ -0,0 +1 @@ +Subproject commit 8167837cde54271855bd0ae849060afc18c1b91e diff --git a/Chronicle.Kotlin b/Chronicle.Kotlin new file mode 160000 index 0000000..c983923 --- /dev/null +++ b/Chronicle.Kotlin @@ -0,0 +1 @@ +Subproject commit c98392311ae9cec70c6d486ef805b987f4c98244 diff --git a/Chronicle.TypeScript b/Chronicle.TypeScript new file mode 160000 index 0000000..48490f5 --- /dev/null +++ b/Chronicle.TypeScript @@ -0,0 +1 @@ +Subproject commit 48490f556c897846fd717570c9186bd46c830da0 diff --git a/web/chronicle-client-docs.yml b/web/chronicle-client-docs.yml new file mode 100644 index 0000000..439c461 --- /dev/null +++ b/web/chronicle-client-docs.yml @@ -0,0 +1,171 @@ +version: 1 + +sharedDocs: + paths: + - ../../Chronicle/Documentation + - ../Chronicle/Documentation + +sharedTopics: + - label: Get started + href: /chronicle/get-started/ + - label: Events and event logs + href: /chronicle/events/ + - label: Event appending + href: /chronicle/events/appending/ + - label: Read models + href: /chronicle/read-models/ + - label: Projections + href: /chronicle/projections/ + - label: Reactors + href: /chronicle/reactors/ + - label: Reducers + href: /chronicle/reducers/ + - label: Constraints + href: /chronicle/constraints/ + - label: Event seeding + href: /chronicle/event-seeding/ + - label: Compliance + href: /chronicle/compliance/ + - label: Transactions and unit of work + href: /chronicle/events/transactions/ + - label: Event evolution + href: /chronicle/understanding-event-evolution/ + +publicDocsAudit: + sharedTopicPatterns: + - concepts/** + - guides/** + - events.md + - event-types.md + - event-type-migrations.md + - event-log.md + - event-sequences.md + - reactors.md + - reducers.md + - read-models.md + - constraints.md + - transactions.md + - migrations.md + - concurrency.md + - compliance/** + # Note: `seeding.md`/`seeding/**` is intentionally NOT in sharedTopicPatterns above. + # Unlike the other topics here, event-seeding has no shared multi-client + # coverage in Chronicle/Documentation/event-seeding/** (it's C#-only there), so each client's + # own seeding.md carries real, non-duplicated API reference rather than a pure bridge. + baselines: + csharp: 0 + # Kotlin's guides/seeding.md is a reviewed exception within the otherwise-pure-bridge + # guides/** folder — it has real Kotlin seeding content for the same reason as above. + kotlin: 1 + elixir: 0 + typescript: 0 + +clients: + csharp: + label: C# + snippets: + paths: + - ../../Chronicle/Documentation/client-snippets + - ../Chronicle/Documentation/client-snippets + legacyBaseline: 0 + publicDocs: + key: dotnet + label: .NET client + paths: + - ../../Chronicle/Documentation/clients/dotnet + - ../Chronicle/Documentation/clients/dotnet + validator: + cwd: + - ../../Chronicle + - ../Chronicle + command: python3 + args: + - Documentation/validate-client-snippets.py + + kotlin: + label: Kotlin + includeByDefault: true + snippets: + paths: + - ../../Chronicle.Kotlin/Documentation/client-snippets + - ../Chronicle.Kotlin/Documentation/client-snippets + legacyBaseline: 0 + publicDocs: + key: kotlin + label: Kotlin client + paths: + - ../../Chronicle.Kotlin/Documentation + - ../Chronicle.Kotlin/Documentation + validator: + cwd: + - ../../Chronicle.Kotlin + - ../Chronicle.Kotlin + command: python3 + args: + - Documentation/validate-client-snippets.py + blockedOutput: + - Unable to locate a Java Runtime + + java: + label: Java + includeByDefault: false + snippets: + paths: + - ../../Chronicle.Kotlin/Documentation/client-snippets-java + - ../Chronicle.Kotlin/Documentation/client-snippets-java + legacyBaseline: 0 + validator: + cwd: + - ../../Chronicle.Kotlin + - ../Chronicle.Kotlin + command: python3 + args: + - Documentation/validate-client-snippets.py + blockedOutput: + - Unable to locate a Java Runtime + + elixir: + label: Elixir + includeByDefault: true + snippets: + paths: + - ../../Chronicle.Elixir/Documentation/client-snippets + - ../Chronicle.Elixir/Documentation/client-snippets + legacyBaseline: 0 + publicDocs: + key: elixir + label: Elixir client + paths: + - ../../Chronicle.Elixir/Documentation + - ../Chronicle.Elixir/Documentation + validator: + cwd: + - ../../Chronicle.Elixir + - ../Chronicle.Elixir + command: python3 + args: + - Documentation/validate-client-snippets.py + blockedOutput: + - "No such file or directory: 'mix'" + + typescript: + label: TypeScript + includeByDefault: true + snippets: + paths: + - ../../Chronicle.TypeScript/Documentation/client-snippets + - ../Chronicle.TypeScript/Documentation/client-snippets + legacyBaseline: 0 + publicDocs: + key: typescript + label: TypeScript client + paths: + - ../../Chronicle.TypeScript/Documentation + - ../Chronicle.TypeScript/Documentation + validator: + cwd: + - ../../Chronicle.TypeScript + - ../Chronicle.TypeScript + command: python3 + args: + - Documentation/validate-client-snippets.py diff --git a/web/package.json b/web/package.json index c4371c4..afe2939 100644 --- a/web/package.json +++ b/web/package.json @@ -17,11 +17,15 @@ "lint:markdown": "node scripts/lint-markdown.mjs", "check:links": "node scripts/check-links.mjs", "check:external": "node scripts/check-external-links.mjs", + "audit:chronicle-client-docs": "node scripts/audit-chronicle-client-docs.mjs --baseline scripts/chronicle-client-docs-baseline.json", + "audit:chronicle-client-docs:strict": "node scripts/audit-chronicle-client-docs.mjs --strict", + "chronicle-client-docs:check": "node scripts/check-chronicle-client-docs.mjs", + "chronicle-client-docs:check:ci": "node scripts/check-chronicle-client-docs.mjs --strict-toolchains", "build:storybook": "cd ../../Components/Source && ../node_modules/.bin/storybook build -o ../../Documentation/web/public/storybook", "build:storybook:arc": "cd ../../Arc/Source/JavaScript && yarn workspace @cratis/arc.react exec storybook build -o ../../../../Documentation/web/public/storybook-arc", "build:storybooks": "npm run build:storybook && npm run build:storybook:arc", "build:api": "node scripts/build-api.mjs", - "check": "npm run build && node scripts/lint-docs.mjs && node scripts/check-links.mjs && node scripts/lint-prose.mjs && node scripts/lint-markdown.mjs && node scripts/check-external-links.mjs" + "check": "npm run build && npm run chronicle-client-docs:check && node scripts/lint-docs.mjs && node scripts/check-links.mjs && node scripts/lint-prose.mjs && node scripts/lint-markdown.mjs && node scripts/check-external-links.mjs" }, "dependencies": { "@astrojs/starlight": "^0.39.2", diff --git a/web/scripts/audit-chronicle-client-docs.mjs b/web/scripts/audit-chronicle-client-docs.mjs new file mode 100644 index 0000000..5b76c02 --- /dev/null +++ b/web/scripts/audit-chronicle-client-docs.mjs @@ -0,0 +1,295 @@ +// Audits the Chronicle client documentation model. +// +// Shared Chronicle docs should be language-neutral. Client SDK code belongs in +// client-owned snippets expanded through , or in the +// client-specific docs under /chronicle/clients//. + +import { existsSync } from 'node:fs'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { loadChronicleClientDocsConfig, webRoot } from './chronicle-client-docs-config.mjs'; + +const chronicleClientDocsConfig = await loadChronicleClientDocsConfig(); +const chronicleDocsRoot = chronicleClientDocsConfig.sharedDocsRoot; +const clients = chronicleClientDocsConfig.clients.map((client) => ({ + key: client.key, + label: client.label, + includeByDefault: client.includeByDefault, + snippetRoot: client.snippetRoot, + docsRoot: client.publicDocs?.root, +})); +const defaultClients = clients.filter((client) => client.includeByDefault); + +const args = new Set(process.argv.slice(2)); +const valueArg = (name) => { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +}; + +const strict = args.has('--strict'); +const baselinePath = valueArg('--baseline'); +const writeBaselinePath = valueArg('--write-baseline'); + +const skipDirs = new Set([ + '.git', + 'bin', + 'client-snippets', + 'client-snippets-java', + 'node_modules', + 'obj', + '_includes', + '_shared', + '_snippets', +]); + +const languageAliases = new Map([ + ['csharp', 'csharp'], + ['cs', 'csharp'], + ['java', 'java'], + ['kotlin', 'kotlin'], + ['elixir', 'elixir'], + ['typescript', 'typescript'], + ['ts', 'typescript'], + ['tsx', 'typescript'], +]); + +async function* markdownFiles(root, current = root) { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (skipDirs.has(entry.name)) continue; + if (path.resolve(current) === path.resolve(root) && entry.name === 'clients') continue; + yield* markdownFiles(root, path.join(current, entry.name)); + continue; + } + + if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) { + yield path.join(current, entry.name); + } + } +} + +function fenceRangesAndLanguages(body) { + const ranges = []; + const fences = []; + const lines = body.split(/\r?\n/); + let offset = 0; + let current; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = line.match(/^([`~]{3,})\s*([A-Za-z0-9_+.-]*)/); + if (match) { + const marker = match[1]; + const markerChar = marker[0]; + const lang = languageAliases.get((match[2] ?? '').toLowerCase()); + + if (!current) { + current = { markerChar, markerLength: marker.length, start: offset, line: i + 1, lang }; + if (lang) { + fences.push({ line: i + 1, lang }); + } + } else if (markerChar === current.markerChar && marker.length >= current.markerLength) { + ranges.push({ start: current.start, end: offset + line.length }); + current = undefined; + } + } + + offset += line.length + 1; + } + + if (current) { + ranges.push({ start: current.start, end: body.length }); + } + + return { ranges, fences }; +} + +function isInRange(index, ranges) { + return ranges.some((range) => index >= range.start && index <= range.end); +} + +function getAttr(attrs, name) { + const match = attrs.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`)); + return match ? (match[2] ?? match[3] ?? '') : null; +} + +async function snippetExists(client, snippet) { + for (const ext of ['.mdx', '.md']) { + try { + await fs.access(path.join(client.snippetRoot, snippet + ext)); + return true; + } catch { + // Try the next supported extension. + } + } + return false; +} + +async function collectAudit() { + const directFences = new Map(); + const placeholders = []; + const missingSnippets = []; + const missingRoots = []; + + for (const client of clients) { + if (!existsSync(client.snippetRoot)) { + missingRoots.push(`${client.label} snippet root: ${client.snippetRoot}`); + } + if (client.docsRoot && !existsSync(client.docsRoot)) { + missingRoots.push(`${client.label} docs root: ${client.docsRoot}`); + } + } + + for await (const file of markdownFiles(chronicleDocsRoot)) { + const body = await fs.readFile(file, 'utf8'); + const rel = path.relative(chronicleDocsRoot, file).replace(/\\/g, '/'); + const { ranges, fences } = fenceRangesAndLanguages(body); + + for (const fence of fences) { + const fileEntry = directFences.get(rel) ?? {}; + fileEntry[fence.lang] = (fileEntry[fence.lang] ?? 0) + 1; + directFences.set(rel, fileEntry); + } + + const componentRe = /^[ \t]*]*)\/>[ \t]*$/gm; + for (const match of body.matchAll(componentRe)) { + const index = match.index ?? 0; + if (isInRange(index, ranges)) continue; + + const attrs = match[1]; + const snippet = getAttr(attrs, 'snippet'); + if (!snippet) { + missingSnippets.push(`${rel}: ChronicleClientTabs is missing snippet="..."`); + continue; + } + + const requested = (getAttr(attrs, 'clients') ?? defaultClients.map((client) => client.key).join(',')) + .split(',') + .map((client) => client.trim().toLowerCase()) + .filter(Boolean); + + placeholders.push({ file: rel, snippet, clients: requested }); + + for (const key of requested) { + const client = clients.find((candidate) => candidate.key === key); + if (!client) { + missingSnippets.push(`${rel}: unknown Chronicle client "${key}" for snippet "${snippet}"`); + continue; + } + if (!(await snippetExists(client, snippet))) { + missingSnippets.push(`${rel}: missing ${client.label} snippet "${snippet}" in ${client.snippetRoot}`); + } + } + } + } + + return { directFences, placeholders, missingSnippets, missingRoots }; +} + +function directFenceBaselineMap(directFences) { + return Object.fromEntries( + [...directFences.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([file, languages]) => [ + file, + Object.fromEntries( + Object.entries(languages) + .filter(([, count]) => count > 0) + .sort(([a], [b]) => a.localeCompare(b)) + ), + ]) + ); +} + +async function readBaseline(filePath) { + const absolute = path.resolve(webRoot, filePath); + const raw = await fs.readFile(absolute, 'utf8'); + const parsed = JSON.parse(raw); + return parsed.directClientLanguageFences ?? {}; +} + +function compareBaseline(current, baseline) { + const regressions = []; + for (const [file, languages] of Object.entries(current)) { + for (const [language, count] of Object.entries(languages)) { + const allowed = baseline[file]?.[language] ?? 0; + if (count > allowed) { + regressions.push(`${file}: ${language} fences increased from ${allowed} to ${count}`); + } + } + } + return regressions; +} + +function totalFences(map) { + return Object.values(map) + .flatMap((languages) => Object.values(languages)) + .reduce((total, count) => total + count, 0); +} + +function printTopDirectFences(current) { + const rows = Object.entries(current) + .map(([file, languages]) => ({ + file, + count: Object.values(languages).reduce((total, count) => total + count, 0), + languages, + })) + .sort((a, b) => b.count - a.count || a.file.localeCompare(b.file)) + .slice(0, 20); + + for (const row of rows) { + const summary = Object.entries(row.languages) + .map(([language, count]) => `${language}:${count}`) + .join(', '); + console.warn(` ${row.file} (${summary})`); + } +} + +const audit = await collectAudit(); +const current = directFenceBaselineMap(audit.directFences); + +if (writeBaselinePath) { + const absolute = path.resolve(webRoot, writeBaselinePath); + const baseline = { + version: 1, + description: 'Known direct client-language fences in shared Chronicle docs. Lower counts are allowed; increases fail the audit.', + directClientLanguageFences: current, + }; + await fs.writeFile(absolute, JSON.stringify(baseline, null, 2) + '\n', 'utf8'); + console.log(`[chronicle-client-docs] Wrote baseline: ${path.relative(webRoot, absolute)}`); +} + +const directFenceCount = totalFences(current); +console.log(`[chronicle-client-docs] Checked ${clients.length} clients`); +console.log(`[chronicle-client-docs] Found ${audit.placeholders.length} ChronicleClientTabs placeholders`); +console.log(`[chronicle-client-docs] Found ${directFenceCount} direct client-language fences in shared Chronicle docs`); + +if (directFenceCount > 0) { + console.warn('[chronicle-client-docs] Top shared-doc files still needing snippet migration:'); + printTopDirectFences(current); +} + +const failures = []; +failures.push(...audit.missingRoots.map((message) => `Missing root: ${message}`)); +failures.push(...audit.missingSnippets); + +if (baselinePath) { + const baseline = await readBaseline(baselinePath); + failures.push(...compareBaseline(current, baseline)); +} + +if (strict && directFenceCount > 0) { + failures.push(`Strict mode failed: ${directFenceCount} direct client-language fences remain in shared Chronicle docs`); +} + +if (failures.length) { + console.error('[chronicle-client-docs] Audit failed:'); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); +} + +console.log('[chronicle-client-docs] Audit passed'); diff --git a/web/scripts/check-chronicle-client-docs.mjs b/web/scripts/check-chronicle-client-docs.mjs new file mode 100644 index 0000000..6901de4 --- /dev/null +++ b/web/scripts/check-chronicle-client-docs.mjs @@ -0,0 +1,207 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { loadChronicleClientDocsConfig, webRoot } from './chronicle-client-docs-config.mjs'; + +const args = new Set(process.argv.slice(2)); +const strictToolchains = args.has('--strict-toolchains'); + +function run(command, commandArgs, cwd) { + return new Promise((resolve) => { + const child = spawn(command, commandArgs, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }); + + let output = ''; + child.stdout.on('data', (chunk) => { output += chunk.toString(); }); + child.stderr.on('data', (chunk) => { output += chunk.toString(); }); + child.on('error', (error) => { + resolve({ code: 127, output: `${error.name}: ${error.message}`, error }); + }); + child.on('close', (code) => { + resolve({ code: code ?? 1, output }); + }); + }); +} + +async function countSnippetFiles(root) { + let count = 0; + let entries; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + return 0; + } + + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + count += await countSnippetFiles(entryPath); + } else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) { + count++; + } + } + + return count; +} + +async function* markdownFiles(root, current = root) { + let entries; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (['.git', 'client-snippets', 'client-snippets-java', 'node_modules'].includes(entry.name)) continue; + yield* markdownFiles(root, entryPath); + } else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) { + yield entryPath; + } + } +} + +function matchesPattern(relativePath, pattern) { + const normalized = relativePath.replace(/\\/g, '/'); + if (pattern.endsWith('/**')) { + const prefix = pattern.slice(0, -3); + return normalized === prefix || normalized.startsWith(`${prefix}/`); + } + return normalized === pattern; +} + +function isSharedTopicBridge(body) { + const match = body.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return false; + return /^sharedTopicBridge:\s*true\s*$/m.test(match[1]); +} + +async function sharedTopicPages(client, patterns) { + if (!client.publicDocs || !patterns.length) return []; + + const matches = []; + for await (const file of markdownFiles(client.publicDocs.root)) { + const rel = path.relative(client.publicDocs.root, file).replace(/\\/g, '/'); + if (patterns.some((pattern) => matchesPattern(rel, pattern))) { + const body = await fs.readFile(file, 'utf8'); + if (isSharedTopicBridge(body)) continue; + matches.push(rel); + } + } + return matches.sort((a, b) => a.localeCompare(b)); +} + +function isBlockedToolchain(client, result) { + if (result.error?.code === 'ENOENT') return true; + return client.validator.blockedOutput.some((pattern) => result.output.includes(pattern)); +} + +function printIndented(output) { + const trimmed = output.trim(); + if (!trimmed) return; + for (const line of trimmed.split(/\r?\n/)) { + console.error(` ${line}`); + } +} + +const config = await loadChronicleClientDocsConfig(); +const failures = []; +const blocked = []; + +console.log('[chronicle-client-docs] Running shared-doc audit'); +const audit = await run(process.execPath, [ + 'scripts/audit-chronicle-client-docs.mjs', + '--strict', + '--baseline', + 'scripts/chronicle-client-docs-baseline.json', +], webRoot); + +if (audit.output.trim()) { + console.log(audit.output.trim()); +} +if (audit.code !== 0) { + failures.push('Shared-doc audit failed'); +} + +console.log('[chronicle-client-docs] Checking legacy snippet baselines'); +for (const client of config.clients) { + const count = await countSnippetFiles(path.join(client.snippetRoot, 'legacy')); + const baseline = client.legacySnippetBaseline; + if (count > baseline) { + failures.push(`${client.label} legacy snippets increased from ${baseline} to ${count}`); + } else { + const suffix = count < baseline ? `, below baseline ${baseline}` : ''; + console.log(`[chronicle-client-docs] ${client.label}: ${count} legacy snippets${suffix}`); + } +} + +console.log('[chronicle-client-docs] Checking client public docs for shared-topic overlap'); +for (const client of config.clients) { + if (!client.publicDocs) continue; + + const matches = await sharedTopicPages(client, config.publicDocsAudit.sharedTopicPatterns); + const baseline = config.publicDocsAudit.baselines[client.key] ?? 0; + if (matches.length > baseline) { + failures.push(`${client.label} public shared-topic pages increased from ${baseline} to ${matches.length}`); + } + + const suffix = matches.length < baseline ? `, below baseline ${baseline}` : ''; + console.log(`[chronicle-client-docs] ${client.label}: ${matches.length} public shared-topic pages${suffix}`); + if (matches.length) { + const preview = matches.slice(0, 8).join(', '); + const more = matches.length > 8 ? `, +${matches.length - 8} more` : ''; + console.log(`[chronicle-client-docs] ${client.label}: ${preview}${more}`); + } +} + +console.log('[chronicle-client-docs] Running client snippet validators'); +for (const client of config.clients) { + if (!client.validator) { + console.log(`[chronicle-client-docs] ${client.label}: no validator configured`); + continue; + } + + const result = await run(client.validator.command, client.validator.args, client.validator.cwd); + if (result.code === 0) { + console.log(`[chronicle-client-docs] ${client.label}: validator passed`); + continue; + } + + if (isBlockedToolchain(client, result)) { + const message = `${client.label}: validator blocked by missing local toolchain`; + blocked.push(message); + console.warn(`[chronicle-client-docs] ${message}`); + continue; + } + + failures.push(`${client.label}: validator failed`); + printIndented(result.output); +} + +if (strictToolchains && blocked.length) { + failures.push(...blocked); +} + +if (failures.length) { + console.error('[chronicle-client-docs] Check failed:'); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); +} + +if (blocked.length) { + console.warn('[chronicle-client-docs] Check passed with blocked local validators:'); + for (const item of blocked) { + console.warn(` - ${item}`); + } + console.warn('[chronicle-client-docs] Use --strict-toolchains in CI to make blocked validators fail.'); +} else { + console.log('[chronicle-client-docs] Check passed'); +} diff --git a/web/scripts/chronicle-client-docs-baseline.json b/web/scripts/chronicle-client-docs-baseline.json new file mode 100644 index 0000000..1f218bb --- /dev/null +++ b/web/scripts/chronicle-client-docs-baseline.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "description": "Known direct client-language fences in shared Chronicle docs. Lower counts are allowed; increases fail the audit.", + "directClientLanguageFences": {} +} diff --git a/web/scripts/chronicle-client-docs-config.mjs b/web/scripts/chronicle-client-docs-config.mjs new file mode 100644 index 0000000..7b0f471 --- /dev/null +++ b/web/scripts/chronicle-client-docs-config.mjs @@ -0,0 +1,136 @@ +import { existsSync } from 'node:fs'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const webRoot = path.resolve(here, '..'); +export const chronicleClientDocsManifestPath = path.join(webRoot, 'chronicle-client-docs.yml'); + +function requireObject(value, name) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`[chronicle-client-docs] ${name} must be an object`); + } + return value; +} + +function requireArray(value, name) { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`[chronicle-client-docs] ${name} must be a non-empty array`); + } + return value; +} + +function resolveFromWebRoot(candidate) { + return path.resolve(webRoot, candidate); +} + +function firstExistingPath(candidates, name) { + const resolved = requireArray(candidates, name).map(resolveFromWebRoot); + return resolved.find((candidate) => existsSync(candidate)) ?? resolved[resolved.length - 1]; +} + +function normalizeValidator(key, validator) { + if (!validator) return null; + requireObject(validator, `clients.${key}.validator`); + + const command = validator.command; + if (!command || typeof command !== 'string') { + throw new Error(`[chronicle-client-docs] clients.${key}.validator.command must be a string`); + } + + return { + cwd: firstExistingPath(validator.cwd, `clients.${key}.validator.cwd`), + command, + args: Array.isArray(validator.args) ? validator.args.map(String) : [], + blockedOutput: Array.isArray(validator.blockedOutput) ? validator.blockedOutput.map(String) : [], + }; +} + +function normalizeSharedTopics(topics) { + if (!topics) return []; + return requireArray(topics, 'sharedTopics').map((topic, index) => { + const item = requireObject(topic, `sharedTopics[${index}]`); + if (!item.label || typeof item.label !== 'string') { + throw new Error(`[chronicle-client-docs] sharedTopics[${index}].label must be a string`); + } + if (!item.href || typeof item.href !== 'string') { + throw new Error(`[chronicle-client-docs] sharedTopics[${index}].href must be a string`); + } + return { + label: item.label, + href: item.href, + }; + }); +} + +function normalizePublicDocsAudit(audit) { + if (!audit) return { sharedTopicPatterns: [], baselines: {} }; + const node = requireObject(audit, 'publicDocsAudit'); + const baselines = node.baselines ? requireObject(node.baselines, 'publicDocsAudit.baselines') : {}; + return { + sharedTopicPatterns: Array.isArray(node.sharedTopicPatterns) + ? node.sharedTopicPatterns.map(String) + : [], + baselines: Object.fromEntries( + Object.entries(baselines).map(([key, value]) => [key, Number(value ?? 0)]) + ), + }; +} + +export async function loadChronicleClientDocsConfig() { + const raw = await fs.readFile(chronicleClientDocsManifestPath, 'utf8'); + const manifest = requireObject(yaml.load(raw), 'manifest'); + const clientsNode = requireObject(manifest.clients, 'clients'); + + const clients = Object.entries(clientsNode).map(([key, value]) => { + const client = requireObject(value, `clients.${key}`); + const snippets = requireObject(client.snippets, `clients.${key}.snippets`); + const publicDocs = client.publicDocs ? requireObject(client.publicDocs, `clients.${key}.publicDocs`) : null; + + return { + key, + label: client.label ?? key, + includeByDefault: client.includeByDefault !== false, + snippetRoot: firstExistingPath(snippets.paths, `clients.${key}.snippets.paths`), + legacySnippetBaseline: Number(snippets.legacyBaseline ?? 0), + publicDocs: publicDocs + ? { + key: publicDocs.key ?? key, + label: publicDocs.label ?? client.label ?? key, + root: firstExistingPath(publicDocs.paths, `clients.${key}.publicDocs.paths`), + } + : null, + validator: normalizeValidator(key, client.validator), + }; + }); + + return { + manifestPath: chronicleClientDocsManifestPath, + sharedDocsRoot: firstExistingPath(manifest.sharedDocs?.paths, 'sharedDocs.paths'), + sharedTopics: normalizeSharedTopics(manifest.sharedTopics), + publicDocsAudit: normalizePublicDocsAudit(manifest.publicDocsAudit), + clients, + snippetClients: clients.map((client) => ({ + key: client.key, + label: client.label, + src: client.snippetRoot, + })), + defaultSnippetClients: clients + .filter((client) => client.includeByDefault) + .map((client) => ({ + key: client.key, + label: client.label, + src: client.snippetRoot, + })), + publicDocsClients: clients + .filter((client) => client.publicDocs) + .map((client) => ({ + key: client.publicDocs.key, + label: client.publicDocs.label, + src: client.publicDocs.root, + clientKey: client.key, + })), + }; +} diff --git a/web/scripts/sync-content.mjs b/web/scripts/sync-content.mjs index 2856f30..38ae489 100644 --- a/web/scripts/sync-content.mjs +++ b/web/scripts/sync-content.mjs @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import yaml from 'js-yaml'; import { existsSync } from 'node:fs'; +import { loadChronicleClientDocsConfig } from './chronicle-client-docs-config.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const webRoot = path.resolve(here, '..'); // Documentation/web @@ -28,12 +29,12 @@ function firstExisting(...candidates) { return candidates.find((c) => existsSync(c)) ?? candidates[candidates.length - 1]; } +const chronicleClientDocsConfig = await loadChronicleClientDocsConfig(); + const PRODUCTS = [ { key: 'chronicle', label: 'Chronicle', icon: 'seti:db', sidebarMode: 'toc', - src: firstExisting( - path.join(reposRoot, 'Chronicle', 'Documentation'), - path.join(docRepoRoot, 'Chronicle', 'Documentation')), + src: chronicleClientDocsConfig.sharedDocsRoot, buckets: [ { label: 'Start here', sections: ['Getting started', 'Tutorial', 'Scenarios'] }, { @@ -49,7 +50,7 @@ const PRODUCTS = [ }, { label: 'Read models and processing', - sections: ['Read Models', 'Projections', 'Reactors', 'Reducers', 'Subscriptions', 'Sinks'], + sections: ['Read Models', 'Projections', 'Reactors', 'Reducers', 'Subscriptions', 'Sinks', 'Jobs', 'Webhooks'], }, { label: 'Running Chronicle', @@ -135,11 +136,19 @@ const PRODUCTS = [ }, ]; +const CHRONICLE_CLIENT_SNIPPETS = chronicleClientDocsConfig.snippetClients; +const DEFAULT_CHRONICLE_CLIENT_SNIPPETS = chronicleClientDocsConfig.defaultSnippetClients; +const CHRONICLE_CLIENT_DOCS = chronicleClientDocsConfig.publicDocsClients; +const CHRONICLE_SHARED_TOPICS = chronicleClientDocsConfig.sharedTopics; + const ASSET_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.html', '.js', '.css', '.json']); const SKIP_DIRS = new Set([ 'node_modules', 'obj', 'bin', '.git', 'storybook-static', '.vitepress', // Shared Markdown snippets are included into pages but should not become pages. '_includes', '_shared', '_snippets', + // Client-owned snippets are expanded into shared Chronicle pages through + // ; they are not standalone public docs pages. + 'client-snippets', 'client-snippets-java', // dotfolders found at the .github repo root that are not documentation '.ai', '.claude', '.github', '.vscode', // the org GitHub landing page (duplicates our front door) — not site content @@ -296,11 +305,13 @@ function resolveInternalLink(ctx, target) { if (!ext) resolvedPath = withTrailingSlash(resolvedPath); } } else { + const contentRoot = ctx.contentRoot ?? ctx.product.src; + const slugBase = ctx.slugBase ?? ctx.product.key; const absoluteTarget = path.resolve(ctx.dir, strippedPath || '.'); - const relToProduct = path.relative(ctx.product.src, absoluteTarget).replace(/\\/g, '/'); + const relToProduct = path.relative(contentRoot, absoluteTarget).replace(/\\/g, '/'); if (relToProduct.startsWith('..') || path.isAbsolute(relToProduct)) return target; const slug = slugifyPath(relToProduct).replace(/^\/+|\/+$/g, ''); - resolvedPath = withTrailingSlash('/' + ctx.product.key + (slug ? '/' + slug : '')); + resolvedPath = withTrailingSlash('/' + slugBase + (slug ? '/' + slug : '')); } return resolvedPath + urlSuffix + titleSuffix; @@ -337,6 +348,123 @@ async function inlineIncludes(body, dir, sourcePath) { return result; } +function getAttr(attrs, name) { + const m = attrs.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`)); + return m ? (m[2] ?? m[3] ?? '') : null; +} + +async function fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readClientSnippet(source, snippet) { + for (const ext of ['.mdx', '.md']) { + const candidate = path.join(source.src, snippet + ext); + if (await fileExists(candidate)) { + const raw = await fs.readFile(candidate, 'utf8'); + const { body } = splitFrontmatter(raw); + return body.trim(); + } + } + throw new Error( + `[sync] Missing Chronicle ${source.label} client snippet "${snippet}" in ${source.src}` + ); +} + +function isInsideFencedCode(body, index) { + const prefix = body.slice(0, index); + const backtickFences = prefix.match(/^```/gm)?.length ?? 0; + const tildeFences = prefix.match(/^~~~/gm)?.length ?? 0; + return backtickFences % 2 === 1 || tildeFences % 2 === 1; +} + +function ensureTabsImport(body) { + const importRe = /import\s+\{([^}]+)\}\s+from\s+['"]@astrojs\/starlight\/components['"];?/; + const existing = body.match(importRe); + if (!existing) { + return `import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n${body}`; + } + + const names = existing[1] + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + for (const name of ['Tabs', 'TabItem']) { + if (!names.includes(name)) names.push(name); + } + + return body.replace(importRe, `import { ${names.join(', ')} } from '@astrojs/starlight/components';`); +} + +async function expandChronicleClientTabs(body, ctx) { + if (ctx.product.key !== 'chronicle' || !body.includes(']*)\/>[ \t]*$/gm; + const parts = []; + let expandedAny = false; + let lastIndex = 0; + + for (const match of body.matchAll(componentRe)) { + const index = match.index ?? 0; + if (isInsideFencedCode(body, index)) { + continue; + } + + const attrs = match[1]; + const snippet = getAttr(attrs, 'snippet'); + if (!snippet) { + throw new Error(`[sync] ChronicleClientTabs in ${ctx.srcPath} is missing snippet="..."`); + } + + const syncKey = getAttr(attrs, 'syncKey') ?? 'chronicle-client'; + const clientsAttr = getAttr(attrs, 'clients'); + const requested = clientsAttr + ? clientsAttr.split(',').map((client) => client.trim().toLowerCase()).filter(Boolean) + : DEFAULT_CHRONICLE_CLIENT_SNIPPETS.map((source) => source.key); + + const tabs = []; + for (const key of requested) { + const source = CHRONICLE_CLIENT_SNIPPETS.find((candidate) => candidate.key === key); + if (!source) { + throw new Error(`[sync] Unknown Chronicle client snippet key "${key}" in ${ctx.srcPath}`); + } + const content = await readClientSnippet(source, snippet); + tabs.push({ source, content }); + } + + const expanded = [ + ``, + ...tabs.flatMap(({ source, content }) => [ + ``, + '', + content, + '', + ``, + ]), + ``, + ].join('\n'); + + parts.push(body.slice(lastIndex, index), expanded); + lastIndex = index + match[0].length; + expandedAny = true; + } + + if (!expandedAny) { + return { body, used: false }; + } + + parts.push(body.slice(lastIndex)); + const result = parts.join(''); + return { body: expandedAny ? ensureTabsImport(result) : result, used: expandedAny }; +} + async function convertFile(raw, ctx) { const { fmText, body, hasFm } = splitFrontmatter(raw); // Parse source front matter and carry over only Starlight-supported keys @@ -354,6 +482,7 @@ async function convertFile(raw, ctx) { let out = stripLeadingH1(body); out = await inlineIncludes(out, ctx.dir, ctx.srcPath ?? path.join(ctx.dir, ctx.basename)); + ({ body: out } = await expandChronicleClientTabs(out, ctx)); out = convertAlerts(out); out = convertXref(out); out = fixLinks(out, ctx); @@ -379,14 +508,15 @@ async function hasSiblingLanding(parentDir, dirName) { } } -async function walk(srcDir, outDir, product) { +async function walk(srcDir, outDir, product, options = {}) { const entries = await fs.readdir(srcDir, { withFileTypes: true }); await fs.mkdir(outDir, { recursive: true }); const demoteIndex = await hasSiblingLanding(path.dirname(srcDir), path.basename(srcDir)); for (const entry of entries) { if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; - await walk(path.join(srcDir, entry.name), path.join(outDir, entry.name), product); + if (product.key === 'chronicle' && !options.slugBase && path.resolve(srcDir) === path.resolve(product.src) && entry.name === 'clients') continue; + await walk(path.join(srcDir, entry.name), path.join(outDir, entry.name), product, options); continue; } // Skip repo READMEs (e.g. the .github org landing) — not site content. @@ -400,6 +530,8 @@ async function walk(srcDir, outDir, product) { basename: entry.name, srcPath, product, + contentRoot: options.contentRoot, + slugBase: options.slugBase, }); // Keep the source extension: `.md` stays Markdown, `.mdx` stays MDX so // authored getting-started pages can use Starlight components (, @@ -416,6 +548,53 @@ async function walk(srcDir, outDir, product) { } } +async function availableChronicleClientDocs() { + const available = []; + for (const client of CHRONICLE_CLIENT_DOCS) { + try { + await fs.access(client.src); + available.push(client); + } catch { + // Client repositories are optional for local partial builds. + } + } + return available; +} + +async function writeChronicleClientsLanding(outDir, clients) { + if (!clients.length) return; + + const clientsDir = path.join(outDir, 'clients'); + await fs.mkdir(clientsDir, { recursive: true }); + const topicLinks = CHRONICLE_SHARED_TOPICS + .map((topic) => `- [${topic.label}](${topic.href})`) + .join('\n'); + const clientLinks = clients + .map((client) => `- [${client.label}](/chronicle/clients/${client.key}/)`) + .join('\n'); + + const body = `---\ntitle: Client SDKs\n---\n\nChronicle concepts, guides, and feature workflows live in the shared Chronicle docs and use language tabs when the client APIs differ. Use the client SDK sections for installation, connection setup, runtime integration, language idioms, and API reference details.\n\n## Shared Chronicle topics\n\n${topicLinks}\n\n## Client SDK details\n\n${clientLinks}\n`; + + await fs.writeFile(path.join(clientsDir, 'index.md'), body, 'utf8'); +} + +async function syncChronicleClientDocs(outDir, product) { + const clients = await availableChronicleClientDocs(); + await writeChronicleClientsLanding(outDir, clients); + + for (const client of clients) { + await walk( + client.src, + path.join(outDir, 'clients', client.key), + product, + { + contentRoot: client.src, + slugBase: `chronicle/clients/${client.key}`, + } + ); + } +} + // ---- Sidebar generation from DocFX toc.yml ---- // Replicates Astro's content-collection slug rule (github-slugger semantics): @@ -560,6 +739,50 @@ function applyBadges(items) { return items; } +async function chronicleClientSidebarItems() { + const clients = await availableChronicleClientDocs(); + const items = []; + + for (const client of clients) { + const slugBase = `chronicle/clients/${client.key}`; + const clientItems = await tocToSidebar(client.src, slugBase); + items.push({ + label: client.label, + collapsed: true, + items: clientItems.length + ? clientItems + : [{ autogenerate: { directory: slugBase } }], + }); + } + + return items; +} + +async function addChronicleClientSidebar(items) { + const clientItems = await chronicleClientSidebarItems(); + if (!clientItems.length) return items; + + const group = { + label: 'Client SDKs', + collapsed: true, + items: [ + { label: 'Overview', slug: 'chronicle/clients' }, + ...clientItems, + ], + }; + + const startHereIndex = items.findIndex((item) => item.label === 'Start here'); + if (startHereIndex >= 0) { + return [ + ...items.slice(0, startHereIndex + 1), + group, + ...items.slice(startHereIndex + 1), + ]; + } + + return [group, ...items]; +} + // Emit one Diataxis-bucketed sidebar per product as a `starlight-sidebar-topics` // topic ({ label, link, icon, items }). The plugin renders the product icons as a // switchable rail at the top of the sidebar and shows the matching product's nav @@ -579,6 +802,7 @@ async function generateSidebar() { items = await tocToSidebar(product.src, product.key); if (items.length === 0) items = [{ autogenerate: { directory: product.key } }]; else if (product.buckets) items = applyBuckets(items, product.buckets); + if (product.key === 'chronicle') items = await addChronicleClientSidebar(items); } else { items = [{ autogenerate: { directory: product.key } }]; } @@ -609,10 +833,14 @@ async function main() { } await fs.rm(outDir, { recursive: true, force: true }); await walk(product.src, outDir, product); + if (product.key === 'chronicle') { + await syncChronicleClientDocs(outDir, product); + } const count = await countFiles(outDir); console.log(`[sync] ${product.key}: ${count} pages -> ${path.relative(webRoot, outDir)}`); } await generateSidebar(); + await clearStaleAstroContentCache(); } async function countFiles(dir) { @@ -625,4 +853,56 @@ async function countFiles(dir) { return n; } +function collectGeneratedContentRefs(value, refs) { + if (typeof value === 'string') { + if (/^src\/content\/docs\/.+\.mdx?$/.test(value)) { + refs.add(value); + } + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectGeneratedContentRefs(item, refs); + } + return; + } + + if (value && typeof value === 'object') { + for (const item of Object.values(value)) { + collectGeneratedContentRefs(item, refs); + } + } +} + +async function clearStaleAstroContentCache() { + const cacheDir = path.join(webRoot, 'node_modules', '.astro'); + const dataStorePath = path.join(cacheDir, 'data-store.json'); + + let raw; + try { + raw = await fs.readFile(dataStorePath, 'utf8'); + } catch { + return; + } + + let dataStore; + try { + dataStore = JSON.parse(raw); + } catch { + await fs.rm(cacheDir, { recursive: true, force: true }); + console.log('[sync] cleared invalid Astro content cache'); + return; + } + + const refs = new Set(); + collectGeneratedContentRefs(dataStore, refs); + const missing = [...refs].filter((ref) => !existsSync(path.join(webRoot, ref))); + + if (missing.length) { + await fs.rm(cacheDir, { recursive: true, force: true }); + console.log(`[sync] cleared stale Astro content cache (${missing.length} missing generated source refs)`); + } +} + await main(); diff --git a/web/src/styles/cratis.css b/web/src/styles/cratis.css index c326eb2..a2e7ba6 100644 --- a/web/src/styles/cratis.css +++ b/web/src/styles/cratis.css @@ -323,6 +323,76 @@ border-radius: 0.5rem; } +/* --- Chronicle client tabs: make language selection read as an explicit + switcher instead of a quiet underline above the code block. */ +starlight-tabs[data-sync-key^='chronicle-client'] .tablist-wrapper { + margin-block: 1.15rem 0.8rem; + border: 0; + overflow-x: auto; +} + +starlight-tabs[data-sync-key^='chronicle-client'] [role='tablist'] { + display: inline-flex; + gap: 0.2rem; + min-width: max-content; + margin: 0; + padding: 0.25rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.55rem; + background: + linear-gradient(180deg, color-mix(in srgb, var(--sl-color-accent) 7%, transparent), transparent), + color-mix(in srgb, var(--sl-color-gray-6) 78%, var(--sl-color-bg)); + box-shadow: + inset 0 1px 0 color-mix(in srgb, var(--sl-color-white) 4%, transparent), + 0 8px 20px -18px color-mix(in srgb, var(--sl-color-accent) 80%, transparent); +} + +starlight-tabs[data-sync-key^='chronicle-client'] .tab { + margin: 0; + border: 0; +} + +starlight-tabs[data-sync-key^='chronicle-client'] a[role='tab'] { + display: block; + min-width: 3.25rem; + padding: 0.4rem 0.75rem; + border: 1px solid transparent; + border-radius: 0.4rem; + color: var(--sl-color-gray-2); + font-size: var(--sl-text-xs); + font-weight: 700; + line-height: 1.2; + text-align: center; + text-decoration: none; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +starlight-tabs[data-sync-key^='chronicle-client'] a[role='tab']:hover, +starlight-tabs[data-sync-key^='chronicle-client'] a[role='tab']:focus-visible { + background: color-mix(in srgb, var(--sl-color-accent) 10%, transparent); + color: var(--sl-color-white); +} + +starlight-tabs[data-sync-key^='chronicle-client'] a[role='tab'][aria-selected='true'] { + border-color: color-mix(in srgb, var(--sl-color-accent) 42%, var(--sl-color-gray-5)); + background: var(--sl-color-bg); + color: var(--sl-color-accent-high); + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.14), + inset 0 1px 0 color-mix(in srgb, var(--sl-color-white) 5%, transparent); +} + +:root[data-theme='light'] starlight-tabs[data-sync-key^='chronicle-client'] [role='tablist'] { + background: + linear-gradient(180deg, color-mix(in srgb, var(--sl-color-accent) 5%, transparent), transparent), + color-mix(in srgb, var(--sl-color-gray-6) 72%, var(--sl-color-bg)); +} + +:root[data-theme='light'] starlight-tabs[data-sync-key^='chronicle-client'] a[role='tab']:hover, +:root[data-theme='light'] starlight-tabs[data-sync-key^='chronicle-client'] a[role='tab']:focus-visible { + color: var(--sl-color-accent-high); +} + /* --- Inline code: a brand-tinted chip instead of the flat gray box --- Following aspire.dev: the surface mixes the surrounding text color (so the chip adapts to its context, including inside asides) with black/white, the