Skip to content

fix(layout): an area whose store is unreachable says so; the fold's anchoring stays rejected - #3031

Merged
rbuergi merged 2 commits into
mainfrom
fix/2640-2876-cross-schema
Sep 2, 2026
Merged

fix(layout): an area whose store is unreachable says so; the fold's anchoring stays rejected#3031
rbuergi merged 2 commits into
mainfrom
fix/2640-2876-cross-schema

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #2876, fixes #2640.

🚨 Read this first: the anchoring #2640 proposes was considered and REJECTED

#2640's body proposes, as its first candidate:

anchor the permission/notification/thread queries to the viewer's partition ({user}/…) + the group-grant partitions

The access bucket is the biggest of the four (465 of 1 030 measured fan-outs, 45%), so it is the natural place to start. It is the one that must not be done that way, and the reasoning is the most valuable thing in this PR — it is now conserved as a doc page rather than as a comment on an issue thread, because the next person will otherwise reach for it.

SecurityQueries"the ONE place the permission-deciding mesh queries are written" — says why several of its reads carry no path: and no namespace::

a GroupMembership lives under the group node, which may sit in a different partition than the grant that names the group

Anchoring the membership read to the viewer's partition IS truncation. A membership record living in the group's partition is not in the viewer's, so it is not returned. Two failures follow, pointing in opposite directions, and nothing goes red either way:

Direction What happens
Grant A group-derived permission vanishes; every surface gated on it disappears at once (#2011)
Deny A group-scoped Denied = true assignment is applied only to the viewers the membership read says are in the group — so a revocation FAILS OPEN and the viewer keeps reading content the deny was written to take away

Paging is off the table for the same reason: SecurityQueries.Enumeration deliberately overwrites a limit: rather than honouring it, because in this fold a page IS the bug. And the trigger is growth, not a change — it fires the moment a mesh's Role/GroupMembership set outgrows whatever bound was introduced, so it appears on the largest install first and nobody will have touched anything.

What this PR actually does

1. #2876 — an area whose store was unreachable now says so

The retry was not missing. MeshQuery.MergeProviderObservables already wraps every provider observable in TransientStorageFaults.RetryTransientConnect (#2521, merged 2026-08-28 — three days before the 08-31 capture on #2876): 250 → 500 → 1000 ms, then the last error surfaces. A database unreachable for 21 s outlives 1.75 s of budget, so the fault reached the render exactly as designed.

What was missing is the answer to "what does the area SHOW when the bounded retry is honestly spent". Before:

⚠️ This area failed to render.

Npgsql.NpgsqlException (0x80004005): The operation has timed out

…i.e. the driver's own text plus the database host the pod could not reach, rendered to an end user, under a log line (Rendering failed for area Catalog) that names the area as the thing that failed — sending every reader hunting for a bug in a view that was fine.

After: a fifth area frame, AreaFrameClassifier.StorageUnavailableId, carrying localized copy (en + de) that says the content is temporarily unavailable and worth reloading.

Four deliberate non-choices, each stated in the code:

  • No retry on the render path. The fan-in's budget is spent; a second retry here would be an unbounded resubscribe aimed at the resource that is already the bottleneck.
  • No log downgrade. An availability failure stays at Error where an operator sees it (the same argument Three more sites report an availability failure as a definitive negative (permission-fold fault → "Access denied", read timeout → "Not found", cold cache → "user unknown") #974 makes). What changes is the wording — it now names the store rather than the area.
  • Not part of IsTransientFrame. That predicate promises "this WILL be replaced without anyone acting". Nothing fires when a database becomes reachable again, so a waiter told this frame was transient would wait forever — while a waiter told it was a verdict would give up on an area that is perfectly fine.
  • One rule, two consumers. The classification moved to StorageFaults.IsTransientConnectFault in MeshWeaver.Data.Contract (the one assembly both the query fan-in and MeshWeaver.Layout can see), typed on the BCL DbException surface since the driver lives in the plugins repo. TransientStorageFaults now forwards to it. Two copies of that rule would drift silently in either direction — a fault the fan-in retries but the renderer reports as a defect, or an outage the renderer excuses that the fan-in never retried — so MeshQueryTransientRetryTest asserts both surfaces agree on a corpus including the boundaries (42P01 and 40P01 are NOT the connect class; a bare TimeoutException is a hub timeout with its own policy).

2. #2640 — triage, the executable census, and the missing guard

SecurityQueries.AllShapes documents itself as "every query shape this class produces, for the completeness test that pins them". That test did not exist — no source file in this repo or in any satellite referenced AllShapes at all. A census nothing reads is a list, not a guard.

SecurityQueryShapesTest is that test, and it pins two independent properties:

No fan-out was eliminated in this PR, and that is deliberate. What the sweep found, and where it landed:

Lever Verdict
Anchor the fold's global reads Rejected — silent permission loss + revocation-fails-open (above)
Page them Rejected — same failure, by design (Enumeration overwrites limit:)
notifications / threads Not in core. The bell (NotificationCenter{,Panel}.razor) and the thread list live in MeshWeaver.Plugins. Core's NotificationService settings reads are already anchored
Admin/Menu/{X} route misses Already fixed83b1892be, an anchored existence GetQuery, 50 minutes after #2640's measurement window closed
UserActivityLayoutAreas.ObserveSharedTargets Needs a decision. The one core query that is unanchored AND uncached AND per-render — the largest core contributor to the access bucket. It cannot be anchored (a share grant lives in the GRANTING partition; pinning it to {user}/… makes everything shared with you disappear). GetQuery would move it from source-side RLS to consumer-side PermissionEvaluator — two implementations AccessControl.md says must agree; a per-viewer cache needs an IIoPool promise slot and an invalidation contract (a bare ConcurrentDictionary<key, IObservable<T>> latches a transient OnError forever, #1369). Both are designs, not patches
Collapse GatedNodes(type) into nodeType:A|B|C Blocked. ParsedQuery.ExtractNodeType returns null for an alternation, and that value drives satellite-TABLE routing — collapsing silently changes which table a gated satellite type reads. Prerequisite: an alternation-aware ExtractNodeTypes whose consumers route only when every value agrees
Honour QueryRoutingHints New finding, and a scope call. MeshConfiguration.ResolveRoutingHints registers rules pinning nodeType:Role/Partition/GlobalSettings to Admin — and InvitationNodeType states in-repo that "the PostgreSQL query router routes purely by the path's first segment and does NOT consume these QueryRoutingHints yet, so this rule is currently inert". Several docstrings in this repo describe their query as pinned on the strength of a rule that does not run. Honouring the hints would remove real fan-outs — and would also silently truncate any Role authored outside Admin, which is the exact failure this PR is about

3. Conserved

🚨 If you want #2640 to stay open until the fan-outs are actually eliminated, reopen it. It is closed here on the strength of the triage + the ratchet + the conserved reasoning, not on an elimination; the elimination plan itself lives on Doc/Architecture/CrossSchemaFanOutElimination and is provider-side (MeshWeaver.Plugins) for the two biggest buckets.

Verification

Full projects, no --filter, all built first (0 Error(s), 0 Warning(s)) with -c Release -warnaserror per touched project:

Project Result
MeshWeaver.Layout.Test 443 passed, 0 failed
MeshWeaver.Hosting.Test 287 passed, 0 failed
MeshWeaver.Documentation.Test 172 passed, 0 failed (link integrity ran against the rebuilt embedded tree — verified the new page is in the assembly under test)
MeshWeaver.Messaging.Hub.Test 341 passed, 0 failed (LocalizationTest)

Mutation-verified. Disabling the new arm in CreateRenderErrorControl fails StorageUnavailableRenderTest.AStoreThatCouldNotBeReached_ServesTheNamedFrame_AndStillPagesTheOperator — the test would have failed on main. Restored and the full project re-run green.

scripts/check-type-forwards.py --base origin/main"OK — no unguarded public-type move".

Follow-up in another repo

The two new localization keys (error.storageUnavailable, error.storageUnavailableHint) have a second home in MeshWeaver.Plugins/clients/react/src/i18n/. Core is the source of truth and merges first; the mirror PR follows.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa

…eaking the driver's error

Fixes #2876, fixes #2640.

#2876: the retry was never missing. MeshQuery.MergeProviderObservables already
wraps every provider observable in TransientStorageFaults.RetryTransientConnect
(#2521, merged three days before the 08-31 capture) — 250/500/1000 ms, then the
last error surfaces. A database unreachable for 21 s outlives 1.75 s of budget,
so the fault reached the render exactly as designed. What was missing is what
the area SHOWS when that budget is honestly spent: the generic panel carried the
driver's own text and the database host the pod could not reach to an end user,
under a log line naming the AREA as the thing that failed.

A fifth area frame (AreaFrameClassifier.StorageUnavailableId) now says the
content is temporarily unavailable and worth reloading, localized en + de.
Deliberately: no retry on the render path (the fan-in's is spent; a second one
would be an unbounded resubscribe aimed at the resource that is already the
bottleneck), no log downgrade (an availability failure stays at Error where an
operator sees it, #974 — only the wording changes, to name the store rather than
the area), and NOT part of IsTransientFrame (nothing fires when a database comes
back, so a waiter told this was transient would wait forever).

The classification moves to StorageFaults.IsTransientConnectFault in
MeshWeaver.Data.Contract — the one assembly both the query fan-in and
MeshWeaver.Layout can see — typed on the BCL DbException surface since the driver
lives in the plugins repo. TransientStorageFaults forwards to it; a corpus test
asserts the two surfaces cannot drift.

#2640: the anchoring its body proposes was considered and REJECTED. A
GroupMembership lives under the GROUP node, which may sit in a different
partition than the grant that names it, so pinning the fold's reads to the
viewer's partition IS truncation: a group-derived permission vanishes in one
direction and a group-scoped DENY fails open in the other, with nothing logged
and nothing failing. Paging is out for the same reason. The reasoning, the
per-shape census with a reason each, the per-lever verdicts (including that
ObserveSharedTargets and the GatedNodes collapse need decisions, not patches,
and that QueryRoutingHints is inert on Postgres so several in-repo docstrings
describe their query as pinned on the strength of a rule that does not run) are
conserved as Doc/Architecture/UnanchoredSecurityReads.

SecurityQueries.AllShapes documented itself as existing "for the completeness
test that pins them" — and no source file in this repo or any satellite
referenced it. SecurityQueryShapesTest is that test: it pins the completeness
stamp (#2011/#2048) AND parses every shape with the real QueryParser to assert
the unanchored population is exactly the declared one, with a positive control
on the anchored per-scope legs so it cannot pass vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa
Copilot AI lite review requested due to automatic review settings September 2, 2026 00:39
…liminated

The fan-out elimination page's plan 2 is the security fold, and its own "read
this first" was missing: anchoring those reads is truncation, which makes a
group-derived permission vanish AND a group-scoped deny fail open, with nothing
logged and nothing failing. A reader arriving at plan 2 now hits that warning
before the plan, rather than after implementing it.

Also names #2876 in the Related line — a transient connect timeout took a whole
area render down inside GetSchemasWithTableAsync, the call that enumerates the
schemas a fan-out is about to UNION, so it is this defect seen from the render
side rather than a separate one.

Fixes #2640, fixes #2876.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change is narrowly scoped, uses a single shared classifier to prevent drift, and is backed by targeted tests and documentation that pin both the new UI behavior and the security-query shape contract.

Pull request overview

Improves layout-area failure handling so transient storage-connectivity outages render a dedicated, localized “storage unavailable” frame (instead of leaking raw driver errors to viewers), and adds an executable ratchet that pins the intended “global vs anchored” shapes of security-fold queries while explicitly rejecting the previously-proposed anchoring approach for global security reads.

Changes:

  • Add a shared StorageFaults.IsTransientConnectFault classifier (in MeshWeaver.Data.Contract) and route both the query-fan-in retry and layout render-failure UI classification through it.
  • Introduce a new area-frame state (AreaFrameClassifier.StorageUnavailableId) + localized viewer copy, and adjust LayoutAreaHost logging to name the store outage rather than blaming the view.
  • Add tests guarding both the new render behavior and the security-query shape census/anchoring ratchet; conserve the rejected-anchoring reasoning + triage findings in docs and a What’s New entry.
File summaries
File Description
test/MeshWeaver.Layout.Test/StorageUnavailableRenderTest.cs New end-to-end test proving storage-connect faults render the named frame, don’t leak driver text, and still log at Error.
test/MeshWeaver.Layout.Test/AreaFrameClassifierTest.cs Adds coverage for the new StorageUnavailableId frame state and ensures it’s not treated as transient.
test/MeshWeaver.Hosting.Test/SecurityQueryShapesTest.cs New ratchet test that pins security query completeness and the declared “deliberately global” (unanchored) population with positive controls.
test/MeshWeaver.Hosting.Test/MeshQueryTransientRetryTest.cs Ensures retry-layer and render-layer classify the same transient-connect corpus via the shared rule.
src/MeshWeaver.Messaging.Hub/Localization/strings.en.json Adds error.storageUnavailable* localization keys (English).
src/MeshWeaver.Messaging.Hub/Localization/strings.de.json Adds error.storageUnavailable* localization keys (German).
src/MeshWeaver.Layout/Composition/LayoutAreaHost.cs Uses new storage-unavailable classification to adjust logging + render a dedicated frame with a well-known id.
src/MeshWeaver.Layout/AreaFrameClassifier.cs Introduces StorageUnavailableId and IsStorageUnavailable classifier on rendered frames.
src/MeshWeaver.Layout/AreaErrorClassifier.cs Adds IsStorageUnavailable(Exception?), delegating to shared StorageFaults.
src/MeshWeaver.Hosting/Persistence/Query/TransientStorageFaults.cs Removes duplicated classification logic; forwards IsTransientConnectFault to StorageFaults.
src/MeshWeaver.Data.Contract/StorageFaults.cs New shared, driver-agnostic transient-connect fault classifier (typed on DbException).
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-a-view-whose-database-was-unreachable-now-says-so.md What’s New entry describing the user-visible improvement.
src/MeshWeaver.Documentation/Data/Architecture/UnanchoredSecurityReads.md New architecture page conserving the rejected anchoring reasoning + per-lever verdicts for #2640.
src/MeshWeaver.Documentation/Data/Architecture/CrossSchemaFanOutElimination.md Links to the new “Unanchored Security Reads” companion page to prevent repeating the rejected approach.
src/MeshWeaver.Documentation/Data/Architecture.md Adds the new security architecture page to the index.
Review details
  • Files reviewed: 15/15 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

222 tests  ±0   222 ✅ ±0   2m 32s ⏱️ +16s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit df88d3a. ± Comparison against base commit 6e9ec2f.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

599 tests  +13   599 ✅ +13   56s ⏱️ +6s
  2 suites ± 0     0 💤 ± 0 
  2 files   ± 0     0 ❌ ± 0 

Results for commit df88d3a. ± Comparison against base commit 6e9ec2f.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 735 tests  ±0   1 735 ✅ ±0   1m 54s ⏱️ ±0s
    3 suites ±0       0 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit df88d3a. ± Comparison against base commit 6e9ec2f.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

420 tests  ±0   420 ✅ ±0   1m 24s ⏱️ -2s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit df88d3a. ± Comparison against base commit 6e9ec2f.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

535 tests  ±0   533 ✅ ±0   1m 31s ⏱️ -1s
  4 suites ±0     2 💤 ±0 
  4 files   ±0     0 ❌ ±0 

Results for commit df88d3a. ± Comparison against base commit 6e9ec2f.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    3 files  ±0      3 suites  ±0   1m 36s ⏱️ +23s
1 224 tests +4  1 032 ✅ +4  192 💤 ±0  0 ❌ ±0 
1 225 runs  +4  1 033 ✅ +4  192 💤 ±0  0 ❌ ±0 

Results for commit df88d3a. ± Comparison against base commit 6e9ec2f.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results

   14 files  ± 0     14 suites  ±0   9m 55s ⏱️ +41s
4 735 tests +17  4 541 ✅ +17  194 💤 ±0  0 ❌ ±0 
4 736 runs  +17  4 542 ✅ +17  194 💤 ±0  0 ❌ ±0 

Results for commit df88d3a. ± Comparison against base commit 6e9ec2f.

@rbuergi
rbuergi merged commit 11a2d36 into main Sep 2, 2026
25 of 26 checks passed
rbuergi added a commit that referenced this pull request Sep 2, 2026
…ot Unknown (#3057)

* fix(mesh): a create whose store is unreachable answers Unavailable, not Unknown

A transient database connect timeout reaching HandleCreateNodeRequest's terminal
error arm fell into the catch-all and was reported twice over as something it was
not: to the operator as "Unexpected error during node creation" (naming the create,
which was fine, rather than the store), and to the caller as
NodeCreationRejectionReason.Unknown -- indistinguishable from a verdict.

That distinction has teeth in one direction. A create that reads as REFUSED tells a
caller to stop; a caller that stops using the id it was retrying and mints a fresh
one on its next attempt writes a DUPLICATE. That is #2229's shape arriving through
the reporting layer instead of through a stale query.

Both ends of the wire already existed. NodeCreationRejectionReason.Unavailable was
added for a different unreachable dependency (#1446) and means exactly this -- "the
create was NOT evaluated ... an availability failure, not a verdict". And
StorageFaults.IsTransientConnectFault is the ONE classification rule, extracted by
#3031 into the assembly every consumer can see. This adds the branch between them.

Deliberately NOT a retry. The bounded retry already ran upstream
(TransientStorageFaults.RetryTransientConnect, #2521: 250 -> 500 -> 1000 ms, then
the last error surfaces), so a fault reaching this arm is one whose budget is
honestly spent; retrying here would aim a second, unbounded-in-aggregate retry at
the resource that is already the bottleneck. Log level stays at Error -- only the
wording changes, to name the store.

The branch is judged by the CONDITION and so sits ABOVE the `ex is
InvalidOperationException` test: an IOE wrapping a driver connect fault is still the
store being unreachable, and answering it ValidationFailed would tell the caller
their request was invalid because a database was down. Same argument
CancellationClassifier makes about the timeout impostor.

Both create verbs carry it -- a guard on one create verb and not the other is a
guard on neither, and the bulk verb is what every installer and static-repo import
travels. On the bulk side it sits BELOW the partial-landing branch: "nothing was
written" is false once a batch has committed a window.

Test: CreateWhenTheStoreIsUnreachableTest drives the real create pipeline through a
storage adapter that faults on marked paths only. On the pre-fix code 3 of its 5
cases fail and 2 pass -- the falsifying case (42P01 must still answer Unknown) and
the positive control (a healthy path still lands) pass before AND after, so the
change is attributable rather than merely correlated.

Docs conserve two findings from the same night:
- StoreUnreachableIsNotARefusal -- the one rule and its three consumers, the
  retry-once-then-answer ladder, the falsification boundary, the known edge left
  open (the in-process exception mapping still collapses Unavailable and Unknown),
  and the measurement showing 89.4% of that pod's Npgsql exceptions landed within
  100 ms of a GC-stall report, in windows covering 1.77% of the log -- so the
  incidents' "the database was unreachable" reading is not supported.
- ReadingASiloEviction -- why a heartbeat newer than the suspect votes does not
  prove a silo was healthy, and the probe-target control arm that separates a
  correct eviction from a false positive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa

* fix(#3051): do not claim "nothing was written" when the write had started

From the automatic review on #3057, and it is a correctness bug rather than
wording. The bulk create answered DescribeNotAttempted unconditionally on a
store-unreachable fault — but attemptedPaths is set once the batch is stamped
and handed to the store, so a fault after that point may have landed part of it.

"Nothing was written" is a claim ABOUT THE DATA. Said after a partial landing it
tells the caller the store is in a state it is not, and the caller's natural
response — retry the whole batch — then double-writes whatever did land. That is
the #2229 shape reached from the opposite direction.

So the verdict is now picked on evidence: DescribeMayHavePartiallyLanded when the
write had started, DescribeNotAttempted when it provably had not. The log line
asserted the same thing unconditionally and now reports which case it is.

Both sentences keep the Unavailable/not-refused distinction the PR is about; the
new one adds "read the current state before retrying" because re-sending is only
safe in the not-attempted case.

Full MeshWeaver.Graph.Test: 1163/1163. Release -warnaserror clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SD7aiLSo59xng2TzU32xEa

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Roland Bürgi <rbuergi@icloud.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants