Skip to content

Outcome Context — Query Layer Build Plan

Date: 2026-05-04 (original); last updated: 2026-05-05 (status banner). Status: Implemented. D1–D4 plus the AmericanFootball template-registry migration shipped on branch LBS-1183 (PR #1389). The plan body below is preserved as the as-designed reference. Reads alongside: gap analysis, convergence reference.

Implementation status (added 2026-05-05)

  • D1 — Rule 3 dropped, worldSetRef is informational, CONTEXT_VERSION_MISMATCH retired.
  • D2OutcomeDefinition.Definition is a postfix-template string?. ExpandDerived recursively expands derived outcomes down to raw stored counters.
  • D3 — Split into IOutcomeTemplateCatalog (sport-agnostic) + ISimulationCatalogStore (per-run rosters + fixtures). OutcomeCatalogMaterializer.Materialise(templates, simCatalog) is the live entry point.
  • D4IOutcomeContextStore extracted; ClickHouse code lifted into LBS.OutcomeContext.Storage; ContextRepository is async.
  • Catalogue migrationAmericanFootballOutcomeCatalogue and IOutcomeCatalogue retired. The full ID surface is materialised from AmericanFootballOutcomeTemplates × SimulationCatalog. The simulator-vs-templates contract test in AmericanFootballOutcomeCatalogueTests.TemplatesContainAllAccumulatorSeasonOutcomes is the regression guard.
  • Box-score additionsTEAM_FIRST_DOWNS, TEAM_TOTAL_YARDS_GAME, penalties (count + yards), 3rd/4th down efficiency, red zone, time of possession, two-point conversions are produced by GameFlowAccumulator + TeamScoringAccumulator. Remaining gaps (per-defender, punter, returner, safeties) blocked on simulation-side Play model extensions; documented inline in AmericanFootballOutcomeTemplates.cs.

This plan covers how the GraphQL query-layer prototype was brought into lbs.foundry/ and docked onto the Outcome Context storage experiment. Originally written as a pre-implementation proposal; the work has now landed.

The plan respects the gap-analysis principle: D1–D4 are committed enough to start coding against, but if implementation surfaces a constraint that breaks an assumption, the gap analysis gets updated rather than worked around.


1. Project placement

The Outcome Context query layer is sport-agnostic — nothing about expressions, postfix tokens, canonicalisation, type checking, or stack evaluation is American-Football-specific. The earlier draft of this section put everything under LBS.Model.AmericanFootball.*, which conflated the engine with the sport binding. Corrected: three new projects, all under a new src/OutcomeContext/ folder.

1.1 New project — LBS.OutcomeContext.Contracts

Sport-agnostic data types referenced by both storage (write side) and the query layer (read side). Class library, no project dependencies, minimal package surface.

Houses:

Type Role
OutcomeRow One outcome's per-world values
OutcomeDefinition (record) + OutcomeValueType (constants) Catalog entry shape; D2 lands Definition here
GameOutcomeContext, SeasonOutcomeContext Storage-side context types
OutcomeIdParser {TYPE}_{TIMEPERIOD}_{PARTICIPANTID} id parser, anchored on time-period vocabulary
TimePeriodConstants GAME / SEASON / HALF1 / HALF2 / Q1 / Q2 / Q3 / Q4 / OT

LBS.Model.AmericanFootball.Accumulation references this project for the storage contract types. The American Football catalogue (AmericanFootballOutcomeCatalogue) and the play accumulators stay in Accumulation — those are genuinely sport-coupled (they take Team parameters, walk plays, derive sport-specific stats).

1.2 New project — LBS.OutcomeContext.Query

Sport-agnostic query engine. Class library, depends only on LBS.OutcomeContext.Contracts (and on whichever project ends up owning IOutcomeContextStore after the storage-layer rework — see §3).

Houses:

Concern Type / module
Query-side context abstraction IOutcomeContext interface
Wire input contract ExpressionInput, OutcomeRefInput, BinaryExprInput, UnaryExprInput
Internal postfix tokens PostfixToken (sealed hierarchy: OutcomeToken, ConstToken, OpToken)
Translation InfixToPostfix.Translate(...)
Canonicalisation ExpressionCanonical (ExpandDerived, Canonicalise, ToPostfix, ToInfix, IsBooleanRoot, HashOfPostfix)
Type-checking TypeRule, DefaultTypeRules, TypeChecker, EvalWarning, EvalError
Evaluation (inner ring) PostfixEvaluator, EvaluationInternalResult
Outer wrapper EvaluationEngine, EvaluationResult
Repository adapter ContextRepository — thin adapter from IOutcomeContextStore to IOutcomeContext[] (Phase 0e)
Discovery types Outcome, OutcomeFilter, OutcomeCatalog, OutcomeCatalogEntry
Hard-rule constants OperationConstants, BinaryOperatorConstants, UnaryOperatorConstants, ContextTypeConstants, ResultTypeConstants, RuleSeverityConstants, EffectiveTypeConstants (prototype enums become static class Foo { public const string Bar = "BAR"; })

Namespace: LBS.OutcomeContext.Query, with sub-namespaces …Query.Expressions, …Query.Evaluation, …Query.Discovery. Keeps the ring boundary the prototype's CLAUDE.md describes: outer-ring types live next to outer-ring types, etc.

1.3 New app — LBS.OutcomeContext.QueryApi (Phase 0e)

Sibling of LBS.Api, Ballr.WebApi, LBS.Ballr.McpServer under src/Apps/. ASP.NET Core minimal host using HotChocolate 14, implements the prototype's Wave 1 contract. Depends on LBS.OutcomeContext.Query and on the storage project that ships IOutcomeContextStore. Provides:

  • /graphql endpoint with the schema bound from Query, GameContext, SeasonContext, Outcome, etc.
  • DI wiring: IOutcomeContextStore resolved from the storage project; ContextRepository resolved as a scoped service.
  • Telemetry / logging through the same ILoggerFactory patterns the rest of Foundry uses.

A dedicated app rather than mounting on LBS.Api / Ballr.WebApi: the query surface is a different consumer audience (model-evaluation clients, not the existing Foundry domain APIs) and a separate process keeps deploy / scale concerns independent.

1.4 Test project — LBS.OutcomeContext.Query.Tests

xUnit v3, run via dotnet run --project .... Covers Contracts (parser + period constants) and Query (translation, canonicalisation, type checker, evaluator, discovery). Sub-folders mirror the source layout: Contracts/, Expressions/, Discovery/, Evaluation/.

The five canned scenarios in the prototype's file header become the integration-test scaffold for Phase 0e (minus the two that retire under D1).

1.5 Where do the gap-analysis "shared types" land?

Type Lands in Rationale
OutcomeRow, OutcomeDefinition, GameOutcomeContext, SeasonOutcomeContext LBS.OutcomeContext.Contracts Storage contract; sport-agnostic. Accumulation references Contracts.
OutcomeIdParser LBS.OutcomeContext.Contracts The id format is part of the storage contract — both sides need to enforce it.
OutcomeValueType constants LBS.OutcomeContext.Contracts Same reason.
TimePeriodConstants LBS.OutcomeContext.Contracts Same reason — the period vocabulary is part of the id format.
IOutcomeContext LBS.OutcomeContext.Query Query-only abstraction — the evaluator's view over storage's concrete types.
ExpressionInput family LBS.OutcomeContext.Query Wire format for the query language; not storage.
Postfix tokens, evaluator, canonicaliser, type checker LBS.OutcomeContext.Query All ring-2/ring-3 internals.
IOutcomeContextStore LBS.OutcomeContext.Storage (after rework — see §3) Storage's sport-agnostic read API. The Query project depends on this abstraction.
IBlobSink, LocalDiskBlobSink, AzureBlobSink LBS.OutcomeContext.Storage (extracted Phase 0r-2) Sport-agnostic byte-level blob abstraction. Was in LBS.Model.AmericanFootball.Storage; the AmericanFootball project keeps only the genuinely sport-coupled PlayByPlayRecord + Parquet writer.
AmericanFootballOutcomeCatalogue, play accumulators, derivers LBS.Model.AmericanFootball.Accumulation (stays) Genuinely sport-coupled — takes Team parameters, walks plays, computes sport-specific derivations.

2. Task sequencing for D1–D4

The gap analysis suggests D1 → D2 → D3 → D4 by size (smallest first, removes code; biggest last, ties into the storage rework). I confirm that order with one tweak: a Phase 0 lift-and-port of the prototype's sport-agnostic engine into LBS.OutcomeContext.Query happens before D1, because D1 is partly a deletion in the ported code — and it's cleaner to delete from a Foundry-conformant version of the engine than to port and delete in the same task.

Phase 0 — Port the prototype engine (large)

Bring FoundryQueryProto.cs into the new Query project, splitting the single file into per-concern files, applying Foundry conventions (no enums, this. prefix, camelCase private fields, DateTimeOffset, file headers).

Outcome Detail
New files One per type/module from the inventory in §1.1 (~25–30 .cs files).
Files changed None on the storage side. claude.md may gain a row pointing to the Query project's docs once they exist.
Tests added Translation, canonicalisation, type-checker, evaluator unit tests (~80% of the prototype's behaviour-bearing surface, since the prototype has no tests we need to write the regression tests as part of porting).
Hard rules Five (D1's Rule 3 still in place at this stage — D1 deletes it next). The two misalignment fixtures are kept temporarily so the port has the full contract under test before D1 removes that branch.
Size Large. Realistic chunking: (i) inner-ring types + tokens + translator, (ii) canonicalisation + type checker, (iii) evaluator, (iv) GraphQL types + resolvers, (v) repository adapter. Each step ships with tests and is mergeable on its own.

Phase 1 — D1 cleanup (small)

Drop Rule 3, retire CONTEXT_VERSION_MISMATCH, delete the misalignment fixtures, recompute worldSetRef informationally only.

Outcome Detail
Files changed Query.SeasonContext (drop Rule 3 check); EvaluationEngine / error builder (retire CONTEXT_VERSION_MISMATCH); ContextRepository test fixtures (delete kc_lar_NEXT, kc_den_w3); spec/ref docs (already noted in gap analysis cleanup list).
New files None.
Tests added None — tests removed (the two fixtures' assertions). The Rule 2 fixtures stay.
Storage impact None.
Size Small.

Phase 2 — D2 Definition field on OutcomeDefinition (medium)

Add Definition (postfix-template string?) to the storage-side OutcomeDefinition. Update the Query project's ExpressionCanonical.ExpandDerived to consume postfix templates with named-placeholder substitution. Migrate the simple read-side derivations from imperative C# to declarative templates.

Outcome Detail
Files changed Accumulation/OutcomeDefinition.cs (gain string? Definition, breaking record signature change). Accumulation/AmericanFootball/AmericanFootballOutcomeCatalogue.cs (existing call sites updated to pass Definition: null or a template string — the catalog is the authority). Query/Expressions/ExpressionCanonical.cs (ExpandDerived now consumes postfix templates; InjectScope becomes slot-substitution).
New files Query/Expressions/PostfixTemplate.cs (template type — wraps a string Postfix plus named-slot metadata, parses and substitutes). Query/Expressions/SlotBindings.cs (the bag of {name → value} substitutions a consumer reference materialises). Probably a Query/Expressions/PostfixParser.cs if the existing infix parser doesn't already understand the template grammar.
Tests added Template substitution (slot present, slot missing, slot referenced twice). Full ExpandDerived chain: consumer leaf → template hit → substitute → reparse → evaluate. The §11.8 example chain ANYTIME_TD_GAME_{p}TOTAL_TDS_GAME_{p} 1 GTE.
Storage impact DDL / catalog row gains a definition column where the catalog is persisted. Where the catalog persists is open (D2 sub-design). Until that's settled, the catalog is generated in C# (current state) and the new Definition column lives only on the in-memory OutcomeDefinition. The persisted catalog table arrives with D3.
Mermaid ERD Update — OutcomeDefinition gains a field.
Size Medium. The mechanical changes are small; the build pipeline (infix authoring → postfix canonicalisation) needs design before implementation — D2 sub-design (§5.1) blocks completion of this phase.

Phase 3 — D3 catalog split + pre-simulation catalog (large)

Split IOutcomeCatalogue into a roster-agnostic outcome template catalog and a roster-driven pre-simulation catalog producer. Persist the template catalog. Stand up the pre-simulation catalog as a new persisted artefact. Replace the prototype's static OutcomeCatalog with a templated catalog that materialises concretes by combining templates with the pre-sim catalog.

Outcome Detail
Files changed Accumulation/IOutcomeCatalogue.cs (renamed/refactored — see new files; the existing interface signature changes shape entirely). Accumulation/AmericanFootball/AmericanFootballOutcomeCatalogue.cs (becomes a template producer — no Team arguments to the template part; the roster-binding code moves to a new pre-sim-catalog producer). Storage rework code (extracts IOutcomeCatalogStore and IPreSimulationCatalogStore accessors). Query project's ContextRepository (depends on the new shape).
New files Accumulation/OutcomeTemplate.cs (template record per gap analysis §"D2 Resolution"). Accumulation/TemplateSlot.cs (slot record). Accumulation/IOutcomeTemplateCatalog.cs (the roster-agnostic interface). Accumulation/IPreSimulationCatalog.cs (the run-scoped catalog interface — exact name TBD via D3 sub-design). The implementations live in the corresponding sport-binding project (Accumulation/AmericanFootball/...) and the storage project (Storage/...).
Tests added Template registry round-trip. Pre-sim catalog round-trip. Concrete materialisation: template + roster → enumerated concrete IDs. Discovery: outcomes(filter) materialises against a run's pre-sim catalog.
Storage impact New persisted shape for the template catalog (DDL or Marten document, depending on D3 sub-design). New persisted shape for the pre-simulation catalog. The accumulator side (which today calls IOutcomeCatalogue.GetGameOutcomeDefinitions(home, away) to know what to emit) needs a corresponding read against the new shapes — that's a real operational change to the accumulator wiring.
Mermaid ERD Update — new tables / documents for both catalogues.
Spec impact Spec §11.1 (discovery) needs a rewrite. §11.2 (entity resolution) likely simplifies — entities are first-class in the pre-sim catalog.
Size Large. The pre-sim catalog is a genuinely new Foundry concept and needs its own short sub-design before implementation — D3 sub-design (§5.2) blocks completion.

Phase 4 — D4 IOutcomeContextStore (medium-to-large)

Extract IOutcomeContextStore as part of the storage-layer rework. Replace the experiment's ad-hoc ClickHouseBackend.ReadGameOutcomeContextAsync etc. with an implementation behind the interface. Wire the Query project's ContextRepository to depend on IOutcomeContextStore rather than building fixtures in-memory.

Outcome Detail
Files changed StorageExperiment/Infrastructure/IStorageBackend.cs already carries a TODO (lines 17–22) suggesting the split — that TODO is now done as part of the rework. The OC-read methods move to the new IOutcomeContextStore interface; PBP read/write moves to IPlayByPlayStore; analytical query moves to IAnalyticalQueryable. The schema/metrics methods stay on IStorageBackend. ClickHouseBackend splits into multiple implementations. ContextRepository (Query project) gains a constructor dependency on IOutcomeContextStore and the in-memory fixture builder retires entirely (or moves to test scaffolding).
New files Storage/IOutcomeContextStore.cs, Storage/IPlayByPlayStore.cs, Storage/IAnalyticalQueryable.cs. Storage/ClickHouse/ClickHouseOutcomeContextStore.cs and siblings. The exact location depends on whether the rework moves ClickHouse code from StorageExperiment into Storage itself or into a new Storage.ClickHouse project — see §3.
Tests added Integration tests for ClickHouseOutcomeContextStore against Testcontainers ClickHouse (the experiment already has these in StorageExperiment.Tests — they migrate). End-to-end Query-API tests against an IOutcomeContextStore fake at the Query.Tests level, against a real ClickHouse at the integration level.
Storage impact The big one. Replaces the experiment-grade read paths with a production interface. D4 sub-design (§5.3) — method shape — blocks this phase.
Mermaid ERD No ERD impact (interfaces, not schema).
Size Medium-to-large depending on how much of the storage rework lands at the same time. Tightly coupled to §3 below.

Sequencing summary

Phase 0 (port engine)
    └─► Phase 1 (D1 cleanup)               ─── small, isolated
            └─► Phase 2 (D2 Definition)    ─── blocked on D2 sub-design (§5.1)
                    └─► Phase 3 (D3 split) ─── blocked on D3 sub-design (§5.2)
                            └─► Phase 4 (D4 store) ─── blocked on D4 sub-design (§5.3) and §3 storage rework

Phase 0 and Phase 1 can start as soon as this plan is signed off — no blockers. Phases 2/3/4 each gate on their own sub-design; we should stand those up in parallel as soon as Phase 0 is in flight.


3. Storage-layer rework dependencies

D4 is "designed as part of the broader storage-layer rework from experiment to production design." From the storage-experiment status docs, that rework is a known but unscoped piece of work. The relevant facts:

  • Production-quality already: Accumulation library; Storage library (Parquet + IBlobSink). These don't need a rework — they need to absorb the parts that bubble up.
  • Experiment-grade today: StorageExperiment (Exe, CLI harness — Program.cs); IStorageBackend (cohesion-bleeding interface, with explicit TODO for splitting); ClickHouseBackend (the bulk of working code — schemas, async_insert lever, parameterised SQL, allowlisted DDL); StreamingOrchestrator (the validated 100K path, with parallelism, merge cap, season-accumulator integration).
  • The validated-but-disposable distinction: the StorageExperiment was scoped from the outset as "experiment-grade (disposable)" — the harness, experiments, results writer, and Docker Compose are scaffolding. The ClickHouse persistence layer underneath is the keepable bit.

What the rework involves

A reasonable scope for the rework — derived from the experiment status doc, the IStorageBackend TODO, and D4:

  1. Promote the ClickHouse layer out of StorageExperiment. Lands in LBS.OutcomeContext.Storage (sport-agnostic; already houses IBlobSink after Phase 0r-2). Avoid putting sport-agnostic infrastructure under LBS.Model.AmericanFootball.* — that's the recurring naming mistake we corrected in Phase 0r and Phase 0r-2.
  2. Split IStorageBackend. The TODO is explicit — into IStorageBackend (schema/metrics), IOutcomeContextStore (OC R/W + streaming), IPlayByPlayStore, IAnalyticalQueryable. D4 lives in the OC interface; that split is the work that delivers it.
  3. Promote the StreamingOrchestrator into the production assembly (or the parts of it that are operational — chunked write, staging-merge, async_insert lever, parallelism cap).
  4. Retire the experiment harness. StorageExperiment Exe + experiments stay around as a benchmark/diagnostic harness, but the production storage code lives elsewhere.

Overlaps with D4

D4 isn't a separate piece of work — it's the OC-read facet of the rework's interface split. Specifically:

  • D4's GetByScopeIdAsync(scopeId, contextVersion?) ↔ existing ReadGameOutcomeContextAsync / ReadSeasonOutcomeContextAsync (the scope discriminator is what unifies them — see D4 sub-design).
  • D4's bulk-read shape ↔ a new method, since the experiment never needed it (no GraphQL bulk fetch existed).
  • D4's projection (subset of outcome IDs) ↔ existing ReadSelectiveGameOutcomeContextAsync — already implemented, just needs the right interface placement.
  • D4's IOutcomeTemplateCatalog accessor ↔ part of the D3 work (catalog persistence target).
  • D4's IPreSimulationCatalog accessor ↔ part of the D3 work.

What needs to happen on the storage side before D4 can land

  1. Decision on: extend Storage or new Storage.ClickHouse. Recommendation: extend.
  2. The interface split per the IStorageBackend TODO — done at the same time as D4.
  3. ClickHouseBackend lift from StorageExperiment into the production assembly. Code stays the same; namespace and project change.
  4. Retire IStorageBackend.ReadGameOutcomeContextAsync etc. as the surface for query-layer reads — the Query project depends on IOutcomeContextStore only.

This makes Phase 4 a coordinated drop with the storage rework rather than a standalone task. Open question for Mark in §6 — is the storage rework planned for the same window as the query layer, or does the query layer need to go in first against the existing IStorageBackend and migrate later? Both are tractable; the first is cleaner.


4. Pull-across from the prototype

Walked through FoundryQueryProto.cs (1,862 lines, single file). The inventory:

4.1 Move as-is (with Foundry-convention rewrites)

These are sport-agnostic, well-bounded, and don't change shape under D1–D4. The "rewrite" is purely mechanical: enum → string constants, file headers, this. prefix, camelCase fields, copyright headers.

Prototype New location Notes
IOutcomeContext Query/IOutcomeContext.cs No changes.
PostfixToken hierarchy + Operation enum Query/Expressions/PostfixToken.cs (record hierarchy stays); Query/Expressions/Operation.cs (enum → string constants) Operation becomes public static class Operation { public const string Add = "ADD"; ... }.
InfixToPostfix Query/Expressions/InfixToPostfix.cs Direct port.
ExpressionInput and inputs Query/Expressions/ExpressionInput.cs (sealed class), OutcomeRefInput.cs, BinaryExprInput.cs, UnaryExprInput.cs Direct port. The BinaryOp / UnaryOp / ContextType enums become string-constant classes; HotChocolate's EnumType<> bindings re-map them on the GraphQL surface.
TypeChecker, DefaultTypeRules, TypeRule, RuleContext, EvalWarning, EvalError, EffectiveType, RuleSeverity Query/Evaluation/TypeChecker.cs etc. Mechanical port.
PostfixEvaluator, EvaluationInternalResult Query/Evaluation/PostfixEvaluator.cs Mechanical port.
Outcome, OutcomeFilter Query/Discovery/Outcome.cs, OutcomeFilter.cs Direct port.
EvaluationResult, EvaluationEngine, ResultType Query/Evaluation/EvaluationResult.cs, EvaluationEngine.cs Direct port. ResultType → string constants.
HotChocolate type bindings (OutcomeTypeEnumType, TimePeriodEnumType, etc.) Query/GraphQL/*Type.cs Mechanical, but needs the corresponding string-constant classes shaped to give HotChocolate the enum-name → constant mapping it expects.
Query, GameContext, SeasonContext resolver classes Query/GraphQL/Query.cs, GameContext.cs, SeasonContext.cs Direct port; D1 strips Rule 3 in Phase 1.

4.2 Move with rewrite

Prototype New location Why a rewrite
OutcomeIdParser Accumulation/OutcomeIdParser.cs Per gap analysis §"Cleanups required by side — Storage", moves to Accumulation so both sides reference one parser. The parser implementation is fine; only the location changes.
OutcomeCatalog (the prototype's flat dictionary) Query/Discovery/OutcomeCatalog.cs (D2/D3-shaped) Under D3 the catalog becomes templated. The prototype's flat-dictionary shape is wrong for the post-convergence world. The new shape has two halves: IOutcomeTemplateCatalog (storage) and a runtime OutcomeCatalog (in-memory, query-side, materialised by combining templates with the run's pre-sim catalog). The prototype's two methods (TryGet, All) survive; the constructor changes.
ContextRepository Query/Adapters/ContextRepository.cs The prototype's BuildFixtures() retires under D4 — fixtures come from IOutcomeContextStore. The class shrinks substantially: it becomes a thin adapter that loads IOutcomeContext instances on demand, plus the OutcomeCatalog getter. The hard-coded NFL data goes (or moves to a test fixture).

4.3 Move with structural change

Prototype New location Change
ExpressionCanonical.ExpandDerived Query/Expressions/ExpressionCanonical.cs Today walks an ExpressionInput tree (the Definition field is ExpressionInput?). Under D2 the field is a postfix template string?ExpandDerived becomes (i) parse template postfix into tokens (or directly substitute into postfix-tokens form), (ii) substitute {slotName} placeholders with the consumer leaf's matching values, (iii) splice into the larger token stream. The infix-tree path goes away on the query side.
OutcomeDefinition (prototype) Already in Accumulation The prototype carries its own OutcomeDefinition with ExpressionInput? Definition. That duplicate goes; the storage-side type gains the Definition field per D2 (as a postfix-template string?, not an ExpressionInput?).

4.4 Drop / replace entirely

Prototype Why dropped
ContextRepository.BuildFixtures() (the in-memory fixtures) Replaced by IOutcomeContextStore reads in D4. Some of the synthetic data may live on as test fixtures; the production code path goes.
kc_lar_NEXT, kc_den_w3 fixtures Drop in D1. Their reason for existing (Rule 3 trigger) is gone.
CONTEXT_VERSION_MISMATCH error code Drop in D1.
Single-file structure The prototype is dotnet run FoundryQueryProto.cs (a .NET 10 file-based app). Splitting into per-concern .cs files in a real .csproj is part of Phase 0.

4.5 Closed OutcomeType enum

The prototype's closed OutcomeType enum (~11 values like ANYTIME_TD, TOTAL_TDS, SCORING_RANK) is far narrower than the ~850 outcome IDs the Accumulation library actually emits. Per gap analysis "Minor gaps #1", the recommendation is open-string + catalog membership check at parse time. This goes from prototype enum → catalog-validated string under D3 (when the templated catalog becomes the authority). For Phase 0 the enum survives as-is so the port doesn't change behaviour; D3 retires it.


5. Sub-designs that block implementation

Three sub-designs are deliberately open per the gap analysis. Each one blocks the corresponding D-task. Below: what each task needs answered, and a recommendation for how to firm them up.

5.1 D2 build pipeline — blocks Phase 2

What Phase 2 needs answered before it can land:

  1. Authoring source of truth. Where do template authors edit derivations? Three plausible options listed in the gap analysis: a derived-outcomes.yaml file, a code-generated DSL, or hand-written postfix in the catalog table. Phase 2 needs to know which — the canonicalisation pipeline (infix → postfix template) only exists in option 2 + part of option 1.
  2. Postfix template grammar. What does {participantId} and {otherParticipantId} actually look like in the postfix string? Recommendation: {slotName} as a literal token in the postfix stream — slot tokens are first-class alongside outcome / const / op tokens. The tokeniser splits on | (existing prototype convention) and recognises {...} as a slot.
  3. Build-time validation. Does the build step typecheck each definition (per TypeChecker.Check)? Verify referenced raw templates exist? Detect cycles? My answer: yes to all three — the alternative is silent UNKNOWN_OUTCOME_ID at evaluation time. This implies the build step uses the same TypeChecker and OutcomeCatalog machinery.
  4. Catalog versioning. How does a template change interact with the §11.5 cache key? Spec §11.8.6 #3 already flags it. Recommendation: each template carries a catalogVersion field; the cache key is (expression_hash, worldSetRef, catalogVersion). The version bumps when any template changes.

How to firm it up: A short standalone sub-design doc — docs/outcome-context/sub-designs/d2-derivation-build-pipeline.md. ~3–4 pages. Done before Phase 2 starts. Owner: storage + query, jointly (the build pipeline lives between them).

5.2 D3 pre-simulation catalog — blocks Phase 3

What Phase 3 needs answered before it can land:

  1. Canonical name. SimulationCatalog? RunManifest? FixtureCatalog? My recommendation: SimulationCatalog — closest to the existing vocabulary. Each name implies different framing; pick one before code.
  2. Persistence target. Marten document store, ClickHouse table, or flat JSON per run? Recommendation: Marten document — reads frequently during a run, queryable for discovery, small enough that a document store fits, and Foundry already has the document-store stack. ClickHouse is overkill for ~one document per run.
  3. Identification scheme. How does a context reference "the pre-sim catalog for this run" given Rule 3 is dropped? Recommendation: (seasonId, contextVersion) is the natural key — already on every context, monotonic, idempotent under the existing ReplacingMergeTree semantics. No new run id is needed.
  4. Slot grammar. How does a template declare what its placeholders bind to? Recommendation: a small grammar — {slotName: roleType} where roleType is one of participant | team | opposingParticipant | season. Authored alongside the template.
  5. Lifecycle. Is the pre-sim catalog mutable mid-run (injuries, substitutions)? Recommendation: no — locked at sim start matches the run-as-coordinated-unit model. Mid-run mutability would push into Rule 3 territory the gap analysis explicitly avoids.

How to firm it up: Short sub-design doc — docs/outcome-context/sub-designs/d3-pre-simulation-catalog.md. ~4–5 pages. Done before Phase 3 starts. Owner: storage (catalog persistence, lifecycle, identification) with query-layer review (slot grammar consumer side).

5.3 D4 IOutcomeContextStore method shape — blocks Phase 4

What Phase 4 needs answered before it can land:

  1. Run-version semantics when omitted. When the caller doesn't specify contextVersion, what's returned — latest? latest-before-cutoff? Recommendation: latest, because ReplacingMergeTree(context_version) already resolves to MAX server-side. But this needs to be policy on the interface, not an accident of merge state — document it on the method, not in the implementation.
  2. Bulk read shape. A seasonContext(seasonId, gameIds:) query loads 1 + N contexts. Recommendation: explicit GetManyByScopeIdAsync(IReadOnlyList<string> scopeIds, ...) on the interface, implementation does one SELECT … WHERE scope_id IN (...) per table. Avoids N+1 entirely.
  3. Projection. A query that only references three outcome IDs shouldn't fetch all ~500 rows. The existing experiment already has ReadSelectiveGameOutcomeContextAsync(gameId, IReadOnlyList<string> outcomeIds, ...). Recommendation: lift it to the interface as an optional parameter on GetByScopeIdAsync / GetManyByScopeIdAsync (IReadOnlyList<string>? outcomeIds = null).
  4. One store or two for catalogues. Does the same IOutcomeContextStore serve the template catalog and the pre-sim catalog, or do they split? Recommendation: split. The OC store is per-context reads; the catalogues are different read patterns and probably different persistence targets (template catalog → ClickHouse or Marten; pre-sim catalog → Marten). Two interfaces (IOutcomeContextStore, IOutcomeTemplateCatalog, IPreSimulationCatalog) cleanly served from the same Storage project.

How to firm it up: Short sub-design doc — docs/outcome-context/sub-designs/d4-store-interface.md. ~3 pages. Done in tandem with the storage rework (§3) — this isn't a Query-project decision, it's a storage decision the Query project consumes. Owner: storage.


6. Open questions for Mark

These are choices I can't make from the docs alone. Each is flagged with the options I see and a default recommendation; please confirm or redirect.

6.1 One Query project, or split engine vs sport binding?

Options: - A. [Original recommendation, since revised — see §1.] Single LBS.Model.AmericanFootball.Query project housing both the sport-agnostic engine and the AmericanFootball-specific binding. - B. [Adopted.] Sport-agnostic split: LBS.OutcomeContext.Contracts + LBS.OutcomeContext.Query + LBS.OutcomeContext.QueryApi. Sport-coupled code stays in LBS.Model.AmericanFootball.Accumulation.

Recommendation: A. Only one sport today; the engine has clear ring boundaries inside the project; lifting it later if a second sport materialises is mechanical.

6.2 Where does the GraphQL endpoint live?

Options: - A. New app LBS.OutcomeContext.QueryApi under src/Apps/. - B. Mount on existing LBS.Api. - C. Mount on existing Ballr.WebApi.

Recommendation: A. Different consumer audience (model-evaluation clients vs domain APIs), independent deploy/scale, doesn't entangle with existing FastEndpoints surfaces.

6.3 Storage rework timing — same window as query layer, or before?

Options: - A. Storage rework happens first (or concurrently). The Query project depends on the new IOutcomeContextStore from day one. Phase 4 lands cleanly. - B. Query layer goes in against the existing IStorageBackend and migrates to IOutcomeContextStore later. Phase 4 has two steps: get on the existing surface, then migrate.

Recommendation: A. Tight coupling between D4 and the storage interface split; doing them together once is cheaper than doing them once-and-a-half. But this depends on capacity — if the storage rework can't start for weeks, B is the right call.

6.4 Catalog persistence target

Options for the outcome template catalog (D2/D3): - A. ClickHouse table (outcome_catalog) — matches spec §9.2 sketch. - B. Marten document — fits Foundry's existing document-store stack. - C. Generated config file (in-memory at startup, source-of-truth in the repo).

Options for the pre-simulation catalog (D3): - A. Marten document per run. - B. ClickHouse table. - C. JSON document on object store, indexed in Marten.

Recommendation: Templates → C (generated config) for now, B (Marten) once authoring tooling exists. Pre-sim catalog → A (Marten document per run). Both matters live in their respective sub-design docs (§5.1, §5.2) and need ratification before code.

6.5 Do we keep the prototype source repo around?

The prototype repo is redundant now that Phase 0–4 have landed. Options: - A. Archive it in-place; mark as superseded. - B. Delete it; the gap analysis + reference docs are the new source of record. - C. Keep it as the reference implementation for the spec's Wave 2/3 features that haven't been ported yet.

Recommendation: C until Wave 1 is fully landed; then A.

6.6 Spec ownership going forward

Foundry_Query_Spec.md (1,365 lines) lives in the prototype repo. Several sections are explicitly affected by D1–D4 (gap analysis §"Cleanups required by side — Prototype"). Options: - A. Move the spec to lbs.foundry/docs/ so it's edited alongside the code that implements it. - B. Leave it in the prototype repo, noting in the convergence reference that it's the long-form doc. - C. Promote sections of it into Foundry's docs tree as they're implemented (incremental migration).

Recommendation: C — promote section by section as each phase lands. The convergence reference covers the post-D1–D4 shape; the spec sections that are already obsolete can be retired from the prototype copy as part of D1's "spec impact" cleanup.


7. What happens after sign-off

Once this plan is signed off:

  1. Open the three sub-design docs (§5.1, §5.2, §5.3) as empty drafts so the work is visible.
  2. Begin Phase 0 — port the prototype engine. Chunk into the 5 mergeable steps in §2 Phase 0 row.
  3. Phase 1 (D1) follows directly off Phase 0's last chunk.
  4. Phases 2/3/4 land as their sub-designs firm up, in that order.

Each phase emits a working set of changes that can be reviewed and merged independently — the plan does not produce a single mega-PR.


8. References