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/:

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:

#PathCategory
1StellaOps.Auth.ServerIntegration/Tenancy/IStellaOpsTenantAccessor.cs:86DEFINITION
2StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantHttpContextExtensions.cs:82Wrapper (HttpContext.RequireTenantGuid()) — zero in-repo callers
3StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:82Test — Throws<UnauthorizedAccessException> when missing
4StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:97Test — Throws<FormatException> when not-a-Guid
5StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:113Test — returns parsed Guid on Guid-form claim

TryResolveTenantGuid(...) — 10 hits:

#PathCategory
1StellaOps.Auth.ServerIntegration/Tenancy/StellaOpsTenantResolver.cs:153DEFINITION
2StellaOps.Auth.ServerIntegration.Tests/StellaOpsTenantAccessorTests.cs:133Test — TryResolveTenantGuid_ValidGuidClaim_Parses
3Doctor/__Tests/.../TenantIsolationTests.cs:133Module test (Doctor) — mirrored fixture
4EvidenceLocker/__Tests/.../TenantIsolationTests.cs:133Module test (EvidenceLocker) — mirrored fixture
5Concelier/__Tests/StellaOps.Excititor.WebService.Tests/TenantIsolationTests.cs:133Module test (Excititor) — mirrored fixture
6Concelier/__Tests/StellaOps.Concelier.WebService.Tests/TenantIsolationTests.cs:133Module test (Concelier) — mirrored fixture
7Notify/__Tests/.../TenantIsolationTests.cs:143Module test (Notify) — mirrored fixture
8Integrations/__Tests/.../TenantIsolationTests.cs:133Module test (Integrations) — mirrored fixture
9Notifier/StellaOps.Notifier.Tests/TenantIsolationTests.cs:133Module test (Notifier) — mirrored fixture
10Findings/StellaOps.Findings.Ledger.Tests/TenantIsolationTests.cs:133Module 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:

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:

  1. 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.
  2. 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 await and a fixture-registered in-memory resolver. The eight mirrored TenantIsolationTests migrate in lock-step from a single template change.
  3. The per-module-local pattern was a workaround for one specific layering constraint (StellaOps.Platform.WebService is a WebService assembly that __Libraries/ cannot project-reference). The shared helper lives in StellaOps.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 on IPlatformTenantResolver (or the equivalent IStellaOpsTenantResolver interface declared in the same Auth library), and have each consuming WebService register an impl.
  4. 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.
  5. Renaming the slug-tolerant primitive Async keeps the migration grep-able and prevents a silent semantic flip on RequireTenantGuid() (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)

Consequences

Positive

Tradeoffs

Migration steps (handed off to S045-002)

  1. Sprint 045 S045-002 (this RFC’s output): convert Sprint 045 deliverables into a per-callsite migration plan with acceptance gates. Sequence per below.
  2. Sprint 044 S044-006 (executed by Auth/Platform owner):
    • Add IStellaOpsTenantResolver interface + Postgres + Identity impls in StellaOps.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 TenantIsolationTests via the shared template at src/__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 IStellaOpsTenantResolver in every WebService’s DI graph that today registers a per-module-local resolver. The five Sprint 044 per-module-local impls (IPolicyPlatformTenantResolver etc.) become shims that delegate to IStellaOpsTenantResolver.
  3. 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 consume IStellaOpsTenantResolver directly.
  4. 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 at src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/RequireTenantOptInConformanceTests.cs) stays green.
    • No production endpoint in src/**/Endpoints/ directly references IPlatformTenantResolver (Platform-WebService variant) after the cleanup sprint — all routed through IStellaOpsTenantResolver.

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