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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
// (<see cref="HasUsableBuild"/>); everything else — null / Unknown /
// Compiling (interrupted) / Error / Ok-but-assembly-gone — recompiles.
Expand Down
22 changes: 21 additions & 1 deletion src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TReduced>
?? 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."
);


Expand Down
1 change: 1 addition & 0 deletions src/MeshWeaver.Documentation/Data/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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: <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="5" rx="1"/><rect x="3" y="11" width="18" height="5" rx="1"/><path d="M3 20h12"/></svg>
---

# 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<T>(…)` | `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<MarkdownContent> 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
39 changes: 30 additions & 9 deletions src/MeshWeaver.Layout/Domain/DomainLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,32 @@ public static UiControl Details(LayoutAreaHost area, RenderingContext ctx)

/// <summary>
/// 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.
/// </summary>
/// <param name="area">The layout area host providing workspace and type-registry access.</param>
/// <param name="ctx">The rendering context for this area.</param>
/// <returns>A catalog UI control for the requested type.</returns>
// 🚨 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)
{
Expand All @@ -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<ITypeRegistry>().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);
}
Expand Down
Loading
Loading