Section: TBD (cross-cutting: Base contribution interfaces, Shell, Settings, Users, Onboarding, Governance, Camps, Tickets, Teams, Debug)
Context
A section can't reference another section's project, so cross-section UI is rendered by string name. Two forms:
- Direct calls:
Component.InvokeAsync("Name", new { ... }) in views (48 call sites).
- Fanouts: contribution records carrying
string ComponentName, rendered by a host with Component.InvokeAsync(x.ComponentName, ...):
ChromeComponent via ISectionChrome / ISectionMemberDashboard → src/Humans.Web/Views/Shared/Components/ChromeSlot/Default.cshtml
SettingsTab via ISectionSettings → src/Sections/Humans.Settings/Views/Shared/Components/SettingsTabs/Default.cshtml
UserPart via IUserPart → src/Sections/Humans.Users/Views/Shared/Components/UserParts/Default.cshtml (passes new { userId })
In both forms the name and the anonymous-object arguments are bound at runtime. Nothing is compile-checked.
Problem
#1756 changed Onboarding's event DTO. /OnboardingWidget/Shifts then threw InvalidCastException (EventSettingsInfo → BurnSettingsInfo) because Shifts' OnboardingShiftsList component still took the old type. The build stayed green, and the only covering test (OnboardingPageRenderTests) is local-only. The fix was #1810. Any renamed component, renamed parameter or changed argument type fails the same way, and a typo in a fanout ComponentName fails the same way too.
A typed wrapper around the string call (e.g. a .Contracts extension that calls InvokeAsync("Name", ...) inside) was considered and rejected: it hides the smell rather than removing it.
Worked example: /Settings tabs
Today.
The seam, in src/Sections/Humans.Settings.Contracts/ISectionSettings.cs:
public sealed record SettingsTab(string Key, string Label, string ComponentName, string? Policy = null, int Weight = 0);
public interface ISectionSettings : ISectionContribution { IEnumerable<SettingsTab> Tabs(); }
The contributors are internal sealed class SectionSettings : ISectionSettings at a section's root. Nine sections have one. Each references only Humans.Settings.Contracts, never the Settings section:
src/Sections/Humans.Shifts/SectionSettings.cs: new SettingsTab("shifts", "Settings_TabShifts", "ShiftsSettingsTab", PolicyNames.AdminOnly)
src/Sections/Humans.Camps/SectionSettings.cs: new SettingsTab("barrios", "Settings_TabBarrios", "CampBarriosSettingsTab", PolicyNames.CampAdminOrAdmin)
Discovery happens at Shell startup, in src/Humans.Web/Extensions/SectionDiscoveryExtensions.cs:
SectionAssemblies() walks DependencyContext.Default.RuntimeLibraries, keeps the assemblies that declare an ISection entry point, and filters them to active sections.
DiscoverImplementations<ISectionContribution>() reflects over each assembly's GetTypes(), including internal types. It creates each concrete marker implementation with Activator.CreateInstance.
RegisterContributions registers each instance as a singleton against every seam interface it implements (services.AddSingleton(typeof(ISectionSettings), instance)).
Rendering:
SettingsTabsViewComponent injects IEnumerable<ISectionSettings>, and SettingsTabComposition merges the tabs and applies each tab's policy.
SettingsTabs/Default.cshtml renders each tab with @await Component.InvokeAsync(tab.ComponentName).
- MVC resolves the string against its view component registry.
SectionViewComponentFeatureProvider adds each section's internal components to that registry, so "ShiftsSettingsTab" resolves to ShiftsSettingsTabViewComponent.
A typo or a rename only fails at request time on /Settings.
Proposed.
- The record becomes
SettingsTab(string Key, string Label, Type Component, ...).
- Shifts writes
new SettingsTab("shifts", ..., typeof(ShiftsSettingsTabViewComponent), ...). That compiles inside Shifts because the class is Shifts' own, internal or not.
- The view calls
Component.InvokeAsync(tab.Component), which is MVC's Type overload.
- Settings still never references Shifts; it only receives a
System.Type through DI. The dependency arrows stay child → contract ← host, and a rename becomes a compile error inside the owning section.
Proposed Solution
Sketch only; needs design:
- Fanouts carry a
Type, not a string (as in the example above). The contributor lives in the owning section, so typeof(...) needs no cross-section reference. The host renders with Component.InvokeAsync(Type, ...).
- Each slot defines its invocation contract (no args for chrome and settings tabs,
Guid userId for UserPart). An architecture test reflects over every contribution and asserts that the component's Invoke/InvokeAsync signature matches its slot's contract, so argument drift fails CI.
- Direct cross-section calls become contributions. The host section defines a slot and the owning section contributes its component by
Type (e.g. Onboarding's shifts step exposes a slot and Shifts fills it). Same-section calls use InvokeAsync<T> / typeof, or <vc:...> tag helpers.
- Ban the string overload. Add an analyzer, or an arch-test ratchet as a stopgap, that rejects
Component.InvokeAsync(string, ...) in any view.
Acceptance Criteria
Key files: src/Humans.Base/Interfaces/ISectionChrome.cs, src/Humans.Base/Interfaces/IUserPart.cs, src/Sections/Humans.Settings.Contracts/ISectionSettings.cs, src/Humans.Web/ViewComponents/ChromeSlotViewComponent.cs and its view, SettingsTabs and UserParts views, every Section*.cs contributor, src/Sections/Humans.*/Views/**/*.cshtml, docs/architecture/design-rules.md
Section: TBD (cross-cutting: Base contribution interfaces, Shell, Settings, Users, Onboarding, Governance, Camps, Tickets, Teams, Debug)
Context
A section can't reference another section's project, so cross-section UI is rendered by string name. Two forms:
Component.InvokeAsync("Name", new { ... })in views (48 call sites).string ComponentName, rendered by a host withComponent.InvokeAsync(x.ComponentName, ...):ChromeComponentviaISectionChrome/ISectionMemberDashboard→src/Humans.Web/Views/Shared/Components/ChromeSlot/Default.cshtmlSettingsTabviaISectionSettings→src/Sections/Humans.Settings/Views/Shared/Components/SettingsTabs/Default.cshtmlUserPartviaIUserPart→src/Sections/Humans.Users/Views/Shared/Components/UserParts/Default.cshtml(passesnew { userId })In both forms the name and the anonymous-object arguments are bound at runtime. Nothing is compile-checked.
Problem
#1756 changed Onboarding's event DTO.
/OnboardingWidget/Shiftsthen threwInvalidCastException(EventSettingsInfo→BurnSettingsInfo) because Shifts'OnboardingShiftsListcomponent still took the old type. The build stayed green, and the only covering test (OnboardingPageRenderTests) is local-only. The fix was #1810. Any renamed component, renamed parameter or changed argument type fails the same way, and a typo in a fanoutComponentNamefails the same way too.A typed wrapper around the string call (e.g. a
.Contractsextension that callsInvokeAsync("Name", ...)inside) was considered and rejected: it hides the smell rather than removing it.Worked example: /Settings tabs
Today.
The seam, in
src/Sections/Humans.Settings.Contracts/ISectionSettings.cs:The contributors are
internal sealed class SectionSettings : ISectionSettingsat a section's root. Nine sections have one. Each references onlyHumans.Settings.Contracts, never the Settings section:src/Sections/Humans.Shifts/SectionSettings.cs:new SettingsTab("shifts", "Settings_TabShifts", "ShiftsSettingsTab", PolicyNames.AdminOnly)src/Sections/Humans.Camps/SectionSettings.cs:new SettingsTab("barrios", "Settings_TabBarrios", "CampBarriosSettingsTab", PolicyNames.CampAdminOrAdmin)Discovery happens at Shell startup, in
src/Humans.Web/Extensions/SectionDiscoveryExtensions.cs:SectionAssemblies()walksDependencyContext.Default.RuntimeLibraries, keeps the assemblies that declare anISectionentry point, and filters them to active sections.DiscoverImplementations<ISectionContribution>()reflects over each assembly'sGetTypes(), including internal types. It creates each concrete marker implementation withActivator.CreateInstance.RegisterContributionsregisters each instance as a singleton against every seam interface it implements (services.AddSingleton(typeof(ISectionSettings), instance)).Rendering:
SettingsTabsViewComponentinjectsIEnumerable<ISectionSettings>, andSettingsTabCompositionmerges the tabs and applies each tab's policy.SettingsTabs/Default.cshtmlrenders each tab with@await Component.InvokeAsync(tab.ComponentName).SectionViewComponentFeatureProvideradds each section's internal components to that registry, so"ShiftsSettingsTab"resolves toShiftsSettingsTabViewComponent.A typo or a rename only fails at request time on
/Settings.Proposed.
SettingsTab(string Key, string Label, Type Component, ...).new SettingsTab("shifts", ..., typeof(ShiftsSettingsTabViewComponent), ...). That compiles inside Shifts because the class is Shifts' own, internal or not.Component.InvokeAsync(tab.Component), which is MVC'sTypeoverload.System.Typethrough DI. The dependency arrows stay child → contract ← host, and a rename becomes a compile error inside the owning section.Proposed Solution
Sketch only; needs design:
Type, not a string (as in the example above). The contributor lives in the owning section, sotypeof(...)needs no cross-section reference. The host renders withComponent.InvokeAsync(Type, ...).Guid userIdforUserPart). An architecture test reflects over every contribution and asserts that the component'sInvoke/InvokeAsyncsignature matches its slot's contract, so argument drift fails CI.Type(e.g. Onboarding's shifts step exposes a slot and Shifts fills it). Same-section calls useInvokeAsync<T>/typeof, or<vc:...>tag helpers.Component.InvokeAsync(string, ...)in any view.Acceptance Criteria
ChromeComponent,SettingsTabandUserPartidentify their component byType; nostring ComponentNameremains on any contribution recordComponent.InvokeAsync("...")string-name calls remain insrc/Key files:
src/Humans.Base/Interfaces/ISectionChrome.cs,src/Humans.Base/Interfaces/IUserPart.cs,src/Sections/Humans.Settings.Contracts/ISectionSettings.cs,src/Humans.Web/ViewComponents/ChromeSlotViewComponent.csand its view,SettingsTabsandUserPartsviews, everySection*.cscontributor,src/Sections/Humans.*/Views/**/*.cshtml,docs/architecture/design-rules.md