Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ public async Task<IActionResult> FirstAdmin(

var user = (string.IsNullOrWhiteSpace(username) ? email.Split('@')[0] : username)
.Trim().ToLowerInvariant();
var request = new UserOnboardingRequest(user, email.Trim(), name ?? user);
// The first global administrator: SEEDED with no learning path — they are setting the
// instance up, not learning it (UserOnboardingDefaults.PinnedPathsFor). The flag decides
// the seed only: this endpoint is re-runnable (see Step below), and on a user who already
// exists CreateUser keeps the pins as they are, so a re-run erases nothing they pinned since.
var request = new UserOnboardingRequest(user, email.Trim(), name ?? user) { IsPlatformBootstrap = true };

logger.LogInformation("Bootstrap: materialising first admin '{User}' ({Email})", user, email);

Expand Down
127 changes: 122 additions & 5 deletions memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;
Expand Down Expand Up @@ -41,6 +42,7 @@ namespace Memex.Portal.Shared.Authentication;
public sealed class UserOnboardingService(
IMeshService meshService,
AccessService accessService,
IMessageHub hub,
ILogger<UserOnboardingService>? logger = null,
IIconGenerator? iconGenerator = null)
{
Expand Down Expand Up @@ -71,9 +73,10 @@ public IObservable<MeshNode> CreateUser(UserOnboardingRequest request)
// so an unshipped tag stores nothing rather than pinning the profile to a language we
// would render in English anyway.
Locale = Locales.TryMatch(request.Locale),
// Pin the four documentation sections so a new user's Pinned tab opens
// onto a clean grid of doc landing pages (each with its own TOC).
PinnedPaths = ["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"],
// The SEED — what a profile starts with. It reaches the store on the create leg only:
// UpsertProfile declares PinnedPaths as create-once, so on a user who already exists
// this value is discarded and the owner keeps the pins it holds.
PinnedPaths = UserOnboardingDefaults.PinnedPathsFor(request.IsPlatformBootstrap),
};

var partitionRootNode = new MeshNode(username)
Expand Down Expand Up @@ -109,7 +112,7 @@ public IObservable<MeshNode> CreateUser(UserOnboardingRequest request)
// unhandled → error storm → the portal climbed to OutOfMemory and wedged. See /async, CreateOrUpdateNode.
return Observable.Using(
() => accessService.ImpersonateAsSystem(),
_ => meshService.CreateOrUpdateNode(partitionRootNode)
_ => UpsertProfile(partitionRootNode)
.Do(__ => logger?.LogInformation(
"Onboarding: upserted partition-root User '{Username}' to {Schema}.mesh_nodes",
username, username.ToLowerInvariant())))
Expand All @@ -120,6 +123,70 @@ public IObservable<MeshNode> CreateUser(UserOnboardingRequest request)
.Do(rootNode => MaybeGenerateAvatar(rootNode, fullDisplayName, avatarIcon));
}

/// <summary>
/// The partition-root upsert, carrying the ONE fold onboarding needs: <see cref="User.PinnedPaths"/>
/// is <b>seeded on the create leg and kept as the owner holds it on the update leg</b>
/// (<see cref="FoldRule.KeepExisting"/> — the create-once member, #4928).
///
/// <para>🚨 Why a fold and not the plain <see cref="IMeshService.CreateOrUpdateNode"/>. The
/// full-instance upsert's update leg takes <c>Content</c> WHOLESALE
/// (<c>UpdateAccordingToSourceNode</c>: <c>Content = sourceNode.Content ?? state.Content</c>), so a
/// second <see cref="CreateUser"/> for a user who exists replaced their pins with the seed —
/// <c>[]</c> for the first administrator — and silently erased everything they had pinned since.
/// The recovery endpoint (<see cref="BootstrapController.FirstAdmin"/>) is documented as
/// re-runnable, and the onboarding page re-submits after a lagged existence check, so that leg
/// is not hypothetical. A client-side "exists → <c>stream.Update</c>, else create" split is not
/// the answer either: that is the exact shape <see cref="CreateUser"/>'s own comment records as
/// removed (the create's exists-check lagged a concurrent create → a patch onto a not-yet-
/// materialised node → error storm → OOM wedge). The fold is the owner-side form of that same
/// Update: the caller states the RULE, the owner applies it against the node as it holds it,
/// inside the one serialised merge — no read, no decision, no race.</para>
///
/// <para>Every other member still lands wholesale, deliberately: the interactive form's
/// re-submission carries values the person just typed, and a re-run of the recovery endpoint
/// repairing a display name is what the existing-user leg is FOR. Only the pins are the user's
/// own curation after onboarding.</para>
///
/// <para>Issued off the router (<see cref="MeshExtensions.NodeOperationIssuingHub"/>) exactly as
/// <c>MeshService.CreateOrUpdateNode</c> does, with the caller's identity stamped on the request
/// (<c>RequestedBy</c>) and on the delivery, because the response's Subscribe lands on an
/// emission thread where the impersonation scope's AsyncLocal is gone. Caller-read-free is not
/// cluster-atomic (see <see cref="ContentFolds"/>) — which is exactly enough here: nothing is
/// counted, a value is merely preserved.</para>
/// </summary>
/// <param name="partitionRootNode">The User node carrying the SEED content.</param>
/// <returns>The node as the owner stored it — created, or merged onto the existing profile.</returns>
private IObservable<MeshNode> UpsertProfile(MeshNode partitionRootNode)
{
// Captured at the call, which CreateUser makes INSIDE its Using scope — so this is the
// System identity the write runs under, pinned by value before any scheduler hop.
var captured = accessService.Context;
var request = new CreateOrUpdateNodeRequest(partitionRootNode)
.WithFolds<User>(hub.JsonSerializerOptions, f => f.KeepExisting(u => u.PinnedPaths))
with { RequestedBy = captured?.ObjectId };
var target = hub.NodeOperationTarget();
// Defer keeps the post cold — it fires on Subscribe, never on construction.
return Observable.Defer(() => hub.NodeOperationIssuingHub()
.Observe(request, o => captured is null
? o.WithTarget(target)
: o.WithTarget(target).WithAccessContext(captured))
.SelectMany(d =>
{
var r = d.Message;
if (r.Success && r.Node is not null)
return Observable.Return(r.Node);
return Observable.Throw<MeshNode>(r.RejectionReason switch
{
NodeUpsertRejectionReason.Unauthorized or NodeUpsertRejectionReason.ValidationFailed =>
new UnauthorizedAccessException(r.Error ?? "Access denied"),
_ => new InvalidOperationException(r.Error ?? "Node upsert failed"),
});
}))
// The SAME identity around every emission that the post was stamped with, so the
// subscriber's callbacks do not inherit whatever is ambient on the emission thread.
.CarryAccessContext(hub.ServiceProvider, captured);
}

/// <summary>
/// Fire-and-forget avatar generation for a freshly-created User node. Reuses the existing
/// <see cref="IIconGenerator"/> (the <c>NodeInitializer</c> agent on the configurable utility
Expand Down Expand Up @@ -233,6 +300,33 @@ public IObservable<MeshNode> GrantPlatformAdmin(string username)
}
}

/// <summary>
/// The learning path a new user is SEEDED with — and who is not. A seed, never a value re-imposed:
/// <see cref="UserOnboardingService.CreateUser"/> applies it on the create leg only.
/// </summary>
public static class UserOnboardingDefaults
{
/// <summary>
/// The four documentation sections an ordinary new user's Pinned tab opens onto: a clean grid
/// of doc landing pages, each with its own TOC.
/// </summary>
public static readonly ImmutableList<string> LearningPath =
["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"];

/// <summary>
/// What to seed for this user.
///
/// <para>🚨 <b>The first global administrator gets NO learning path.</b> That person is setting
/// the instance up, not learning it — a Pinned tab full of doc landing pages in front of the
/// setup work is noise at the one moment it costs most. Ordinary users keep it. Policy
/// <c>first-admin-no-learning-path</c> (<c>Doc/Architecture/FirstRunSetupOnAProvisionedInstance</c>,
/// "The first administrator learns nothing here").</para>
/// </summary>
/// <param name="isPlatformBootstrap">Whether this user is the instance's first global administrator.</param>
public static ImmutableList<string> PinnedPathsFor(bool isPlatformBootstrap) =>
isPlatformBootstrap ? [] : LearningPath;
}

/// <summary>
/// Input shape for <see cref="UserOnboardingService.CreateUser"/>. Mirrors the form
/// model in <c>Onboarding.razor</c>; kept in this assembly so unit tests can
Expand All @@ -250,4 +344,27 @@ public sealed record UserOnboardingRequest(
// Accept-Language header onto AccessContext.Locale. Resolved through Locales.TryMatch at the
// point of use, so a tag this deployment does not ship stores nothing rather than pinning the
// profile to a language we would only ever render in English anyway.
string? Locale = null);
string? Locale = null)
{
/// <summary>
/// True when this user is the instance's FIRST global administrator — the bootstrap path
/// (<see cref="BootstrapController.FirstAdmin"/>, and the first-user promotion the onboarding
/// page decides). Such a user is setting the instance up, not learning it, and is
/// <b>seeded</b> with no learning path (<see cref="UserOnboardingDefaults.PinnedPathsFor"/>).
///
/// <para>🚨 A CALLER's decision, not a form field — which is why it is an init-only property
/// and not a primary-constructor parameter. The primary constructor mirrors what the person
/// typed on the onboarding form; this flag is what the code orchestrating the onboarding
/// knows about the instance. Keeping it out of the constructor also keeps the record's
/// constructor signature — the one a module compiled earlier calls — exactly as it was
/// (<c>check-record-signatures.py</c>: adding a parameter, even with a default, replaces
/// that signature).</para>
///
/// <para>It decides the SEED only. On a user who already exists the flag changes nothing:
/// <see cref="UserOnboardingService.CreateUser"/> keeps an existing profile's pins as they are
/// (the <c>KeepExisting</c> fold), so a re-run of the recovery endpoint — which is documented
/// as re-runnable — cannot erase what the administrator pinned after the original
/// onboarding.</para>
/// </summary>
public bool IsPlatformBootstrap { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,50 @@ app, and a customer portal must not send through that. Until the instance has a
its own, an invitation is **recorded and not delivered**, and the page says so rather than reporting
a send that did not happen.

### The objects that do not exist

*"It should also show me secrets which don't actually exist"* — policy `setup-secrets-three-valued`
([Policy Not Prose](../PolicyNotProse) carries when and by whom). Provenance and the object name are
not enough; the third state is a mapping the record or repository **declares whose object is not in
the vault**. The ramp-up gates cannot know this — they deliberately carry no
credential, so they assert that a mapping is *declared*, never that its object *exists*. The dialog
runs on the instance, which mounts that vault with *Key Vault Secrets User*: a read right, which is
exactly what an existence check needs. It asks the object's **versions listing**, so existence is
answered without a value ever entering the process.

The answer is three-valued on purpose. `200` is *exists*, `404` is *missing*, and everything else —
a 403, a timeout, no route — is *not checked*, never either answer: "missing" on a permission error
sends somebody to create an object that exists, and "exists" hides the very row the probe is for.
Missing rows sort first and say what a deploy would otherwise say: a declared-but-absent object fails
the whole CSI mount, every new pod pending, so it is created **before** the deploy.

The measured case: PartnerRe's record mapped `GitHub__App__PrivateKey → memex-GitHub-App-PrivateKey`
until 2026-09-20, an object that never existed in `memexaks-kv-i6gzgik26ydg`, and nothing surfaced it
until a deploy tripped over it.

### The first administrator learns nothing here

*"The learning path I don't need for first onboarding of global admin"* — policy
`first-admin-no-learning-path` ([Policy Not Prose](../PolicyNotProse)). An ordinary new user is
**seeded** with four documentation sections in `User.PinnedPaths`; the first global administrator is
seeded with none. They are setting the instance up, not learning it. The flag that says which is
`UserOnboardingRequest.IsPlatformBootstrap`, set by the code that KNOWS it is materialising the first
administrator — `BootstrapController.FirstAdmin` today — never by the person filling in a form.

**A seed, not a value every onboarding re-imposes.** The recovery endpoint is documented as
re-runnable ("already exists" is a successful step, because a re-run still wants the Admin grants),
and the onboarding page re-submits after a lagged existence check. Both reach `CreateUser` for a user
who already exists, and that write lands the profile through the full-instance upsert, whose update
leg takes `Content` wholesale — so until this rule was pinned, a re-run replaced the pins with the
seed: the four sections for an ordinary user, `[]` for the first administrator, silently erasing
whatever they had pinned since (found in review, #5077). `CreateUser` therefore declares
`PinnedPaths` as a **create-once member** on the upsert (`FoldRule.KeepExisting`, the shape #4928
added for exactly this): the seed lands when the profile is created, and on an existing profile the
owner keeps the pins it holds, whatever the request carries. Every other profile member still lands
wholesale — a re-submitted form carries what the person just typed, and repairing a display name is
what the existing-user leg is for. `OnboardingPinsAreSeededOnceTest` holds both sides: a first call
seeds, and a second call after the user has pinned something leaves that pin exactly where it was.

## The plugin catalog in the wizard

The wizard shows the catalog of the registry the instance is mounted on, lets the person select what
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ written.
| `open-vocabulary-string-constants` | A vocabulary that is persisted, serialised, or extended by a module is a `static class` of `const string` named exactly as the enum would have been — never a C# `enum`. It stays OPEN: any other party may add its own values from its own constants class, so the platform's set is never treated as exhaustive. Consumers compare against constants and always carry a branch for a value this build does not know. | in force | 2026-09-21 | maintainer |
| `thread-graceful-error` | Wherever user code is executed, innermost in a thread, it must gracefully error: every failure path ends in a stamped terminal state on the node. A failure the thread's own hub cannot stamp — because it is the hub that died — is observed, cleaned up and relaunched under a bound, and what a relaunch cannot fix is filed into bug triage. The relaunch/dispatch side is bounded by a configurable pool cap (`maxConcurrentAgents`, default 50) whose queue is a page, not a log. | in force | 2026-09-20 | maintainer |
| `roll-migrates-first` | A schema change ships as a new image, and every roll runs that image's database migration FIRST — outside the portal, as its own run-once Job that must report succeeded — before the image moves. The operator half is `hosting-migrate`; the `Roll` plan calling it is the Plugins half. | in force | 2026-09-21 | maintainer |
| `first-admin-no-learning-path` | The instance's FIRST global administrator is seeded with no learning path (`User.PinnedPaths` empty); an ordinary new user is seeded with the four documentation sections. The learning path is a create-time SEED, never a value a later onboarding write re-imposes. | in force | 2026-09-21 | maintainer |
| `setup-secrets-three-valued` | The first-run setup dialog's secrets step shows a declared vault mapping whose object does not exist as its own state, `missing`, decided by the object's versions listing: `200` is exists, `404` is missing, anything else is `not checked` and never either answer. Missing rows sort first. | in force | 2026-09-21 | maintainer |

Cited by: [Release Process](../ReleaseProcess) ·
[The Self-Update Schema Wall](../SelfUpdateSchemaWall) ·
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Memex.Portal.Shared.Authentication;
using Xunit;

namespace Memex.Portal.Shared.Test;

/// <summary>
/// The first global administrator is SEEDED with no learning path; an ordinary user still is —
/// policy <c>first-admin-no-learning-path</c> (<c>Doc/Architecture/PolicyNotProse</c>). They are
/// setting the instance up, not learning it.
///
/// <para>These are the pure halves: the seed function and the request's default. What the seed
/// does on a LIVE mesh — and, the case that matters, what a second onboarding of the same user
/// does NOT do to pins added in between — is <see cref="OnboardingPinsAreSeededOnceTest"/>.</para>
/// </summary>
public class FirstAdministratorHasNoLearningPathTest
{
[Fact]
public void TheFirstAdministratorIsPinnedToNothing()
{
Assert.Empty(UserOnboardingDefaults.PinnedPathsFor(isPlatformBootstrap: true));
}

[Fact]
public void AnOrdinaryUserKeepsTheFourDocSections()
{
Assert.Equal(
["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"],
UserOnboardingDefaults.PinnedPathsFor(isPlatformBootstrap: false));
}

[Fact]
public void TheRequestDefaultsToAnOrdinaryUser()
{
// Every existing caller constructs the request without the flag, and every one of them
// creates an ordinary user: the default must not silently strip anybody's pins.
Assert.False(new UserOnboardingRequest("u", "u@x.example").IsPlatformBootstrap);
}
}
Loading
Loading