ADR-010: Shared tenant-resolver migration — retire the Guid-only helpers, do not bifurcate
Status: Accepted Date: 2026-05-29 Sprint: SPRINT_20260529_045_Auth_shared_tenant_resolver_design_rfc.md (S045-001) Related ADRs: ADR-005 (single-operator multi-environment tenancy), ADR-006 (execution plugin framework) Related sprints: Sprint 040 S040-001 (commit 454c7ef074) + S040-002 (commit 200bf24395) — proven IPlatformTenantResolver adoption pattern. Sprint 044 S044-001…S044-005 (commits ac3c231854, 3145a0f8d0, d3a619ca89, 54909dbd09, f835307395) — per-module-local resolver pattern landed across 5 endpoint surfaces. Related code: src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/IStellaOpsTenantAccessor.cs:86 (RequireTenantGuid); src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantResolver.cs:153 (TryResolveTenantGuid); src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantHttpContextExtensions.cs:74 (HttpContext.RequireTenantGuid() wrapper).
This record settles how the two shared, Guid-only tenant-resolver helpers retire: migrate them in place to async + slug-tolerant, then delete the Guid-only methods after a one-sprint deprecation window — rather than bifurcating the codebase into a permanent slug-tolerant pattern and a Guid-only pattern. Read it before adding a new tenant-scoped endpoint, declaring another per-module-local resolver, or touching the StellaOps.Auth.ServerIntegration.Tenancy helpers.
Context
Sprint 040 S040-001 + S040-002 + Sprint 044 S044-001…S044-005 audited every Guid.TryParse(tenant-claim) HTTP entry point in the repo and converged on a single adoption pattern: each module declares a local IXxxPlatformTenantResolver interface (byte-compatible with StellaOps.Platform.WebService.Services.IPlatformTenantResolver — single method Task<Guid?> ResolveTenantUuidAsync(string? tenantSlugOrId, CancellationToken)), wires a Postgres impl that reads shared.tenants, and an Identity in-memory impl for test factories. Endpoint helpers become async, fall back to legacy Guid-parse when no resolver is DI-registered, and preserve their existing failure shape.
That work left one open item: the two shared helpers in StellaOps.Auth.ServerIntegration.Tenancy/:
IStellaOpsTenantAccessor.RequireTenantGuid()(instance method on the accessor; callsGuid.TryParse(TenantId)and throwsFormatExceptionon slug).StellaOpsTenantResolver.TryResolveTenantGuid(HttpContext, out Guid, out string)(static; returnsfalse+error="tenant_invalid_format"on slug).
These are the root of the anti-pattern — every other endpoint that has the bug imported it from here.
Inventory (verified 2026-05-29 against feat/execution-plugin-platform HEAD)
The Sprint 044 dossier estimated “21 callsites”. Actual grep of *.cs (excluding XML doc-comments shipped with CLI plugin packages, which are not callsites) returns:
RequireTenantGuid() — 5 hits:
| # | Path | Category |
|---|---|---|
| 1 | StellaOps.Auth.ServerIntegration/Tenancy/IStellaOpsTenantAccessor.cs:86 | DEFINITION |
| 2 | StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantHttpContextExtensions.cs:82 | Wrapper (HttpContext.RequireTenantGuid()) — zero in-repo callers |
| 3 | StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:82 | Test — Throws<UnauthorizedAccessException> when missing |
| 4 | StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:97 | Test — Throws<FormatException> when not-a-Guid |
| 5 | StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:113 | Test — returns parsed Guid on Guid-form claim |
TryResolveTenantGuid(...) — 10 hits:
| # | Path | Category |
|---|---|---|
| 1 | StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantResolver.cs:153 | DEFINITION |
| 2 | StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:133 | Test — TryResolveTenantGuid_ValidGuidClaim_Parses |
| 3 | Doctor/__Tests/.../TenantIsolationTests.cs:133 | Module test (Doctor) — mirrored fixture |
| 4 | EvidenceLocker/__Tests/.../TenantIsolationTests.cs:133 | Module test (EvidenceLocker) — mirrored fixture |
| 5 | Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TenantIsolationTests.cs:133 | Module test (Excititor) — mirrored fixture |
| 6 | Concelier/__Tests/StellaOps.Concelier.WebService.Tests/TenantIsolationTests.cs:133 | Module test (Concelier) — mirrored fixture |
| 7 | Notify/__Tests/.../TenantIsolationTests.cs:143 | Module test (Notify) — mirrored fixture |
| 8 | Integrations/__Tests/.../TenantIsolationTests.cs:133 | Module test (Integrations) — mirrored fixture |
| 9 | Notifier/StellaOps.Notifier.Tests/TenantIsolationTests.cs:133 | Module test (Notifier) — mirrored fixture |
| 10 | Findings/StellaOps.Findings.Ledger.Tests/TenantIsolationTests.cs:133 | Module test (Findings.Ledger) — mirrored fixture |
(All eight TenantIsolationTests.cs are near-identical copies; the call asserts resolved == true + tenantGuid == expected against a Guid-form claim, i.e. they pin the back-compat case, not slug-tolerance.)
Categorisation summary:
- Production endpoint consumers needing slug-tolerance: 0. The
HttpContext.RequireTenantGuid()wrapper exists but has no in-repo callers — every production endpoint already migrated to its per-module-local resolver in Sprints 040 / 044. - Test consumers pinning Guid-only contract: 11 (3 in Auth tests, 8 in mirrored
TenantIsolationTests). All eight module fixtures assert the Guid-form claim path returnstrue; none of them assert behaviour on a slug claim. - Persistence projections: 0 (the helpers are HTTP-layer; persistence projections call into the per-module repository contracts with the already-resolved Guid).
- Audit log fields: 0 (audit logs flow off
ITenantContextAccessor/IStellaOpsTenantAccessor.TenantContext.TenantId— the string accessor, not the Guid one).
The original “21 callsites” figure in the Sprint 044 / Sprint 045 dossiers was unverified speculation. The real surface is two helpers with zero production consumers and 11 test consumers, all of which assert the back-compat case the chosen design preserves anyway.
Three candidate approaches considered
(A) Migrate the shared helpers in place: Task<Guid> RequireTenantGuidAsync(CancellationToken) + Task<(bool, Guid)> TryResolveTenantGuidAsync(HttpContext, IPlatformTenantResolver, CT). Single sprint converts both signatures to async + slug-tolerant. Tests update to await + supply a resolver fixture. The helpers become the canonical surface; per-module-local interfaces declared in Sprint 044 become temporary shims that retire as a follow-up.
(B) Add slug-tolerant Async overloads alongside the existing sync helpers — keep both forever. Back-compat preserved at the helper boundary. Per-callsite migration over multiple sprints. Helper grows a parallel surface; the codebase carries both shapes indefinitely.
© Per-module-local IXxxPlatformTenantResolver pattern (status quo from Sprint 040 / 044) — shared helpers stay Guid-only; never retire. Wave A+B already shipped this pattern across 5 endpoint surfaces (Policy, ExportCenter, Verdict, Artifact, Platform crypto). No further migration; declare victory and leave the Guid-only helpers as a deprecated surface with an [Obsolete] attribute and zero production callers.
Decision
Adopt (A) — migrate the shared helpers to async + slug-tolerant, then delete the Guid-only methods after a one-sprint deprecation window. Retire the per-module-local resolver interfaces over the same window.
Rationale:
- No half-finished implementations (CLAUDE.md +
feedback_finish_and_archive_sprints). (B) and © both leave the codebase in a permanent bifurcation: a slug-tolerant pattern AND a Guid-only pattern, with operator-facing module owners forced to choose between them every time a new endpoint is added. The user’s binding preference is to converge, not to bifurcate. - Zero production callers to migrate. The “21 callsites that pin Guid-only behaviour” never existed. The 11 real test callsites all assert the back-compat case (Guid-form claim returns the same Guid) which the new async + slug-tolerant helper preserves byte-for-byte. Test churn is mechanical: add
awaitand a fixture-registered in-memory resolver. The eight mirroredTenantIsolationTestsmigrate in lock-step from a single template change. - The per-module-local pattern was a workaround for one specific layering constraint (
StellaOps.Platform.WebServiceis a WebService assembly that__Libraries/cannot project-reference). The shared helper lives inStellaOps.Auth.ServerIntegration, which IS in__Libraries/— there is no layering reason to declare a per-module-local interface here. The cleanest design is to let the shared helper depend onIPlatformTenantResolver(or the equivalentIStellaOpsTenantResolverinterface declared in the same Auth library), and have each consuming WebService register an impl. - The shared helper’s design intent was always “the canonical tenant gate”. Sprint 040 S040-001 only declared a per-module-local interface because it was the smallest possible blast radius to ship one fix. Sprint 044 wave A+B repeated the pattern five times because the shared helper was BLOCKED on this RFC. With the RFC settled, the shared helper takes its rightful place.
- Renaming the slug-tolerant primitive
Asynckeeps the migration grep-able and prevents a silent semantic flip onRequireTenantGuid()(which the dossier rightly flagged as a contract break). Old callers see a compile error; new callers get the slug-tolerant behaviour.
Shape (target)
The shared helper grows one new instance method on IStellaOpsTenantAccessor and one new static on StellaOpsTenantResolver. Both delegate to the IStellaOpsTenantResolver interface declared in the same Tenancy/ directory (single method, byte-compatible with the per-module-local IPlatformTenantResolver):
// In StellaOps.Auth.ServerIntegration.Tenancy:
/// <summary>Single seam for slug-or-Guid → canonical tenant UUID.</summary>
public interface IStellaOpsTenantResolver
{
Task<Guid?> ResolveTenantUuidAsync(string? tenantSlugOrId, CancellationToken ct = default);
}
public interface IStellaOpsTenantAccessor
{
// ...existing surface unchanged...
/// <summary>Returns the canonical tenant Guid. Slug-tolerant when an
/// <see cref="IStellaOpsTenantResolver"/> is DI-registered; falls back to
/// the legacy <see cref="Guid.TryParse"/> path otherwise.</summary>
Task<Guid> RequireTenantGuidAsync(IStellaOpsTenantResolver? resolver, CancellationToken ct = default);
}
public static class StellaOpsTenantResolver
{
public static async ValueTask<(bool Success, Guid TenantGuid, string? Error)>
TryResolveTenantGuidAsync(HttpContext context, IStellaOpsTenantResolver? resolver, CancellationToken ct = default);
}
The synchronous RequireTenantGuid() and TryResolveTenantGuid(...) are marked [Obsolete("Use RequireTenantGuidAsync / TryResolveTenantGuidAsync; slug-form tenant claims silently fail under the sync helpers.", error: false)] for one sprint, then deleted in the sprint after migration completes.
Per-module-local IPolicyPlatformTenantResolver / IExportCenterPlatformTenantResolver (Sprint 044) become trivial wrappers around IStellaOpsTenantResolver for the duration of the deprecation window, then retire alongside the sync helpers. No new per-module-local resolver interfaces are declared after this ADR lands.
Failure semantics (preserved)
- Missing tenant claim →
UnauthorizedAccessException(unchanged). - Tenant claim present, resolver returns
null(slug not inshared.tenants) →FormatExceptionwith the new message"Tenant identifier '<slug>' does not resolve to a known tenant."(was:"Tenant identifier '<slug>' is not a valid GUID."). This is operator-visible behaviour change in the failure message string, but the exception type and HTTP status (still 400 at the endpoint surface) are unchanged. Worth a callout in the migration sprint’s release notes. - No resolver registered (legacy DI shape) → fall back to
Guid.TryParse(current behaviour). This is the back-compat path that keeps the 11 test callsites green during the deprecation window.
Consequences
Positive
- Single canonical pattern. Future endpoint authors call
accessor.RequireTenantGuidAsync(resolver, ct)and stop. No module-local interface boilerplate, no Postgres/Identity impl pair per module. - Zero permanent bifurcation. After the deprecation window, the sync Guid-only helpers and all per-module-local resolver interfaces are gone. CLAUDE.md “no half-finished implementations” satisfied.
- Test churn is mechanical. The 11 test callsites all want the Guid-form back-compat case, which the new async helper preserves identically when a resolver is registered.
- Sprint 044 work is not wasted. The five per-module-local resolvers landed in Sprint 044 act as the shim layer during the deprecation window — they retire cleanly when the sync helpers retire.
Tradeoffs
- One additional sprint of churn (the deprecation-window cleanup) compared to leaving option © in place. Acceptable because © is a permanent maintenance tax.
- Async cascade. Every consumer of the shared helper now has to be
async. In practice every Sprint 044 callsite already became async, so the cascade is bounded — and theHttpContext.RequireTenantGuid()wrapper has zero production consumers, so the cascade reaches no other code. - Failure message string changes. Existing UI/CLI assertions on the literal
"is not a valid GUID."text would break. There are none in-repo (verified viaGrep "is not a valid GUID") but external operator scripts might key on it — flag in release notes.
Migration steps (handed off to S045-002)
- Sprint 045 S045-002 (this RFC’s output): convert Sprint 045 deliverables into a per-callsite migration plan with acceptance gates. Sequence per below.
- Sprint 044 S044-006 (executed by Auth/Platform owner):
- Add
IStellaOpsTenantResolverinterface +Postgres+Identityimpls inStellaOps.Auth.ServerIntegration.Tenancy/. - Add
RequireTenantGuidAsync+TryResolveTenantGuidAsync(signatures above). - Mark legacy helpers
[Obsolete(..., error: false)]. - Update the 3 Auth tests + add 3 slug-tolerant cases (slug→canonical / Guid→pass-through / unknown→
Guid.Empty). - Update the 8 mirrored
TenantIsolationTestsvia the shared template atsrc/__Tests/architecture/(one template change ripples to all 8 — they ARE near-identical copies). The Guid-form assertion stays; add a slug-form assertion using an in-memory resolver fixture. - Register
IStellaOpsTenantResolverin every WebService’s DI graph that today registers a per-module-local resolver. The five Sprint 044 per-module-local impls (IPolicyPlatformTenantResolveretc.) become shims that delegate toIStellaOpsTenantResolver.
- Add
- Sprint 044 S044-006 follow-up (one sprint later):
- Delete the sync
RequireTenantGuid()+TryResolveTenantGuid(...). - Delete the five per-module-local resolver interfaces + their Postgres/Identity impls.
- Migrate the five Sprint 044 endpoint helpers (
UnknownsEndpoints,ExceptionReportEndpoints,VerdictEndpoints,ArtifactController,PlatformCryptoPreferenceProvider) to consumeIStellaOpsTenantResolverdirectly.
- Delete the sync
- Acceptance gates:
- Tenant-isolation test suite stays green at every step (no temporary regressions). The 8 mirrored fixtures must all pass between each PR.
RequireTenantOptInConformanceTests(the architecture-contract test atsrc/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/RequireTenantOptInConformanceTests.cs) stays green.- No production endpoint in
src/**/Endpoints/directly referencesIPlatformTenantResolver(Platform-WebService variant) after the cleanup sprint — all routed throughIStellaOpsTenantResolver.
Alternatives Considered
(B) Add slug-tolerant Async overloads alongside Guid-only — keep both forever
Rejected. Pros: minimal one-sprint blast radius; back-compat preserved at every callsite. Cons: every future endpoint author has to decide which surface to call. The Guid-only helpers stay as a footgun (“looks safe; silently fails on slug claims”). This is the exact bifurcation CLAUDE.md prohibits — it leaves the wrong-default surface in place forever.
© Per-module-local resolver pattern only — never touch the shared helpers
Rejected. Pros: zero risk; Sprint 044 work is the entirety of the answer. Cons: every new endpoint surface costs a per-module-local interface + Postgres + Identity impl pair (Sprint 044 paid this cost five times in a single day and it is non-trivial boilerplate). The shared helpers become a deprecated-but-undeletable surface — [Obsolete] without a migration path. New module authors will reach for them by accident because they have the most discoverable names (accessor.RequireTenantGuid() vs accessor.SomePolicyLocalResolverShim). This converges nowhere.
(D) Delete the Guid helpers immediately without an async replacement
Rejected. The 11 test callsites would all need migration in a single PR, and there would be a window where the shared helper has zero Guid-handling surface — endpoints needing canonical Guids would have to call Guid.Parse(accessor.RequireTenant()) directly. That re-introduces the anti-pattern at every callsite. The async replacement is the migration path; deletion without replacement is the destination, not the route.
References
docs/implplan/SPRINT_20260529_045_Auth_shared_tenant_resolver_design_rfc.md(S045-001 outcome captures the RFC narrative)docs/implplan/SPRINT_20260528_044_Crossmodule_IPlatformTenantResolver_repo_audit_followup.md(Sprint 044, S044-006 task; this ADR is the design that unblocks it)src/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/IStellaOpsTenantAccessor.cs:86— currentRequireTenantGuid()definitionsrc/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantResolver.cs:153— currentTryResolveTenantGuiddefinitionsrc/Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantHttpContextExtensions.cs:74— theHttpContext.RequireTenantGuid()wrapper (zero in-repo callers)src/Platform/StellaOps.Platform.WebService/Services/PlatformTenantResolver.cs:20— provenIPlatformTenantResolvershape (the newIStellaOpsTenantResolveris byte-compatible)- Sprint 040 commits
454c7ef074+200bf24395— the pattern’s origin - Sprint 044 commits
ac3c231854,3145a0f8d0,d3a619ca89,54909dbd09,f835307395— the five per-module-local impls that act as the deprecation-window shim layer src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/RequireTenantOptInConformanceTests.cs— architecture-contract guard, must stay green throughout migration
