toolup-forgetoolup-forge

Migration — 0.X.0 PlatformMode → Surfaces (Phase 66 consumer adoption)

Migration — 0.X.0 PlatformMode → Surfaces (Phase 66 consumer adoption)

Version range. The release that retires PlatformMode ships as a coordinated ToolUp.* minor bump (0.X.0) via the ToolUp.Sdk meta-manifest. Per the SemVer-on-0.x policy this is a breaking minor bump — a consumer pinning via <ToolUpSdkVersion>0.X.0</ToolUpSdkVersion> and applying the rename inventory below restores clean.

What changes. PlatformMode (Anonymous / AuthenticatedEphemeral / Individual / Team / MultiTeam) is retired wholesale. The replacement is the three-term Subject model documented in docs/platform/surfaces.md:

  • ServerConfig.Mode: PlatformModeServerConfig.Surfaces: SurfaceProfile list (non-empty; default Surfaces.anonymous).
  • ClientConfig.Mode: PlatformModeClientConfig.Surfaces: SurfaceProfile list (mirrors the server field).
  • AccessContext.Mode: PlatformModeAccessContext.Subject: Subject. Helpers AccessContext.isAnonymous / isAuthenticated / inTeamScope / isClaimBearer / kindLabel replace match ctx.Mode with predicate reads.
  • Env-var rename. TOOLUP_PLATFORM_MODETOOLUP_PLATFORM_SURFACES (single token for single-surface deployments; comma-/semicolon-/space-separated for mixed-mode). Client side: __TOOLUP_PLATFORM_MODE__ Vite define → __TOOLUP_PLATFORM_SURFACES__; BundleConstants.platformModeBundleConstants.platformSurfaces. No alias — clean cutover.
  • ServerConfig.AnonymousRoutePrefixes + AcceptShallowAnonymousRoutePrefix are deleted. The replacement is per-route SurfaceRequirement.claimBearerOnly / .anonymousOnly / .public_ declared via the module API (see Diff §5).
  • Five Accept*InAuthenticatedMode flags rename — see Diff §3.
  • Auth pipeline. AuthEnforcementMiddleware is retired; SurfaceEnforcementMiddleware driven by SurfaceRequirementRegistry replaces it. ShareTokenAuthMiddleware lands ahead of scope resolution. CSRF carve-out derivation moves from prefix-list reads to registry reads (routes whose AcceptedSubjects admits AnonymousKind or ClaimBearerKind skip CSRF).
  • Composition root. ITeamStore auto-wires when any SurfaceProfile.Team is declared; IShareTokenStore auto-promotes NoShareTokenStoreEnabledShareTokenStore when any SurfaceProfile.ClaimBearer is declared. Two new builders ship: ServerApp.withSubjectMigrator (anonymous → user data migration on first sign-in) and ServerApp.withShareTokenStoreDecorator (the RevokeOnIssuerRemoved companion under src/ShareTokenStoreDecorators/ is the first consumer).
  • Audit subsystem. IAuditSink.Deliver takes AuditEnvelope list instead of AuditEvent list; the envelope carries Subject: AuditSubject, ScopeId, OccurredAt, plus the original Event. IAuditSink gains abstract SchemaVersion: int (current = 2; pre-66 = 1). Shipped sinks (SplunkHec, DatadogLogs, S3Archive, InMemoryAuditSink) declare schema version 2 + the envelope deserialiser; custom sinks update the same way.
  • Client identity flow. UserSession.configure takes SubjectKind instead of PlatformMode; UserSession.getMode ()UserSession.getSubjectKind (). AuthUIProvider.gate takes SubjectKindAnonymousKind and ClaimBearerKind are leading pass-through arms (anonymous-only deployments mount no sign-in flow at all, regardless of AuthUI setting). ClientModule gains a Visibility: SubjectKind -> bool field driving the sidebar filter per module (replaces the previous shell-level blanket-hide).
  • Per-shape RateLimitConfig. RateLimitConfig is now { Default: RateLimitPolicy option; PerShape: Map<SubjectKind, RateLimitPolicy> }. ServerConfig.RateLimit is non-option; RateLimitConfig.none is the new default. Partition keys flow from subject kind via RateLimitPolicy.partitionFor (IP / user / team / token).

Diff to apply

Canonical edit shapes — apply at every site that matches.

// §1 — ServerConfig field rename
{ ServerConfig.defaults with Mode = Individual }
{ ServerConfig.defaults with Surfaces = Surfaces.individual }

// §2 — ClientConfig field rename (same idiom)
{ ClientConfig.defaults with Mode = Individual }
{ ClientConfig.defaults with Surfaces = Surfaces.individual }

// §3 — Accept*InAuthenticatedMode flags
//   AcceptHeaderAuthInAuthenticatedMode        → AcceptHeaderAuthWhenAuthRequired
//   AcceptPlaintextSecretsInAuthenticatedMode  → AcceptPlaintextSecretsWhenAuthRequired
//   AcceptNoRateLimitInAuthenticatedMode       → AcceptNoRateLimitWhenAuthRequired
//   AcceptQueryParamSseAuthInAuthenticatedMode → AcceptQueryParamSseAuthWhenAuthRequired
//   AcceptUnboundAudienceInAuthenticatedMode   → AcceptUnboundAudienceWhenAuthRequired

// §4 — `match ctx.Mode with` predicate-read rewrites
match ctx.Mode with Anonymous -> shortCircuit () | _ -> proceed ()
if AccessContext.isAnonymous ctx then shortCircuit () else proceed ()

match ctx.Mode with Team | MultiTeam -> teamPath ctx.TeamId.Value | _ -> userPath ctx.UserId
match ctx.Subject with TeamMember(uid, tid) -> teamPath tid | _ -> userPath ctx.UserId

// §5 — Anonymous prefix → per-route SurfaceRequirement (Forms-shape example)
ServerApp.empty
|> ServerApp.withAnonymousRoute "/api/forms/public/"
ServerApp.empty
// Declare a synthetic module that registers the per-route override:
let publicSubmitModule =
    ServerModule.create "MyApp.PublicSubmit"
    |> ServerModule.withRouteSurfaceRequirement (POST, "/api/forms/public/SubmitWithToken")
                                                SurfaceRequirement.claimBearerOnly

// §6 — Handler-factory `mode: PlatformMode` parameter drop (B.5 shape)
let myHandler (config: ServerConfig) (mode: PlatformMode) ... =
let myHandler (config: ServerConfig) ... =                  // reads Subject from HttpContext.Items["ToolUp.Subject"]

// §7 — Custom IAuditSink contract update
type MySink() =
    interface IAuditSink with
        member _.SchemaVersion = AuditSchemaVersion.current     // = 2
        member _.Deliver(envelopes: AuditEnvelope list) = ...   // was AuditEvent list

// §8 — Client identity helpers
UserSession.configure mode                                  // mode: PlatformMode
UserSession.configure subjectKind                           // subjectKind: SubjectKind
let mode = UserSession.getMode ()
let kind = UserSession.getSubjectKind ()

// §9 — Deployment manifest env var
TOOLUP_PLATFORM_MODE=individual
TOOLUP_PLATFORM_SURFACES=individual                         // single shape
TOOLUP_PLATFORM_SURFACES=anonymous,individual               // mixed-mode

Five worked examples

Each is a complete ServerConfig / ClientConfig excerpt for the named deployment archetype. The five cover the deployment shapes docs/design/mixed-mode-platform.md §5.3 enumerates.

Example 1 — Pure-Individual internal-tools deployment

// Server
{ ServerConfig.defaults with Surfaces = Surfaces.individual }
// Client (mirror)
{ ClientConfig.defaults with Surfaces = Surfaces.individual }
// Manifest
TOOLUP_PLATFORM_SURFACES=individual

Every route inherits the module default userOrTeam; every client module inherits Visibility.visibleToAuthenticated. No further migration beyond the field rename.

Example 2 — Federation deployment pair (peer-bearer orthogonal)

{ ServerConfig.defaults with
    Surfaces = Surfaces.individual
    AcceptHeaderAuthWhenAuthRequired = true        // localhost waiver
    AcceptPlaintextSecretsWhenAuthRequired = true  // localhost waiver
    AcceptQueryParamSseAuthWhenAuthRequired = true // localhost waiver
    PeerRoutePrefixes = [ "/api/peers/" ] }        // unchanged — peer-bearer is orthogonal

The three rename-only flags carry their localhost-testing semantics through verbatim. PeerRoutePrefixes + PeerBearerAuthMiddleware are deliberately untouched — federation routes resolve identity from the peer bearer, not from a Subject.

Example 3 — Pure-Anonymous public portal

{ ServerConfig.defaults with Surfaces = Surfaces.anonymous }
{ ClientConfig.defaults with Surfaces = Surfaces.anonymous }
TOOLUP_PLATFORM_SURFACES=anonymous

Each module declares DefaultSurfaceRequirement = SurfaceRequirement.public_ server-side and Visibility = Visibility.visibleToAll client-side. AuthUIProvider.gate is a pass-through in this shape — no sign-in component is mounted. withAuth is optional. CSRF middleware is skipped (no session to bind the nonce against).

Example 4 — Public-utility-with-admin pathology ([anonymous; individual])

The deployment shape previously approximated via Mode = Individual + DevDefaultUserId = "dev-admin". The workaround retires entirely; the new declaration serves the actual use case directly.

{ ServerConfig.defaults with Surfaces = Surfaces.anonymousAndIndividual }
{ ClientConfig.defaults with Surfaces = Surfaces.anonymousAndIndividual }
TOOLUP_PLATFORM_SURFACES=anonymous,individual

The public-calculator modules declare DefaultSurfaceRequirement = SurfaceRequirement.public_ + Visibility = visibleToAll. The admin module declares DefaultSurfaceRequirement = SurfaceRequirement.userOrTeam + Visibility = visibleToAuthenticated. Anonymous callers reach the calculator surfaces directly and see no sidebar entry for the admin module; an authenticated admin sees both. The DevDefaultUserId field on ClientConfig is deleted.

Example 5 — [anonymous; team; claimBearer] end-to-end SaaS

Public landing + private team dashboard + share-token-gated public-submit endpoint.

{ ServerConfig.defaults with
    Surfaces = [ SurfaceProfile.anonymous
                 SurfaceProfile.team
                 SurfaceProfile.claimBearer ]
    PublicBaseUrl = Some "https://app.example.com" }
|> ServerApp.withShareTokenStoreDecorator
       (RevokeOnIssuerRemoved.decorator notificationChannel)

{ ClientConfig.defaults with Surfaces = [ SurfaceProfile.anonymous; SurfaceProfile.team; SurfaceProfile.claimBearer ] }
TOOLUP_PLATFORM_SURFACES=anonymous,team,claim_bearer

SurfaceCoherenceValidator checks the invariants this deployment must satisfy: a withAuth provider must be registered (Rule 8 — non-Anonymous surface present); IShareTokenStore auto-promotes to EnabledShareTokenStore (auto-promotion logged at Info; Rule 5 surfaces a Warning if a custom decorator is wired without it); a public landing module declares public_ + visibleToAll, the team-dashboard module declares teamScoped + visibleToAuthenticated, and the public-submit handler declares claimBearerOnly per-route per Diff §5.

Verification

Per the SDK adoption tracking mandate, an adoption PR is gated by:

  1. dotnet build <consumer>.sln clean after the rename sweep.

  2. dotnet fable -o output clean from the client project — the Mode-field migration on ClientConfig is the most common Fable-side break.

  3. Consumer's test suite (Expecto runners via dotnet run --project) exits 0.

  4. Startup-log pin. The deployment's bootSummary line now reads surfaces=<+-joined-label> (or surfaces=<single-name> for single-surface). Single-surface deployments produce the verbatim pre-66 mode name (anonymous / trial / individual / team / multi_team); mixed-mode produces a +-joined list. Pin with:

    Select-String -Path .\boot.log -Pattern '\bsurfaces=' | Select-Object -First 1
    
  5. SurfaceCoherenceValidator smoke test. Boot once with an intentionally broken config (e.g. Surfaces = []) and confirm the validator refuses startup with the expected rule citation. The 10 rules are enumerated in docs/platform/surfaces.md.

  6. Mixed-mode browser pass (only when the deployment declared a list of ≥2 surfaces). Hit a public route anonymously, sign in, hit a private route, sign out, confirm the private route 401s and the public route still resolves.

Rollback

Revert the <ToolUpSdkVersion> bump in the consumer's Directory.Packages.props. Every rename above has a 1:1 inverse — restoring the prior version mechanically re-establishes the prior code shape with no data-shape consequences (subjects are derived per-request and never persisted; storage scope containers (session-{id} / user-{id} / team-{id}) are byte-identical across both versions for single-surface deployments). The RevokeOnIssuerRemoved decorator + SurfaceCoherenceValidator rules are additive: reverting removes the new checks without leaving stale state behind. Mixed-mode deployments cannot roll back to a pre-66 SDK — the prior model does not represent the shape.

See workspace SDK-ADOPTION.md for the per-consumer adoption matrix.