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: PlatformMode→ServerConfig.Surfaces: SurfaceProfile list(non-empty; defaultSurfaces.anonymous).ClientConfig.Mode: PlatformMode→ClientConfig.Surfaces: SurfaceProfile list(mirrors the server field).AccessContext.Mode: PlatformMode→AccessContext.Subject: Subject. HelpersAccessContext.isAnonymous/isAuthenticated/inTeamScope/isClaimBearer/kindLabelreplacematch ctx.Mode withpredicate reads.- Env-var rename.
TOOLUP_PLATFORM_MODE→TOOLUP_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.platformMode→BundleConstants.platformSurfaces. No alias — clean cutover. ServerConfig.AnonymousRoutePrefixes+AcceptShallowAnonymousRoutePrefixare deleted. The replacement is per-routeSurfaceRequirement.claimBearerOnly/.anonymousOnly/.public_declared via the module API (see Diff §5).- Five
Accept*InAuthenticatedModeflags rename — see Diff §3. - Auth pipeline.
AuthEnforcementMiddlewareis retired;SurfaceEnforcementMiddlewaredriven bySurfaceRequirementRegistryreplaces it.ShareTokenAuthMiddlewarelands ahead of scope resolution. CSRF carve-out derivation moves from prefix-list reads to registry reads (routes whoseAcceptedSubjectsadmitsAnonymousKindorClaimBearerKindskip CSRF). - Composition root.
ITeamStoreauto-wires when anySurfaceProfile.Teamis declared;IShareTokenStoreauto-promotesNoShareTokenStore→EnabledShareTokenStorewhen anySurfaceProfile.ClaimBeareris declared. Two new builders ship:ServerApp.withSubjectMigrator(anonymous → user data migration on first sign-in) andServerApp.withShareTokenStoreDecorator(theRevokeOnIssuerRemovedcompanion undersrc/ShareTokenStoreDecorators/is the first consumer). - Audit subsystem.
IAuditSink.DelivertakesAuditEnvelope listinstead ofAuditEvent list; the envelope carriesSubject: AuditSubject,ScopeId,OccurredAt, plus the originalEvent.IAuditSinkgainsabstract 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.configuretakesSubjectKindinstead ofPlatformMode;UserSession.getMode ()→UserSession.getSubjectKind ().AuthUIProvider.gatetakesSubjectKind—AnonymousKindandClaimBearerKindare leading pass-through arms (anonymous-only deployments mount no sign-in flow at all, regardless ofAuthUIsetting).ClientModulegains aVisibility: SubjectKind -> boolfield driving the sidebar filter per module (replaces the previous shell-level blanket-hide). - Per-shape
RateLimitConfig.RateLimitConfigis now{ Default: RateLimitPolicy option; PerShape: Map<SubjectKind, RateLimitPolicy> }.ServerConfig.RateLimitis non-option;RateLimitConfig.noneis the new default. Partition keys flow from subject kind viaRateLimitPolicy.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:
dotnet build <consumer>.slnclean after the rename sweep.dotnet fable -o outputclean from the client project — theMode-field migration onClientConfigis the most common Fable-side break.Consumer's test suite (Expecto runners via
dotnet run --project) exits 0.Startup-log pin. The deployment's
bootSummaryline now readssurfaces=<+-joined-label>(orsurfaces=<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 1SurfaceCoherenceValidatorsmoke 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 indocs/platform/surfaces.md.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.