diff --git a/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs b/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs index 70d4372c13..ee201dc0d2 100644 --- a/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs +++ b/src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs @@ -141,8 +141,8 @@ public static IDisposable InstallCompileWatcher( // .mesh-cache assembly that has since been cleaned up. Trusting it // strands the NodeType — the kickoff skips, no recompile runs, and // every instance hub falls back to the default config (no - // MeshNodeReference reducer → "No reducer defined for - // MeshNodeReference" on every subscribe). The ONLY safe skip condition + // MeshNodeReference reducer, so every subscribe is refused with "has no + // reducer from MeshNodeReference to MeshNode"). The ONLY safe skip condition // is "Ok AND the compiled assembly still exists on disk" // (); everything else — null / Unknown / // Compiling (interrupted) / Error / Ok-but-assembly-gone — recompiles. diff --git a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs index 66fe0e453f..7c58fdf5ff 100644 --- a/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs +++ b/src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs @@ -1545,10 +1545,30 @@ void Refuse(Exception error) request.Reference, config => config.WithClientId(request.StreamId).WithSubscriber(request.Subscriber) ); + // 🚨 A REFUSAL THAT NAMES NEITHER THE HUB NOR THE SUBSCRIBER IS NOT DIAGNOSABLE (#5120). This + // read `"No reducer defined for {TReference} from {TReference}"` — the SAME type parameter + // twice (the "from" half was meant to be the REDUCED type, and the double space is the other + // half of the same copy-paste), and neither the hub that refused nor the subscriber that + // asked. Production carried one occurrence of it — a bare `MeshNodeReference` (no Path) + // subscribed to `portal/nodeops-{meshId}` — and the line answered none of the three questions + // a reader has: which hub, asked by whom, for what. + // + // The condition itself is a real and specific configuration fact: a hub that carries + // `AddData()` has a workspace, so `Workspace.SubscribeToClient` runs and reaches here, but + // only `AddMeshDataSource` registers the `MeshNodeReference` reducer — so the mesh's + // infrastructure hubs (`portal/nodeops-{meshId}`, `portal/reads-{meshId}`), which have a + // workspace and deliberately own NO mesh node of their own, accept the subscribe and then + // have nothing to reduce it with. Naming the hub is what turns that from a mystery into one + // lookup, and naming the subscriber is what identifies the caller that asked an address with + // no node for its node — which is the half this refusal exists to make findable. var reduced = fromWorkspace as ISynchronizationStream ?? throw new DataSourceConfigurationException( - $"No reducer defined for {typeof(TReference).Name} from {typeof(TReference).Name}" + $"Hub '{hub.Address}' has no reducer from {typeof(TReference).Name} to " + + $"{typeof(TReduced).Name}, so it cannot serve stream {request.StreamId} for " + + $"subscriber '{request.Subscriber}'. Either the reference is addressed at the wrong " + + "hub, or this hub's configuration is missing the data source that registers that " + + "reducer." ); diff --git a/src/MeshWeaver.Documentation/Data/Architecture.md b/src/MeshWeaver.Documentation/Data/Architecture.md index f2e3f0a11d..7255f9cafd 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture.md +++ b/src/MeshWeaver.Documentation/Data/Architecture.md @@ -108,6 +108,7 @@ Each theme starts with its introductory page, followed by related architecture t ### Reading & writing nodes - **Start here:** [CQRS — Queries vs. Content Access](CqrsAndContentAccess) +- [Three Registries a Read Can Miss](ThreeRegistriesAReadCanMiss) — "the type is not registered" is three unrelated tables (the hub's type registry, the workspace's mapped collections, the reduce manager), so a guard that checks one while the read uses another passes having checked nothing; plus what a refusal and a recovery failure must NAME to be followed up - [An Answer Nobody Gave Is Not Cached](AnswerNobodyGaveIsNotCached) — a synced chain replays its FIRST frame for the life of the process, and a provider that completes without an Initial is counted as an empty one, so a cold moment used to become a permanent false "absent"; the frame now names who never answered and an unanswered frame is delivered but not kept - [The Query Fan-In's Stall Terminal](QueryFanInStallTerminal) — a provider that neither emits, completes nor errors used to starve the all-providers Initial gate for ever, with no error and nothing to grep; the merge now faults naming it, at a DERIVED rung strictly inside the permission fold's own budget, and every consumer that decides access already fails closed on it - [MeshNode Stream Cache](MeshNodeStreamCache) diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ThreeRegistriesAReadCanMiss.md b/src/MeshWeaver.Documentation/Data/Architecture/ThreeRegistriesAReadCanMiss.md new file mode 100644 index 0000000000..81edee3a51 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/Architecture/ThreeRegistriesAReadCanMiss.md @@ -0,0 +1,139 @@ +--- +Name: Three Registries a Read Can Miss +Category: Architecture +Description: A read can fail for three different "not registered" reasons — the hub's type registry, the workspace's mapped collections, and the reduce manager's reference table — and they are three different sets. A guard that interrogates one while the read uses another passes having checked nothing, and a refusal that names neither the hub nor the asker cannot be followed up. +Icon: +--- + +# Three Registries a Read Can Miss + +"The type is not registered" is three unrelated facts in this platform, each with its own table, its +own owner and its own failure site. A view that renders empty, an area that reports a defect, and a +subscription that is refused can all be the same sentence in a triage report and none of them the same +bug. + +| # | The table | What it answers | Filled by | Failure site | +|---|---|---|---|---| +| 1 | `ITypeRegistry` (per hub) | what a `$type` discriminator means here | `WithType`/`WithTypes`, `DataContext.Initialize`, and serialization itself as a side effect | a value degrades to `JsonElement` — **silent** | +| 2 | `DataContext.TypeSources` / `DataSourcesByCollection` | which COLLECTIONS this workspace can stream | a data source's `WithType(…)` | `WorkspaceStreams.CreateWorkspaceStream` throws `ArgumentException` | +| 3 | `ReduceManager` | which `(reference → reduced)` pairs this workspace can serve | `AddWorkspaceReference` / `AddWorkspaceReferenceStream`, i.e. the data source extensions (`AddMeshDataSource` registers `MeshNodeReference → MeshNode`) | `JsonSynchronizationStream.CreateSynchronizationStream` throws `DataSourceConfigurationException` | + +The sets are **nested only by convention, never by construction**. Registry 1 is the widest by a long +way: `DataContext.Initialize` registers every mapped type source into it, *and* `PolymorphicTypeInfoResolver` +adds any non-collectible type the hub merely serialises, *and* every content type a NodeType declares +lands there when its instances are read. Registry 2 holds only what a data source maps. Registry 3 +holds only what a data source extension declared. + +## Rule 1 — a guard interrogates the registry the read will USE + +A precondition check against a neighbouring table is not a check. `DomainLayoutAreas.Catalog` resolved +its `ITypeDefinition` from registry 1 and then handed it to a render that streams +`Workspace.GetStream(new CollectionReference(collectionName))`, i.e. registry 2. For the mapped types +the two agree, which is why it passed review and passed every test; for a content type that lives only +on individual nodes they do not, and the guard let that through to die three frames deeper: + +```text +System.ArgumentException: Collections Harness are not mapped to any source. + at MeshWeaver.Data.WorkspaceStreams.CreateWorkspaceStream[TReduced,TReference](…) + at MeshWeaver.Data.ReduceManager`1.ReduceStream[TReduced](…) + at MeshWeaver.Layout.Domain.DomainCatalogLayoutArea.RenderCatalog(…) +``` + +Two things go wrong at once there, and only the first is about registries: + +- **The area aborts** instead of declining. An unhandled throw out of a render reaches + `LayoutAreaHost`'s top-level `Catch`, which logs at `Error` — the level that auto-files an incident — + and renders the exception's own text. So an AUTHORING mistake (`@@Catalog/Harness` naming a type that + is not a workspace collection) is reported as an engineering defect, and the viewer is shown a + sentence about the framework's collection map. +- **It is the same class the classifier already demotes.** `AreaErrorClassifier` exists precisely to + keep user-action and authoring outcomes off the error dashboard — `IsAccessDenied` and + `IsNodeGoneNotFound` both log at `Warning` with viewer-appropriate copy. An unmapped collection + belongs in that company, and the cheapest way to put it there is to never throw: decline in the area, + in the viewer's language. + +The fix is to ask registry 2 — `area.Workspace.DataContext.GetTypeSource(collection)` — because it is +*the same map, keyed the same way*: `DataContext.Initialize` builds `TypeSources` from +`typeSource.CollectionName` and `DataSourcesByCollection` from `TypeRegistry.GetCollectionName(mappedType)` +in one pass. It is also the set `AddTypesCatalogs` enumerates to OFFER catalog links, so the accept set +and the offer set become one set — which is the invariant that stops the guard drifting from the read a +second time. + +## Rule 2 — a refusal names the hub, the asker and both types + +Registry 3's refusal used to read: + +```text +No reducer defined for MeshNodeReference from MeshNodeReference +``` + +The same type parameter twice — the "from" half was meant to be the REDUCED type, and the doubled space +is the other half of the same copy-paste — and neither the hub that refused nor the subscriber that +asked. A reader gets the one thing they already knew from the stack frame and none of the three things +they need: which hub, asked by whom, for what. + +The condition is specific and worth stating plainly, because it is a real and reachable configuration +shape rather than an impossible one: + +> A hub configured with `AddData()` HAS a workspace, so `Workspace.SubscribeToClient` runs and reaches +> the reducer lookup — but only `AddMeshDataSource` registers `MeshNodeReference → MeshNode`. + +The mesh's infrastructure hubs are exactly that shape. `portal/nodeops-{meshId}` (node CRUD execution) +and `portal/reads-{meshId}` (read issuing) carry a workspace and deliberately own **no mesh node of +their own** — see [Transient Node Probes](../TransientNodeProbes) for the same invariant on a different +family of addresses. So a `SubscribeRequest` carrying a bare `MeshNodeReference` addressed at one of +them is accepted, routed, and then has nothing to reduce with. + +**Registering a reducer there would be the wrong fix.** Those hubs own no node on purpose: a +`CreateNodePermissionAttribute` anchors its check at the RECEIVING hub's path, which is why +`WithNodeOperationExecution` must never land on a per-node hub. Giving one a MeshNode data source would +hand it a node it must not have. The question the refusal has to make answerable is instead *who asked +an address with no node for its node*, and that is the subscriber — which is why the subscriber is now +in the message. + +## Rule 3 — a recovery failure names its subject + +`ObjectAsExtensions` is the sanctioned reader for an `object` payload, and it reports an unconvertible +value rather than returning a silent null. Two of its three report sites named the subject the caller +passed (`for {What}` — a node path, a control name); the third, the one reached when stored JSON will +not bind to the target at all, dropped it: + +```text +As could not recover value: JsonException. Content and exception details withheld. +``` + +Withholding CONTENT and the exception MESSAGE is load-bearing: a custom converter can put the whole +value into an exception, and a `JsonException` path can carry user-controlled dictionary keys. The +SUBJECT is neither. Without it the line is a fact with no referent — not the node, not the partition, +not which of the many writers of markdown content sent it — and production carried it for twelve days +with nobody able to act. + +The general form: **a diagnostic exports no new class of information by naming the thing it is about.** +If a path is already safe in the sibling branch of the same method (and it was, in both overloads), it +is safe here. + +## Distinguish this from an absent NODE + +All three registry misses are about CONFIGURATION, and they look from the outside like the unrelated +case of a node that is not there. That one has its own machinery and its own answer: + +- A point read of a path with no node is answered by routing with + `DeliveryFailureException: No node found at 'X'. Closest ancestor is 'Y' …`, which + `AreaErrorClassifier.IsNodeGoneNotFound` recognises, `TryGetMissingNodePath` unwraps, and + `AreaStreamRetry` deliberately does NOT retry (a gone node does not come back on its own). +- A node-bound BINDING does not mint that failure at all: `MeshNodeBindingExtensions.Bind` gates the + content read behind a live `scope:children`-style existence query, so an optional-node field draws + empty and follows the node if it later appears, and the point read that would have NotFound-stormed + the path is never issued. + +So "the view is empty" is at least four distinct roots. The instrument that separates them is the +exception TYPE plus the frame that threw, never the symptom. + +## See also + +- [CQRS — Queries vs. Content Access](../CqrsAndContentAccess) — why existence and content are two reads +- [Transient Node Probes](../TransientNodeProbes) — addresses that can never carry a node +- [MeshNode Stream Cache](../MeshNodeStreamCache) — the process-wide per-path mirror these reads go through +- [What the DataContext Init Time-Box Bounds](../DataContextInitializationTimeout) — the registries above + are filled during the init this bounds +- [JSON Serialization](../Serialization) — registry 1's other job diff --git a/src/MeshWeaver.Layout/Domain/DomainLayoutAreas.cs b/src/MeshWeaver.Layout/Domain/DomainLayoutAreas.cs index 00ed1fd672..96b6a94174 100644 --- a/src/MeshWeaver.Layout/Domain/DomainLayoutAreas.cs +++ b/src/MeshWeaver.Layout/Domain/DomainLayoutAreas.cs @@ -72,12 +72,32 @@ public static UiControl Details(LayoutAreaHost area, RenderingContext ctx) /// /// Renders a catalog view listing all entities of the type named in the area reference Id. - /// Returns an actionable caution when no type is specified; throws when the named type is not - /// registered in the data context. + /// Returns an actionable, localized caution when no type is specified and when the named type is + /// not a collection of this host's workspace. /// /// The layout area host providing workspace and type-registry access. /// The rendering context for this area. /// A catalog UI control for the requested type. + // 🚨 THE GUARD MUST INTERROGATE THE REGISTRY THE RENDER ACTUALLY READS (#5065). This resolved the + // type from the hub's ITypeRegistry and then handed it to a render that streams + // `Workspace.GetStream(new CollectionReference(...))` — i.e. it checked one registry and read + // another. The two are NOT the same set and are not meant to be: ITypeRegistry is the hub's + // SERIALIZATION registry, which every content type a NodeType declares lands in (DataContext + // registers each type source into it, and PolymorphicTypeInfoResolver adds any type the hub + // merely serialises), while `DataContext.DataSourcesByCollection` holds only the collections a + // data source MAPS. A type in the first and not the second walked straight past the guard and + // died three frames deeper in `WorkspaceStreams.CreateWorkspaceStream`, as a bare + // `ArgumentException: Collections Harness are not mapped to any source` — an unhandled throw out + // of the render, so `LayoutAreaHost` logged it at ERROR ("Rendering failed for area Catalog", + // which auto-files an incident) and put that framework sentence in the viewer's face for what is + // an AUTHORING mistake: `@@Catalog/Harness` naming a type that is not a workspace collection. + // + // `DataContext.GetTypeSource(collection)` is the right question because it is the SAME map, keyed + // the same way: `DataContext.Initialize` builds `TypeSources` from `typeSource.CollectionName` + // and `DataSourcesByCollection` from `TypeRegistry.GetCollectionName(mappedType)` in the same + // pass, and it is also the set `AddTypesCatalogs` enumerates to OFFER catalog links. So after + // this the accept set and the offer set are one set, which is the invariant that stops the guard + // drifting from the read again. [Browsable(false)] public static UiControl Catalog(LayoutAreaHost area, RenderingContext ctx) { @@ -86,14 +106,15 @@ public static UiControl Catalog(LayoutAreaHost area, RenderingContext ctx) // (@@Catalog) it has no Id — that used to throw a dead-end "No type specified" error. // Guide the author to the right syntax instead: a type catalog needs the type, and a // Space's own contents are the separate node-children "Search" area. - return Error( - "**Catalog needs a type.** Use `@@Catalog/TypeName` to list every entity of a " - + "registered type (e.g. `@@Catalog/User`). To embed this node's own contents, use " - + "`@@(\"area/Search\")` instead."); - var typeDefinition = area.Hub.ServiceProvider.GetRequiredService().GetTypeDefinition(collection); + return Error(area.Localize("catalog.needsType")); + + var typeDefinition = area.Workspace.DataContext.GetTypeSource(collection)?.TypeDefinition; if (typeDefinition == null) - throw new DataSourceConfigurationException( - $"Collection {collection} is not mapped in Address {area.Hub.Address}."); + // An author naming a type this workspace does not hold as a collection is an authoring + // fault, so it renders as the same actionable caution as the missing-Id case above and + // is NOT thrown: a throw here is reported as a rendering DEFECT and files an incident. + // The type's own name is all an author needs; the framework's map is not their business. + return Error(area.Localize("catalog.notAWorkspaceCollection", collection)); return DomainCatalogLayoutArea.GetCatalog(area, typeDefinition, ctx); } diff --git a/src/MeshWeaver.Mesh.Contract/ObjectAsExtensions.cs b/src/MeshWeaver.Mesh.Contract/ObjectAsExtensions.cs index 629b45a12c..5a7b947de1 100644 --- a/src/MeshWeaver.Mesh.Contract/ObjectAsExtensions.cs +++ b/src/MeshWeaver.Mesh.Contract/ObjectAsExtensions.cs @@ -99,7 +99,7 @@ public static class ObjectAsExtensions // InvalidOperationException — neither derives from JsonException, so catching // JsonException alone would let a read fault its caller, which is the one thing // this accessor exists to prevent. - LogRecoveryFailure(logger, typeof(T), ex); + LogRecoveryFailure(logger, typeof(T), ex, what); return null; } case JsonNode jn: @@ -109,7 +109,7 @@ public static class ObjectAsExtensions } catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) { - LogRecoveryFailure(logger, typeof(T), ex); + LogRecoveryFailure(logger, typeof(T), ex, what); return null; } default: @@ -192,7 +192,7 @@ public static class ObjectAsExtensions } catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) { - LogRecoveryFailure(logger, type, ex); + LogRecoveryFailure(logger, type, ex, what); return null; } case JsonNode jn: @@ -202,7 +202,7 @@ public static class ObjectAsExtensions } catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) { - LogRecoveryFailure(logger, type, ex); + LogRecoveryFailure(logger, type, ex, what); return null; } default: @@ -231,13 +231,30 @@ public static class ObjectAsExtensions } /// - /// Record the failed conversion without exporting node content, node paths, or exception - /// messages. Custom converters can put the entire value into an exception, and JsonException - /// paths can contain user-controlled dictionary keys, so neither is safe diagnostic metadata. + /// Record the failed conversion without exporting node CONTENT or exception MESSAGES. Custom + /// converters can put the entire value into an exception, and JsonException paths can contain + /// user-controlled dictionary keys, so neither is safe diagnostic metadata. + /// + /// 🚨 But the SUBJECT is, and withholding it made this line undiagnosable + /// (Systemorph/MeshWeaver#4597). Every caller already supplies what — a node path, a + /// control name — and the sibling not-convertible branches in both overloads print it as + /// {What}; only this one dropped it on the floor. Production therefore carried + /// "As<MarkdownContent> could not recover value: JsonException" four times over + /// twelve days with nothing to look up: not the node, not the partition, not which of the many + /// writers of markdown content sent it. The incident's own review recorded that as the blocker — + /// "LogRecoveryFailure deliberately withholds content AND carries no node path, so there is + /// nothing to look up" — and the fix is to say the thing the caller already told us. + /// + /// This exports no new CLASS of information: a node path is already in this file's other + /// two log lines, ContentAs<T> is documented as "this same conversion with the + /// node's path in the diagnostics", and every neighbouring seam that reports a typing mismatch + /// (NodeUpdatePipeline's two Warning branches) names the path. Content and exception text + /// stay withheld, which is the part of the contract that was load-bearing. /// - private static void LogRecoveryFailure(ILogger? logger, Type target, Exception exception) => + private static void LogRecoveryFailure( + ILogger? logger, Type target, Exception exception, string? what) => logger?.LogError( - "As<{TargetType}> could not recover value: {ExceptionType}. " + "As<{TargetType}> for {What} could not recover value: {ExceptionType}. " + "Content and exception details withheld.", - target.Name, exception.GetType().Name); + target.Name, what ?? "value", exception.GetType().Name); } diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json index 0ad1f6b7a4..58f506b278 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json @@ -79,6 +79,8 @@ "error.identityUnavailableHint": "Das ist ein vorübergehendes Problem auf unserer Seite und liegt nicht an Ihrem Konto. Sie sind weiterhin angemeldet — bitte einen Moment warten und die Seite neu laden. Eine erneute Anmeldung hilft nicht.", "error.checkUnavailable": "Das konnte gerade nicht geladen werden.", "error.checkUnavailableHint": "Etwas auf unserer Seite hat nicht rechtzeitig geantwortet, daher konnte dieser Inhalt nicht geprüft werden. Er ist weiterhin vorhanden, und an Ihrem Zugriff hat sich nichts geändert — bitte einen Moment warten und die Seite neu laden.", + "catalog.needsType": "**Katalog benötigt einen Typ.** Verwenden Sie `@@Catalog/TypName`, um alle Einträge eines registrierten Typs aufzulisten (z. B. `@@Catalog/User`). Um den Inhalt dieses Knotens einzubetten, verwenden Sie stattdessen `@@(\"area/Search\")`.", + "catalog.notAWorkspaceCollection": "**`{0}` ist hier kein Katalogtyp.** `@@Catalog` listet die Einträge eines Typs auf, von dem dieser Bereich eine Sammlung führt, und `{0}` gehört nicht dazu — möglicherweise handelt es sich um einen Inhaltstyp, der nur auf einzelnen Knoten vorkommt. Um den Inhalt dieses Knotens einzubetten, verwenden Sie stattdessen `@@(\"area/Search\")`.", "error.viewFailed": "Diese Ansicht konnte nicht dargestellt werden.", "error.areaFailed": "Dieser Bereich konnte nicht dargestellt werden.", "error.areaReconnecting": "Diese Ansicht wird neu verbunden …", diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json index b4ced4a590..96524795a3 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json @@ -79,6 +79,8 @@ "error.identityUnavailableHint": "This is a temporary problem on our side, not a problem with your account. You are still signed in — please wait a moment and reload the page. Signing in again will not help.", "error.checkUnavailable": "We could not load this just now.", "error.checkUnavailableHint": "Something on our side did not answer in time, so we could not check this content. It is still there, and your access to it has not changed — please wait a moment and reload the page.", + "catalog.needsType": "**Catalog needs a type.** Use `@@Catalog/TypeName` to list every entity of a registered type (e.g. `@@Catalog/User`). To embed this node's own contents, use `@@(\"area/Search\")` instead.", + "catalog.notAWorkspaceCollection": "**`{0}` is not a catalog type here.** `@@Catalog` lists the entities of a type this space keeps a collection of, and `{0}` is not one of them — it may be a content type that only ever appears on individual nodes. To embed this node's own contents, use `@@(\"area/Search\")` instead.", "error.viewFailed": "This view failed to render.", "error.areaFailed": "This area failed to render.", "error.areaReconnecting": "This view is reconnecting…", diff --git a/test/MeshWeaver.Layout.Test/DomainLayoutServiceTest.cs b/test/MeshWeaver.Layout.Test/DomainLayoutServiceTest.cs index d6190258c3..47b515a7ca 100644 --- a/test/MeshWeaver.Layout.Test/DomainLayoutServiceTest.cs +++ b/test/MeshWeaver.Layout.Test/DomainLayoutServiceTest.cs @@ -27,6 +27,11 @@ protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration .WithRoutes(r => r.RouteAddress(ClientType, (_, d) => d.Package()) ) + // 🚨 The SECOND type is the whole point of TestCatalogOfATypeThatIsNotAWorkspaceCollection + // (#5065): registered in the hub's TypeRegistry, mapped to NO data source. That is the + // ordinary production state of every content type a NodeType declares, and it is the one + // case the Catalog area's guard used to walk straight past. + .WithTypes(typeof(UnmappedRecord)) .AddData(data => data.AddSource( ds => @@ -165,4 +170,50 @@ public async Task TestCatalog() dataStream.Should().BeOfType>().Which.Should().HaveCount(2); } + + /// + /// The negative control for , and the reproduction of + /// Systemorph/MeshWeaver#5065: a type the hub's ITypeRegistry knows but that NO data source + /// maps renders the area's actionable caution, rather than letting + /// WorkspaceStreams.CreateWorkspaceStream throw "Collections X are not mapped to any + /// source" out of the render. + /// + /// The two tests sit on opposite sides of exactly one condition — DataRecord is + /// mapped, UnmappedRecord is not — and both types are in the same TypeRegistry, which is + /// why the old guard could not tell them apart. + /// + /// Asserting on the CONTROL TYPE is deliberate: before the fix the failing render still put + /// a control in the area (the host's top-level Catch renders an error panel), so "the area + /// produced something" proves nothing. What distinguishes the two outcomes is a + /// carrying the area's own guidance versus the render-error control + /// carrying a framework exception message. + /// + [HubFact] + public async Task TestCatalogOfATypeThatIsNotAWorkspaceCollection() + { + var reference = DomainLayoutAreas.GetCatalogReference(nameof(UnmappedRecord)); + var client = GetClient(); + var workspace = client.GetWorkspace(); + var stream = workspace.GetRemoteStream( + CreateHostAddress(), + reference + ); + + var content = await stream.GetControlStream(reference.Area!) + .Should().Within(10.Seconds()).Match(x => x != null); + + var text = content.Should().BeOfType().Subject.Markdown?.ToString() + ?? string.Empty; + text.Should().Contain(nameof(UnmappedRecord), + "the author needs to know WHICH type they named"); + text.Should().NotContain("not mapped to any source", + "the framework's own workspace diagnostic must never reach a viewer"); + } + + /// + /// A type registered for serialization only — no data source, therefore no workspace collection. + /// Deliberately minimal: what matters is that it is resolvable by name in the TypeRegistry and + /// absent from DataContext.TypeSources. + /// + public record UnmappedRecord(string Id, string Label); } diff --git a/test/MeshWeaver.Messaging.Hub.Test/ObjectAsPrivatePayloadTest.cs b/test/MeshWeaver.Messaging.Hub.Test/ObjectAsPrivatePayloadTest.cs index 60fc60aa2e..b77b6e2115 100644 --- a/test/MeshWeaver.Messaging.Hub.Test/ObjectAsPrivatePayloadTest.cs +++ b/test/MeshWeaver.Messaging.Hub.Test/ObjectAsPrivatePayloadTest.cs @@ -27,7 +27,20 @@ public class ObjectAsPrivatePayloadTest public void FailedReadReportsTheFailureWithoutPublishingContentOrExceptionText( bool node, bool runtimeType, int failure) { + // 🚨 THE CONTENT AND THE SUBJECT ARE TWO DIFFERENT STRINGS, and this test used to use ONE + // (Systemorph/MeshWeaver#4597). It passed the private text as the `what` argument and then + // asserted the log did not contain it — which reads as "content is not published" and is + // actually "the caller-supplied subject is dropped". Those are not the same claim, and only + // the first is this accessor's contract: `what` is documented as "a node path, a control + // name", `ContentAs` passes `node.Path` into it, and the accessor's OTHER two report sites + // have always printed it as `{What}`. So a caller who puts content there was already + // publishing it through those branches, and dropping it here bought no privacy — it only made + // the one remaining line unattributable, which is the whole of #4597's open half. + // + // With the two separated the test says both things, and the privacy half is now tested + // against a string that is genuinely CONTENT rather than one the caller chose to name. const string privateText = "synthetic-private-correspondence"; + const string subject = "acme/Drafts/QuarterlyNote"; var json = JsonSerializer.Serialize(new { message = privateText }); object value = node ? JsonNode.Parse(json)! : JsonSerializer.Deserialize(json); var options = new JsonSerializerOptions(); @@ -35,8 +48,8 @@ public void FailedReadReportsTheFailureWithoutPublishingContentOrExceptionText( var logger = new CapturingLogger(); var result = runtimeType - ? value.As(typeof(Payload), options, logger, privateText) - : value.As(options, logger, privateText); + ? value.As(typeof(Payload), options, logger, subject) + : value.As(options, logger, subject); result.Should().BeNull(); logger.Entries.Should().ContainSingle(); @@ -45,6 +58,9 @@ public void FailedReadReportsTheFailureWithoutPublishingContentOrExceptionText( entry.Text.Should().NotContain(privateText); entry.Exception.Should().BeNull("converter exceptions may contain the entire private value"); entry.Text.Should().Contain(nameof(Payload)); + entry.Text.Should().Contain(subject, + "a recovery failure that names no subject cannot be followed up — #4597 carried exactly " + + "that for twelve days"); entry.Text.Should().Contain(failure switch { 0 => nameof(JsonException),