[ADR-0020] Composition Configuration Rules¶
Status: Accepted
Date: 2026-07-29
Decision Makers: Nick Cipollina, Claude (design review)
Context¶
docs/mvp.md's Milestone 3 scope lists three related but distinct configuration surfaces: "collection-size configuration," "type/member rule prototype," and (implicitly, as the mechanism behind both) whatever shared abstraction they end up built on. docs/public-api.md sketches the target shape:
builder.For<Customer>()
.Member(x => x.Status)
.Use(CustomerStatus.Active);
builder.For<Customer>()
.Member(x => x.Email)
.Use(context => context.Semantic.Email());
This ADR settles four things: how a type rule (an unqualified .For<T>().Use(...)) and a member rule (.For<T>().Member(...).Use(...)) reach pipeline stage 4 (renamed configuration rules in this design review — see ADR-0018's terminology correction); how a member rule's matching identity avoids colliding across similarly-shaped members of different types; how type-rule and member-rule precedence works; and — the one genuine correction from this ADR's first draft — why collection-size configuration is not a stage-4 rule at all.
Decision Drivers¶
- Pipeline stage 4 is an ordered
ICompositionProvidercollection (ADR-0010) — a provider produces the value itself for the request it claims. Collection size is not a value; it's policy that stage 7's collection machinery consumes while building a value. Forcing a size rule through stage 4 would mean either stage 4 constructing the collection itself (duplicating stage 7's element-resolution/ retry logic, per ADR-0013) or givingICompositionProvidera second, undocumented kind of result — both rejected. - The public provider-authoring interface question was explicitly deferred to Milestone 5 during this design review — rules cannot require a user-authored type to implement any public engine contract, or they'd reopen that deferred question.
docs/mvp.md: "Evaluate whether these should share common abstractions" — value rules (type and member) should share one mechanism where the two are genuinely the same shape; collection size shouldn't be forced to share it where it isn't.ADR-0012's reproducibility contract (structural path identity, not type identity) still governs every value a rule produces, and matching must not silently collide across two different declaring types with similarly-named/-typed members.- Member identity has to be something a hand-written builder callback can actually express — it cannot depend on the generator's compile-time-assigned
Ordinal(ADR-0012 Amendment 1), which no user-facing API exposes.
Considered Options¶
Where collection size lives¶
- Immutable configuration policy, queried by stage 7.
WithCollectionSize(n)(global) and.For<T>().Member(...).WithCollectionSize(n)(member-scoped) are captured as plain data onCompositionConfiguration(ADR-0017), not compiled into a stage-4 provider. Generated collection plans (ADR-0013/ADR-0014) ask the context for the size to build, at the point they'd otherwise use their current hardcoded literal3. - A stage-4 provider that "produces" a size, requiring stage 7 to detect and specially interpret an
int-shaped stage-4 result as a size hint rather than a value. - A stage-4 provider that constructs the entire collection value directly, duplicating stage 7's element resolution and
UniqueValueResolverretry logic inside a rule-authored provider.
Value-rule mechanism (type and member rules)¶
- Compile into internal, Compono-authored
ICompositionProviderinstances — a user never implementsICompositionProviderdirectly;.For<T>().Use(...)and.For<T>().Member(...).Use(...)are pure data/delegate capture on the public builder surface, compiled byBuild()into small internal provider implementations registered into stage 4. - A distinct, non-provider lookup mechanism, bypassing stage 4's existing ordered-collection dispatch and diagnostics tracing entirely.
- Require the user to implement a public provider interface directly — pulling the Milestone-5-deferred question forward early.
Member-rule matching identity¶
- (Declaring type, member name), read directly off the request's explicit metadata. A member rule captures the CLR declaring type and member name (via
MemberInfo, extracted from thex => x.Emailexpression) at the point.Member(...)is called; at match time, an incoming request matches when itsCompositionRequest.DeclaringTypeequals the captured declaring type and itsNameequals the captured member name. - (Requested value type, member name) only — the shape this ADR's first draft implied, omitting the declaring type from the key.
- (Declaring type, member name), inferred from the parent path node — this ADR's first draft: instead of a request-carried field, read the declaring type off the parent
CompositionPathNode.RequestedTypein the path chain. - (Declaring type, generator-assigned
Ordinal) — reusing ADR-0012's canonical member identity directly instead ofName.
Decision Outcome¶
Collection size — Option 1, immutable policy queried by stage 7¶
Confirmed per design-review feedback: a size rule doesn't produce the requested collection value, so it doesn't belong in ICompositionProvider's value-producing contract at all. CompositionConfiguration holds a CollectionSizePolicy — a global default (WithCollectionSize(n), falling back to ADR-0013's existing default of 3 if never set) plus a member-scoped override table ((declaring type, member name) → size, same identity shape as a member rule's matching key, established below).
Corrected after review: ResolveCollectionSize() takes no parameters at all — not a descriptor, not a root/member overload pair. An earlier draft of this ADR had a generated collection plan pass its own CompositionRequestDescriptor into ResolveCollectionSize(in CompositionRequestDescriptor descriptor), but that's not possible: ICompositionPlan<T>.Compose(ICompositionContext context) (the interface every generated plan, collection or otherwise, implements) receives only the context — it has no descriptor to pass, because CompositionContext.Resolve<TValue> already consumed and expanded the descriptor before ever dispatching into CollectionPlanCache<TValue>.Instance.Compose(this). Threading a descriptor through Compose would mean changing ICompositionPlan<T>'s signature for every generated plan in the codebase, not just collection ones — out of proportion to what this ADR actually needs.
The fix needs no interface change at all, because the context already has everything it needs internally by the time it reaches stage 7's collection dispatch. CompositionContext.ResolveCore expands the incoming descriptor into the internal CompositionRequest — carrying that same DeclaringType field this ADR added earlier, base-aware for an inherited required member per ADR-0012's inheritance-ordinal algorithm — at the very top of the method, before any pipeline stage runs. By the time collection dispatch (stage 7) is reached, that already-expanded CompositionRequest for the current request (the collection member itself — collection dispatch resolves this exact request, not some child of it) is still in scope. This is the same "the context already knows, no parameter needed" shape ResolveRoot<T>() (ADR-0010 Amendment) already uses for the analogous "no descriptor at the root" case — a collection plan simply doesn't need to hand the context back data the context itself already has:
public interface ICompositionContext
{
// ...Resolve<T> overloads unchanged...
int ResolveCollectionSize();
}
One method, one call site shape, used identically by a root-level collection plan and a member-scoped one — there is no second overload and no "which one do I call" question for generated code to get right. Internally:
private int ResolveCollectionSizeCore(in CompositionRequest currentRequest)
{
if (currentRequest is { DeclaringType: { } declaringType, Path.Segment.Name: { } memberName }
&& _configuration.CollectionSizePolicy.MemberOverrides.TryGetValue((declaringType, memberName), out var overrideSize))
{
return overrideSize;
}
return _configuration.CollectionSizePolicy.GlobalDefault ?? CollectionDefaults.Size; // ADR-0013's 3
}
Corrected after a second review pass: the lookup key comes from CompositionRequest.DeclaringType — the exact same field value-rule matching (above) already uses — not from the parent path node's RequestedType, an earlier draft of this section's own mistake. Those two are not the same thing for an inherited required member: for Derived : Base with Base declaring Orders, a .Member(x => x.Orders) rule captures DeclaringType = typeof(Base) (reflection on an inherited member reports its declaring type, not the derived type it's accessed through) — exactly what CompositionRequest.DeclaringType for that request already carries, copied forward from the generator-emitted descriptor, correctly base-aware. The parent path node's RequestedType in that same scenario would be typeof(Derived) (the composed/runtime type) — a different value that would silently never match a .Member(x => x.Orders) rule's key. Reading CompositionRequest.DeclaringType directly, rather than re-deriving anything from path state, is both simpler than the rejected alternative and automatically correct for inheritance, since it's the identical field/identical value the value-rule matching earlier in this ADR already relies on — one shared notion of "this request's declaring type," not two independently-derived ones that can disagree. A root-level collection request has no DeclaringType at all (root requests aren't member requests — ResolveRoot<T>() never sets it), so the member-override branch never matches and resolution falls straight through to the global default or ADR-0013's built-in 3, the correct outcome for a root. Precedence is otherwise unchanged: member-scoped override, then global default, then ADR-0013's built-in 3 — a plain, three-level data lookup with no randomness or hashing involved (it never advances IRandomSource, never pushes a path segment of its own). This is a parameterization of ADR-0013's previously-fixed constant, not a change to that ADR's retry/uniqueness/ordering semantics, which remain exactly as decided — noted here explicitly since ADR-0013/ADR-0014 are Accepted and this ADR doesn't reopen either.
The exact shape shown above (in CompositionRequest currentRequest, Path.Segment.Name) describes what state needs to be read, not a requirement that ResolveCollectionSizeCore literally takes a CompositionRequest parameter with that exact member-access chain — implementation may thread the current request through CompositionContext's private state however is cleanest against its real current shape, as long as the member-override key is read from the same DeclaringType/member-name pair value-rule matching uses, never re-derived from a caller-supplied parameter.
The public builder surface stays unified even though the internal mechanism now differs from value rules:
builder.WithCollectionSize(3); // global default
builder.For<Customer>()
.Member(x => x.PastOrders)
.WithCollectionSize(5); // member-scoped override
Negative sizes are rejected immediately, at the call site — added per design review, not deferred to Build(). Without this, a negative WithCollectionSize(n) would flow unchecked into CollectionSizePolicy, and only fail later, deep inside a generated collection plan's new List<T>(size)/array allocation, as a generic ArgumentOutOfRangeException/OverflowException with no connection back to the configuration call that caused it — directly contradicting this milestone's own fail-fast-at-Composer.Create(...) principle, which every other configuration mistake (duplicate registrations, duplicate rules, duplicate scalars) already honors. Both WithCollectionSize(int size) (global) and .Member(x => x.Y).WithCollectionSize(int size) (member-scoped) validate size >= 0 via ArgumentOutOfRangeException.ThrowIfNegative(size), matching the same immediate-argument-validation pattern Composer.CreateMany<T>(int count) already established for an identical shape of mistake (docs/adr/0017-...'s Amendment doesn't apply here — this is ordinary argument validation, not a cross-entry configuration conflict, so it isn't part of Build()'s aggregated CompositionConfigurationError list; it's a plain ArgumentOutOfRangeException, thrown synchronously from the builder call itself, the same as a negative CreateMany count).
Value rules — Option 1, compiled into internal Compono-authored providers¶
Unchanged from this ADR's first draft: no public provider contract needed, stage 4's existing ordered-dispatch and diagnostics-tracing behavior is reused as-is, and it keeps the Milestone-5 provider-interface deferral intact.
Type rules — the gap in the first draft, now specified¶
.For<T>().Use(value) / .For<T>().Use(context => value) — without a .Member(...) call — registers a type rule: matches any stage-4 request for exactly type T, regardless of which member/position requested it.
- API.
For<T>()returnsCompositionTypeRuleBuilder<T>(per ADR-0017's builder-scoping precedent — a thin view over the same sharedCompositionBuilderstate, not a second configuration root). Calling.Use(...)directly on it (no.Member(...)in between) registers a type rule; calling.Member(x => x.Y)first returns a further-scoped builder whose.Use(...)/.WithCollectionSize(...)register a member rule instead, per the original sketch. - Precedence: member rule beats type rule for the same effective request, specificity-based, not call-order-based. If a member rule exists for
Customer.Email(itself astringrequest) and a type rule exists forstring, the member rule wins regardless of which was registered first — stage 4's compiled provider list places every member rule ahead of every type rule internally, because specificity is a property of the rule itself, not of when a builder call happened to run. This is a deliberate, narrow departure from ADR-0018's "precedence is call order" framing: that framing describes conflict resolution among rules that could both claim the identical key (still call-order/throw-based, unchanged), not dispatch order among rules of genuinely different specificity that were never in conflict with each other to begin with. - Matching: exact type only, no assignability, for M3. A type rule for
IClockdoes not also satisfy a request forSystemClock(a concrete type assignable toIClock) unless the request's requested type is literallyIClock. Chosen per the user's explicit direction ("exact type matching unless a broader match has a concrete requirement") — no concrete M3 requirement motivates assignability matching, and it introduces real ambiguity (which of several assignable type rules wins for a diamond-shaped interface hierarchy?) that exact matching avoids entirely by construction. Deferred, not designed, pending a real need. - Duplicate/conflict behavior: same as ADR-0019's registrations. Two type rules for the exact same type (whether from the same profile, different profiles, or a profile and a direct call) is a build-time
CompositionConfigurationException, naming both sources via ADR-0018's provenance chain. Two member rules for the exact same(declaring type, member name)pair, likewise. A member rule and a type rule are never a conflict with each other even when both could match the same request — they're different specificity (see Precedence above), not the same key.
Member-rule matching identity — Option 1, (declaring type, member name), read directly off the request¶
Confirmed as the corrected design, revised once more per a second design-review pass: a member rule's key is the pair of the declaring/containing type and the member name — (typeof(Customer), "Email"), not merely ("Email") or (typeof(string), "Email"). This closes the exact collision the design review flagged: two unrelated types each having a string Email member no longer share a key, because the declaring type is part of it.
Declaring-type identity is read from a new, nullable DeclaringType field this ADR adds to CompositionRequestDescriptor/CompositionRequest — not inferred from the parent CompositionPathNode.RequestedType in the path chain, this ADR's own first-draft approach:
public readonly struct CompositionRequestDescriptor
{
public CompositionRequestKind Kind { get; }
public int Ordinal { get; }
public string Name { get; }
public Type? DeclaringType { get; } // new - nullable, see below
public Nullability Nullability { get; }
public CompositionRequestDescriptor(
CompositionRequestKind kind, int ordinal, string name, Type? declaringType, Nullability nullability)
{
Kind = kind;
Ordinal = ordinal;
Name = name;
DeclaringType = declaringType;
Nullability = nullability;
}
}
Type?, not Type — corrected per design review. An earlier draft of this field was non-nullable, which leaves no contract-valid value for the three descriptor kinds this same ADR already says have no declaring type at all (CollectionElement, DictionaryKey, DictionaryValue — see the unused-for sentence below, unchanged in substance, just now correctly typed). A non-nullable Type would have forced Compono.Generators to either invent an undocumented sentinel (typeof(void), say) for those three kinds, or emit a nullable-warning- suppressed null! into generated code — both exactly the kind of "magic" this repo's principles reject. Type? represents "no declaring type" as the thing it actually is: absence, not a placeholder value standing in for absence. Matching (both value-rule matching, above, and the collection-size lookup, below) already only ever proceeds inside a not-null pattern match (DeclaringType: { } declaringType), so this correction changes the field's declared type without changing any matching logic already described in this ADR.
DeclaringType is the type whose constructor/required-member declares this parameter/member — for Customer(string firstName, ...), every constructor parameter's descriptor carries DeclaringType = typeof(Customer), generator-emitted exactly like Ordinal/Name already are (no new generator capability needed — the declaring type is already in scope at descriptor-emission time, since it's the type the whole plan is being generated for, or an ancestor type for an inherited required member per ADR-0012 Amendment 2's base-to-derived ordinal algorithm — DeclaringType is the type that declares the member, which for an inherited required member is the base type, not the composed leaf type). CompositionContext carries DeclaringType forward unchanged onto the internal CompositionRequest it expands the descriptor into, so this ADR's compiled rule matching reads it directly off the request rather than inferring it from path state. CompositionRequestKind.ConstructorParameter/ RequiredMember are the only kinds that populate it with a real value — DeclaringType is null for a request with no member identity to speak of (a collection element/dictionary key/value, or a ManualResolve invocation, per ADR-0019), never a sentinel standing in for that absence. This is purely additive to CompositionRequestDescriptor's existing shape (ADR-0010's Accepted Decision Outcome, unedited by this ADR — see that ADR's own text for the fields this extends) — every existing field is unchanged, and DeclaringType is never an input to random-fork hashing (ADR-0012's existing ban on type identity feeding hashing is unaffected; DeclaringType is used only for rule matching, evaluated at stage 4, before any hashing for that request happens). Compono.Generators' descriptor- emission templates and every existing generated-plan snapshot need updating to emit the new argument — a mechanical, global::-qualified typeof(...) reference alongside the existing Kind/Ordinal/Name/Nullability arguments.
Path-parent inference happened to be correct for straightforward cases but is indirection a rule shouldn't need: it silently assumes "the type that requested this member" and "the type whose path node is the immediate parent" always coincide, which is true today but is exactly the kind of assumption a future generated-plan change (or a not-yet-designed request shape) could break without anything here failing loudly. Carrying DeclaringType as explicit request metadata means matching reads a field whose meaning is fixed by the generator at the point the request is emitted, not re-derived from unrelated plumbing (the path chain) that happens to currently correlate with it. This is more reliable specifically for nested graphs (a Customer reachable three levels deep still carries the same DeclaringType, regardless of path shape), constructor parameters and required members alike, records (a positional record constructor parameter is exactly as explicit here as a required member), manual resolves (which have no declaring type at all — DeclaringType is simply unused for PathSegment.ManualResolve and any other non-member request kind, never defaulted/inferred), and any future generated-plan change, since the field's value is fixed at generation time rather than depending on how the pipeline happens to be threading path state at match time.
Why Name, not the generator-assigned Ordinal, is the matching identity — addressed directly, since it's a deliberate departure from ADR-0012's ordinal-for- hashing preference, not an oversight. Ordinal (ADR-0012 Amendment 1) is assigned by Compono.Generators at compile time, per composed type, as an internal detail no public API exposes — a hand-written builder.For<Customer>().Member(x => x.Email) call has no way to know or express Customer.Email's generator-assigned ordinal. Name, by contrast, is directly recoverable from the x => x.Email expression tree (a MemberExpression whose Member.Name is "Email") and is already present on every request specifically for human-facing use (ADR-0012: "Name is carried for diagnostic display only, never for hashing"). This ADR is a second, equally legitimate consumer of that same field for the same reason it exists — human-facing identification — extended from "diagnostic display" to "diagnostic display and rule matching," both non-hashing uses. This does not touch or weaken ADR-0012's hashing rule: Ordinal remains the only input to random-fork key derivation, unconditionally; Name/DeclaringType-based matching is a completely separate concern (stage-4 dispatch, evaluated before any hashing happens for that request) that happens to reuse fields already on the request — never fed into any hash, exactly as ADR-0012's existing type-identity-never-hashed rule already establishes for DeclaringType specifically.
Known limitation, stated explicitly per review: .Member(x => x.Y) name matching depends on the composed type's property name equaling its generated request's Name — which is not guaranteed for every possible constructor shape. A .Member(...) expression can only ever capture a property name (that's what MemberExpression.Member.Name reports); Compono.Generators emits a ConstructorParameter request's Name as the constructor parameter's own identifier, exactly as written in source (CompositionPlan.scriban's parameter.name_literal), unchanged since Milestone 1 — it was never correlated to any property, because nothing before this ADR needed that correlation (Name was purely diagnostic display until now). For a positional record — Customer(string FirstName, string LastName), the shape every example throughout this repo's docs uses — the constructor parameter's name and the compiler- synthesized property's name are the identical string, by C#'s own record semantics (no casing transform happens at all), so exact-match matching always works. For a required/init member (RequiredMember requests), the descriptor's Name is always the member's own actual name — also always matches. For a traditional hand-written class with a conventionally-cased constructor parameter mapped to a differently-cased (or differently-named) property — public Customer(string firstName) { FirstName = firstName; } — the parameter name ("firstName") and the property name ("FirstName") are different strings, and a .Member(x => x.FirstName) rule will silently never match that parameter's request.
This is accepted as a documented scope limitation for Milestone 3, not solved here — building a real constructor-parameter-to-property correlation into Compono.Generators (matching by position + type + case-insensitive/convention- based name comparison, handling ambiguous or unmappable cases) is real generator design work with its own edge cases, out of proportion to fold into a configuration-rules ADR as a side effect. Per docs/mvp.md's explicit non-goal ("Compono is not an AutoFixture migration layer and does not aim for feature parity"), Compono's primary supported authoring style is the positional-record shape already used throughout every doc example, for which this limitation simply doesn't arise. A consumer hitting this with a hand-written class has two immediate workarounds without waiting on a generator fix: match the constructor parameter's own name-as-written instead of the property's (i.e. name the parameter FirstName too, not firstName — a source change entirely within the consumer's control), or use an exact type registration (Register<T>, ADR-0019) for that specific type instead of a member rule. A real generator-side name correlation is a candidate for a future ADR if this friction turns out to matter in practice — not designed here.
Expression parsing: at .Member(...)/.Use(...) call time, not deferred to Build()¶
Corrected inconsistency from this ADR's first draft, which stated both. Settled: x => x.Email is parsed into (declaring type, member name) immediately, at the point .Member(...) is called — not deferred to Build(). This matches ADR-0018's eager-profile-application philosophy (a rule's shape is fully known the moment the builder call that describes it runs), and means a malformed expression (anything other than a direct member access, e.g. x => x.Email.Length or x => SomeMethod(x)) throws immediately at the call site with the offending lambda still in scope for a clear error message, rather than resurfacing generically at Build() time once the original call-site context is gone. Build()'s validation pass (ADR-0017) still runs — but only over the already-extracted (declaring type, member name) identities, checking for cross-rule conflicts (the duplicate-key case above); it performs no expression parsing of its own.
Positive Consequences¶
- Collection-size configuration no longer strains
ICompositionProvider's value-producing contract — it's data, read as data, by exactly the stage-7 machinery that already needs it. - One internal mechanism (a small, Compono-owned provider family) backs both type and member value rules; adding a future rule kind (a semantic-hint rule for Bogus in M6) is "add another compiled-provider case."
- No public provider contract needed for any of this — the M5-deferral decision stays intact.
- Member-rule matching can no longer collide across two types with similarly-named, similarly-typed members — the exact bug class flagged during review.
- Compile-time-safe member references (
x => x.Email) instead of stringly-typed member names, with immediate, call-site-local error reporting for a malformed expression.
Negative Consequences¶
- Two internal execution paths exist for what looks like one unified public DSL (value rules → stage-4 providers; collection size → queried policy) — a deliberate, documented split, but real enough to flag: someone extending this system later has to know which bucket a new rule kind belongs in, not assume "everything under
.For<T>()becomes a provider." Expression<Func<T, TMember>>parsing at call time is a small amount of reflection-adjacent work (walking an expression tree the caller wrote directly, not runtime type discovery) — doesn't conflict with ADR-0001's no-reflection- fallback rule (which concerns construction, not parsing a lambda the caller handed the builder), but worth stating explicitly rather than leaving an implicit "no reflection anywhere at all" assumption unqualified.CompositionTypeRuleBuilder<T>'s fluent shape is a third fluent-builder flavor alongsideCompositionBuilderitself and a profile'sConfigure— more surface to document, even though each is a thin wrapper over the same underlying state.
Pros and Cons of the Options¶
Collection size — immutable policy queried by stage 7 (chosen)¶
- Good, because it doesn't stretch
ICompositionProvider's contract to cover a second, structurally different kind of result. - Good, because it reuses ADR-0013's existing size-application point (generated collection plans) almost unchanged — a parameter instead of a literal.
- Bad, because it's a second mechanism alongside stage-4 providers rather than one uniform "everything is a provider" story — accepted, since the alternative (below) is worse.
Collection size — provider "produces" a size (rejected)¶
- Bad, because it requires stage 7 to special-case an
intstage-4 result as a size hint rather than a value, an undocumented dual meaning forICompositionProvider's return type that every future provider author would need to know about.
Collection size — provider constructs the whole collection (rejected)¶
- Bad, because it duplicates ADR-0013's element-resolution and
UniqueValueResolverretry/uniqueness logic inside a second, rule-authored code path — exactly the kind of duplication the "share common abstractions" driver warns against, in the opposite direction.
Value rules — Compono-authored internal providers (chosen)¶
Unchanged reasoning from this ADR's first draft — see Links for the original pros/cons, still valid: needs no public provider contract, fits stage 4's existing definition, keeps the M5 deferral intact.
Member-rule identity — (requested value type, member name) only (rejected)¶
- Bad, because it's exactly the collision the design review flagged: two unrelated types each declaring a same-named, same-typed member would share a key.
Member-rule identity — (declaring type, member name) inferred from the parent path node (superseded by the request-carried field, above)¶
- Good, because it needed no descriptor/request shape change — this ADR's first draft chose it for exactly that reason.
- Bad, because it's indirection: it depends on "the request's declaring type always equals its parent path node's requested type" holding, an assumption that's true today but isn't guaranteed by anything that would fail loudly if a future generated-plan or request-shape change broke it. A field the generator sets explicitly, once, at the point it knows the real answer, doesn't carry that risk.
- Bad, because it gave
ManualResolve(which has no declaring type) no clean way to participate in the same matching code path without a special case — a request-carried field that's simply unset/unused for non-member request kinds is more uniform.
Member-rule identity — (declaring type, generator Ordinal) (rejected for M3)¶
- Good, because it would reuse ADR-0012's canonical member identity exactly, with no second identity concept.
- Bad, because no public API surface currently exposes a generator-assigned ordinal to hand-written builder code — adopting this would require inventing a new mechanism just to let a rule author discover an internal compiler output, solely to satisfy identity purity, for no behavioral benefit over
Name(which is already unique per type for ordinary C# members — two members of the same declaring type can't share a name).
Links¶
- docs/mvp.md — Milestone 3 scope: collection-size configuration, type/member rule prototype
- docs/public-api.md — Type and Member Rules, Bogus Integration sections
- docs/architecture.md — Resolution Pipeline stage 4 (configuration rules), stage 7 (collection dispatch)
- ADR-0010 — stage 4's existing definition and diagnostics tracing behavior
- ADR-0011 — the in-process
Type-equality precedent this ADR's declaring-type matching reuses - ADR-0012 — the
Ordinal-for-hashing /Name-for-display-and-matching split this ADR relies on and extends (non-hashing use only) - ADR-0013 — the default-size-3 constant this ADR parameterizes without reopening its retry/uniqueness/ordering decisions
- ADR-0014 — the generated collection plans that call the new
ResolveCollectionSizequery instead of a hardcoded literal - ADR-0017 — the
Build()step where value-rule data compiles into providers, and whereCollectionSizePolicyis assembled intoCompositionConfiguration - ADR-0018 — profile-sourced rules follow identical conflict/provenance semantics to direct ones; the stage-4 terminology correction
- ADR-0019 — the
ICompositionContextfactory-resolve surface a.Use(context => ...)rule factory shares with registration factories