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),