From 9910f444756d349356c5c52f6bf80f3c3110dca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:26:51 +0200 Subject: [PATCH 1/3] feat(onboarding): the first global administrator is pinned to no learning path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer, 2026-09-21, from an attempt to onboard on partnerre.meshweaver.cloud: 'the learning path i don't need for first onboarding of global admin.' 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 the four sections. UserOnboardingRequest gains IsPlatformBootstrap (default false, so no existing caller's user loses a pin), UserOnboardingDefaults.PinnedPathsFor is the one rule, and BootstrapController — the headless bootstrap path — passes true. The first-user promotion in Onboarding.razor (MeshWeaver.Plugins) sets the same flag once this seals; until then it behaves as today. 3 of 3 tests pass. Co-Authored-By: Claude Fable 5.1 --- .../Authentication/BootstrapController.cs | 4 ++- .../Authentication/UserOnboardingService.cs | 35 +++++++++++++++--- ...FirstAdministratorHasNoLearningPathTest.cs | 36 +++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs diff --git a/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs b/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs index 6c171199f1..d33983c3de 100644 --- a/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs +++ b/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs @@ -45,7 +45,9 @@ public async Task 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: no learning path pinned — they are setting the + // instance up, not learning it (UserOnboardingDefaults.PinnedPathsFor). + var request = new UserOnboardingRequest(user, email.Trim(), name ?? user, IsPlatformBootstrap: true); logger.LogInformation("Bootstrap: materialising first admin '{User}' ({Email})", user, email); diff --git a/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs b/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs index 969e17ea53..221b11c74a 100644 --- a/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs +++ b/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System; using System.Reactive.Disposables; using System.Reactive.Linq; @@ -71,9 +72,7 @@ public IObservable 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"], + PinnedPaths = UserOnboardingDefaults.PinnedPathsFor(request.IsPlatformBootstrap), }; var partitionRootNode = new MeshNode(username) @@ -233,6 +232,30 @@ public IObservable GrantPlatformAdmin(string username) } } +/// The learning path a new user is pinned to — and who is not. +public static class UserOnboardingDefaults +{ + /// + /// 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. + /// + public static readonly ImmutableList LearningPath = + ["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"]; + + /// + /// What to pin for this user. + /// + /// 🚨 The first global administrator gets NO learning path. Maintainer, 2026-09-21, + /// from an attempt to onboard on partnerre.meshweaver.cloud: "the learning path i don't need + /// for first onboarding of global admin." 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. + /// + /// Whether this user is the instance's first global administrator. + public static ImmutableList PinnedPathsFor(bool isPlatformBootstrap) => + isPlatformBootstrap ? [] : LearningPath; +} + /// /// Input shape for . Mirrors the form /// model in Onboarding.razor; kept in this assembly so unit tests can @@ -250,4 +273,8 @@ 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, + // True when this user is the instance's FIRST global administrator — the bootstrap path + // (BootstrapController, and the first-user promotion in Onboarding.razor). Such a user is + // setting the instance up, not learning it, and is pinned to no learning path. + bool IsPlatformBootstrap = false); diff --git a/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs b/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs new file mode 100644 index 0000000000..535f96020a --- /dev/null +++ b/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs @@ -0,0 +1,36 @@ +using Memex.Portal.Shared.Authentication; +using Xunit; + +namespace Memex.Portal.Shared.Test; + +/// +/// The first global administrator is pinned to no learning path; an ordinary user still is. +/// +/// Maintainer, 2026-09-21, onboarding on partnerre.meshweaver.cloud: "the learning path i +/// don't need for first onboarding of global admin." They are setting the instance up, not +/// learning it. +/// +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); + } +} From 30800e740879ed94b10d0bc7fe6147312105061c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:27:23 +0200 Subject: [PATCH 2/3] docs(setup): the objects that do not exist, and no learning path for the first administrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two requirements from the 2026-09-21 PartnerRe onboarding attempt, recorded on the design page: the dialog's third field state — a declared mapping whose vault object is not there, probed from the instance with the read right it already holds, three-valued so a permission error is never reported as either answer — and the first global administrator being pinned to no learning path. Co-Authored-By: Claude Fable 5.1 --- .../FirstRunSetupOnAProvisionedInstance.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md b/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md index 7f302834c3..7a3b5073d2 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md @@ -232,6 +232,32 @@ 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 + +Maintainer, 2026-09-21: *"it should also show me secrets which don't actually exist."* 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 + +Maintainer, same day: *"the learning path i don't need for first onboarding of global admin."* An +ordinary new user is pinned to four documentation sections; the first global administrator is pinned +to none. They are setting the instance up, not learning it. + ## The plugin catalog in the wizard The wizard shows the catalog of the registry the instance is mounted on, lets the person select what From ecb7536fe9f86f4256dc97c7949c729587803cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:13:26 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(onboarding):=20the=20learning=20path=20?= =?UTF-8?q?is=20a=20create-time=20seed=20=E2=80=94=20a=20re-run=20keeps=20?= =?UTF-8?q?the=20user's=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found a data-loss defect in the first cut: BootstrapController.FirstAdmin is documented as re-runnable, and CreateUser lands the profile through the full-instance upsert whose update leg takes Content wholesale, so a second call on an existing user replaced PinnedPaths with the seed — [] for the first administrator — and erased every pin added since. The two-sided control reproduces it verbatim against the previous code (OnboardingPinsAreSeededOnceTest: first-call cases green, both re-run cases red). The fix keeps CreateUser as ONE owner-side upsert (the client-side create-vs-update split is the shape its own comment records as removed) and declares PinnedPaths a create-once member on it (FoldRule.KeepExisting, #4928): the seed lands when the profile is created, and on an existing profile the owner keeps the pins it holds. Every other member still lands wholesale on purpose. Issued off the router exactly as MeshService.CreateOrUpdateNode does, identity stamped on the request and the delivery. IsPlatformBootstrap moves from the record's primary constructor to an init-only property: the flag is the caller's decision, not a form field, and the constructor signature a module compiled earlier calls stays exactly as it was — so the Public surface gate has nothing to declare and no allow entry is needed. Docs: the two dated maintainer quotes the first cut added become policy register rows (first-admin-no-learning-path, setup-secrets-three-valued) cited from the page. Co-Authored-By: Claude Opus 5 (1M context) --- .../Authentication/BootstrapController.cs | 8 +- .../Authentication/UserOnboardingService.cs | 116 +++++++++-- .../FirstRunSetupOnAProvisionedInstance.md | 30 ++- .../Data/Architecture/PolicyNotProse.md | 2 + ...FirstAdministratorHasNoLearningPathTest.cs | 10 +- .../OnboardingPinsAreSeededOnceTest.cs | 189 ++++++++++++++++++ 6 files changed, 329 insertions(+), 26 deletions(-) create mode 100644 test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs diff --git a/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs b/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs index d33983c3de..b73d1cfd80 100644 --- a/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs +++ b/memex/Memex.Portal.Shared/Authentication/BootstrapController.cs @@ -45,9 +45,11 @@ public async Task FirstAdmin( var user = (string.IsNullOrWhiteSpace(username) ? email.Split('@')[0] : username) .Trim().ToLowerInvariant(); - // The first global administrator: no learning path pinned — they are setting the - // instance up, not learning it (UserOnboardingDefaults.PinnedPathsFor). - var request = new UserOnboardingRequest(user, email.Trim(), name ?? user, IsPlatformBootstrap: true); + // 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); diff --git a/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs b/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs index 221b11c74a..c129c85c0a 100644 --- a/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs +++ b/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs @@ -42,6 +42,7 @@ namespace Memex.Portal.Shared.Authentication; public sealed class UserOnboardingService( IMeshService meshService, AccessService accessService, + IMessageHub hub, ILogger? logger = null, IIconGenerator? iconGenerator = null) { @@ -72,6 +73,9 @@ public IObservable 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), + // 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), }; @@ -108,7 +112,7 @@ public IObservable 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()))) @@ -119,6 +123,70 @@ public IObservable CreateUser(UserOnboardingRequest request) .Do(rootNode => MaybeGenerateAvatar(rootNode, fullDisplayName, avatarIcon)); } + /// + /// The partition-root upsert, carrying the ONE fold onboarding needs: + /// is seeded on the create leg and kept as the owner holds it on the update leg + /// ( — the create-once member, #4928). + /// + /// 🚨 Why a fold and not the plain . The + /// full-instance upsert's update leg takes Content WHOLESALE + /// (UpdateAccordingToSourceNode: Content = sourceNode.Content ?? state.Content), so a + /// second for a user who exists replaced their pins with the seed — + /// [] for the first administrator — and silently erased everything they had pinned since. + /// The recovery endpoint () 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 → stream.Update, else create" split is not + /// the answer either: that is the exact shape '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. + /// + /// 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. + /// + /// Issued off the router () exactly as + /// MeshService.CreateOrUpdateNode does, with the caller's identity stamped on the request + /// (RequestedBy) 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 ) — which is exactly enough here: nothing is + /// counted, a value is merely preserved. + /// + /// The User node carrying the SEED content. + /// The node as the owner stored it — created, or merged onto the existing profile. + private IObservable 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(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(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); + } + /// /// Fire-and-forget avatar generation for a freshly-created User node. Reuses the existing /// (the NodeInitializer agent on the configurable utility @@ -232,7 +300,10 @@ public IObservable GrantPlatformAdmin(string username) } } -/// The learning path a new user is pinned to — and who is not. +/// +/// The learning path a new user is SEEDED with — and who is not. A seed, never a value re-imposed: +/// applies it on the create leg only. +/// public static class UserOnboardingDefaults { /// @@ -243,13 +314,13 @@ public static class UserOnboardingDefaults ["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"]; /// - /// What to pin for this user. + /// What to seed for this user. /// - /// 🚨 The first global administrator gets NO learning path. Maintainer, 2026-09-21, - /// from an attempt to onboard on partnerre.meshweaver.cloud: "the learning path i don't need - /// for first onboarding of global admin." 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. + /// 🚨 The first global administrator gets NO learning path. 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 + /// first-admin-no-learning-path (Doc/Architecture/FirstRunSetupOnAProvisionedInstance, + /// "The first administrator learns nothing here"). /// /// Whether this user is the instance's first global administrator. public static ImmutableList PinnedPathsFor(bool isPlatformBootstrap) => @@ -273,8 +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, - // True when this user is the instance's FIRST global administrator — the bootstrap path - // (BootstrapController, and the first-user promotion in Onboarding.razor). Such a user is - // setting the instance up, not learning it, and is pinned to no learning path. - bool IsPlatformBootstrap = false); + string? Locale = null) +{ + /// + /// True when this user is the instance's FIRST global administrator — the bootstrap path + /// (, and the first-user promotion the onboarding + /// page decides). Such a user is setting the instance up, not learning it, and is + /// seeded with no learning path (). + /// + /// 🚨 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 + /// (check-record-signatures.py: adding a parameter, even with a default, replaces + /// that signature). + /// + /// It decides the SEED only. On a user who already exists the flag changes nothing: + /// keeps an existing profile's pins as they are + /// (the KeepExisting 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. + /// + public bool IsPlatformBootstrap { get; init; } +} diff --git a/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md b/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md index 7a3b5073d2..46299a7221 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/FirstRunSetupOnAProvisionedInstance.md @@ -234,9 +234,10 @@ a send that did not happen. ### The objects that do not exist -Maintainer, 2026-09-21: *"it should also show me secrets which don't actually exist."* 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 +*"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 @@ -254,9 +255,26 @@ until a deploy tripped over it. ### The first administrator learns nothing here -Maintainer, same day: *"the learning path i don't need for first onboarding of global admin."* An -ordinary new user is pinned to four documentation sections; the first global administrator is pinned -to none. They are setting the instance up, not learning it. +*"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 diff --git a/src/MeshWeaver.Documentation/Data/Architecture/PolicyNotProse.md b/src/MeshWeaver.Documentation/Data/Architecture/PolicyNotProse.md index 83ccc1b3eb..049ef15bc6 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/PolicyNotProse.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/PolicyNotProse.md @@ -136,6 +136,8 @@ written. | `query-fanin-stall-terminal` | A query provider that neither emits, completes nor errors TERMINATES its merged query with a named error instead of hanging: the fan-in's Initial gate is a bound, not a wait. Every consumer that turns a mesh read into a decision reads that error as an availability failure — an access-control read fails CLOSED and reports that it could not be established, never `denied` and never `granted`. | in force | 2026-09-21 | maintainer | | `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 | +| `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) · [Issue Taxonomy and the Release Readiness Gate](../IssueTaxonomy) · diff --git a/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs b/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs index 535f96020a..8fb1976583 100644 --- a/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs +++ b/test/Memex.Portal.Shared.Test/FirstAdministratorHasNoLearningPathTest.cs @@ -4,11 +4,13 @@ namespace Memex.Portal.Shared.Test; /// -/// The first global administrator is pinned to no learning path; an ordinary user still is. +/// The first global administrator is SEEDED with no learning path; an ordinary user still is — +/// policy first-admin-no-learning-path (Doc/Architecture/PolicyNotProse). They are +/// setting the instance up, not learning it. /// -/// Maintainer, 2026-09-21, onboarding on partnerre.meshweaver.cloud: "the learning path i -/// don't need for first onboarding of global admin." They are setting the instance up, not -/// learning it. +/// 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 . /// public class FirstAdministratorHasNoLearningPathTest { diff --git a/test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs b/test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs new file mode 100644 index 0000000000..eff8efdb51 --- /dev/null +++ b/test/Memex.Portal.Shared.Test/OnboardingPinsAreSeededOnceTest.cs @@ -0,0 +1,189 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Memex.Portal.Shared.Authentication; +using MeshWeaver.Data; +using MeshWeaver.Fixture; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Memex.Portal.Shared.Test; + +/// +/// is SEEDED at onboarding and never re-imposed: the first global +/// administrator is created with no learning path, an ordinary user with the four documentation +/// sections, and a second for a user who exists +/// leaves whatever they have pinned since exactly as it is. +/// +/// 🚨 Why the second call is the case that matters. BootstrapController.FirstAdmin is +/// documented as re-runnable — "already exists" is a successful step, because a re-run still wants +/// the Admin grants — and CreateUser lands the profile through the full-instance upsert, +/// whose update leg takes Content wholesale. Before this test, a re-run therefore replaced +/// the user's pins with the seed: [] for the first administrator, which silently erased every +/// pin added after the original onboarding (Copilot's finding on #5077). A test that names only the +/// FIRST call cannot see that, which is why the pure +/// did not. +/// +/// Real mesh, no doubles: the profile is read back through GetMeshNodeStream, and the +/// second write is proven to have LANDED (its FullName change is awaited on the node) before +/// the pins are asserted — otherwise "the pin is still there" would also be true of a write that +/// never arrived. +/// +public class OnboardingPinsAreSeededOnceTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + private static readonly string[] LearningPath = + ["Doc/Architecture", "Doc/DataMesh", "Doc/GUI", "Doc/AI"]; + + private const string MyOwnPin = "something/i/pinned"; + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + => ConfigureMeshBase(builder) + .ConfigureServices(services => + { + services.AddScoped(); + return services; + }); + + /// Pre-warm the User type hub so the partition-root write does not cold-start it. + protected override void PreWarmNodeTypeHubs() + { + base.PreWarmNodeTypeHubs(); + var typeNode = Mesh.ServiceProvider.FindStaticNode("User"); + if (typeNode?.HubConfiguration is { } cfg) + _ = Mesh.GetHostedHub(new Address("User"), cfg); + } + + // ---------------------------------------------------------------- the first call (controls) + + [Fact(Timeout = 60000)] + public async Task TheFirstAdministratorIsCreatedWithNoLearningPath() + { + var username = NewUsername("boot"); + await Onboard(FirstAdministrator(username), TestContext.Current.CancellationToken); + + var profile = await AwaitProfile(username, u => u.Email == Email(username), TestContext.Current.CancellationToken); + + profile.PinnedPaths.Should().BeEmpty( + "the first global administrator is setting the instance up, not learning it"); + } + + [Fact(Timeout = 60000)] + public async Task AnOrdinaryUserIsCreatedWithTheLearningPath() + { + var username = NewUsername("ord"); + await Onboard(Ordinary(username), TestContext.Current.CancellationToken); + + var profile = await AwaitProfile(username, u => u.Email == Email(username), TestContext.Current.CancellationToken); + + profile.PinnedPaths.Should().Equal(LearningPath, + "an ordinary new user's Pinned tab opens onto the four documentation sections"); + } + + // ---------------------------------------------------------------- the second call (the finding) + + [Fact(Timeout = 60000)] + public async Task ARerunOfTheRecoveryEndpointLeavesThePinsAlone() + { + // The recovery endpoint, run twice: once to materialise the first administrator, and again + // later — after they have pinned something — because an operator re-ran it for the grants. + var username = NewUsername("boot2"); + await Onboard(FirstAdministrator(username), TestContext.Current.CancellationToken); + await AwaitProfile(username, u => u.Email == Email(username), TestContext.Current.CancellationToken); + + await Pin(username, MyOwnPin, TestContext.Current.CancellationToken); + await AwaitProfile(username, u => u.PinnedPaths.Contains(MyOwnPin), TestContext.Current.CancellationToken); + + // The re-run carries a different display name so that its landing is OBSERVABLE: the pins + // are asserted only once the node shows the second write's name. + await Onboard(FirstAdministrator(username) with { FullName = "Re-run Name" }, TestContext.Current.CancellationToken); + var profile = await AwaitProfile(username, u => u.FullName == "Re-run Name", TestContext.Current.CancellationToken); + + profile.PinnedPaths.Should().Equal(new[] { MyOwnPin }, + "the seed applies when the profile is CREATED; a re-run of the recovery endpoint must not " + + "erase what the administrator pinned after the original onboarding"); + } + + [Fact(Timeout = 60000)] + public async Task ARerunForAnOrdinaryUserKeepsTheirOwnPinsToo() + { + // The same rule for an ordinary user, whose seed is the learning path: a second onboarding + // (a re-submitted form after a lagged existence check) must not re-impose the four sections + // over what they have curated since. + var username = NewUsername("ord2"); + await Onboard(Ordinary(username), TestContext.Current.CancellationToken); + await AwaitProfile(username, u => u.PinnedPaths.SequenceEqual(LearningPath), TestContext.Current.CancellationToken); + + await Unpin(username, "Doc/GUI", TestContext.Current.CancellationToken); + await Pin(username, MyOwnPin, TestContext.Current.CancellationToken); + await AwaitProfile(username, u => u.PinnedPaths.Contains(MyOwnPin), TestContext.Current.CancellationToken); + + await Onboard(Ordinary(username) with { FullName = "Re-run Name" }, TestContext.Current.CancellationToken); + var profile = await AwaitProfile(username, u => u.FullName == "Re-run Name", TestContext.Current.CancellationToken); + + profile.PinnedPaths.Should().Equal( + new[] { "Doc/Architecture", "Doc/DataMesh", "Doc/AI", MyOwnPin }, + "the learning path is a create-time seed, not a value every onboarding re-imposes"); + } + + // ---------------------------------------------------------------- helpers + + private static string NewUsername(string tag) => $"{tag}-{Guid.NewGuid():N}"[..16].ToLowerInvariant(); + + private static string Email(string username) => $"{username}@example.com"; + + private static UserOnboardingRequest Ordinary(string username) => + new(username, Email(username), FullName: "First Name"); + + private static UserOnboardingRequest FirstAdministrator(string username) => + Ordinary(username) with { IsPlatformBootstrap = true }; + + /// Drives exactly as the controller and + /// the onboarding page do — the service opens its own System scope for the write. + private Task Onboard(UserOnboardingRequest request, CancellationToken cancellationToken) + { + var service = Mesh.ServiceProvider.GetRequiredService(); + Output.WriteLine($"CreateUser {request.Username} bootstrap={request.IsPlatformBootstrap} name={request.FullName}"); + return service.CreateUser(request) + .FirstAsync() + .Timeout(TestTimeouts.WriteConvergence) + .Await(cancellationToken); + } + + /// What the user does between the two calls — the ordinary pin write, on the live node. + private Task Pin(string username, string path, CancellationToken cancellationToken) => + Edit(username, u => u with { PinnedPaths = [.. u.PinnedPaths, path] }, cancellationToken); + + private Task Unpin(string username, string path, CancellationToken cancellationToken) => + Edit(username, u => u with { PinnedPaths = [.. u.PinnedPaths.Where(p => p != path)] }, cancellationToken); + + private Task Edit(string username, Func change, CancellationToken cancellationToken) + { + var access = Mesh.ServiceProvider.GetRequiredService(); + return access.RunAsSystem(() => Mesh.GetWorkspace().GetMeshNodeStream(username) + .Update(node => node.ContentAs(Mesh.JsonSerializerOptions) is { } u + ? node with { Content = change(u) } + : node)) + .FirstAsync() + .Timeout(TestTimeouts.WriteConvergence) + .Await(cancellationToken); + } + + private async Task AwaitProfile(string username, Func predicate, CancellationToken cancellationToken) + { + var node = await Mesh.GetWorkspace().GetMeshNodeStream(username) + .Where(n => n?.ContentAs(Mesh.JsonSerializerOptions) is { } u && predicate(u)) + .FirstAsync() + .Timeout(TestTimeouts.Convergence) + .Await(cancellationToken); + var profile = node.ContentAs(Mesh.JsonSerializerOptions)!; + Output.WriteLine($"profile {username}: name={profile.FullName} pins=[{string.Join(", ", profile.PinnedPaths)}]"); + return profile; + } +}